diff --git a/src/projects/elemental-showdown/ElementalShowdown.test.tsx b/src/projects/elemental-showdown/ElementalShowdown.test.tsx index 1bd507a..1fd7e7d 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.test.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.test.tsx @@ -13,10 +13,17 @@ import type { NextMatchup } from "./matchup-selection"; import { REVEAL_LINGER_MS } from "./Reveal"; import type { ShowdownElement } from "./schema"; import type { RateWindow, VoteCast } from "./showdown-schema"; +import { statsElementOf } from "./showdown-stats"; +import { UNLOCK_MS } from "./UnlockMoment"; // The route's only contribution is the Matchup the loader drew and the two -// server functions, so the tests hand those in. -vi.mock("@tanstack/react-router", () => import("@/test/router-stub")); +// server functions, so the tests hand those in. `vi.mock` is hoisted above +// this declaration, so the factory has to read it lazily. +const navigate = vi.fn(); +vi.mock("@tanstack/react-router", async () => ({ + ...(await import("@/test/router-stub")), + useNavigate: () => navigate, +})); const element = ( id: number, @@ -89,7 +96,9 @@ const cast = (value: number) => ({ afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); vi.useRealTimers(); + navigate.mockClear(); }); describe("the Tug as a slider", () => { @@ -259,6 +268,69 @@ describe("after a Vote", () => { }); }); +describe("the Vote that unlocks the Stats", () => { + const THE_ROSTER = [fire, water, plant].map(statsElementOf); + + // The reveal is read out as ever; the moment comes after it, on the clock + // the draining bar runs on. + async function castTheUnlockingVote() { + vi.useFakeTimers(); + showdown({ + castVote: vi.fn(async () => + reveal({ unlockedElements: THE_ROSTER }), + ), + }); + fireEvent.click(pill()); + await act(async () => {}); + await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS)); + } + + it("flips a tile to every Element and stamps the screen before the Stats", async () => { + await castTheUnlockingVote(); + + expect(screen.getByText("stats unlocked")).toBeInTheDocument(); + for (const element of THE_ROSTER) + expect(screen.getByText(element.emoji)).toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + + await act(async () => void vi.advanceTimersByTime(UNLOCK_MS)); + + expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" }); + }); + + it("goes on showing Matchups when the Vote unlocked nothing", async () => { + vi.useFakeTimers(); + showdown(); + fireEvent.click(pill()); + await act(async () => {}); + + await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS)); + + expect(screen.getByText("plant")).toBeInTheDocument(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("skips the wave for a Voter who asks for less motion", async () => { + vi.stubGlobal("matchMedia", (media: string) => ({ + matches: media === "(prefers-reduced-motion: reduce)", + })); + + await castTheUnlockingVote(); + + expect(screen.queryByText("stats unlocked")).toBeNull(); + expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" }); + }); + + it("opens the Stats on a browser with no Vibration API", async () => { + expect(navigator.vibrate).toBeUndefined(); + + await castTheUnlockingVote(); + await act(async () => void vi.advanceTimersByTime(UNLOCK_MS)); + + expect(navigate).toHaveBeenCalledWith({ to: "/elemental-showdown/stats" }); + }); +}); + describe("a Vote the address has no room left for", () => { it("says slow down and keeps the Matchup up", async () => { const user = userEvent.setup(); diff --git a/src/projects/elemental-showdown/ElementalShowdown.tsx b/src/projects/elemental-showdown/ElementalShowdown.tsx index 1303767..d3a3523 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.tsx @@ -1,4 +1,4 @@ -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { INK, PAPER, textOn } from "./element-colour"; import type { VoteCastResult, VoteLimited, VoteReveal } from "./matchup-score"; @@ -6,7 +6,15 @@ import type { NextMatchup } from "./matchup-selection"; import { PrimaryLink } from "./PrimaryLink"; import { ShareButton } from "./ShareButton"; import type { VoteCast, VoteValue } from "./showdown-schema"; +import type { StatsElement } from "./showdown-stats"; import { Tug } from "./Tug"; +import { UnlockMoment } from "./UnlockMoment"; + +// The wave is motion a visitor can ask not to be shown; asked, the Stats take +// the screen at once instead. `matchMedia` belongs to a browser, and this is +// read only when a Vote lands in one. +const prefersReducedMotion = () => + window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true; // The whole screen: one Matchup at a time, in the Project's own full-height // layout rather than the Portfolio's shell. @@ -19,10 +27,13 @@ export function ElementalShowdown({ castVote: (options: { data: VoteCast }) => Promise; nextMatchup: () => Promise; }) { + const navigate = useNavigate(); const [matchup, setMatchup] = useState(shown); const [reveal, setReveal] = useState(null); const [limited, setLimited] = useState(null); const [votesCast, setVotesCast] = useState(0); + // The Elements of the moment being played, once a Vote has unlocked them. + const [unlocking, setUnlocking] = useState(null); // A ref for the guard: a second Enter can arrive before a re-render would // have told it the first Vote is already on its way. const casting = useRef(false); @@ -58,8 +69,18 @@ export function ElementalShowdown({ }); } + const openStats = useCallback(() => { + void navigate({ to: "/elemental-showdown/stats" }); + }, [navigate]); + // The reveal's own clock calls this, and so does a Voter who has read enough. const advance = useCallback(() => { + // The Stats this Vote earned take the screen rather than the next Matchup. + if (reveal?.unlockedElements) { + if (prefersReducedMotion()) openStats(); + else setUnlocking(reveal.unlockedElements); + return; + } const drawn = drawing.current; if (!drawn) return; drawing.current = null; @@ -73,7 +94,10 @@ export function ElementalShowdown({ // The reveal stays up rather than an empty screen taking its place. console.error(new Error("drawing the next Matchup failed", { cause })); }); - }, []); + }, [reveal, openStats]); + + if (unlocking) + return ; return (
+ (place % MOSAIC_COLUMNS) + Math.floor(place / MOSAIC_COLUMNS) * 2; + +// The Stats opening on the screen the Vote that opened them was cast on: the +// locked screen's blank mosaic, a tile per Active Element, flips to its own +// Element in a diagonal wave, and the stamp lands under it. +export function UnlockMoment({ + elements, + onDone, +}: { + elements: StatsElement[]; + onDone: () => void; +}) { + const [hasFlipped, setFlipped] = useState(false); + + // A frame on the blank mosaic, so the wave is seen crossing it rather than + // having already crossed. + useEffect(() => { + const flipping = requestAnimationFrame(() => setFlipped(true)); + return () => cancelAnimationFrame(flipping); + }, []); + + useEffect(() => { + navigator.vibrate?.(UNLOCK_BUZZ); + const opening = setTimeout(onDone, UNLOCK_MS); + return () => clearTimeout(opening); + }, [onDone]); + + return ( +
+
+ {elements.map((element, place) => { + const flipDelay = `${waveStepsTo(place) * WAVE_STEP_MS}ms`; + return ( + + + {element.emoji} + + + ); + })} +
+

