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

View file

@ -0,0 +1,22 @@
---
import Icon from './Icon.astro';
const { class: className, chorecoin, gem } = Astro.props
---
<div class={className}>
<span><slot /></span>
<Icon {...{chorecoin, gem}} />
</div>
<style>
div {
display: flex;
gap: 0.2em;
align-items: center;
}
.editMode {
display: none;
hidden: true;
}
</style>

17
src/components/Icon.astro Normal file
View file

@ -0,0 +1,17 @@
---
const { chorecoin, gem } = Astro.props;
---
<img src={
chorecoin
? "/static/chorecoin.svg"
: gem
? "/static/gem.svg"
: undefined
} />
<style>
img {
height: 1cap;
}
</style>

45
src/components/Tab.astro Normal file
View file

@ -0,0 +1,45 @@
---
let { path } = Astro.props;
if (path.startsWith('/')) path = path.slice(1);
const activePath = Astro.url.pathname.slice(1);
const active = activePath === path;
---
<div class="tab">
<a href={'/' + path} class={active ? 'active' : 'inactive'}>
<slot />
<sup class="badge"
hx-trigger={active ? 'load, questComplete from:body' : 'load'}
hx-get={`/page/incomplete_count?route=${path}`}
hx-target="this"
hx-swap="innerText"
>
?
</sup>
</a>
</div>
<style>
.tab {
position: relative;
}
a {
color: black;
text-decoration: none;
}
.tab:has(a.active) {
border-bottom: 2px solid black;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: small;
margin-left: -0.5em;
}
</style>

51
src/components/tabs.astro Normal file
View file

@ -0,0 +1,51 @@
---
import Tab from './Tab.astro'
import { Page } from '../lib/Page';
const path = Astro.url.pathname;
const pageData = Page.db.query('select route, name from pages').values();
---
<nav>
{ pageData.map(([path, name]) => <Tab {...{path}}>{name}</Tab>) }
<form class="editMode" hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="name" placeholder="Page name" />
<input name="path" placeholder="/path" />
<input name="resetSchedule" placeholder="daily" />
<button
hx-put="/event/page/create"
hx-target="closest form"
hx-swap="beforebegin"
>
Create
</button>
</form>
</nav>
<style>
nav {
display: flex;
flex-direction: column;
gap: 1rem;
}
nav .tab {
height: fit-content;
}
nav a {
color: black;
text-decoration: none;
}
.tab:has(a.active) {
border-bottom: 2px solid black;
}
form.editMode {
display: none;
hidden: true;
width: 6em;
flex-direction: column;
gap: 0.5em;
margin-top: 1em;
}
</style>

182
src/layouts/Layout.astro Normal file
View file

@ -0,0 +1,182 @@
---
import Bank from '../pages/bank.astro';
import Vertitabs from '../components/tabs.astro';
const { tabs } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<script src="https://unpkg.com/htmx.org@2" is:inline></script>
<script src="https://unpkg.com/luxon@3" is:inline></script>
<title>Gachore</title>
</head>
<body>
<div class="topbar">
<Bank />
<a href={ tabs ? "/gacha" : "/" }>{`To ${ tabs ? "Gacha" : "Quests" }`}</a>
<button id="edit_mode">Edit Mode</button>
</div>
<div class="row">
{ tabs ? <Vertitabs /> : null }
<main>
<slot />
</main>
</div>
</body>
</html>
<script>
window.sessionStorage.removeItem("gachore:mode");
const button = document.getElementById("edit_mode");
const setVisibility = () => {
if (window.sessionStorage?.getItem("gachore:mode") === "edit") {
document.querySelectorAll('.editMode').forEach(el => {
el.style.display = 'flex';
el.style.hidden = false;
});
document.querySelectorAll('.viewMode').forEach(el => {
el.style.display = 'none';
el.style.hidden = true;
});
} else {
document.querySelectorAll('.editMode').forEach(el => {
el.style.display = 'none';
el.style.hidden = true;
});
document.querySelectorAll('.viewMode').forEach(el => {
el.style.removeProperty('display');
el.style.hidden = false;
});
}
};
button?.addEventListener('click', (evt) => {
evt.preventDefault();
if (window.sessionStorage?.getItem("gachore:mode") === "edit") {
button.innerText = "Edit Mode"
window.sessionStorage.removeItem("gachore:mode");
} else {
button.innerText = "View Mode"
window.sessionStorage.setItem("gachore:mode", "edit");
}
setVisibility();
});
window.addEventListener('htmx:afterRequest', () => {
setVisibility();
});
</script>
<style>
#edit_mode {
font-size: 80%;
}
.topbar {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
gap: 1em;
a {
color: black;
}
}
.row {
display: flex;
flex-direction: row;
height:100%;
gap: 2rem;
}
main {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 1rem;
}
</style>
<style is:global>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
}
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem;
width: 100%;
max-width: 800px;
font-size: 24px;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
p {
text-wrap: pretty;
}
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
text-align: right;
}
.editMode {
display: none;
hidden: true;
}
.editMode input, input.editMode {
min-width: 1.5em;
}
input {
border: none;
border-bottom: 1px solid gray;
margin: 0;
padding-left: 0;
padding-right: 0;
&.coins-reward {
width: 1.5em;
}
}
</style>

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();
};

95
src/pages/QuestRow.astro Normal file
View file

