kripke: add stats

This commit is contained in:
2025-09-24 20:22:19 +09:00
parent 4895dd2863
commit 3edaed9327
9 changed files with 842 additions and 186 deletions

View File

@@ -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",

View File

@@ -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" }}
/>

View 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>

View File

@@ -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⃣"];

View 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}

View File

@@ -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" }}
/>