kripke: add stats
This commit is contained in:
@@ -23,7 +23,7 @@ export const buttonVariants = tv({
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
default: "h-9 px-4 py-2 pb-2.5",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { type Formula, isomorphic, validWorlds } from "@cannorin/kripke";
|
||||
import LuRotateCw from "lucide-svelte/icons/rotate-cw";
|
||||
import LuX from "lucide-svelte/icons/x";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import FrameInput from "./components/frame-input.svelte";
|
||||
import Game, { type GameStatus } from "./components/game.svelte";
|
||||
import Rules from "./components/rules.svelte";
|
||||
import Share from "./components/share.svelte";
|
||||
|
||||
import Template from "./components/template.svelte";
|
||||
import { daily } from "./lib/store";
|
||||
import { getFrameBySeed, getTimeUntilNextGame } from "./lib/system";
|
||||
|
||||
@@ -20,14 +11,9 @@ const { id, frame } = getFrameBySeed(seed);
|
||||
const guess = (frameId: number) => isomorphic[frameId] === id;
|
||||
const check = (formula: Formula) => validWorlds(frame, formula).length;
|
||||
const getAnswer = () => id;
|
||||
const getSeed = () => seed;
|
||||
const relationSize = frame.relations.size;
|
||||
|
||||
let status: GameStatus = $state("playing");
|
||||
let dialogOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (status !== "playing") dialogOpen = true;
|
||||
});
|
||||
|
||||
let timeUntilNextGame = $state(getTimeUntilNextGame());
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -39,71 +25,31 @@ onMount(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col min-h-screen max-w-full items-center gap-12 lg:gap-16 py-8">
|
||||
<h1 class="font-display text-6xl">KRiPkE</h1>
|
||||
{#snippet title()}
|
||||
<h2 class="text-sm">
|
||||
<span class="font-bold">Daily Challenge: </span>
|
||||
{timeUntilNextGame.hours}:{timeUntilNextGame.minutes}:{timeUntilNextGame.seconds} until the next game.
|
||||
</h2>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col md:flex-row-reverse gap-x-20 gap-y-8">
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2 class="text-sm">
|
||||
<span class="font-bold">Daily Challenge: </span>
|
||||
{timeUntilNextGame.hours}:{timeUntilNextGame.minutes}:{timeUntilNextGame.seconds} until the next game.
|
||||
</h2>
|
||||
<Game
|
||||
bind:moves={$daily.moves} bind:status relationSize={relationSize}
|
||||
guess={guess} check={check} getAnswer={getAnswer}
|
||||
onShare={() => { dialogOpen = true; }} />
|
||||
</section>
|
||||
{#snippet description()}
|
||||
<h2>Daily Challenge!</h2>
|
||||
<ul>
|
||||
<li>The answer of the game changes every day.</li>
|
||||
<li>The progress of the game persists until the next day ({timeUntilNextGame.hours}:{timeUntilNextGame.minutes}:{timeUntilNextGame.seconds} from now).</li>
|
||||
<li>You can also play <a href="/kripke/random">Random Challenge</a>.</li>
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
<section class="w-[300px] prose prose-sm">
|
||||
<h2>Daily Challenge!</h2>
|
||||
<ul>
|
||||
<li>The answer of the game changes every day.</li>
|
||||
<li>The progress of the game persists until the next day ({timeUntilNextGame.hours}:{timeUntilNextGame.minutes}:{timeUntilNextGame.seconds} from now).</li>
|
||||
<li>You can also play <a href="/kripke/random">Random Challenge</a>.</li>
|
||||
</ul>
|
||||
|
||||
<Rules relationSize={relationSize} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{#if status !== "playing"}
|
||||
{#await getAnswer() then answerId}
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="animate-fade-in">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{#if status === "win"}
|
||||
YOU WIN!
|
||||
{:else if status === "lose"}
|
||||
YOU LOSE!
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
The answer was:
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col items-center w-fit rounded bg-background mx-auto">
|
||||
<span class="text-xs text-muted self-start px-2 py-1">id: {answerId}, seed: <a class="text-primary underline font-medium" href="/kripke/random/{seed}">{seed}</a></span>
|
||||
<FrameInput class="pb-6" disabled width={250} height={250} frame={frame} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="flex flex-col md:flex-row gap-2 w-full justify-end">
|
||||
<Share date={$daily.date} moves={$daily.moves} status={status} />
|
||||
<Button
|
||||
href="/kripke/random">
|
||||
<LuRotateCw class="w-4 h-4 mt-[2px]" /> Play Random Challenge
|
||||
</Button>
|
||||
<Button
|
||||
variant="foreground"
|
||||
onclick={() => (dialogOpen = false)}>
|
||||
<LuX class="w-4 h-4 mt-[2px]" /> Close
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/await}
|
||||
{/if}
|
||||
<Template
|
||||
bind:moves={$daily.moves}
|
||||
relationSize={relationSize}
|
||||
guess={guess}
|
||||
check={check}
|
||||
getAnswer={getAnswer}
|
||||
getSeed={getSeed}
|
||||
getShareProps={(moves, status) => ({ moves, status, date: $daily.date })}
|
||||
title={title}
|
||||
description={description}
|
||||
newGame={{ href: "/kripke/random", text: "Play Random Challenge" }}
|
||||
/>
|
||||
|
||||
63
apps/web/src/routes/kripke/components/chart.svelte
Normal file
63
apps/web/src/routes/kripke/components/chart.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { scaleLog } from "d3-scale";
|
||||
|
||||
import { Area, AreaChart, Axis, Labels, Points, Svg } from "layerchart";
|
||||
|
||||
export type Props = {
|
||||
playerData: number[];
|
||||
};
|
||||
|
||||
let { playerData }: Props = $props();
|
||||
|
||||
const data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ({
|
||||
move: i,
|
||||
player: playerData[i] ?? 1,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<AreaChart
|
||||
data={data}
|
||||
x="move"
|
||||
series={[
|
||||
{
|
||||
key: "player",
|
||||
label: "You",
|
||||
color: "rgb(var(--primary))"
|
||||
},
|
||||
]}
|
||||
seriesLayout="overlap"
|
||||
yScale={scaleLog()}
|
||||
yDomain={[1, 1000]}
|
||||
renderContext="svg">
|
||||
<Svg>
|
||||
<Axis
|
||||
placement="left"
|
||||
tickLength={10}
|
||||
grid={{ ["class"]: "stroke-muted", style: "stroke-dasharray: 2" }}
|
||||
ticks={[1,10,100,1000]}
|
||||
class="fill-foreground" />
|
||||
<Axis
|
||||
placement="bottom"
|
||||
tickLength={10}
|
||||
class="fill-foreground" />
|
||||
|
||||
<Area
|
||||
y1={(d) => d.player}
|
||||
fill={"rgb(var(--primary))"}
|
||||
fillOpacity={0.3}
|
||||
line={{ class: "stroke-2", stroke: "rgb(var(--primary))" }}
|
||||
/>
|
||||
<Points
|
||||
y={(d) => d.player}
|
||||
r={9}
|
||||
class="fill-primary"
|
||||
/>
|
||||
<Labels
|
||||
y={(d) => d.player}
|
||||
placement="center"
|
||||
offset={-1.1}
|
||||
format={(x) => x}
|
||||
class="text-[10px] fill-background font-bold"
|
||||
/>
|
||||
</Svg>
|
||||
</AreaChart>
|
||||
@@ -5,15 +5,12 @@ import SiMisskey from "@icons-pack/svelte-simple-icons/icons/SiMisskey";
|
||||
import SiX from "@icons-pack/svelte-simple-icons/icons/SiX";
|
||||
import type { GameStatus, Move } from "./game.svelte";
|
||||
|
||||
let {
|
||||
date,
|
||||
moves,
|
||||
status,
|
||||
seed,
|
||||
}: { moves: Move[]; status: GameStatus } & (
|
||||
export type ShareProps = { moves: Move[]; status: GameStatus } & (
|
||||
| { date: string; seed?: undefined }
|
||||
| { seed: number; date?: undefined }
|
||||
) = $props();
|
||||
);
|
||||
|
||||
let { date, moves, status, seed }: ShareProps = $props();
|
||||
|
||||
const numberEmojis = ["0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣"];
|
||||
|
||||
|
||||
172
apps/web/src/routes/kripke/components/template.svelte
Normal file
172
apps/web/src/routes/kripke/components/template.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
|
||||
import {
|
||||
type Formula,
|
||||
getFrame,
|
||||
nontrivials,
|
||||
tryParse,
|
||||
validWorlds,
|
||||
} from "@cannorin/kripke";
|
||||
import Game, { type GameStatus, type Move } from "./game.svelte";
|
||||
|
||||
import LuRotateCw from "lucide-svelte/icons/rotate-cw";
|
||||
import LuX from "lucide-svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import Chart from "./chart.svelte";
|
||||
import FrameInput from "./frame-input.svelte";
|
||||
import Rules from "./rules.svelte";
|
||||
import Share, { type ShareProps } from "./share.svelte";
|
||||
|
||||
export type Props = {
|
||||
status?: GameStatus;
|
||||
moves?: Move[];
|
||||
relationSize: number;
|
||||
guess: (frameId: number) => boolean | Promise<boolean>;
|
||||
check: (formula: Formula) => number | Promise<number>;
|
||||
getAnswer: () => number | Promise<number>;
|
||||
getSeed?: () => number | Promise<number>;
|
||||
|
||||
getShareProps: (
|
||||
moves: Move[],
|
||||
status: GameStatus,
|
||||
) => ShareProps | Promise<ShareProps>;
|
||||
title: Snippet;
|
||||
description: Snippet;
|
||||
newGame: { href: string; text: string };
|
||||
};
|
||||
|
||||
let {
|
||||
status = $bindable<GameStatus>("playing"),
|
||||
moves = $bindable<Move[]>([]),
|
||||
relationSize,
|
||||
guess,
|
||||
check,
|
||||
getAnswer,
|
||||
getSeed,
|
||||
getShareProps,
|
||||
title,
|
||||
description,
|
||||
newGame,
|
||||
}: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (status !== "playing") dialogOpen = true;
|
||||
});
|
||||
|
||||
function evaluate() {
|
||||
let frames = nontrivials
|
||||
.map((id) => ({ id, frame: getFrame(id) }))
|
||||
.filter((f) => f.frame.relations.size === relationSize);
|
||||
if (frames.length === 0) return undefined;
|
||||
|
||||
const res: number[] = [frames.length];
|
||||
for (const move of moves) {
|
||||
if (move.type === "guess") {
|
||||
frames = frames.filter((f) => f.id !== move.frameId);
|
||||
}
|
||||
if (move.type === "check") {
|
||||
if (move.valid < 0 || move.valid > 4) return undefined;
|
||||
const formula = tryParse(move.formulaStr);
|
||||
if (!formula) return undefined;
|
||||
frames = frames.filter(
|
||||
(f) => validWorlds(f.frame, formula).length === move.valid,
|
||||
);
|
||||
}
|
||||
|
||||
if (frames.length === 0) return undefined;
|
||||
res.push(frames.length);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col min-h-screen max-w-full items-center gap-12 lg:gap-16 py-8">
|
||||
<h1 class="font-display text-6xl">KRiPkE</h1>
|
||||
|
||||
<div class="flex flex-col md:flex-row-reverse gap-x-20 gap-y-8">
|
||||
<section class="flex flex-col gap-2">
|
||||
{@render title()}
|
||||
<Game
|
||||
bind:moves bind:status relationSize={relationSize}
|
||||
guess={guess} check={check} getAnswer={getAnswer}
|
||||
onShare={() => { dialogOpen = true; }}/>
|
||||
</section>
|
||||
|
||||
<section class="w-[300px] prose prose-sm">
|
||||
{@render description()}
|
||||
|
||||
<Rules relationSize={relationSize} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{#if status !== "playing"}
|
||||
{#await getAnswer() then answerId}
|
||||
{#await getShareProps(moves, status) then shareProps}
|
||||
{@const answerFrame = getFrame(answerId)}
|
||||
{@const playerData = evaluate()}
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="animate-fade-in">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{#if status === "win"}
|
||||
YOU WIN!
|
||||
{:else if status === "lose"}
|
||||
YOU LOSE!
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
The answer was:
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col items-center w-full bg-background mx-auto">
|
||||
<div class="w-fit">
|
||||
<span class="text-xs text-muted self-start px-2 py-1">
|
||||
{#if getSeed}
|
||||
{#await getSeed() then seed}
|
||||
id: {answerId}, seed: <a class="text-primary underline font-medium" href="/kripke/random/{seed}">{seed}</a>
|
||||
{/await}
|
||||
{:else}
|
||||
id: {answerId}
|
||||
{/if}
|
||||
</span>
|
||||
<FrameInput disabled width={250} height={250} frame={answerFrame} />
|
||||
</div>
|
||||
<div class="w-full h-[125px] md:h-[200px] px-2 flex flex-col">
|
||||
<span class="text-xs pb-2">
|
||||
Statistics (frames left / moves)
|
||||
</span>
|
||||
{#if playerData}
|
||||
<Chart playerData={playerData} />
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
Invalid play data
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="flex flex-col md:flex-row gap-2 w-full justify-end">
|
||||
<Share {...shareProps} />
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
href={newGame.href}>
|
||||
<LuRotateCw class="w-4 h-4 mt-[2px]" /> {newGame.text}
|
||||
</Button>
|
||||
<Button
|
||||
variant="foreground"
|
||||
onclick={() => (dialogOpen = false)}>
|
||||
<LuX class="w-4 h-4 mt-[2px]" /> Close
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/await}
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -1,14 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { type Formula, isomorphic, validWorlds } from "@cannorin/kripke";
|
||||
import LuRotateCw from "lucide-svelte/icons/rotate-cw";
|
||||
import LuX from "lucide-svelte/icons/x";
|
||||
|
||||
import FrameInput from "../../components/frame-input.svelte";
|
||||
import Game, { type GameStatus, type Move } from "../../components/game.svelte";
|
||||
import Rules from "../../components/rules.svelte";
|
||||
import Share from "../../components/share.svelte";
|
||||
import Template from "../../components/template.svelte";
|
||||
import { getFrameBySeed } from "../../lib/system";
|
||||
|
||||
let { data } = $props();
|
||||
@@ -17,88 +9,39 @@ const { id, frame } = getFrameBySeed(seed);
|
||||
const guess = (frameId: number) => isomorphic[frameId] === id;
|
||||
const check = (formula: Formula) => validWorlds(frame, formula).length;
|
||||
const getAnswer = () => id;
|
||||
const getSeed = () => seed;
|
||||
const relationSize = frame.relations.size;
|
||||
|
||||
let moves: Move[] = $state([]);
|
||||
let status: GameStatus = $state("playing");
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
$effect(() => {
|
||||
if (status !== "playing") dialogOpen = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet seedNumber()}
|
||||
<a class="text-primary font-medium underline" href={`/kripke/random/${seed}`}>{seed}</a>
|
||||
{/snippet}
|
||||
|
||||
<main class="flex flex-col min-h-screen max-w-full items-center gap-12 lg:gap-16 py-8">
|
||||
<h1 class="font-display text-6xl">KRiPkE</h1>
|
||||
{#snippet title()}
|
||||
<h2 class="text-sm">
|
||||
<span class="font-bold">Random Challenge</span>
|
||||
<span>(seed: {@render seedNumber()})</span>
|
||||
</h2>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col md:flex-row-reverse gap-x-20 gap-y-8">
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2 class="text-sm">
|
||||
<span class="font-bold">Random Challenge</span>
|
||||
<span>(seed: {@render seedNumber()})</span>
|
||||
</h2>
|
||||
<Game
|
||||
bind:moves bind:status relationSize={relationSize}
|
||||
guess={guess} check={check} getAnswer={getAnswer}
|
||||
onShare={() => { dialogOpen = true; }}/>
|
||||
</section>
|
||||
{#snippet description()}
|
||||
<h2>Random Challenge!</h2>
|
||||
<ul>
|
||||
<li>The answer of the game is determined by a seed number {@render seedNumber()}.</li>
|
||||
<li>You can right-click on the seed number to obtain a permalink to this exact game.</li>
|
||||
<li>Unlike Daily Challenge, the progress of the game does not persist.</li>
|
||||
<li>You can also play <a href="/kripke">Daily Challenge</a>, if you have not yet.</li>
|
||||
</ul>
|
||||
{/snippet}
|
||||
|
||||
<section class="w-[300px] prose prose-sm">
|
||||
<h2>Random Challenge!</h2>
|
||||
<ul>
|
||||
<li>The answer of the game is determined by a seed number {@render seedNumber()}.</li>
|
||||
<li>You can right-click on the seed number to obtain a permalink to this exact game.</li>
|
||||
<li>Unlike Daily Challenge, the progress of the game does not persist.</li>
|
||||
<li>You can also play <a href="/kripke">Daily Challenge</a>, if you have not yet.</li>
|
||||
</ul>
|
||||
|
||||
<Rules relationSize={relationSize} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{#if status !== "playing"}
|
||||
{#await getAnswer() then answerId}
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="animate-fade-in">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{#if status === "win"}
|
||||
YOU WIN!
|
||||
{:else if status === "lose"}
|
||||
YOU LOSE!
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
The answer was:
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col items-center w-fit rounded bg-background mx-auto">
|
||||
<span class="text-xs text-muted self-start px-2 py-1">id: {answerId}, seed: <a class="text-primary underline font-medium" href="/kripke/random/{seed}">{seed}</a></span>
|
||||
<FrameInput class="pb-6" disabled width={250} height={250} frame={frame} />
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<div class="flex flex-col md:flex-row gap-2 w-full justify-end">
|
||||
<Share seed={seed} moves={moves} status={status} />
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
href="/kripke/random">
|
||||
<LuRotateCw class="w-4 h-4 mt-[2px]" /> Play New Game
|
||||
</Button>
|
||||
<Button
|
||||
variant="foreground"
|
||||
onclick={() => (dialogOpen = false)}>
|
||||
<LuX class="w-4 h-4 mt-[2px]" /> Close
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/await}
|
||||
{/if}
|
||||
<Template
|
||||
relationSize={relationSize}
|
||||
guess={guess}
|
||||
check={check}
|
||||
getAnswer={getAnswer}
|
||||
getSeed={getSeed}
|
||||
getShareProps={(moves, status) => ({ moves, status, seed })}
|
||||
title={title}
|
||||
description={description}
|
||||
newGame={{ href: "/kripke/random", text: "Play New Game" }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user