@ -0,0 +1,95 @@
---
export const partial = true;
export const prerender = false;
import { Quest } from '../lib/Quest';
import Currency from '../components/Currency.astro';
const { id } = Astro.props;
const quest = Quest.load(id);
---
<div class={quest.claimed ? "quest claimed" : "quest"} hx-target="this" hx-swap="outerHTML">
<div class="editMode buttons" hx-target="closest fieldset">
<button hx-post={`/event/quest/reorder?up&id=${id}`}>^</button>
<button hx-post={`/event/quest/delete?id=${id}`}>x</button>
<button hx-post={`/event/quest/reorder?down&id=${id}`}>v</button>
</div>
<input type="checkbox"
id={`quest-${id}-completed`}
class="viewMode"
checked={quest.completed}
disabled={quest.claimed}
hx-post={`/event/quest/complete?id=${id}`}
hx-disabled-elt="this"
/>
<label for={`quest-${id}-completed`} class="name viewMode">{quest.name}</label>
<input class="name editMode" name="name" value={quest.name}
hx-post=`/event/quest/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous"
hx-swap="textContent"
/>
<Currency class="coins-reward viewMode" chorecoin>{quest.coinsReward}</Currency>
<Currency class="coins-reward editMode" chorecoin>
<input class="coins-reward" name="coinsReward" value={quest.coinsReward}
type="number"
hx-post=`/event/quest/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous span"
hx-swap="textContent"
/>
</Currency>
<button
disabled={!quest.completed || quest.claimed}
hx-post={`/event/quest/claim?id=${id}`}
hx-disabled-elt="this"
class="claim viewMode"
>
Claim{quest.claimed ? "ed!" : ""}
</button>
<button disabled class="claim editMode">Claim{quest.claimed ? "ed!" : ""}</button>
</div>
<style>
.buttons.editMode {
flex-direction: column;
gap: 0;
font-size: x-small;
opacity: 100%;
button {
margin: 0 0 0 -1em;
}
}
.quest {
border: 1px solid black;
border-radius: 5px;
padding: 0 1rem;
gap: 0.5em;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
.name {
flex-grow: 1;
flex-basis: 20px;
}
}
.quest.claimed {
color: rgba(0, 0, 0, 0.3);
img { filter: invert(70%); }
order: 1;
}
button.claim {
margin: 1rem 0;
}
</style>

View file

@ -0,0 +1,76 @@
---
export const prerender = false;
const pageRoute = Astro.params.pageRoute ?? '';
import Layout from '../layouts/Layout.astro';
import QuestlineDisp from './questline/[id].astro';
import ResetTimer from './page/reset_timer.astro';
import ClaimAllButton from './page/claim_all_button.astro';
import ResetButton from './page/reset_button.astro';
import { Questline } from '../lib/Questline';
let ok = true;
const pageId = Questline.db.query('select id from pages where route = ?').values(pageRoute)[0]?.[0];
let questlineIds: DbId[] = [];
if (pageId) {
questlineIds = Questline.db.query('select id from questlines where page_id = ?').values(pageId);
} else {
ok = false;
Astro.response.status = 404;
Astro.response.statusText = `No page with route '${pageRoute}'`;
}
---
<Layout tabs>
{ ok ? null : `${Astro.response.status}: ${Astro.response.statusText}` }
{ questlineIds.length ? <div id="reset">
<span>
Reset in <ResetTimer page={pageId} />
</span>
<ClaimAllButton page={pageId} />
<ResetButton page={pageId} />
</div> : null }
{ questlineIds.map(id => <QuestlineDisp {...{ id }} />) }
<form class="editMode addQuestLine" hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="name" placeholder="Questline" />
<input name="reward" placeholder="Coins" width="3" />
<button
hx-put=`/event/questline/create?page=${pageId}`
hx-target="closest form"
hx-swap="beforebegin"
>Create</button>
</form>
</Layout>
<style>
.editMode {
display: none;
}
#reset {
margin-top: 1em;
font-size: 80%;
display: flex;
width: 100%;
gap: 0.8em;
& span {
flex-grow: 1;
}
}
form.editMode {
display: none;
hidden: true;
gap: 0.5em;
input[name="name"] {
flex-grow: 1;
}
input[name="reward"] {
width: 4em;
}
}
</style>

147
src/pages/bank.astro Normal file
View file

