Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/lib/components/daily/guess-input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<script lang="ts">
import { Input } from "$lib/components/ui/input/index.js";
import { DEFAULT_QUERY, queryDex, type DexListEntry } from "$lib/dex/list.js";
import { cn } from "$lib/utils.js";

let {
pool,
exclude,
disabled = false,
onguess,
}: {
pool: DexListEntry[];
exclude: Set<number>;
disabled?: boolean;
onguess: (entry: DexListEntry) => void;
} = $props();

let text = $state("");
let highlighted = $state(0);
let input = $state<HTMLInputElement | null>(null);

// Only names are searched here; filters like spe>100 would give the answer away.
const options = $derived(
text.trim() && !/[:<>=]/.test(text)
? queryDex(pool, { ...DEFAULT_QUERY, q: text })
.filter((e) => !exclude.has(e.id))
.slice(0, 6)
: [],
);

function submit(entry: DexListEntry | undefined) {
if (!entry) return;
onguess(entry);
text = "";
highlighted = 0;
input?.focus();
}

function onkeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") highlighted = Math.min(options.length - 1, highlighted + 1);
else if (event.key === "ArrowUp") highlighted = Math.max(0, highlighted - 1);
else if (event.key === "Enter") submit(options[highlighted]);
else if (event.key === "Escape") text = "";
else return;
event.preventDefault();
}
</script>