+ stats unlocked +

+
+ ); +} diff --git a/src/projects/elemental-showdown/elemental-showdown.server.test.ts b/src/projects/elemental-showdown/elemental-showdown.server.test.ts index 060d432..e81952f 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.test.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.test.ts @@ -40,6 +40,43 @@ async function insertElement( const storedVotes = () => db.select().from(votes); +type Matchup = { elementLow: number; elementHigh: number }; + +const A_WEAK_WIN: VoteValue = 1; + +// Seven Elements give 21 Matchups, more than the Voter's own number, so each +// of their Votes below is on a Matchup of its own. +const ELEMENTS_FOR_THE_GATE = 7; + +async function insertMatchups(elementCount: number) { + const ids: number[] = []; + for (let at = 0; at < elementCount; at++) + ids.push(await insertElement(`element ${at}`)); + return ids.flatMap((elementLow, at) => + ids.slice(at + 1).map((elementHigh) => ({ elementLow, elementHigh })), + ); +} + +// The Voter takes a Matchup each; the rest of the crowd piles onto the first +// of them, one Voter apiece so that every Vote stands. +async function castVotes( + matchups: Matchup[], + { own, everyone }: { own: number; everyone: number }, +) { + const [crowded] = matchups; + if (!crowded) throw new Error("no Matchup to vote on"); + await db.insert(votes).values([ + ...matchups + .slice(0, own) + .map((matchup) => ({ voter: VOTER, ...matchup, value: A_WEAK_WIN })), + ...Array.from({ length: everyone - own }, () => ({ + voter: crypto.randomUUID(), + ...crowded, + value: A_WEAK_WIN, + })), + ]); +} + describe("castVote", () => { it("stores a Vote cast with the higher-id Element on top the way round the Matchup is kept, sign and all", async () => { const fire = await insertElement("fire"); @@ -172,6 +209,98 @@ describe("castVote", () => { }); }); +describe("the Vote that unlocks the Stats", () => { + // Both numbers one Vote short, with a Matchup left for the Voter to cast the + // Vote that crosses them on. + async function oneVoteShortOfBoth() { + const matchups = await insertMatchups(ELEMENTS_FOR_THE_GATE); + await castVotes(matchups, { + own: OWN_VOTES_TO_UNLOCK - 1, + everyone: EVERY_VOTES_TO_UNLOCK - 1, + }); + return { + matchups, + unlocking: theVoteOn(matchups, OWN_VOTES_TO_UNLOCK - 1), + }; + } + + function theVoteOn(matchups: Matchup[], at: number) { + const unvoted = matchups[at]; + if (!unvoted) throw new Error(`no Matchup ${at} to vote on`); + return { + topElementId: unvoted.elementLow, + bottomElementId: unvoted.elementHigh, + value: A_WEAK_WIN, + }; + } + + it("carries the Active Elements the wave flips a tile for", async () => { + const { unlocking } = await oneVoteShortOfBoth(); + + const reveal = await castVote(VOTER, unlocking); + + expect(reveal.unlockedElements).toHaveLength(ELEMENTS_FOR_THE_GATE); + expect(reveal.unlockedElements?.[0]).toEqual({ + id: expect.any(Number), + name: "element 0", + emoji: "🔥", + colour: "#f2541b", + }); + }); + + it("says nothing of an unlock on the Vote before both numbers are met", async () => { + const matchups = await insertMatchups(ELEMENTS_FOR_THE_GATE); + await castVotes(matchups, { + own: OWN_VOTES_TO_UNLOCK - 2, + everyone: EVERY_VOTES_TO_UNLOCK - 2, + }); + + const reveal = await castVote( + VOTER, + theVoteOn(matchups, OWN_VOTES_TO_UNLOCK - 2), + ); + + expect(reveal.unlockedElements).toBeUndefined(); + }); + + it("says nothing of an unlock on the Vote after it", async () => { + const { matchups, unlocking } = await oneVoteShortOfBoth(); + await castVote(VOTER, unlocking); + + const reveal = await castVote( + VOTER, + theVoteOn(matchups, OWN_VOTES_TO_UNLOCK), + ); + + expect(reveal.unlockedElements).toBeUndefined(); + }); + + it("says nothing of an unlock on a repeat Vote that stored nothing", async () => { + const { unlocking } = await oneVoteShortOfBoth(); + await castVote(VOTER, unlocking); + + const reveal = await castVote(VOTER, unlocking); + + expect(reveal.unlockedElements).toBeUndefined(); + }); + + it("opens the Stats to the Voter the moment their unlocking Vote lands", async () => { + const { unlocking } = await oneVoteShortOfBoth(); + // The locked screen the Voter is reading holds the crowd's number for a + // minute, and their unlocking Vote arrives inside it. + const whileHeldMs = nextRead(); + expect(await showdownStats(VOTER, whileHeldMs)).toMatchObject({ + state: "locked", + }); + + await castVote(VOTER, unlocking); + + expect(await showdownStats(VOTER, whileHeldMs)).toMatchObject({ + state: "unlocked", + }); + }); +}); + // The limiter is one per Machine, so each test here brings an address of its // own rather than spending another test's cap. describe("castVoteFromAddress", () => { @@ -281,43 +410,6 @@ describe("showdownAggregate", () => { }); describe("showdownStats", () => { - type Matchup = { elementLow: number; elementHigh: number }; - - const A_WEAK_WIN: VoteValue = 1; - - // Seven Elements give 21 Matchups, more than the Voter's own number, so each - // of their Votes below is on a Matchup of its own. - const ELEMENTS_FOR_THE_GATE = 7; - - async function insertMatchups(elementCount: number) { - const ids: number[] = []; - for (let at = 0; at < elementCount; at++) - ids.push(await insertElement(`element ${at}`)); - return ids.flatMap((elementLow, at) => - ids.slice(at + 1).map((elementHigh) => ({ elementLow, elementHigh })), - ); - } - - // The Voter takes a Matchup each; the rest of the crowd piles onto the first - // of them, one Voter apiece so that every Vote stands. - async function castVotes( - matchups: Matchup[], - { own, everyone }: { own: number; everyone: number }, - ) { - const [crowded] = matchups; - if (!crowded) throw new Error("no Matchup to vote on"); - await db.insert(votes).values([ - ...matchups - .slice(0, own) - .map((matchup) => ({ voter: VOTER, ...matchup, value: A_WEAK_WIN })), - ...Array.from({ length: everyone - own }, () => ({ - voter: crypto.randomUUID(), - ...crowded, - value: A_WEAK_WIN, - })), - ]); - } - // The gate is met exactly, so every test below reads the open Stats. async function unlockFor(elementCount: number) { const matchups = await insertMatchups(elementCount); diff --git a/src/projects/elemental-showdown/elemental-showdown.server.ts b/src/projects/elemental-showdown/elemental-showdown.server.ts index c17946a..842a268 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.ts @@ -22,6 +22,7 @@ import { crowdCallsOf, isStatsUnlocked, type ShowdownStats, + statsElementOf, type UnlockCounts, } from "./showdown-stats"; import { type OwnVote, storiesOf } from "./showdown-stories"; @@ -48,7 +49,9 @@ async function loadAggregate(): Promise { // One copy per Machine: a suspended Machine wakes with an empty cache and a // second Machine holds its own, both of which the Project can live with. -export const showdownAggregate = cacheAggregate(loadAggregate); +const { readAggregate, forgetAggregate } = cacheAggregate(loadAggregate); + +export const showdownAggregate = readAggregate; // A visitor with no cookie has voted on nothing, so they are offered the whole // roster's Matchups. Their own Votes are read fresh every time: the cache would @@ -140,7 +143,9 @@ export async function castVote( eq(votes.elementHigh, elementHigh), ); - await db + // A repeat Vote stores nothing, so it leaves both counts where they were and + // cannot be the Vote that crosses them. + const inserted = await db .insert(votes) .values({ voter, @@ -150,11 +155,13 @@ export async function castVote( }) .onConflictDoNothing({ target: [votes.voter, votes.elementLow, votes.elementHigh], - }); + }) + .returning({ voter: votes.voter }); // The split is read fresh for this one Matchup, never from the cache the - // Stats read: the Voter's own Vote has to be in it. - const [tally, [stood]] = await Promise.all([ + // Stats read: the Voter's own Vote has to be in it. So are both unlock + // numbers, which the cached copy is up to a minute too old to decide on. + const [tally, [stood], [countsAfter]] = await Promise.all([ db .select({ value: votes.value, voteCount: count() }) .from(votes) @@ -164,16 +171,48 @@ export async function castVote( .select({ value: votes.value }) .from(votes) .where(and(thisMatchup, eq(votes.voter, voter))), + db + .select({ + everyVoteCount: count(), + ownVoteCount: + sql`count(*) filter (where ${votes.voter} = ${voter})`.mapWith( + Number, + ), + }) + .from(votes), ]); if (!stood) throw new Error( `the Vote on Matchup ${elementLow}-${elementHigh} was cast but is not there to read back`, ); + if (!countsAfter) + throw new Error("counting the Votes cast so far gave no row back"); const counts = noVotes(); for (const { value: stored, voteCount } of tally) counts[asShown(stored)] += voteCount; - return revealFor(counts, asShown(stood.value)); + const reveal = revealFor(counts, asShown(stood.value)); + + const countsBefore: UnlockCounts = + inserted.length === 1 + ? { + ownVoteCount: countsAfter.ownVoteCount - 1, + everyVoteCount: countsAfter.everyVoteCount - 1, + } + : countsAfter; + if (isStatsUnlocked(countsBefore) || !isStatsUnlocked(countsAfter)) + return reveal; + + // The Stats gate reads the cached count, which is a minute behind this Vote + // at worst: the Voter who has just watched them open must not be dropped + // onto the locked screen. + forgetAggregate(); + const roster = await db + .select() + .from(elements) + .where(eq(elements.isActive, true)) + .orderBy(elements.name); + return { ...reveal, unlockedElements: roster.map(statsElementOf) }; } // One cap per Machine, as the aggregate's cache is one copy per Machine. diff --git a/src/projects/elemental-showdown/matchup-score.ts b/src/projects/elemental-showdown/matchup-score.ts index b57d2c2..940d711 100644 --- a/src/projects/elemental-showdown/matchup-score.ts +++ b/src/projects/elemental-showdown/matchup-score.ts @@ -3,6 +3,7 @@ import { VOTE_VALUES, type VoteValue, } from "./showdown-schema"; +import type { StatsElement } from "./showdown-stats"; // What the crowd's Votes on one Matchup add up to. Nothing here may import // server code: the reveal, the Stats and the Stories all read these values in @@ -65,6 +66,10 @@ export type VoteReveal = { // Where the crowd's mean sits on the Vote scale, or null below the verdict // gate, where a handful of Votes is no crowd to stand against. crowdMean: number | null; + // The Active Elements the unlock's wave flips a tile for, here on the one + // Vote that opened the Stats to this Voter and on no other. Only the cast + // path can tell: it counts the Votes either side of this one. + unlockedElements?: StatsElement[]; }; // A Vote that was never stored: this address has cast more than the window diff --git a/src/projects/elemental-showdown/showdown-aggregate.test.ts b/src/projects/elemental-showdown/showdown-aggregate.test.ts index 9f3ad59..dc5d217 100644 --- a/src/projects/elemental-showdown/showdown-aggregate.test.ts +++ b/src/projects/elemental-showdown/showdown-aggregate.test.ts @@ -120,35 +120,46 @@ describe("cacheAggregate", () => { it("loads once however many times it is read inside the lifetime", async () => { const { load, loaded } = loadCounting(); - const aggregate = cacheAggregate(load); + const { readAggregate } = cacheAggregate(load); - await aggregate(1_000); - await aggregate(1_000 + AGGREGATE_LIFETIME_MS - 1); + await readAggregate(1_000); + await readAggregate(1_000 + AGGREGATE_LIFETIME_MS - 1); expect(loaded()).toBe(1); }); it("loads again on the first read after the lifetime is up", async () => { const { load, loaded } = loadCounting(); - const aggregate = cacheAggregate(load); + const { readAggregate } = cacheAggregate(load); - await aggregate(1_000); - await aggregate(1_000 + AGGREGATE_LIFETIME_MS); + await readAggregate(1_000); + await readAggregate(1_000 + AGGREGATE_LIFETIME_MS); + + expect(loaded()).toBe(2); + }); + + it("loads again on the next read once what it holds is forgotten", async () => { + const { load, loaded } = loadCounting(); + const { readAggregate, forgetAggregate } = cacheAggregate(load); + await readAggregate(1_000); + + forgetAggregate(); + await readAggregate(1_000); expect(loaded()).toBe(2); }); it("holds nothing when the load fails, so the next read tries again", async () => { let attempt = 0; - const aggregate = cacheAggregate(() => { + const { readAggregate } = cacheAggregate(() => { attempt++; return attempt === 1 ? Promise.reject(new Error("the database said no")) : Promise.resolve(aggregateOf({ elements: [fire], matchupSums: [] })); }); - await expect(aggregate(1_000)).rejects.toThrow("the database said no"); + await expect(readAggregate(1_000)).rejects.toThrow("the database said no"); - expect(await aggregate(1_000)).toMatchObject({ elements: [fire] }); + expect(await readAggregate(1_000)).toMatchObject({ elements: [fire] }); }); }); diff --git a/src/projects/elemental-showdown/showdown-aggregate.ts b/src/projects/elemental-showdown/showdown-aggregate.ts index bffddd4..8aae568 100644 --- a/src/projects/elemental-showdown/showdown-aggregate.ts +++ b/src/projects/elemental-showdown/showdown-aggregate.ts @@ -90,9 +90,16 @@ export const AGGREGATE_LIFETIME_MS = 60_000; // serves a stale read. export function cacheAggregate(load: () => Promise) { let held: { loadedAtMs: number; aggregate: ShowdownAggregate } | undefined; - return async (nowMs: number): Promise => { - if (!held || nowMs - held.loadedAtMs >= AGGREGATE_LIFETIME_MS) - held = { loadedAtMs: nowMs, aggregate: await load() }; - return held.aggregate; + return { + readAggregate: async (nowMs: number): Promise => { + if (!held || nowMs - held.loadedAtMs >= AGGREGATE_LIFETIME_MS) + held = { loadedAtMs: nowMs, aggregate: await load() }; + return held.aggregate; + }, + // A Vote whose own count has to be right this second, rather than within + // the minute the copy is worth: the next read pays for one more load. + forgetAggregate: () => { + held = undefined; + }, }; } diff --git a/src/styles.css b/src/styles.css index d5e0556..979e2b2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -143,6 +143,15 @@ body { } } +/* Frames only: when the stamp lands and how long it takes come inline from + UnlockMoment.tsx. It is held out of sight until then by `both`. */ +@keyframes stats-unlocked-stamp { + from { + scale: 3; + opacity: 0; + } +} + /* Frames only: the blink rate and its step timing come inline from note-overlay.tsx, and motion-reduce:animate-none! leaves the caret steady. */ @keyframes desk-caret {