@ -0,0 +1,147 @@
---
export const prerender = false;
export const partial = true;
import Currency from '../components/Currency.astro';
import Icon from '../components/Icon.astro';
import { Model } from '../lib/Model';
import { squish } from '../lib/tags';
const result = Model.db.query(squish`
select
ifnull(sum(coins_claimed), 0) - ifnull(sum(coins_spent), 0),
ifnull(sum(gems_claimed), 0) - ifnull(sum(gems_spent), 0)
from events
`).values();
const chorecoins = result[0][0] ?? 0;
const gems = result[0][1] ?? 0;
const transactions = Model.db.query(squish`
select coins_claimed, gems_claimed, coins_spent, gems_spent, record_time, kind, qs.name as quest, ql.name as questline
from events ev
full outer join questlines ql on ql.id = ev.questline_id
full outer join quests qs on qs.id = ev.quest_id
where (
ev.coins_claimed > 0
or ev.gems_claimed > 0
or ev.coins_spent > 0
or ev.gems_spent > 0
)
order by record_time desc
`).all();
const isoDate = (d: Date) => d.toISOString().split('T')[0];
---
<div id="bank" hx-get="/bank" hx-trigger="bankUpdate from:body" data-micromodal-trigger="earnlog-modal">
<button id="earnlog-shower">
<Currency chorecoin>{chorecoins}</Currency>
<Currency gem>{gems}</Currency>
</button>
<dialog id="earnlog">
<header>
<h2 id="earnlog-title">
Transactions
</h2>
<form method="dialog">
<button aria-label="Close modal"></button>
</form>
</header>
<div class="content">
<div>Date</div>
<div>Event</div>
<div class="span2"><Icon chorecoin /></div>
<div class="span2"><Icon gem /></div>
{transactions.map(({ record_time, kind, quest, questline, ...event }) => (
<>
<div>{ isoDate(new Date(record_time)) }</div>
<div>{
kind === "claim_quest" ? `Claimed ${quest}` :
kind === "claim_questline" ? `Completed ${questline}` :
kind
}</div>
<div class="debit">{ event.coins_spent ?? '' }</div>
<div class="credit">{ event.coins_claimed ?? '' }</div>
<div class="debit">{ event.gems_spent ?? '' }</div>
<div class="credit">{ event.gems_claimed ?? '' }</div>
</>
))}
</div>
</dialog>
</div>
<script>
const earnlog = document.getElementById("earnlog");
document.getElementById("earnlog-shower").addEventListener("click", () => {
earnlog.showModal();
});
earnlog.addEventListener("click", (evt) => {
if (evt.target === earnlog)
earnlog.close();
});
</script>
<style>
#earnlog-shower {
display: flex;
flex-direction: row;
gap: 2em;
background: transparent;
border: 0;
cursor: pointer;
}
dialog {
margin: auto;
max-height: 85vh;
overflow-y: auto;
}
dialog header {
display: flex;
justify-content: space-between;
align-items: center;
}
dialog header button {
background: transparent;
border: 0;
cursor: pointer;
}
dialog header button:before { content: "\2715"; }
dialog .content {
--gap: 1em;
width: 100%;
display: grid;
gap: 0;
grid-template-columns: 1fr 2fr repeat(4, 5ch);
border-top: 1px solid gray;
border-left: 1px solid gray;
& > * {
border-right: 1px solid gray;
border-bottom: 1px solid gray;
padding: 0 calc(var(--gap) / 2);
}
.span2 {
grid-column: span 2;
display: flex;
align-items: center;
justify-content: center;
}
.debit, .credit {
text-align: right;
}
.span2:nth-child(3),
.credit:nth-child(6n + 2) {
border-right: 5px double gray;
}
}
</style>

View file

@ -0,0 +1,53 @@
---
export const prerender = false;
export const partial = true;
import ClaimAllButton from '../../page/claim_all_button.astro';
import { Page } from '../../../lib/Page';
import { squish } from '../../../lib/tags';
const pageId = Astro.url.searchParams.get('page');
const questClaims = Page.db.query(squish`
update quests set claimed = true where id in (
select qs.id from quests qs
join questlines ql on ql.id = qs.questline_id
join pages ps on ps.id = ql.page_id
where qs.completed = true
and qs.claimed = false
and ps.id = ?
)
returning id, coins_reward;
`).values(pageId);
const claimQuestCoins = Page.db.query(squish`
insert into events(kind, quest_id, coins_claimed)
values ('claim_quest', ?, ?)
`);
Page.db.transaction(claims => {
for(const [id, coins] of claims) claimQuestCoins.run(id, coins)
})(questClaims);
const questlineClaims = Page.db.query(squish`
update questlines set claimed = true where id in (
select ql.id from questlines ql
join pages ps on ps.id = ql.page_id
where ps.id = ?
and ql.claimed = false
and (select min(completed) from quests qs where qs.questline_id = ql.id) = 1
)
returning id, coins_reward
`).values(pageId);
const claimQuestlineCoins = Page.db.query(squish`
insert into events(kind, questline_id, coins_claimed)
values ('claim_questline', ?, ?)
`);
Page.db.transaction(claims => {
for(const [id, coins] of claims) claimQuestlineCoins.run(id, coins)
})(questlineClaims);
Astro.response.headers.set('HX-Trigger', 'claimedAll, questClaimed, bankUpdate');
---
<ClaimAllButton page={pageId} />

View file

@ -0,0 +1,33 @@
---
export const prerender = false;
export const partial = true;
import { Quest } from '../../../lib/Quest';
import { squish } from '../../../lib/tags';
import Tab from '../../../components/Tab.astro';
const form = await Astro.request.formData();
const name = form.get("name") as string;
const path = form.get("path") as string;
const reset = form.get("resetSchedule") as string;
let ok = true;
if (name.length < 1) {
ok = false;
Astro.response.status = 400;
} else {
try {
Quest.db.query(squish`
insert into pages (name, route, reset_schedule)
values (?1, ?2, ?3)
`).run(name, path, reset);
} catch(e) {
ok = false;
console.log(`Following error is for name [${name}] path [${path}]`);
console.error(e);
Astro.response.status=400;
}
}
---
{ ok && <Tab path={path}>{name}</Tab> }

View file

@ -0,0 +1,34 @@
---
export const prerender = false;
export const partial = true;
import ResetButton from '../../page/reset_button.astro';
import { Page } from '../../../lib/Page';
import { squish } from '../../../lib/tags';
const pageId = Astro.url.searchParams.get('page');
Page.db.query(squish`
update quests set completed = false, claimed = false where id in (
select qs.id from quests qs
join questlines ql on ql.id = qs.questline_id
join pages ps on ps.id = ql.page_id
where qs.completed = true
and ps.id = ?
)
`).run(pageId);
Page.db.query(squish`
update questlines set claimed = false where id in (
select ql.id from questlines ql
join pages ps on ps.id = ql.page_id
where ql.claimed = true
and ps.id = ?
)
`).run(pageId);
Astro.response.headers.set('HX-Trigger', 'reset');
---
<ResetButton page={pageId} />

