initial commit
This commit is contained in:
commit
803205ee7f
59 changed files with 3437 additions and 0 deletions
22
src/components/Currency.astro
Normal file
22
src/components/Currency.astro
Normal 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
17
src/components/Icon.astro
Normal 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
45
src/components/Tab.astro
Normal 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
51
src/components/tabs.astro
Normal 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
182
src/layouts/Layout.astro
Normal 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
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();
|
||||
};
|
||||
95
src/pages/QuestRow.astro
Normal file
95
src/pages/QuestRow.astro
Normal 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>
|
||||
76
src/pages/[pageRoute].astro
Normal file
76
src/pages/[pageRoute].astro
Normal 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
147
src/pages/bank.astro
Normal 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>
|
||||
53
src/pages/event/page/claim_all.astro
Normal file
53
src/pages/event/page/claim_all.astro
Normal 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} />
|
||||
33
src/pages/event/page/create.astro
Normal file
33
src/pages/event/page/create.astro
Normal 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> }
|
||||
34
src/pages/event/page/reset.astro
Normal file
34
src/pages/event/page/reset.astro
Normal 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} />
|
||||
66
src/pages/event/pull.astro
Normal file
66
src/pages/event/pull.astro
Normal 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>
|
||||
</>
|
||||
26
src/pages/event/quest/claim.astro
Normal file
26
src/pages/event/quest/claim.astro
Normal 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} />
|
||||
17
src/pages/event/quest/complete.astro
Normal file
17
src/pages/event/quest/complete.astro
Normal 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} />
|
||||
34
src/pages/event/quest/create.astro
Normal file
34
src/pages/event/quest/create.astro
Normal 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} /> }
|
||||
22
src/pages/event/quest/delete.astro
Normal file
22
src/pages/event/quest/delete.astro
Normal 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}>
|
||||
20
src/pages/event/quest/edit.astro
Normal file
20
src/pages/event/quest/edit.astro
Normal 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}
|
||||
51
src/pages/event/quest/reorder.astro
Normal file
51
src/pages/event/quest/reorder.astro
Normal 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}>
|
||||
24
src/pages/event/questline/claim.astro
Normal file
24
src/pages/event/questline/claim.astro
Normal 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} />
|
||||
36
src/pages/event/questline/create.astro
Normal file
36
src/pages/event/questline/create.astro
Normal 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} /> }
|
||||
20
src/pages/event/questline/edit.astro
Normal file
20
src/pages/event/questline/edit.astro
Normal 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}
|
||||
34
src/pages/event/reward/create.astro
Normal file
34
src/pages/event/reward/create.astro
Normal 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' }
|
||||
37
src/pages/event/reward/edit.astro
Normal file
37
src/pages/event/reward/edit.astro
Normal 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)
|
||||
: '?'
|
||||
}
|
||||
50
src/pages/event/reward/reorder.astro
Normal file
50
src/pages/event/reward/reorder.astro
Normal 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
166
src/pages/gacha.astro
Normal 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 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
6
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
import QuestlinePage from './[pageRoute].astro';
|
||||
|
||||
---
|
||||
|
||||
<QuestlinePage />
|
||||
28
src/pages/page/claim_all_button.astro
Normal file
28
src/pages/page/claim_all_button.astro
Normal 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>
|
||||
18
src/pages/page/incomplete_count.astro
Normal file
18
src/pages/page/incomplete_count.astro
Normal 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}
|
||||
28
src/pages/page/reset_button.astro
Normal file
28
src/pages/page/reset_button.astro
Normal 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>
|
||||
63
src/pages/page/reset_timer.astro
Normal file
63
src/pages/page/reset_timer.astro
Normal 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>
|
||||
70
src/pages/questline/[id].astro
Normal file
70
src/pages/questline/[id].astro
Normal 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>
|
||||
64
src/pages/questline/claim_row.astro
Normal file
64
src/pages/questline/claim_row.astro
Normal 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
127
src/pages/reward/list.astro
Normal 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>
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue