From e7f944570a1fa295898c292d37b13122d924e171 Mon Sep 17 00:00:00 2001 From: Jacob Cheatley Date: Tue, 22 Sep 2026 09:50:39 +1200 Subject: [PATCH] Elemental Showdown: reveal the crowd's split on the Vote just cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Casting a Vote now hands back the reveal: the five counts with the Voter's own Vote in them, the Vote that stands, the share who voted the same, a headline kind and the crowd's mean when the Matchup has enough Votes to have a verdict. The counts are a fresh per-request tally of that one Matchup, so a Voter always sees their own Vote counted, and a repeat Vote reads the first one back out of the table rather than echoing what was sent. A Matchup is stored from the lower-id Element's side and shown from the top Element's, so the server turns the whole reveal — counts, the Vote and the mean — into the orientation the Tug is showing before it leaves. The result carries a `state` tag, which is where the rate limit's `limited` and the unlock flag go later. On screen the seam stays where the Voter left it and the five tabs become five count bars: a fixed-width label, a faint divider, then a zero-based bar whose length is that value's share and nothing else. The Voter's own bar takes the pill's place with a heavier outline and a "your vote" tag, the dashed crowd line travels to the mean, and a card on the winner's side carries the headline, "N% voted the same, of M" and a draining bar. Under five Votes there is no mean line and no verdict, only "first to call it" or "early days". The next Matchup is drawn while the Voter reads, and slides in when the bar drains or at once on a tap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVEtgjCoEbaNHrYfzyCRNH --- .../ElementalShowdown.test.tsx | 106 ++++++++-- .../elemental-showdown/ElementalShowdown.tsx | 35 +++- src/projects/elemental-showdown/Reveal.tsx | 186 ++++++++++++++++++ src/projects/elemental-showdown/Tug.tsx | 97 ++++++--- .../elemental-showdown.fn.ts | 2 +- .../elemental-showdown.server.test.ts | 57 ++++++ .../elemental-showdown.server.ts | 60 +++++- .../elemental-showdown/matchup-score.test.ts | 25 +++ .../elemental-showdown/matchup-score.ts | 57 +++++- .../elemental-showdown/showdown-schema.ts | 4 +- src/projects/elemental-showdown/tug.ts | 7 +- src/styles.css | 8 + 12 files changed, 576 insertions(+), 68 deletions(-) create mode 100644 src/projects/elemental-showdown/Reveal.tsx diff --git a/src/projects/elemental-showdown/ElementalShowdown.test.tsx b/src/projects/elemental-showdown/ElementalShowdown.test.tsx index d26a331..4dbf4cc 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.test.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.test.tsx @@ -1,8 +1,16 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ElementalShowdown } from "./ElementalShowdown"; +import type { VoteReveal } from "./matchup-score"; import type { NextMatchup } from "./matchup-selection"; +import { REVEAL_LINGER_MS } from "./Reveal"; import type { ShowdownElement } from "./schema"; import type { VoteCast } from "./showdown-schema"; @@ -33,17 +41,27 @@ const matchup = ( bottom: ShowdownElement, ): NextMatchup => ({ state: "matchup", top, bottom }); +// Ten Votes with the crowd behind fire: the Voter crushed it too, as half of +// them did. +const reveal = (crowd: Partial = {}): VoteReveal => ({ + state: "reveal", + counts: { "-2": 1, "-1": 0, "0": 1, "1": 3, "2": 5 }, + vote: 2, + sameShare: 0.5, + headline: "with", + crowdMean: 1.4, + ...crowd, +}); + +type CastVote = (options: { data: VoteCast }) => Promise; + function showdown({ shown = matchup(fire, water), - castVote = vi.fn<(options: { data: VoteCast }) => Promise>( - async () => {}, - ), + castVote = vi.fn(async () => reveal()), nextMatchup = async () => matchup(plant, fire), }: { shown?: NextMatchup; - castVote?: ReturnType< - typeof vi.fn<(options: { data: VoteCast }) => Promise> - >; + castVote?: ReturnType>; nextMatchup?: () => Promise; } = {}) { render( @@ -65,6 +83,7 @@ const cast = (value: number) => ({ afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); describe("the Tug as a slider", () => { @@ -137,23 +156,80 @@ describe("the Tug as a slider", () => { }); }); -describe("after a Vote", () => { - it("brings on the next Matchup", async () => { +describe("the reveal", () => { + it("keeps the crowd out of sight until the Vote is cast", () => { + showdown(); + + expect(screen.queryByText("▲ your vote")).toBeNull(); + expect(screen.queryByText("the crowd")).toBeNull(); + expect(screen.queryByText(/voted the same/)).toBeNull(); + }); + + it("marks the Voter's own bar and says where the crowd stands", async () => { const user = userEvent.setup(); showdown(); await user.click(pill()); - await screen.findByText("plant"); + const ownBar = await screen.findByText("▲ your vote"); + expect(ownBar.parentElement).toHaveTextContent("🔥 crushes"); + expect(screen.getByText("with the crowd")).toBeInTheDocument(); + expect(screen.getByText("50% voted the same, of 10")).toBeInTheDocument(); + expect(screen.getByText("the crowd")).toBeInTheDocument(); + }); + + it("dresses a Matchup nobody has settled up as no crowd at all", async () => { + const user = userEvent.setup(); + showdown({ + castVote: vi.fn(async () => + reveal({ + counts: { "-2": 0, "-1": 1, "0": 0, "1": 1, "2": 1 }, + vote: 2, + sameShare: 1 / 3, + headline: "early", + crowdMean: null, + }), + ), + }); + + await user.click(pill()); + + await screen.findByText("early days, only 3 votes"); + expect(screen.queryByText("the crowd")).toBeNull(); + expect(screen.queryByText(/voted the same/)).toBeNull(); + }); +}); + +describe("after a Vote", () => { + it("brings on the next Matchup when the draining bar runs out", async () => { + // the clock, not userEvent: the wait the draining bar shows is a timer + vi.useFakeTimers(); + showdown(); + fireEvent.click(pill()); + await act(async () => {}); + expect(screen.getByText("with the crowd")).toBeInTheDocument(); + + await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS)); + + expect(screen.getByText("plant")).toBeInTheDocument(); expect(screen.queryByText("water")).toBeNull(); }); + it("brings on the next Matchup at once when the Voter taps", async () => { + const user = userEvent.setup(); + showdown(); + await user.click(pill()); + await screen.findByText("with the crowd"); + + fireEvent.pointerDown(tug()); + + await screen.findByText("plant"); + }); + it("casts once, however often the Tug is let go while the Vote is on its way", async () => { const user = userEvent.setup(); const castVote = showdown({ - castVote: vi.fn<(options: { data: VoteCast }) => Promise>( - () => new Promise(() => {}), - ), + castVote: vi.fn(() => new Promise(() => {})), }); await user.click(pill()); @@ -166,9 +242,7 @@ describe("after a Vote", () => { const failed = vi.spyOn(console, "error").mockImplementation(() => {}); const user = userEvent.setup(); showdown({ - castVote: vi.fn<(options: { data: VoteCast }) => Promise>(() => - Promise.reject(new Error("offline")), - ), + castVote: vi.fn(() => Promise.reject(new Error("offline"))), }); await user.click(pill()); diff --git a/src/projects/elemental-showdown/ElementalShowdown.tsx b/src/projects/elemental-showdown/ElementalShowdown.tsx index 24a7f40..76c66ca 100644 --- a/src/projects/elemental-showdown/ElementalShowdown.tsx +++ b/src/projects/elemental-showdown/ElementalShowdown.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { INK, PAPER, textOn } from "./element-colour"; +import type { VoteReveal } from "./matchup-score"; import type { NextMatchup } from "./matchup-selection"; import type { VoteCast, VoteValue } from "./showdown-schema"; import { Tug } from "./Tug"; @@ -13,14 +14,18 @@ export function ElementalShowdown({ nextMatchup, }: { shown: NextMatchup; - castVote: (options: { data: VoteCast }) => Promise; + castVote: (options: { data: VoteCast }) => Promise; nextMatchup: () => Promise; }) { const [matchup, setMatchup] = useState(shown); + const [reveal, setReveal] = useState(null); const [votesCast, setVotesCast] = useState(0); // 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); + // Drawn while the Voter reads the reveal, so the next Matchup is there the + // moment they are done with this one. + const drawing = useRef | null>(null); function cast(value: VoteValue) { if (matchup.state !== "matchup" || casting.current) return; @@ -29,10 +34,9 @@ export function ElementalShowdown({ castVote({ data: { topElementId: top.id, bottomElementId: bottom.id, value }, }) - .then(nextMatchup) - .then((next) => { - setMatchup(next); - setVotesCast((count) => count + 1); + .then((crowd) => { + setReveal(crowd); + drawing.current = nextMatchup(); }) .catch((cause: unknown) => { // Nothing was stored, so the Matchup stays up and the next drag is @@ -44,6 +48,23 @@ export function ElementalShowdown({ }); } + // The reveal's own clock calls this, and so does a Voter who has read enough. + const advance = useCallback(() => { + const drawn = drawing.current; + if (!drawn) return; + drawing.current = null; + drawn + .then((next) => { + setMatchup(next); + setReveal(null); + setVotesCast((count) => count + 1); + }) + .catch((cause: unknown) => { + // The reveal stays up rather than an empty screen taking its place. + console.error(new Error("drawing the next Matchup failed", { cause })); + }); + }, []); + return (
) : ( diff --git a/src/projects/elemental-showdown/Reveal.tsx b/src/projects/elemental-showdown/Reveal.tsx new file mode 100644 index 0000000..bba5c76 --- /dev/null +++ b/src/projects/elemental-showdown/Reveal.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; +import { INK, PAPER } from "./element-colour"; +import type { HeadlineKind, VoteReveal } from "./matchup-score"; +import type { ShowdownElement } from "./schema"; +import { SEAM_LANDINGS, seamAt, tabLabel } from "./tug"; + +// How long the reveal holds the screen on its own, which is what the draining +// bar drains over. +export const REVEAL_LINGER_MS = 3500; + +// The bars growing out of their labels and the crowd line travelling to the +// mean, both in the Tug's own spring. +const SETTLE_MS = 700; + +// Every label is one width, so every count bar starts at the same divider and +// its length is that value's share of the Votes and nothing else. The gap is +// the run a bar at 100% leaves at the far edge. +const LABEL_WIDTH_PX = 100; +const DIVIDER_WIDTH_PX = 1.5; +const EDGE_GAP_PX = 12; + +const barWidth = (share: number) => + `calc(${LABEL_WIDTH_PX + DIVIDER_WIDTH_PX}px + ${share} * (100% - ${LABEL_WIDTH_PX + EDGE_GAP_PX}px))`; + +// A percentage sits over the Element's colour when its bar is too short to +// hold it, so it is drawn in white cut out of ink. +const PERCENTAGE_OUTLINE = [ + "1.5px 0", + "-1.5px 0", + "0 1.5px", + "0 -1.5px", + "1px 1px", + "-1px -1px", + "1px -1px", + "-1px 1px", +] + .map((offset) => `${offset} ${INK}`) + .join(", "); + +const percent = (share: number) => `${Math.round(share * 100)}%`; + +// Placeholder copy, as the prototype wrote it: the owner writes the real words. +function headlineCopy(kind: HeadlineKind, voteCount: number) { + switch (kind) { + case "first": + return "first to call it"; + case "early": + return `early days, only ${voteCount} votes`; + case "split": + return "the crowd is split"; + case "with": + return "with the crowd"; + case "close": + return "close to the crowd"; + case "against": + return "against the crowd"; + } +} + +// The crowd's split on the Matchup just voted on, in the Tug's own place: the +// five tabs have become five count bars and the seam has not moved. +export function Reveal({ + reveal: { counts, vote, sameShare, headline, crowdMean }, + top, + bottom, + onAdvance, +}: { + reveal: VoteReveal; + top: ShowdownElement; + bottom: ShowdownElement; + onAdvance: () => void; +}) { + const [hasSettled, setSettled] = useState(false); + const voteCount = Object.values(counts).reduce( + (total, forValue) => total + forValue, + 0, + ); + + // A frame at the tabs' own length, so the bars are seen growing out of them. + useEffect(() => { + const settling = requestAnimationFrame(() => setSettled(true)); + return () => cancelAnimationFrame(settling); + }, []); + + useEffect(() => { + const advancing = setTimeout(onAdvance, REVEAL_LINGER_MS); + return () => clearTimeout(advancing); + }, [onAdvance]); + + return ( + <> + {SEAM_LANDINGS.map((landing) => { + const isOwn = landing === vote; + const share = counts[landing] / voteCount; + return ( + + + + + {percent(share)} + + + + + {tabLabel(landing, top, bottom)} + + {isOwn && ( + + ▲ your vote + + )} + + + ); + })} + + {crowdMean !== null && ( +
+ + the crowd + +
+ )} + + {/* The card takes the winner's side, below the link back to the + Portfolio when that is the top one. */} +
= 0 + ? "top-[calc(max(0.75rem,env(safe-area-inset-top))+1.75rem)]" + : "bottom-[max(0.875rem,env(safe-area-inset-bottom))]" + }`} + style={{ background: INK, color: PAPER }} + > +

+ {headlineCopy(headline, voteCount)} +

+ {crowdMean !== null && ( +

+ {percent(sameShare)} voted the same, of {voteCount} +

+ )} +
+
+ + ); +} diff --git a/src/projects/elemental-showdown/Tug.tsx b/src/projects/elemental-showdown/Tug.tsx index 3699234..199b7ad 100644 --- a/src/projects/elemental-showdown/Tug.tsx +++ b/src/projects/elemental-showdown/Tug.tsx @@ -1,5 +1,7 @@ import { useRef, useState } from "react"; import { INK, PAPER, textOn } from "./element-colour"; +import type { VoteReveal } from "./matchup-score"; +import { Reveal } from "./Reveal"; import type { ShowdownElement } from "./schema"; import type { VoteValue } from "./showdown-schema"; import { @@ -31,6 +33,8 @@ export function Tug({ bottom, isFirstMatchup, onCast, + reveal, + onAdvance, }: { top: ShowdownElement; bottom: ShowdownElement; @@ -38,6 +42,10 @@ export function Tug({ // in, and it is the one that says how this works. isFirstMatchup: boolean; onCast: (value: VoteValue) => void; + // The crowd, once this Voter's own Vote is in it. Until then the Tug is the + // whole screen and the crowd cannot sway them. + reveal: VoteReveal | null; + onAdvance: () => void; }) { const [value, setValue] = useState(0); const [isDragging, setDragging] = useState(false); @@ -45,7 +53,13 @@ export function Tug({ const seam = seamAt(value); const sentence = voteSentence(value, top, bottom); + // Revealed, the Tug has nothing left to drag and a touch anywhere is the + // Voter asking for the next Matchup rather than waiting out the bar. function startDrag(event: React.PointerEvent) { + if (reveal) { + onAdvance(); + return; + } dragFrom.current = event.clientY; setDragging(true); event.currentTarget.setPointerCapture(event.pointerId); @@ -68,6 +82,12 @@ export function Tug({ } function onKeyDown(event: React.KeyboardEvent) { + if (reveal) { + if (event.key !== "Enter") return; + event.preventDefault(); + onAdvance(); + return; + } const step = ARROW_STEPS[event.key]; if (step !== undefined) { event.preventDefault(); @@ -100,39 +120,54 @@ export function Tug({ - {SEAM_LANDINGS.map((landing) => ( - - {tabLabel(landing, top, bottom)} - - ))} + {reveal ? ( + + ) : ( + <> + {SEAM_LANDINGS.map((landing) => ( + + {tabLabel(landing, top, bottom)} + + ))} - + + + )} - {isFirstMatchup && ( + {isFirstMatchup && !reveal && (

{ const voter = voterOfRequest() ?? crypto.randomUUID(); setCookie(VOTER_COOKIE, voter, VOTER_COOKIE_OPTIONS); - await castVote(voter, data); + return castVote(voter, data); }); diff --git a/src/projects/elemental-showdown/elemental-showdown.server.test.ts b/src/projects/elemental-showdown/elemental-showdown.server.test.ts index 4ee17e3..ee2aee0 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.test.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.test.ts @@ -87,6 +87,63 @@ describe("castVote", () => { expect(await storedVotes()).toMatchObject([{ value: 2 }]); }); + it("counts the Vote just cast in the reveal it hands back", async () => { + const fire = await insertElement("fire"); + const water = await insertElement("water"); + + const reveal = await castVote(VOTER, { + topElementId: fire, + bottomElementId: water, + value: 2, + }); + + expect(reveal).toMatchObject({ + state: "reveal", + counts: { "2": 1 }, + vote: 2, + sameShare: 1, + headline: "first", + }); + }); + + it("reveals the Matchup from the side it was shown, whichever Element is on top", async () => { + const fire = await insertElement("fire"); + const water = await insertElement("water"); + // fire crushes water, from fire's side: the Matchup's own way round + await castVote(OTHER_VOTER, { + topElementId: fire, + bottomElementId: water, + value: 2, + }); + + // this Voter is shown water on top, and gives fire a weak win from there + const reveal = await castVote(VOTER, { + topElementId: water, + bottomElementId: fire, + value: -1, + }); + + expect(reveal).toMatchObject({ + counts: { "-2": 1, "-1": 1, "0": 0, "1": 0, "2": 0 }, + vote: -1, + }); + }); + + it("reveals the Matchup again on a repeat Vote, showing the Vote that stands", async () => { + const fire = await insertElement("fire"); + const water = await insertElement("water"); + const matchup = { topElementId: fire, bottomElementId: water }; + await castVote(VOTER, { ...matchup, value: 2 }); + + const reveal = await castVote(VOTER, { ...matchup, value: 1 }); + + expect(reveal).toMatchObject({ + state: "reveal", + counts: { "1": 0, "2": 1 }, + vote: 2, + }); + }); + it("keeps one Vote per Voter, so another Voter's verdict on the same Matchup is its own", async () => { const fire = await insertElement("fire"); const water = await insertElement("water"); diff --git a/src/projects/elemental-showdown/elemental-showdown.server.ts b/src/projects/elemental-showdown/elemental-showdown.server.ts index 865460b..3aed08a 100644 --- a/src/projects/elemental-showdown/elemental-showdown.server.ts +++ b/src/projects/elemental-showdown/elemental-showdown.server.ts @@ -1,8 +1,13 @@ -import { eq } from "drizzle-orm"; +import { and, count, eq } from "drizzle-orm"; import { db } from "@/db/index.server"; +import { noVotes, revealFor, type VoteReveal } from "./matchup-score"; import { drawMatchup, type NextMatchup } from "./matchup-selection"; import { elements, votes } from "./schema"; -import { type VoteCast, voteValueSchema } from "./showdown-schema"; +import { + type VoteCast, + type VoteValue, + voteValueSchema, +} from "./showdown-schema"; // A visitor with no cookie has voted on nothing, so they are offered the whole // roster's Matchups. @@ -25,24 +30,61 @@ export async function nextMatchup( return drawMatchup({ elements: roster, votes: cast, random }); } +// A Matchup is stored from the lower-id Element's side and shown from the top +// Element's, so when the higher-id Element is on top every Vote of it reads +// with its sign flipped, going either way. +const fromTheOtherSide = (value: VoteValue) => voteValueSchema.parse(-value); + // The Tug reads a Vote from the top Element's side; a Matchup is stored one way -// round only, so a Vote cast with the higher-id Element on top comes in with -// its sign flipped. A Voter who votes on the same Matchup twice (two tabs, a -// double submit) is not an error: their first Vote stands. +// round only. A Voter who votes on the same Matchup twice (two tabs, a double +// submit) is not an error: their first Vote stands, and the reveal is the +// crowd as it is now, read back from the Matchup's own side. export async function castVote( voter: string, { topElementId, bottomElementId, value }: VoteCast, -) { +): Promise { const topIsLow = topElementId < bottomElementId; + const elementLow = topIsLow ? topElementId : bottomElementId; + const elementHigh = topIsLow ? bottomElementId : topElementId; + const asShown = (stored: VoteValue) => + topIsLow ? stored : fromTheOtherSide(stored); + const thisMatchup = and( + eq(votes.elementLow, elementLow), + eq(votes.elementHigh, elementHigh), + ); + await db .insert(votes) .values({ voter, - elementLow: topIsLow ? topElementId : bottomElementId, - elementHigh: topIsLow ? bottomElementId : topElementId, - value: topIsLow ? value : voteValueSchema.parse(-value), + elementLow, + elementHigh, + value: topIsLow ? value : fromTheOtherSide(value), }) .onConflictDoNothing({ target: [votes.voter, votes.elementLow, votes.elementHigh], }); + + // 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([ + db + .select({ value: votes.value, voteCount: count() }) + .from(votes) + .where(thisMatchup) + .groupBy(votes.value), + db + .select({ value: votes.value }) + .from(votes) + .where(and(thisMatchup, eq(votes.voter, voter))), + ]); + if (!stood) + throw new Error( + `the Vote on Matchup ${elementLow}-${elementHigh} was cast but is not there to read back`, + ); + + const counts = noVotes(); + for (const { value: stored, voteCount } of tally) + counts[asShown(stored)] += voteCount; + return revealFor(counts, asShown(stood.value)); } diff --git a/src/projects/elemental-showdown/matchup-score.test.ts b/src/projects/elemental-showdown/matchup-score.test.ts index c011134..75bb563 100644 --- a/src/projects/elemental-showdown/matchup-score.test.ts +++ b/src/projects/elemental-showdown/matchup-score.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import { headlineFor, mirrorEffectiveness, + noVotes, + revealFor, scoreMatchup, + type VoteCounts, } from "./matchup-score"; import type { VoteValue } from "./showdown-schema"; @@ -143,3 +146,25 @@ describe("headlineFor", () => { expect(headlineFor(split, -2)).toBe("split"); }); }); + +describe("revealFor", () => { + // The Votes on one Matchup as counts, the Voter's own among them. + const counted = (...cast: VoteValue[]): VoteCounts => { + const counts = noVotes(); + for (const value of cast) counts[value] += 1; + return counts; + }; + + it("gives the share of the crowd that voted as the Voter did", () => { + expect(revealFor(counted(2, 2, 1, 0, -2), 2).sameShare).toBe(0.4); + }); + + it("scores the crowd from the counts, the Voter's own Vote among them", () => { + expect(revealFor(counted(...votes(5, 1)), 1).headline).toBe("with"); + }); + + it("keeps the crowd's mean back until the Matchup has a verdict", () => { + expect(revealFor(counted(...votes(4, 1)), 1).crowdMean).toBeNull(); + expect(revealFor(counted(...votes(5, 1)), 1).crowdMean).toBeCloseTo(0.71); + }); +}); diff --git a/src/projects/elemental-showdown/matchup-score.ts b/src/projects/elemental-showdown/matchup-score.ts index 9651295..4526953 100644 --- a/src/projects/elemental-showdown/matchup-score.ts +++ b/src/projects/elemental-showdown/matchup-score.ts @@ -1,4 +1,4 @@ -import type { VoteValue } from "./showdown-schema"; +import { VOTE_VALUES, type VoteValue } from "./showdown-schema"; // 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 @@ -35,6 +35,34 @@ export type MatchupSums = { squareSum: number; }; +// How many Votes each of the five values has, read from one Element's side. +export type VoteCounts = Record; + +// A Matchup nobody has voted on: what counting starts from, since a tally only +// names the values somebody chose. +export const noVotes = (): VoteCounts => ({ + "-2": 0, + "-1": 0, + "0": 0, + "1": 0, + "2": 0, +}); + +// What a Voter is shown the moment their Vote lands, read from the side the +// Matchup was shown to them. +export type VoteReveal = { + state: "reveal"; + // The Voter's own Vote is one of these. + counts: VoteCounts; + // The Vote that stands: a Voter who votes twice keeps their first. + vote: VoteValue; + sameShare: number; + headline: HeadlineKind; + // 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; +}; + export type MatchupScore = { voteCount: number; // Read from the lower-id Element's side, the orientation a Vote is stored in. @@ -159,3 +187,30 @@ export function headlineFor( if (stepsApart === 0) return "with"; return stepsApart === 1 ? "close" : "against"; } + +const sumsOf = (counts: VoteCounts): MatchupSums => + VOTE_VALUES.reduce( + (sums, value) => ({ + voteCount: sums.voteCount + counts[value], + valueSum: sums.valueSum + value * counts[value], + squareSum: sums.squareSum + value * value * counts[value], + }), + { voteCount: 0, valueSum: 0, squareSum: 0 }, + ); + +// The reveal of one Matchup to the Voter who has just cast `vote` on it, from +// the counts their own Vote is already in. +export function revealFor(counts: VoteCounts, vote: VoteValue): VoteReveal { + const sums = sumsOf(counts); + const score = scoreMatchup(sums); + const headline = headlineFor(score, vote); + const hasVerdict = headline !== "first" && headline !== "early"; + return { + state: "reveal", + counts, + vote, + sameShare: counts[vote] / sums.voteCount, + headline, + crowdMean: hasVerdict ? score.meanVote : null, + }; +} diff --git a/src/projects/elemental-showdown/showdown-schema.ts b/src/projects/elemental-showdown/showdown-schema.ts index a658564..36a8a8c 100644 --- a/src/projects/elemental-showdown/showdown-schema.ts +++ b/src/projects/elemental-showdown/showdown-schema.ts @@ -11,7 +11,9 @@ export type ElementKind = z.infer; // One Vote read from the first Element's side: two steps is a strong win, one a // weak win, zero too close to call. -export const voteValueSchema = z.literal([-2, -1, 0, 1, 2]); +export const VOTE_VALUES = [-2, -1, 0, 1, 2] as const; + +export const voteValueSchema = z.literal(VOTE_VALUES); export type VoteValue = z.infer; diff --git a/src/projects/elemental-showdown/tug.ts b/src/projects/elemental-showdown/tug.ts index d9f6e87..69ec0d4 100644 --- a/src/projects/elemental-showdown/tug.ts +++ b/src/projects/elemental-showdown/tug.ts @@ -15,9 +15,10 @@ const SEAM_STEP_PERCENT = 17; // The five landings, top to bottom: one edge tab marks each. export const SEAM_LANDINGS = [2, 1, 0, -1, -2] as const satisfies VoteValue[]; -// Where the seam rests, as a percentage down the screen. The winner takes -// ground, so the seam moves away from the Element that is winning. -export const seamAt = (value: VoteValue) => 50 + SEAM_STEP_PERCENT * value; +// Where the seam rests, as a percentage down the screen, for any point on the +// Vote scale: the five landings and the crowd's mean between them. The winner +// takes ground, so the seam moves away from the Element that is winning. +export const seamAt = (value: number) => 50 + SEAM_STEP_PERCENT * value; // A drag down is the top Element taking ground, a drag up the bottom one. export const draggedVote = (dragPx: number): VoteValue => { diff --git a/src/styles.css b/src/styles.css index 291aca3..d5e0556 100644 --- a/src/styles.css +++ b/src/styles.css @@ -135,6 +135,14 @@ body { } } +/* Frames only: how long the reveal lingers comes inline from Reveal.tsx, which + moves on by the same clock. */ +@keyframes reveal-drain { + to { + transform: scaleX(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 {