View file

@ -0,0 +1,66 @@
---
export const partial = true;
export const prerender = false;
import Currency from '../../components/Currency.astro';
import { RewardState } from '../../lib/Reward';
import { pullChances } from '../../lib/gacha_pull';
import { Model } from '../../lib/Model';
import { squish } from '../../lib/tags';
const rand = Math.random();
const pulled = pullChances.find(which => which.cdf > rand);
let reward = null;
console.log({ pulled });
if (pulled.gems) {
Model.db.query(squish`
insert into events (kind, coins_spent, gems_claimed)
values ('gacha_pull', 100, ?)
`).run(pulled.gems)
} else {
const rewards = Model.db.query(squish`
select id, name, chance from rewards where state > ? order by sort_order
`).all(RewardState.inactive);
let rand = Math.random();
// in case we get floating-point'd, change chance of the last reward in the list to be
// 1.0. The cdf at that point would be within epsilon, but technically possible that
// it's just on the wrong side of epsilon away from rand
rewards[rewards.length-1].chance = 1;
for (const { id, name, chance } of rewards) {
if (chance > rand) {
// won
reward = { kind: 'reward', id, name, chance };
break;
} else {
rand -= chance;
}
}
console.log({ reward });
Model.db.query(squish`
insert into events (kind, coins_spent, reward_id)
values ('gacha_pull', 100, ?)
`).run(reward.id);
}
Astro.response.headers.set('HX-Trigger', 'bankUpdate');
---
<>
<header>
<h3>Result</h3>
<form method="dialog">
<button aria-label="Close modal"></button>
</form>
</header>
<div class={`rarity ${pulled.name}`}>
{ pulled.gems ? <Currency gem>{pulled.gems}</Currency> : reward.name }
</div>
</>

View file

@ -0,0 +1,26 @@
---
import { Quest } from '../../../lib/Quest';
import { squish } from '../../../lib/tags';
import QuestRow from '../../QuestRow.astro';
export const prerender = false;
export const partial = true;
const id = Astro.url.searchParams.get('id') || '';
const quest = Quest.load(id);
if (!quest.claimed) {
quest.claimed = true;
quest.save();
Quest.db.prepare(squish`
insert into events(
kind, quest_id, coins_claimed
) values ("claim_quest", ?, ?)
`).values(id, quest.coinsReward);
Astro.response.headers.set('HX-Trigger', 'questClaimed, bankUpdate');
}
---
<QuestRow id={id} />

View file

@ -0,0 +1,17 @@
---
import QuestRow from '../../QuestRow.astro';
import { Quest } from '../../../lib/Quest';
export const prerender = false;
export const partial = true;
const id = Astro.url.searchParams.get('id') || '';
const quest = Quest.load(id);
quest.completed = !quest.completed;
quest.save();
Astro.response.headers.set('HX-Trigger', `questComplete, questComplete-ql${quest.questlineId}`);
---
<QuestRow id={id} />

View file

@ -0,0 +1,34 @@
---
export const prerender = false;
export const partial = true;
import { Quest } from '../../../lib/Quest';
import { squish } from '../../../lib/tags';
import QuestlineDisp from '../../questline/[id].astro';
const questlineId = Astro.url.searchParams.get('questline') || '';
const form = await Astro.request.formData();
const name = form.get("name") as string;
const reward = form.get("reward") as string;
const coins = Number.parseInt(reward, 10);
let ok = true;
if (!questlineId || name.length < 1 || reward.length < 1 || Number.isNaN(coins)) {
ok = false;
Astro.response.status = 400;
} else {
try {
Quest.db.query(squish`
insert into quests (name, coins_reward, questline_id, sort_order, completed, claimed)
values (?1, ?2, ?3, (select 1+max(sort_order) from quests where questline_id = ?3), false, false)
`).run(name, coins, questlineId);
} catch(e) {
ok = false;
console.log(`Following error for name [${name}] coins [${coins}] qlid [${questlineId}]`);
console.error(e);
Astro.response.status=400;
}
}
---
{ ok && <QuestlineDisp id={questlineId} /> }

View file

@ -0,0 +1,22 @@
---
export const prerender = false;
export const partial = true;
import { Quest } from '../../../lib/Quest';
import { squish } from '../../../lib/tags';
import QuestlineDisp from '../../questline/[id].astro';
const id = Astro.url.searchParams.get('id') || '';
const quest = Quest.load(id);
const questline = quest.questline;
// TODO soft delete so logs don't break
Quest.db
.query(squish`
delete from quests
where id = ?
`).values(id)
---
<QuestlineDisp id={questline.id}>

View file

@ -0,0 +1,20 @@
---
export const prerender = false;
export const partial = true;
import { Quest } from '../../../lib/Quest';
const id = Astro.url.searchParams.get('id');
const form = await Astro.request.formData();
const name = form.get("name") as string;
const coinsReward = form.get("coinsReward") as string;
if (name) {
Quest.db.query("update quests set name=? where id=?").values(name, id)
} else if (coinsReward) {
Quest.db.query("update quests set coins_reward=? where id=?").values(coinsReward, id)
}
---
{name ? name : coinsReward}

View file