<div class="relative">
<Input
bind:ref={input}
bind:value={text}
oninput={() => (highlighted = 0)}
{onkeydown}
{disabled}
role="combobox"
aria-expanded={options.length > 0}
aria-controls="daily-options"
aria-activedescendant={options.length ? `daily-option-${options[highlighted]?.id}` : undefined}
aria-autocomplete="list"
aria-label="Guess a Pokémon"
placeholder={disabled ? "Come back tomorrow for a new Pokémon" : "Type a Pokémon’s name"}
autocomplete="off"
spellcheck={false}
class="h-11 rounded-lg bg-card text-base md:text-base"
/>
{#if options.length}
<ul id="daily-options" role="listbox" aria-label="Matching Pokémon" class="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-lg border bg-popover py-1 shadow-lg">
{#each options as entry, i (entry.id)}
<li
id="daily-option-{entry.id}"
role="option"
aria-selected={i === highlighted}
class={cn("cursor-pointer px-3 py-2", i === highlighted && "bg-muted")}
onpointerenter={() => (highlighted = i)}
onpointerdown={(event) => {
event.preventDefault();
submit(entry);
}}
>
{entry.name}
</li>
{/each}
</ul>
{/if}
</div>
2 changes: 1 addition & 1 deletion src/lib/components/site/search-trigger.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
variant="outline"
size="sm"
onclick={() => (palette.open = true)}
class="hidden w-60 justify-start gap-2 bg-background text-muted-foreground sm:flex dark:bg-background"
class="hidden w-60 justify-start gap-2 lg:w-44 xl:w-60 bg-background text-muted-foreground sm:flex dark:bg-background"
>
<SearchIcon />
Search Pokémon
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/site/site-header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
</script>

<header class="sticky top-0 z-40 border-b bg-card">
<div class="mx-auto flex h-14 max-w-7xl items-center gap-8 px-4 sm:px-6">
<div class="mx-auto flex h-14 max-w-7xl items-center gap-6 px-4 sm:px-6 xl:gap-8">
<a href="/" class="flex items-center gap-2 rounded-md" aria-label="Dexa home">
<Logo class="size-6" />
<span class="font-display text-lg font-bold tracking-tight">dexa</span>
</a>

<nav aria-label="Main" class="hidden self-stretch lg:block">
<ul class="flex h-full items-stretch gap-5">
<ul class="flex h-full items-stretch gap-4 xl:gap-5">
{#each navItems as item (item.href)}
<li>
<a
Expand Down
67 changes: 67 additions & 0 deletions src/lib/daily/game.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vite-plus/test";
import { answerFor, compareGuess, dateKey, puzzleNumber, shareText } from "./game.js";
import type { BaseStats, TypeName } from "$lib/pokemon/types.js";

const mon = (
types: TypeName[],
generation: number,
height: number,
weight: number,
stats: BaseStats,
) => ({
types,
generation,
height,
weight,
stats,
});

const charizard = mon(["fire", "flying"], 1, 17, 905, [78, 84, 78, 109, 85, 100]);
const talonflame = mon(["fire", "flying"], 6, 12, 245, [78, 81, 71, 74, 69, 126]);
const moltres = mon(["fire", "flying"], 1, 20, 600, [90, 100, 90, 125, 85, 90]);
const pidgeot = mon(["normal", "flying"], 1, 15, 395, [83, 80, 75, 70, 70, 101]);
const gyarados = mon(["water", "flying"], 1, 65, 2350, [95, 125, 79, 60, 100, 81]);

describe("puzzle dates", () => {
it("numbers puzzles by local calendar day", () => {
expect(puzzleNumber(new Date(2026, 8, 28, 0, 5))).toBe(1);
expect(puzzleNumber(new Date(2026, 8, 28, 23, 55))).toBe(1);
expect(puzzleNumber(new Date(2026, 9, 1, 9))).toBe(4);
expect(dateKey(new Date(2026, 0, 5))).toBe("2026-01-05");
});
});

describe("answerFor", () => {
const pool = Array.from({ length: 50 }, (_, i) => ({ speciesId: i + 1 }));

it("is the same for everyone on a day and cycles through every species", () => {
expect(answerFor(7, [...pool].reverse())).toEqual(answerFor(7, pool));
const seen = new Set(Array.from({ length: 50 }, (_, i) => answerFor(i + 1, pool).speciesId));
expect(seen.size).toBe(50);
expect(answerFor(51, pool)).toBe(answerFor(1, pool));
});
});

describe("compareGuess", () => {
const hints = (guess: ReturnType<typeof mon>) =>
compareGuess(guess, charizard).map((c) => c.hint);

it("marks exact, misplaced and missing types", () => {
expect(hints(talonflame).slice(0, 2)).toEqual(["match", "match"]);
expect(hints(pidgeot).slice(0, 2)).toEqual(["miss", "match"]);
expect(compareGuess(mon(["flying"], 1, 1, 1, charizard.stats), charizard)[0].hint).toBe("near");
});

it("points toward higher or lower numbers", () => {
expect(hints(talonflame).slice(2)).toEqual(["lower", "higher", "higher", "higher"]);
expect(hints(moltres).slice(2)).toEqual(["match", "lower", "higher", "lower"]);
expect(hints(gyarados)[3]).toBe("lower");
});
});

describe("shareText", () => {
it("summarises guesses without names", () => {
const rows = [compareGuess(pidgeot, charizard), compareGuess(charizard, charizard)];
expect(shareText(3, rows, true)).toBe("Dexa daily #3 2/8\n⬛🟩🟩🔼🔼🔼\n🟩🟩🟩🟩🟩🟩");
});
});
127 changes: 127 additions & 0 deletions src/lib/daily/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { DexListEntry } from "$lib/dex/list.js";
import { statTotal } from "$lib/dex/list.js";
import { titleCase } from "$lib/pokemon/format.js";

export const MAX_GUESSES = 8;

/** Puzzle 1 was played on this local date. */
const FIRST_DAY = Date.UTC(2026, 8, 28);
const DAY = 86_400_000;

/** Local calendar date as YYYY-MM-DD, so the puzzle turns over at each player's midnight. */
export function dateKey(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

export function puzzleNumber(date: Date): number {
const local = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
return Math.floor((local - FIRST_DAY) / DAY) + 1;
}

function mulberry32(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
};
}

/**
* The answer for a puzzle. A fixed shuffle of the whole dex is walked one step a day, so no
* Pokémon repeats until every species has had its turn.
*/
export function answerFor<T extends Pick<DexListEntry, "speciesId">>(
puzzle: number,
pool: readonly T[],
): T {
const sorted = [...pool].sort((a, b) => a.speciesId - b.speciesId);
const random = mulberry32(0xdec5a);
for (let i = sorted.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[sorted[i], sorted[j]] = [sorted[j], sorted[i]];
}
const index = (((puzzle - 1) % sorted.length) + sorted.length) % sorted.length;
return sorted[index];
}

/** match: same; near: the type is the answer's other type; higher/lower: the answer's value is. */
export type Hint = "match" | "near" | "miss" | "higher" | "lower";

export interface Clue {
key: "type1" | "type2" | "generation" | "height" | "weight" | "total";
label: string;
value: string;
hint: Hint;
}

type Guessable = Pick<DexListEntry, "types" | "generation" | "height" | "weight" | "stats">;

function numeric(guess: number, answer: number): Hint {
if (guess === answer) return "match";
return answer > guess ? "higher" : "lower";
}

function typeHint(guess: Guessable, answer: Guessable, slot: 0 | 1): Hint {
const type = guess.types[slot];
if (type === answer.types[slot]) return "match";
if (type && answer.types.includes(type)) return "near";
return "miss";
}

export function compareGuess(guess: Guessable, answer: Guessable): Clue[] {
return [
{
key: "type1",
label: "Type 1",
value: titleCase(guess.types[0]),
hint: typeHint(guess, answer, 0),
},
{
key: "type2",
label: "Type 2",
value: guess.types[1] ? titleCase(guess.types[1]) : "None",
hint: typeHint(guess, answer, 1),
},
{
key: "generation",
label: "Gen",
value: String(guess.generation),
hint: numeric(guess.generation, answer.generation),
},
{
key: "height",
label: "Height",
value: `${guess.height / 10} m`,
hint: numeric(guess.height, answer.height),
},
{
key: "weight",
label: "Weight",
value: `${guess.weight / 10} kg`,
hint: numeric(guess.weight, answer.weight),
},
{
key: "total",
label: "Stat total",
value: String(statTotal(guess)),
hint: numeric(statTotal(guess), statTotal(answer)),
},
];
}

const EMOJI: Record<Hint, string> = {
match: "🟩",
near: "🟨",
miss: "⬛",
higher: "🔼",
lower: "🔽",
};

/** A spoiler-free summary to paste into a chat. */
export function shareText(puzzle: number, rows: Clue[][], won: boolean): string {
const score = won ? `${rows.length}/${MAX_GUESSES}` : `X/${MAX_GUESSES}`;
const grid = rows.map((row) => row.map((clue) => EMOJI[clue.hint]).join("")).join("\n");
return `Dexa daily #${puzzle} ${score}\n${grid}`;
}
1 change: 1 addition & 0 deletions src/lib/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export const navItems: NavItem[] = [
{ href: "/types", label: "Types" },
{ href: "/team", label: "Team builder" },
{ href: "/compare", label: "Compare" },
{ href: "/daily", label: "Daily" },
{ href: "/quiz", label: "Quiz" },
];
Loading
Loading