@ -0,0 +1,51 @@
---
export const prerender = false;
export const partial = true;
import { Quest } from '../../../lib/Quest';
import { squish } from '../../../lib/tags';
import QuestlineDisp from '../../questline/[id].astro';
const up = Astro.url.searchParams.has('up');
const down = Astro.url.searchParams.has('down');
const id = Astro.url.searchParams.get('id') || '';
const quest = Quest.load(id);
const questline = quest.questline;
const sortOrders = Quest.db
.query(squish`
update quests
set sort_order = subq.questline_order
from (
select *, row_number() over(order by sort_order) as questline_order
from quests where questline_id=? order by sort_order
) subq
where quests.id = subq.id
returning sort_order
`).values(questline.id)
const maxSortOrder = sortOrders[sortOrders.length - 1][0];
quest.load();
const canReorder = up
? quest.sortOrder > 1
: down
? quest.sortOrder < maxSortOrder
: false;
if (canReorder) {
Quest.db.query(squish`
update quests
set sort_order = sort_order ${ up ? '+' : '-' } 1
where questline_id=? and sort_order=?
`).values(questline.id, up ? quest.sortOrder - 1 : quest.sortOrder + 1);
Quest.db.query(squish`
update quests
set sort_order = sort_order ${ up ? '-' : '+' } 1
where id=?
`).values(id);
}
---
<QuestlineDisp id={questline.id}>

View file

@ -0,0 +1,24 @@
---
import { Questline } from '../../../lib/Questline';
import { squish } from '../../../lib/tags';
import QuestlineClaimRow from '../../questline/claim_row.astro';
export const prerender = false;
export const partial = true;
const id = Astro.url.searchParams.get('id') || '';
const questline = Questline.load(id);
questline.claimed = true;
questline.save();
Questline.db.prepare(squish`
insert into events(
kind, questline_id, coins_claimed
) values ("claim_questline", ?, ?)
`).values(id, questline.coinsReward);
Astro.response.headers.set('HX-Trigger', 'bankUpdate');
---
<QuestlineClaimRow id={id} />

View file

@ -0,0 +1,36 @@
---
export const prerender = false;
export const partial = true;
import { Quest } from '../../../lib/Quest';
import { squish } from '../../../lib/tags';
import QuestlineDisp from '../../questline/[id].astro';
const pageId = Astro.url.searchParams.get('page') || '';
const form = await Astro.request.formData();
const name = form.get("name") as string;
const reward = form.get("reward") as string;
const coins = Number.parseInt(reward, 10);
let ok = true;
let newQuestlineId = 0;
if (!pageId || name.length < 1 || reward.length < 1 || Number.isNaN(coins)) {
ok = false;
Astro.response.status = 400;
} else {
try {
newQuestlineId = Quest.db.query(squish`
insert into questlines (name, coins_reward, page_id, claimed)
values (?1, ?2, ?3, false)
`).run(name, coins, pageId)
.lastInsertRowid;
} catch(e) {
ok = false;
console.log(`Following error for name [${name}] coins [${coins}] pageid [${pageId}]`);
console.error(e);
Astro.response.status=400;
}
}
---
{ ok && <QuestlineDisp id={newQuestlineId} /> }

View file

@ -0,0 +1,20 @@
---
export const prerender = false;
export const partial = true;
import { Questline } from '../../../lib/Questline';
const id = Astro.url.searchParams.get('id');
const form = await Astro.request.formData();
const name = form.get("name") as string;
const coinsReward = form.get("coinsReward") as string;
if (name) {
Questline.db.query("update questlines set name=? where id=?").values(name, id)
} else if (coinsReward) {
Questline.db.query("update questlines set coins_reward=? where id=?").values(coinsReward, id)
}
---
{name ? name : coinsReward}

View file

@ -0,0 +1,34 @@
---
export const prerender = false;
export const partial = true;
import { Model } from '../../../lib/Model';
import { squish } from '../../../lib/tags';
const form = await Astro.request.formData();
const name = form.get("name") as string;
const link = (form.get("link") ?? '') as string;
const dollars = form.get("dollars") as string;
const cents = Math.floor(Number.parseFloat(dollars) * 100);
let ok = true;
if (name.length < 1 || Number.isNaN(cents)) {
ok = false;
Astro.response.status = 400;
} else {
try {
Model.db.query(squish`
insert into rewards (name, link, cents)
values (?1, ?2, ?3)
`).run(name, link, cents);
} catch(e) {
ok = false;
console.error(e);
Astro.response.status=400;
}
}
Astro.response.headers.set('HX-Trigger', 'rewardEdit');
---
{ ok ? '' : 'Error' }

View file

@ -0,0 +1,37 @@
---
export const prerender = false;
export const partial = true;
import { Model } from '../../../lib/Model';
const id = Astro.url.searchParams.get('id');
const form = await Astro.request.formData();
let name = form.get("name") as string;
let link = form.get("link") as string;
const dollars = form.get("dollars") as string;
let cents = undefined;
console.log({ name, link, dollars });
if (name !== null) {
[name, link] = Model.db.query(
"update rewards set name=? where id=? returning name, link"
).values(name, id)[0] as string[];
} else if (link !== null) {
[name, link] = Model.db.query(
"update rewards set link=? where id=? returning name, link"
).values(link === "" ? null : link, id)[0] as string[];
} else if (dollars !== null) {
cents = Math.floor(Number.parseFloat(dollars) * 100);
console.log({ cents });
Model.db.query("update rewards set cents=? where id=?").run(cents, id);
Astro.response.headers.set('HX-Trigger', 'rewardEdit');
}
---
{ link !== null ? <a class="viewMode" href={link}>{name}</a>
: name !== null ? <span class="viewMode">{name}</span>
: cents !== null ? (cents * 200)
: '?'
}

View file

@ -0,0 +1,50 @@
---
export const prerender = false;
export const partial = true;
import RewardList from '../../reward/list.astro';
import { Model } from '../../../lib/Model';
import { squish } from '../../../lib/tags';
const up = Astro.url.searchParams.has('up');
const down = Astro.url.searchParams.has('down');
const id = Number.parseInt(Astro.url.searchParams.get('id'), 10);
const refreshedSortOrders = Model.db
.query(squish`
update rewards
set sort_order = subq.reordered
from (
select *, row_number() over(order by sort_order) as reordered
from rewards
) subq
where rewards.id = subq.id
returning id, sort_order
`).all()
console.log({ refreshedSortOrders });
const maxSortOrder = refreshedSortOrders[refreshedSortOrders.length - 1].sort_order;
const currentSortOrder = refreshedSortOrders.find(each => each.id === id).sort_order;
const canReorder = up ?
currentSortOrder > 1 : down ?
currentSortOrder < maxSortOrder :
false;
if (canReorder) {
Model.db.query(squish`
update rewards
set sort_order = sort_order ${ up ? '+' : '-' } 1
where sort_order=?
`).values(up ? currentSortOrder - 1 : currentSortOrder + 1);
Model.db.query(squish`
update rewards
set sort_order = sort_order ${ up ? '-' : '+' } 1
where id=?
`).values(id);
}
---
<RewardList />

166
src/pages/gacha.astro Normal file
View file

@ -0,0 +1,166 @@
---
import Layout from '../layouts/Layout.astro';
import RewardList from './reward/list.astro';
import Currency from '../components/Currency.astro';
import { Model } from '../lib/Model';
import { squish } from '../lib/tags';
import { pullChances } from '../lib/gacha_pull';
const bankedCoins = Model.db.query(squish`
select ifnull(sum(coins_claimed), 0) - ifnull(sum(coins_spent), 0)
from events
`).values()?.[0]?.[0] as number;
const inventory = Model.db.query(squish`
select rd.id as id, rd.name as name, rd.gems_cost / 2 as sellFor from inventory inv
join rewards rd on inv.reward_id = rd.id
where inv.state is null;
`).all();
---
<Layout>
<div id="pull_chances">
{pullChances.map(({ chance, gems }) => (<div>
<span>{(chance*100).toPrecision(3)}%</span>
{ gems ? <Currency gem>{gems}</Currency> : <span>↓</span> }
</div>))}
<div>
<button disabled={bankedCoins < 100}
hx-get="/event/pull"
hx-target="#pull_results"
onclick="document.getElementById('pull_results').showModal()"
>
<span>Pull</span>
<Currency chorecoin class="cost">100</Currency>
</button>
</div>
{/*
<div>
<button disabled={bankedCoins < 1000}>
<span>Pull&nbsp;10</span>
<Currency chorecoin class="cost">1000</Currency>
</button>
</div>
*/}
</div>
<dialog id="pull_results">
</dialog>
<div id="inventory">
{ inventory.map(({ id, name, sellFor }) => (
<>
<div data-id={id}>{name}</div>
<div>{sellFor}</div>
</>
))}
</div>
<RewardList />
<form class="addReward editMode" hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="name" placeholder="Reward" autofocus hx-on::after-request="if(event.detail.successful) this.focus()"/>
<input name="link" placeholder="link (optional)" />
<span class="dollars">
<input id="dollars" name="dollars" placeholder="0.00" type="number" step="0.01"/>
</span>
<button
hx-put=`/event/reward/create`
hx-target="closest form"
hx-swap="afterend"
>Create</button>
</form>
</Layout>
<style>
#pull_chances {
display: flex;
margin-top: 1em;
& div {
display: flex;
flex-direction: column;
padding-right: 1ch;
padding-left: 1ch;
border: 2px solid gray;
border-right: 0;
align-items: center;
justify-content: center;
&:first-child {
border-radius: 1em 0 0 1em;
}
&:last-child {
border-right: 2px solid gray;
border-radius: 0 1em 1em 0;
}
}
button {
display: flex;
flex-direction: column;
align-items: center;
.cost {
font-size: small;
}
}
}
form {
gap: 1em;
width: 100%;
}
input {
width: 15ch;
&[name="name"] {
flex-grow: 1;
}
}
.dollars {
display: inline-block;
position: relative;
font-family: monospace;
}
.dollars::before {
content: "$";
position: absolute;
left: 0.25em;
}
input[name="dollars"] {
min-width: 7ch;
width: 7ch;
}
dialog {
margin: auto;
header {
display: flex;
justify-content: space-between;
align-items: center;
button {
background: transparent;
border: 0;
cursor: pointer;
&:before { content: "\2715"; }
}
}
}
</style>
<style is:global>
.rarity {
aspect-ratio: 1/1;
display: flex;
align-items: center;
justify-content: center;
&.rare img { background-image: url('/static/rare.webp'); }
&.epic img { background-image: url('/static/epic.webp'); }
&.legendary img { background-image: url('/static/legendary.webp'); }
div {
flex-direction: column-reverse;
gap: 0;
img {
height: 80px;
background-size: contain;
}
}
}
</style>

6
src/pages/index.astro Normal file
View file

@ -0,0 +1,6 @@
---
import QuestlinePage from './[pageRoute].astro';
---
<QuestlinePage />

View file

@ -0,0 +1,28 @@
---
export const prerender = false;
export const partial = true;
import { Page } from '../../lib/Page';
import { squish } from '../../lib/tags';
const pageId = Astro.props.page ?? Astro.url.searchParams.get('page');
const canClaim = Page.db.query(squish`
select count(*) from pages p
join questlines ql on p.id = ql.page_id
join quests qs on ql.id = qs.questline_id
where qs.completed = true
`).values()?.[0]?.[0] > 0;
---
<div
hx-get={`/page/claim_all_button?page=${pageId}`}
hx-trigger="questComplete from:body"
>
<button
disabled={!canClaim}
hx-post={`/event/page/claim_all?page=${pageId}`}
hx-target="closest div"
hx-swap="outerHTML"
>
Claim all
</button>
</div>

View file

@ -0,0 +1,18 @@
---
export const partial = true;
export const prerender = false;
const pageRoute = Astro.url.searchParams.get('route') ?? '';
import { Page } from '../../lib/Page';
import { squish } from '../../lib/tags';
const count = Page.db.query(squish`
select count(*) from quests qs
join questlines ql on qs.questline_id = ql.id
join pages pg on ql.page_id = pg.id
where qs.completed = false
and pg.route = ?
`).values(pageRoute)?.[0]?.[0] ?? '?';
---
{count}

View file

@ -0,0 +1,28 @@
---
export const prerender = false;
export const partial = true;
import { Page } from '../../lib/Page';
import { squish } from '../../lib/tags';
const pageId = Astro.props.page ?? Astro.url.searchParams.get('page');
const canReset = ((Page.db.query(squish`
select count(*) from pages p
join questlines ql on p.id = ql.page_id
join quests qs on ql.id = qs.questline_id
where qs.completed = true
`).values()?.[0]?.[0] as number | bigint | undefined) ?? 0) > 0;
---
<div
hx-get={`/page/reset_button?page=${pageId}`}
hx-trigger="questComplete from:body"
>
<button
disabled={!canReset}
hx-post={`/event/page/reset?page=${pageId}`}
hx-target="closest div"
hx-swap="outerHTML"
>
Reset
</button>
</div>

View file

@ -0,0 +1,63 @@
---
export const partial = true;
export const prerender = false;
import { getSeasonStart } from '@postlight/seasons';
import { DateTime, Duration } from 'luxon';
import { Page } from '../../lib/Page';
const pageId = Astro.props.page ?? Astro.url.searchParams.get('page');
const schedule = Page.db.query("select reset_schedule from pages where id = ?").values(pageId)?.[0]?.[0];
const now = DateTime.now();
let stopstamp: number = 0
if (schedule === "daily") {
stopstamp = DateTime.local(now.year, now.month, now.day + 1, 1).toMillis();
} else if (schedule === "weekly") {
const day = now.weekday;
stopstamp = DateTime.local(now.year, now.month, now.day + (8 - day) % 7, 1).toMillis();
} else if (schedule === "fortnightly") {
const weeknum = now.weekNumber;
const day = now.weekday;
const toMonday = (8 - day) % 7;
const toFortnight = toMonday + (weeknum % 2) * 7;
stopstamp = DateTime.local(now.year, now.month, now.day + toFortnight, 1).toMillis();
} else if (schedule === "monthly") {
stopstamp = DateTime.local(now.year, now.month + 1, 1, 1).toMillis();
} else if (schedule === "seasonally") {
stopstamp = DateTime.fromJSDate(
getSeasonStart(now.month, now.year)
).toMillis();
}
let remaining = 'unknown'
if (stopstamp > 0) {
let precision = 0;
let ms = stopstamp - Date.now();
if (ms > 604_800_000) precision = 86_400_000;
else if (ms > 86_400_000) precision = 3_600_000;
else precision = 1000;
ms = Math.floor(ms / precision) * precision;
remaining = Duration.fromMillis(ms, { conversionAccuracy: 'longeterm' })
.rescale().toHuman({ unitDisplay: 'short' });
}
---
<span id="resetstamp">{remaining}</span>
<script define:vars={{ stopstamp }} is:inline type="module">
const el = document.getElementById('resetstamp');
if (el !== null && stopstamp > 0 && luxon) {
setInterval(() => {
let precision = 0;
let ms = stopstamp - Date.now();
if (ms > 604_800_000) precision = 86_400_000;
else if (ms > 86_400_000) precision = 3_600_000;
else precision = 1000;
ms = Math.floor(ms / precision) * precision;
el.innerText= luxon.Duration.fromMillis(ms, { conversionAccuracy: 'longeterm' })
.rescale().toHuman({ unitDisplay: 'short' });
}, 500);
}
</script>

View file

@ -0,0 +1,70 @@
---
export const partial = true;
export const prerender = false;
import Currency from '../../components/Currency.astro';
import QuestlineClaimRow from './claim_row.astro';
import QuestRow from '../QuestRow.astro';
import { Quest } from '../../lib/Quest';
import { Questline } from '../../lib/Questline';
const id = Astro.props.id ?? Astro.params.id;
const questline = Questline.load(id);
const questIds = Quest.db
.query('select id from quests where questline_id = ? order by sort_order')
.values(id)
.map(([id]) => id as number);
---
<fieldset class="questline"
hx-trigger="claimedAll from:body, reset from:body"
hx-get={`/questline/${id}`}
hx-target="this"
hx-swap="outerHTML"
>
<legend>
<span class="viewMode">{questline.name}</span>
<input class="name editMode" name="name" value={questline.name}
hx-post=`/event/questline/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous"
hx-swap="textContent"
/>
</legend>
<QuestlineClaimRow id={id} />
{questIds.map((questId: number) => (<QuestRow id={questId} />))}
<form class="editMode addQuest" hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="name" placeholder="Quest" autofocus />
<Currency chorecoin>
<input name="reward" class="coins-reward" placeholder="0" type="number" />
</Currency>
<button
hx-put=`/event/quest/create?questline=${id}`
hx-target="closest fieldset"
hx-swap="outerHTML"
>Create</button>
</form>
</fieldset>
<style>
form.editMode {
gap: 0.5em;
input[name="name"] {
flex-grow: 1;
}
}
.questline {
display: flex;
flex-direction: column;
gap: 0.5em;
}
legend {
display: flex;
align-items: center;
gap: 1rem;
}
</style>

View file

@ -0,0 +1,64 @@
---
export const prerender = false;
export const partial = true;
import Currency from '../../components/Currency.astro';
import { Model } from '../../lib/Model';
import { Questline } from '../../lib/Questline';
const id = Astro.props?.id ?? Astro.url.searchParams.get('id');
const questline = Questline.load(id);
const allDone = Model.db.query(
'select count(*) from quests where questline_id=? and completed=false'
).values(id)[0][0] === 0;
---
<div class={ allDone ? 'allDone complete' : 'allDone incomplete' }
hx-get={`/questline/claim_row?id=${id}`}
hx-trigger={`questComplete-ql${id} from:body`}
hx-swap="outerHTML"
hx-target="this"
>
<span>
{ allDone ? 'All quests complete!' : 'Complete all quests to claim!' }
</span>
<Currency class="coins-reward viewMode" chorecoin>{questline.coinsReward}</Currency>
<Currency class="coins-reward editMode" chorecoin>
<input class="coins-reward" name="coinsReward" value={questline.coinsReward}
type="number"
hx-post=`/event/questline/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous span"
hx-swap="textContent"
/>
</Currency>
<button
disabled={!allDone || questline.claimed}
hx-post={`/event/questline/claim?id=${id}`}
hx-disabled-elt="this"
class="viewMode"
>
Claim{questline.claimed ? "ed!" : ""}
</button>
<button disabled class="editMode">
Claim{questline.claimed ? "ed!" : ""}
</button>
</div>
<style>
.allDone {
display: flex;
flex-direction: row;
width: 100%;
span {
flex-grow: 1
}
font-size: 80%;
gap: 1em;
&.incomplete {
color: #aaa;
}
}
</style>

127
src/pages/reward/list.astro Normal file
View file

@ -0,0 +1,127 @@
---
export const partial = true;
export const prerender = false;
import Currency from '../../components/Currency.astro';
import { Model } from '../../lib/Model';
import { RewardState } from '../../lib/Reward';
import { squish } from '../../lib/tags';
const rewards = Model.db.query(squish`
select id, name, link, gems_cost, chance
from rewards
where state > ${String(RewardState.deleted)}
order by sort_order
`).values() as Array<[number, string, string, number, number]>;
const bankedGems = Number.parseInt(Model.db.query(
'select ifnull(sum(gems_claimed), 0) - ifnull(sum(gems_spent), 0) from events'
).values()?.[0]?.[0], 10);
---
<div id="rewards" hx-get="/reward/list" hx-trigger="rewardEdit from:body" hx-swap="outerHTML">
<div class="header name">Reward</div>
<div class="header gems">Purchase</div>
<div class="header chance">Chance</div>
{ rewards.map(([id, name, link, gemsCost, chance]) => (
<>
<div class="name">
{ link?.length
? <a class="viewMode" href={link}>{name}</a>
: <span class="viewMode">{name}</span>
}
<div class="editMode buttons" hx-target="#rewards">
<button hx-post={`/event/reward/reorder?up&id=${id}`}>^</button>
<button hx-post={`/event/reward/delete?id=${id}`}>x</button>
<button hx-post={`/event/reward/reorder?down&id=${id}`}>v</button>
</div>
<input class="editMode" name="name" value={name}
placeholder="name"
hx-post=`/event/reward/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous .viewMode"
hx-swap="outerHTML"
/>
<input class="editMode" name="link" value={link}
placeholder="link"
hx-post=`/event/reward/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous .viewMode"
hx-swap="outerHTML"
/>
</div>
<div class="gems">
<button class="viewMode" disabled={gemsCost > bankedGems}>
<Currency gem>{gemsCost}</Currency>
</button>
<span class="editMode dollars">
<input name="dollars" placeholder="0.00" type="number" step="0.01"
value={gemsCost / 200}
hx-post=`/event/reward/edit?id=${id}`
hx-trigger="change changed"
hx-target="previous .viewMode"
hx-swap="textContent"
/>
</span>
</div>
<div class="chance">
<span class="viewMode">
{(Number.parseFloat(chance) * 100).toPrecision(2)}%
</span>
</div>
</>
))}
</div>
<style>
.header {
font-weight: bold;
}
.buttons.editMode {
flex-direction: column;
gap: 0;
font-size: x-small;
opacity: 100%;
button {
margin: 0 0 0 -1em;
}
}
#rewards {
display: grid;
grid-template-columns: 1fr max-content max-content;
margin-top: 1em;
gap: 1em;
> div {
display: flex;
gap: 1em;
}
}
.gems {
display: flex;
justify-content: flex-end;
}
input {
width: 15ch;
&[name="name"] {
flex-grow: 1;
}
&[name="dollars"] {
width: 7ch;
}
}
.dollars {
position: relative;
font-family: monospace;
}
.dollars::before {
content: "$";
position: absolute;
left: 0.25em;
}
</style>