Something went wrong. Try again.
geometric packing game (tetris-ish) on an icosahedron
Something went wrong. Try again.
99 kB · 2782 lines
TypeScript
at appview
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783import { test, expect, type Page } from "@playwright/test";
async function inspect(page: Page) { return page.evaluate(async () => { const { game, view } = window.__rad;
return { phase: game.phase, occupied: [...game.state.occupied], cells: game.placement?.cellIds, legal: game.legal, current: game.current, queue: game.queue, anchor: game.anchor, point: game.pointerPosition, remaining: game.remaining, duration: game.duration, level: game.level, levelProgress: game.levelProgress, camera: view.camera.position.toArray(), rotation: game.rotation, mirrored: game.mirrored, rewards: [...game.rewards], rewardActive: game.rewardActive, returningPiece: game.returningPiece, }; });}
// Fixed cell IDs in these interaction regressions refer to the original k=5 mesh.test.beforeEach(async ({ page }) => { await page.clock.install(); await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?k=5&seed=1234` : "/tests/fixture.html?k=5&seed=1234", ); await expect(page.locator("#scene canvas.three-canvas")).toBeVisible(); await page.clock.runFor(32);});
test("save a board scenario, launch and resume its challenge, then restart", async ({ page }) => { await page.goto("/tests/fixture.html?k=5&mode=free&seed=1234"); await expect(page.locator("#scene canvas.three-canvas")).toBeVisible(); await page.evaluate(() => { const { game } = window.__rad; const cellId = game.board.cells[0].id; game.state.occupied.add(cellId); game.state.colors.set(cellId, 2); });
await page.locator("#scenarios-button").click(); await page.locator("#scenario-name").fill("Resume challenge setup"); await page.locator("#scenario-save").click(); await expect(page.locator("#scenarios-status")).toContainText("Saved");
// Listing and launching scenarios now lives on the canonical library page; // the game dialog only creates or names a scenario. await page.goto("/library/?tab=scenarios"); await expect(page.getByRole("heading", { name: "Library", exact: true })).toBeVisible(); const scenario = page.locator(".scenario-card").filter({ hasText: "Resume challenge setup" }); await expect(scenario).toHaveCount(1); const level = scenario.getByRole("spinbutton", { name: "Challenge starting level" }); await level.fill("6"); await scenario.getByRole("button", { name: "Timed challenge", exact: true }).click(); await expect(page).toHaveURL(/\/game\/$/); await expect(page.locator("#level")).toHaveText("6"); await expect(page.locator("#scene canvas.three-canvas")).toBeVisible();
await page.getByRole("button", { name: "Start", exact: true }).click(); await page.locator("#saved-games-button").click(); await page.locator("#save-game-name").fill("Challenge checkpoint"); await page.locator("#save-game-submit").click(); await expect(page.locator("#save-games-status")).toContainText("Saved"); await expect(page).toHaveURL(/\/game\/$/);
const savedCheckpoint = await page.evaluate(() => JSON.parse(localStorage.getItem("rad-cohesion:game-saves:v1")!).saves[0], );
const started = savedCheckpoint.snapshot; expect(savedCheckpoint.name).toBe("Challenge checkpoint"); expect(savedCheckpoint.startingLevel).toBe(6); expect(savedCheckpoint.challengeIdentity).toBeTruthy(); expect(started.challenge.startingLevel).toBe(6); expect(started.challenge.identity).toBe(savedCheckpoint.challengeIdentity); expect(started.game.state.occupied).toHaveLength(1); expect(started.game.state.colors).toEqual([[started.game.state.occupied[0], 2]]); expect(started.game.state.score).toBe(0); expect(started.game.state.misses).toBe(0); expect(started.game.state.facesCleared).toBe(0); expect(started.game.startingLevel).toBe(6); expect(started.game.duration).toBe(5); expect(started.game.rewards).toEqual([0, 0, 0]); expect(started.game.rngDraws).toBe(3);
await page.locator("#saved-games-close").click(); await page.getByRole("navigation", { name: "Main navigation" }).getByRole("link", { name: "Library", exact: true }).click(); await expect(page).toHaveURL(/\/library\/?(?:\?tab=saved)?$/); await expect(page.getByRole("heading", { name: "Library", exact: true })).toBeVisible(); const savedGame = page.locator(".saved-game-card").filter({ hasText: "Challenge checkpoint" }); await expect(savedGame).toContainText("Timed challenge · Level 6"); await savedGame.getByRole("link", { name: "Resume", exact: true }).click(); await expect(page).toHaveURL(/\/game\/$/); await expect(page.locator("#level")).toHaveText("6"); await expect(page.locator("#pause")).toHaveText("Resume");
await page.locator("#saved-games-button").click(); await page.locator("#save-game-name").fill("Forked challenge checkpoint"); await page.locator("#save-game-as-new").click(); await expect(page.locator("#save-games-status")).toContainText("Created"); await page.locator("#saved-games-close").click();
await page.getByRole("button", { name: "Restart", exact: true }).click(); await expect(page.locator("#level")).toHaveText("6"); await page.locator("#saved-games-button").click(); await page.locator("#save-game-name").fill("Restarted challenge checkpoint"); await page.locator("#save-game-submit").click(); await expect(page.locator("#save-games-status")).toContainText("Saved");
const restarted = await page.evaluate(() => JSON.parse(localStorage.getItem("rad-cohesion:game-saves:v1")!).saves.find( (save: { name: string }) => save.name === "Restarted challenge checkpoint", ).snapshot, );
expect(restarted.challenge.identity).toBe(started.challenge.identity); expect(restarted.game.state.occupied).toEqual(started.game.state.occupied); expect(restarted.game.state.colors).toEqual(started.game.state.colors); expect(restarted.game.state.score).toBe(0); expect(restarted.game.state.misses).toBe(0); expect(restarted.game.state.facesCleared).toBe(0); expect(restarted.game.startingLevel).toBe(6); expect(restarted.game.duration).toBe(5); expect(restarted.game.rewards).toEqual([0, 0, 0]); expect(restarted.game.rngDraws).toBe(3);});
test("mobile shared navigation opens the scenarios and saved games pages", async ({ page }) => { await page.setViewportSize({ width: 390, height: 740 }); await page.goto("/game/?mode=free&seed=1234"); await expect(page.locator("#scene canvas.three-canvas")).toBeVisible();
const navigation = page.getByRole("navigation", { name: "Main navigation" }); await expect(navigation.getByRole("link")).toHaveText(["Play", "Leaderboards", "Scenarios", "Library"]); await navigation.getByRole("link", { name: "Library", exact: true }).click(); await page.getByRole("navigation", { name: "Library sections" }).getByRole("link", { name: "Scenarios", exact: true }).click(); await expect(page).toHaveURL(/\/library\/\?tab=scenarios$/); await expect(page.getByRole("heading", { name: "Library", exact: true })).toBeVisible(); await navigation.getByRole("link", { name: "Library", exact: true }).click(); await expect(page).toHaveURL(/\/library\/?(?:\?tab=saved)?$/); await expect(page.getByRole("heading", { name: "Library", exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);});
test("start, rotate, reflect, manual lock, and deterministic restart", async ({ page,}) => { const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); await page.getByRole("button", { name: "Start", exact: true }).click(); await page.clock.runFor(32); const initial = await inspect(page); expect(initial.legal).toBe(true); expect(initial.cells).toHaveLength(4); await page.keyboard.press("r"); await page.keyboard.press("f"); await page.clock.runFor(32); const rotated = await inspect(page); expect(rotated.occupied).toHaveLength(0); expect(rotated.mirrored).toBe(true); await page.keyboard.press("Space"); await page.clock.runFor(150); expect((await inspect(page)).occupied).toHaveLength(4); await page.getByRole("button", { name: "Restart", exact: false }).click(); await page.clock.runFor(32); const fresh = await inspect(page); expect(fresh.occupied).toHaveLength(0); expect(fresh.current).toBe(initial.current); expect(fresh.queue).toEqual(initial.queue); await expect(page.locator("#strikes")).toHaveText("0 / 3"); expect(errors).toEqual([]);});
test("drag orbits to the rear without locking; reset and wheel work", async ({ page,}) => { await page.getByRole("button", { name: "Start", exact: true }).click();
const start = await inspect(page), rect = (await page.locator("#scene canvas.three-canvas").boundingBox())!;
const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + rect.width * 0.45, y + 40, { steps: 12 }); await page.mouse.up(); await page.clock.runFor(250); const end = await inspect(page); expect(end.occupied).toHaveLength(0); expect(end.camera).not.toEqual(start.camera); await page.mouse.move(x, y); await page.clock.runFor(32); expect((await inspect(page)).cells).toHaveLength(4); await page.mouse.wheel(0, -400); await page.clock.runFor(250); expect((await inspect(page)).camera).not.toEqual(end.camera); await page.getByRole("button", { name: "Reset view" }).click(); await page.clock.runFor(32); const reset = await inspect(page); reset.camera.forEach((v: number, k: number) => expect(v).toBeCloseTo(start.camera[k], 5), );});
test("click picks its target before locking, and ghost materializes automatically", async ({ page,}) => { await page.getByRole("button", { name: "Start", exact: true }).click(); await page.clock.runFor(32); const rect = (await page.locator("#scene canvas.three-canvas").boundingBox())!; await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2); await page.clock.runFor(150); expect((await inspect(page)).occupied).toHaveLength(4); await page.getByRole("button", { name: "Restart", exact: false }).click(); await page.mouse.move(0, 0); await page.clock.fastForward(5000); expect((await inspect(page)).remaining).toBeCloseTo(5, 0); await expect(page.locator("#time-track")).toHaveAttribute( "aria-valuenow", /49|50/, ); await page.clock.fastForward(5300); expect((await inspect(page)).occupied).toHaveLength(4);});
test("seam placement, occupied feedback, three strikes, and game-over restart", async ({ page,}) => { await page.getByRole("button", { name: "Start", exact: true }).click();
const cross = await page.evaluate(async () => { const { game } = window.__rad;
const p = game.index.byPiece[game.current].find( (p) => new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), ).size > 1, );
if (!p) throw new Error("No placement matches the test setup");
game.move(p.rootCellId); game.placement = p; game.rotation = p.rotation; game.mirrored = p.mirrored; game.revision++;
return new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), ).size; });
expect(cross).toBeGreaterThan(1); await page.clock.runFor(32); await page.keyboard.press("Space"); await page.clock.runFor(150); expect((await inspect(page)).occupied).toHaveLength(4);
for (let strike = 1; strike <= 3; strike++) { const before = await page.evaluate(async () => { const { game } = window.__rad;
if (!game.placement) throw new Error("Expected an active placement"); game.state.occupied.add(game.placement.cellIds[0]); game.revision++;
return [...game.state.occupied]; });
await page.clock.runFor(32); await expect(page.locator("#placement-status")).toContainText("BLOCKED"); await page.clock.fastForward(10100); expect((await inspect(page)).occupied).toEqual(before); await expect(page.locator("#strikes")).toHaveText(`${strike} / 3`);
if (strike < 3) await page.clock.runFor(350); }
await expect(page.locator("#overlay")).toBeVisible(); await expect(page.locator("#overlay-text")).toContainText("Three strikes"); await page.locator("#start").click(); await page.clock.runFor(32); expect((await inspect(page)).occupied).toHaveLength(0); await expect(page.locator("#overlay")).toBeHidden();});
test("simultaneous face clear pulses, scores 400, and retains neighboring occupancy", async ({ page,}) => { await page.getByRole("button", { name: "Start", exact: true }).click();
const survivor = await page.evaluate(async () => { const { game } = window.__rad;
const p = game.index.byPiece[game.current].find( (p) => new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), ).size === 2, );
if (!p) throw new Error("No placement matches the test setup");
const faces = new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), );
for (const f of faces) for (const id of game.board.faces[f]) if (!p.cellIds.includes(id)) game.state.occupied.add(id);
const outsideCell = game.board.cells.find( (c) => !faces.has(c.parentFaceId), );
if (!outsideCell) throw new Error("Expected a cell outside the cleared faces"); const outside = outsideCell.id;
game.state.occupied.add(outside); game.move(p.rootCellId); game.placement = p; game.revision++;
return outside; });
await page.clock.runFor(32); await page.keyboard.press("Space"); await page.clock.runFor(16); await expect(page.locator("#score")).toHaveText("400"); await expect(page.locator("#faces")).toHaveText("2"); await expect(page.locator("#feedback")).toContainText("2 faces cleared"); expect((await inspect(page)).occupied).toEqual([survivor]); await page.clock.runFor(350); expect((await inspect(page)).phase).toBe("playing");});
test("pause hides the board and resumes the same piece and timer", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(1000); await page.keyboard.press("p"); await page.clock.runFor(32); const before = await inspect(page); await expect(page.locator("#scene")).toHaveCSS("visibility", "hidden"); await expect(page.locator("#start")).toHaveText("Resume"); await page.clock.fastForward(20000); expect((await inspect(page)).remaining).toBe(before.remaining); expect((await inspect(page)).occupied).toEqual(before.occupied); await page.keyboard.press("Escape"); await page.clock.runFor(32); await expect(page.locator("#scene")).toHaveCSS("visibility", "visible"); expect((await inspect(page)).current).toBe(before.current); await page.clock.runFor(500); expect((await inspect(page)).remaining).toBeLessThan(before.remaining);});
test("free mode selects shapes, never auto-locks, and rejects conflicts without strikes", async ({ page,}) => { await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?mode=free&seed=1234` : "/tests/fixture.html?k=5&mode=free&seed=1234", ); await expect(page.locator("#free-play-tools")).toBeVisible(); await expect(page.locator("#time-track")).toBeHidden(); await expect(page.locator("#strikes-stat")).toBeHidden(); await page.getByRole("button", { name: "Select Hook" }).click(); await page.clock.runFor(32); expect((await inspect(page)).current).toBe(1); await page.clock.fastForward(60000); expect((await inspect(page)).occupied).toHaveLength(0); await page.keyboard.press("Space"); await page.clock.runFor(150); const placed = await inspect(page); expect(placed.occupied).toHaveLength(4); expect(placed.current).toBe(1);
for (let i = 0; i < 4; i++) await page.keyboard.press("Space"); await page.clock.runFor(32); expect((await inspect(page)).occupied).toEqual(placed.occupied); expect((await inspect(page)).phase).toBe("playing"); await page.getByRole("button", { name: "Select Crown" }).click(); await page.clock.runFor(32); expect((await inspect(page)).current).toBe(2); await page.getByRole("button", { name: "Clear board" }).click(); await page.clock.runFor(32); expect((await inspect(page)).occupied).toHaveLength(0); expect((await inspect(page)).current).toBe(2);});
test("Ribbon keeps its selected orientation when the mouse moves from cell 144 to 148", async ({ page,}) => { await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?mode=free&seed=1234` : "/tests/fixture.html?k=5&mode=free&seed=1234", ); await page.getByRole("button", { name: "Select Ribbon" }).click();
const positions = await page.evaluate(() => { const { game, view } = window.__rad; game.restart(144);
return [144, 148].map((id) => { const cell = game.board.cells[id], point = view.camera.position.clone().set(0, 0, 0);
for (const v of cell.vertexIds) { const [x, y, z] = game.board.vertices[v]; point.x += x / 3; point.y += y / 3; point.z += z / 3; }
point.project(view.camera); const rect = view.renderer.domElement.getBoundingClientRect();
return { x: rect.left + ((point.x + 1) * rect.width) / 2, y: rect.top + ((1 - point.y) * rect.height) / 2, }; }); });
await page.mouse.move(positions[0].x, positions[0].y); await page.clock.runFor(32); expect((await inspect(page)).anchor).toBe(144); await page.keyboard.press("r"); await page.keyboard.press("r"); await page.clock.runFor(32); const before = await inspect(page); await page.mouse.move(positions[1].x, positions[1].y); await page.clock.runFor(32); const after = await inspect(page); expect(after.anchor).toBe(148); expect(after.rotation).toBe(before.rotation); expect(after.mirrored).toBe(before.mirrored); expect(after.cells).toHaveLength(4); expect(after.occupied).toHaveLength(0); await page.screenshot({ path: "/tmp/rad-cohesion-fixed-hover.png" });});
test("phone touch drags rotate without placing, taps place, buttons work, and pinch never places", async ({ browser,}) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, });
const page = await context.newPage(); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); // Keep native event diagnostics for intermittent browser touch delivery failures. await page.addInitScript(() => { window.__touchLog = [];
for (const type of [ "pointerdown", "pointerup", "touchstart", "touchend", "click", "pointercancel", ]) document.addEventListener( type, (e) => window.__touchLog.push({ type, target: e.target instanceof Element ? e.target.id : undefined, x: e instanceof MouseEvent ? e.clientX : undefined, y: e instanceof MouseEvent ? e.clientY : undefined, time: e.timeStamp, prevented: e.defaultPrevented, }), true, ); }); await page.clock.install(); await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?mode=free&seed=1234` : "http://127.0.0.1:5187/tests/fixture.html?k=5&mode=free&seed=1234", ); await page.clock.runFor(32); await expect(page.locator("#centered-controls")).toHaveCount(0); await expect(page.locator("#scene")).toHaveClass(/center-aim/); const rect = (await page.locator("#scene canvas.three-canvas").boundingBox())!; expect(rect.width).toBeGreaterThan(350); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( 390, );
const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
const cdp = await context.newCDPSession(page);
const touch = async ( type: "touchStart" | "touchMove" | "touchEnd" | "touchCancel", points: { x: number; y: number; id: number }[], ) => { await cdp.send("Input.dispatchTouchEvent", { type, touchPoints: points }); await page.clock.runFor(16); };
const initial = await inspect(page); await touch("touchStart", [{ x, y, id: 0 }]);
for (let i = 1; i <= 8; i++) await touch("touchMove", [{ x: x + i * 12, y: y + i * 6, id: 0 }]); await touch("touchEnd", []); const dragged = await inspect(page); expect(dragged.camera).not.toEqual(initial.camera); expect(dragged.occupied).toHaveLength(0); await page.clock.runFor(500); expect((await inspect(page)).camera).toEqual(dragged.camera);
const aim = await page.evaluate(() => { const { game, view } = window.__rad;
if (!game.pointerPosition) throw new Error("Expected the pointer to hit the board");
return view.camera.position .clone() .fromArray(game.pointerPosition) .project(view.camera) .toArray(); });
expect(aim[0]).toBeCloseTo(0, 5); expect(aim[1]).toBeCloseTo(0, 5); await page.locator("#rotate-right").tap(); // Touch compatibility clicks can arrive after touch-up; wait for their result. await expect .poll(async () => (await inspect(page)).rotation) .toBe((dragged.rotation + 1) % 6) .catch(async (error) => { const diagnostic = await page.evaluate(() => { const { game } = window.__rad; const button = document.getElementById("rotate-right")!; const rect = button.getBoundingClientRect();
return { events: window.__touchLog, phase: game.phase, mode: game.mode, paused: game.paused, rotation: game.rotation, mirrored: game.mirrored, occupied: [...game.state.occupied], rect: rect.toJSON(), hit: document.elementFromPoint( rect.x + rect.width / 2, rect.y + rect.height / 2, )?.outerHTML, }; });
throw new Error( error.message + "\nTouch diagnostic: " + JSON.stringify(diagnostic), ); }); await page.clock.runFor(32); await page.locator("#flip-piece").tap(); await page.clock.runFor(32); await expect .poll(async () => (await inspect(page)).mirrored) .toBe(!dragged.mirrored); await page.locator("#reset-view").tap(); await page.getByRole("button", { name: "Clear board" }).tap(); await page.clock.runFor(32); const target = await inspect(page); expect(target.legal).toBe(true);
if (!target.cells) throw new Error("Expected a legal target footprint"); await page.touchscreen.tap(x + 90, y); // Taps place center preview, not the touched triangle. await page.clock.runFor(150); expect((await inspect(page)).occupied.sort()).toEqual([...target.cells].sort()); await page.getByRole("button", { name: "Clear board" }).tap(); await page.clock.runFor(32); const beforePinch = await inspect(page); await touch("touchStart", [ { x: x - 40, y, id: 0 }, { x: x + 40, y, id: 1 }, ]); await touch("touchMove", [ { x: x - 80, y, id: 0 }, { x: x + 80, y, id: 1 }, ]); await touch("touchEnd", []); expect((await inspect(page)).occupied).toHaveLength(0); expect((await inspect(page)).camera).not.toEqual(beforePinch.camera); await touch("touchStart", [{ x, y, id: 0 }]); await touch("touchCancel", []); expect((await inspect(page)).occupied).toHaveLength(0); await page.screenshot({ path: "/tmp/rad-cohesion-mobile-controls.png" }); expect(errors).toEqual([]); await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?k=5&seed=1234` : "http://127.0.0.1:5187/tests/fixture.html?k=5&seed=1234", ); await page.clock.runFor(32); await page.locator("#start").tap(); await expect.poll(async () => (await inspect(page)).phase).toBe("playing"); await page.clock.runFor(32); await expect(page.locator(".arena #time-track")).toBeVisible(); await expect(page.locator("#seconds, #timer")).toHaveCount(0); const phoneBar = (await page.locator("#time-track").boundingBox())!; const phoneCanvas = (await page.locator("#scene canvas.three-canvas").boundingBox())!; expect(phoneBar.x).toBeGreaterThan(phoneCanvas.x); expect(phoneBar.x + phoneBar.width).toBeLessThan( phoneCanvas.x + phoneCanvas.width, ); expect(phoneBar.y).toBeLessThan(phoneCanvas.y + phoneCanvas.height / 3); await page.locator("#audio").tap(); await expect( page.getByRole("button", { name: "Audio", exact: true }), ).toHaveAttribute("aria-pressed", "true"); await expect .poll(async () => page.evaluate(() => window.__rad.audio.playing)) .toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( 390, ); await page.screenshot({ path: "/tmp/rad-cohesion-mobile-timer.png" }); expect(errors).toEqual([]); await context.close();});
test("desktop defaults to ordinary hover placement and never turns at an edge without dragging", async ({ page,}) => { await expect(page.locator("#centered-controls")).toHaveCount(0); await page.locator("#start").click(); const rect = (await page.locator("#scene canvas.three-canvas").boundingBox())!; const initial = await inspect(page); await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); await page.mouse.move(rect.x + rect.width - 2, rect.y + rect.height / 2, { steps: 10, }); await page.clock.runFor(500); expect((await inspect(page)).camera).toEqual(initial.camera); await expect(page.locator("#scene")).not.toHaveClass(/center-aim/);});
test("desktop center aim toggle keeps drag orbit and places the centered preview", async ({ page,}) => { await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?mode=free&seed=1234` : "/tests/fixture.html?k=5&mode=free&seed=1234", ); const button = page.locator("#center-aim"); await expect(button).toHaveAttribute("aria-pressed", "false"); await button.click(); await expect(button).toHaveAttribute("aria-pressed", "true"); await page.clock.runFor(32); const rect = (await page.locator("#scene canvas.three-canvas").boundingBox())!;
const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
const initial = await inspect(page); await page.mouse.move(rect.x + rect.width - 3, y); await page.clock.runFor(500); expect((await inspect(page)).camera).toEqual(initial.camera); expect((await inspect(page)).anchor).toBe(initial.anchor); await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + 110, y + 50, { steps: 10 }); await page.mouse.up(); await page.clock.runFor(32); const dragged = await inspect(page); expect(dragged.camera).not.toEqual(initial.camera); expect(dragged.occupied).toHaveLength(0);
const aim = await page.evaluate(() => { const { game, view } = window.__rad;
if (!game.pointerPosition) throw new Error("Expected the pointer to hit the board");
return view.camera.position .clone() .fromArray(game.pointerPosition) .project(view.camera) .toArray(); });
expect(aim[0]).toBeCloseTo(0, 5); expect(aim[1]).toBeCloseTo(0, 5); await page.getByRole("button", { name: "Reset view" }).click(); await page.getByRole("button", { name: "Clear board" }).click(); await page.clock.runFor(32); const target = await inspect(page); expect(target.legal).toBe(true);
if (!target.cells) throw new Error("Expected a legal target footprint"); await page.mouse.click(x + 110, y); await page.clock.runFor(150); expect((await inspect(page)).occupied.sort()).toEqual([...target.cells].sort()); await button.click(); await expect(button).toHaveAttribute("aria-pressed", "false"); await page.mouse.move(x + 70, y); await page.clock.runFor(32); await expect(page.locator("#scene")).not.toHaveClass(/center-aim/);
const hover = await page.evaluate(() => { const { game, view } = window.__rad;
if (!game.pointerPosition) throw new Error("Expected the pointer to hit the board");
return view.camera.position .clone() .fromArray(game.pointerPosition) .project(view.camera).x; });
expect(hover).toBeGreaterThan(0.05);});
for (const faces of [2, 3, 4]) { test(`${faces}-face clear banks a ${5 - faces}-tile piece that can be used once without advancing the queue`, async ({ page, }) => { await page.locator("#start").click(); await page.evaluate((faces) => { const { game } = window.__rad;
const p = game.index.byPiece .slice(0, 3) .flat() .find( (p) => new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), ).size === faces, );
if (!p) throw new Error("No placement matches the test setup");
game.current = p.pieceType; game.move(p.rootCellId); game.placement = p;
const touched = new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), );
for (const f of touched) for (const id of game.board.faces[f]) if (!p.cellIds.includes(id)) game.state.occupied.add(id); game.revision++; }, faces); await page.clock.runFor(32); await page.keyboard.press("Space"); await page.clock.runFor(32); await expect(page.locator("#score")).toHaveText( (100 * faces ** 2).toLocaleString("en-US"), ); await expect(page.locator(`#reward-count-${faces + 1}`)).toHaveText("×1"); await expect(page.locator("#feedback")).toContainText("saved"); await page.clock.runFor(350); const original = await inspect(page); const use = page.locator(`#use-reward-${faces + 1}`); await expect(use).toBeEnabled(); await use.click(); await page.clock.runFor(32); const reward = await inspect(page); expect(reward.current).toBe(faces + 1); expect(reward.cells).toHaveLength(5 - faces); expect(reward.queue).toEqual(original.queue); expect(reward.rewardActive).toBe(true); expect(reward.rewards).toEqual([0, 0, 0]); await expect(page.locator("#reward-active")).toBeVisible(); await expect(use).toBeDisabled(); await page.keyboard.press("Space"); await page.clock.runFor(150); const resumed = await inspect(page); expect(resumed.rewardActive).toBe(false); expect(resumed.current).toBe(original.current); expect(resumed.queue).toEqual(original.queue); expect(resumed.occupied).toHaveLength(original.occupied.length + 5 - faces); await expect(page.locator("#reward-active")).toBeHidden(); await expect(use).toBeDisabled();
if (faces === 2) await page.screenshot({ path: "/tmp/rad-cohesion-saved-pieces.png" }); await page.getByRole("button", { name: "Restart", exact: true }).click(); await page.clock.runFor(32); expect((await inspect(page)).rewards).toEqual([0, 0, 0]); });}
test("free mode selects and repeats all small shapes while saved items remain hidden and unavailable", async ({ page,}) => { await page.goto( process.env.RAD_BROWSER_FILE ? `file://${process.env.RAD_BROWSER_FILE}?mode=free&seed=1234` : "/tests/fixture.html?k=5&mode=free&seed=1234", );
for (const [name, id, size] of [ ["Triamond", 3, 3], ["Diamond", 4, 2], ["Moniamond", 5, 1], ] as const) { await page.getByRole("button", { name: "Clear board" }).click(); await page .getByRole("button", { name: `Select ${name}`, exact: true }) .click(); await page.clock.runFor(32); const selected = await inspect(page); expect(selected.current).toBe(id); expect(selected.cells).toHaveLength(size); expect(selected.legal).toBe(true); await page.keyboard.press("Space"); await page.clock.runFor(150); const placed = await inspect(page); expect(placed.current).toBe(id); expect(placed.occupied).toHaveLength(size); expect(placed.rewards).toEqual([0, 0, 0]); expect(placed.rewardActive).toBe(false); await page.evaluate(() => window.__rad.game.move(300)); await page.clock.runFor(32); await page.keyboard.press("Space"); await page.clock.runFor(150); expect((await inspect(page)).occupied).toHaveLength(2 * size); }
await page.getByRole("button", { name: "Clear board" }).click(); await page.evaluate(() => { window.__rad.game.rewards = [1, 0, 0]; }); await page.clock.runFor(32); await expect(page.locator("#saved-pieces")).toBeHidden(); await expect(page.locator("#use-reward-3")).toBeDisabled(); expect(await page.evaluate(() => window.__rad.game.useReward(3))).toBe(false); expect((await inspect(page)).rewardActive).toBe(false); await expect( page.getByRole("button", { name: "Select Moniamond", exact: true }), ).toBeEnabled();});
test("levels advance on five faces, score at the starting level, and the board timer is bar-only", async ({ page,}) => { // Only explicit clock steps should consume the piece timer during UI assertions. await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1_000)); await expect(page.locator("#time-track")).toBeHidden(); await page.locator("#start").click(); await page.clock.runFor(32); await expect(page.locator(".arena #time-track")).toBeVisible(); await expect(page.locator(".piece-panel #time-track")).toHaveCount(0); await expect(page.locator("#seconds, #timer")).toHaveCount(0); await expect(page.locator("#level")).toHaveText("1"); await expect(page.locator("#level-progress-label")).toHaveText("0 / 5 faces"); const canvas = (await page.locator("#scene canvas.three-canvas").boundingBox())!; const bar = (await page.locator("#time-track").boundingBox())!; expect(bar.x + bar.width / 2).toBeCloseTo(canvas.x + canvas.width / 2, 0); expect(bar.y).toBeGreaterThan(canvas.y); expect(bar.y).toBeLessThan(canvas.y + canvas.height / 3); await page.clock.runFor(2000); await page.keyboard.press("p"); await page.clock.runFor(32); await expect(page.locator("#time-track")).toBeHidden(); const beforePause = await inspect(page);
await page.clock.fastForward(20000); expect((await inspect(page)).remaining).toBe(beforePause.remaining); await page.keyboard.press("p"); await page.clock.runFor(32); await expect(page.locator("#time-track")).toBeVisible();
const beforeBar = Number( await page.locator("#time-track").getAttribute("aria-valuenow"), );
expect(beforeBar).toBeGreaterThan(75); expect(beforeBar).toBeLessThan(85);
const fillFace = async (setFaces?: number) => { await page.evaluate((setFaces) => { const { game } = window.__rad;
if (setFaces !== undefined) game.state.facesCleared = setFaces;
const p = game.index.byPiece[game.current].find( (p) => p.cellIds.every((id: number) => !game.state.occupied.has(id)) && new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), ).size === 1, );
if (!p) throw new Error("No placement matches the test setup");
game.move(p.rootCellId); game.placement = p; const f = game.board.cells[p.cellIds[0]].parentFaceId;
for (const id of game.board.faces[f]) if (!p.cellIds.includes(id)) game.state.occupied.add(id); game.revision++; }, setFaces); await page.keyboard.press("Space"); await page.clock.runFor(32); };
await fillFace(4); await expect(page.locator("#level")).toHaveText("2"); await expect(page.locator("#level-progress-label")).toHaveText("0 / 5 faces"); await expect(page.locator("#score")).toHaveText("100"); await expect(page.locator("#feedback")).toContainText("+100"); await expect(page.locator("#feedback")).toContainText("Level 2"); await page.clock.runFor(350); expect((await inspect(page)).duration).toBe(9); await fillFace(); await expect(page.locator("#score")).toHaveText("300"); await expect(page.locator("#feedback")).toContainText("+200"); await expect(page.locator("#score-multiplier")).toHaveText("×2 score"); await expect(page.locator("#level-track")).toHaveAttribute( "aria-valuenow", "1", ); await page.screenshot({ path: "/tmp/rad-cohesion-level-progress.png" }); await page.getByRole("button", { name: "Restart", exact: true }).click(); await page.clock.runFor(32); await expect(page.locator("#level")).toHaveText("1"); await expect(page.locator("#score-multiplier")).toHaveText("×1 score"); await expect(page.locator("#level-track")).toHaveAttribute( "aria-valuenow", "0", ); expect((await inspect(page)).duration).toBe(10);});
test("audio loads only on demand, loops the real theme, follows pause, and remembers its preference", async ({ page,}) => { const state = () => page.evaluate(() => { const { audio } = window.__rad;
return { enabled: audio.enabled, playing: audio.playing, context: audio.context?.state ?? null, duration: audio.source?.buffer?.duration, loop: audio.source?.loop, gain: audio.gain?.gain.value, time: audio.context?.currentTime, }; });
const button = page.getByRole("button", { name: "Audio", exact: true }); await expect(page.locator(".game-toolbar > :last-child")).toHaveAttribute( "id", "mode-switch", ); await expect(button).toHaveText(""); await expect(button.locator(".audio-muted")).toBeVisible(); await expect(button).toHaveAttribute("aria-pressed", "false"); expect((await state()).context).toBe(null); expect( await page.evaluate(() => performance .getEntriesByType("resource") .some((e) => e.name.endsWith(".mp3")), ), ).toBe(false); await button.click(); await expect(button).toHaveAttribute("aria-pressed", "true"); await expect(button).toHaveAttribute("aria-busy", "false"); await expect.poll(async () => (await state()).playing).toBe(true); await expect(button.locator(".audio-waves")).toBeVisible(); await expect(button.locator(".audio-muted")).toBeHidden(); const loaded = await state(); expect(loaded.duration).toBeCloseTo(72, 1); expect(loaded.loop).toBe(true); await expect.poll(async () => (await state()).gain).toBeGreaterThan(0.2); await page.evaluate(() => { window.__originalMusicSource = window.__rad.audio.source; }); await page.locator("#start").click(); await page.clock.runFor(32); await page.keyboard.press("p"); await page.clock.runFor(120); await expect.poll(async () => (await state()).context).toBe("suspended"); const paused = await state(); expect(paused.enabled).toBe(true); await page.clock.runFor(500); expect((await state()).time).toBe(paused.time); await page.keyboard.press("p"); await page.clock.runFor(32); await expect.poll(async () => (await state()).playing).toBe(true); await page.getByRole("button", { name: "Restart", exact: true }).click(); await page.clock.runFor(32); expect( await page.evaluate( () => window.__originalMusicSource === window.__rad.audio.source, ), ).toBe(true); await button.click(); await page.clock.runFor(120); await expect(button).toHaveAttribute("aria-pressed", "false"); await expect.poll(async () => (await state()).context).toBe("suspended"); await button.click(); await expect(button).toHaveAttribute("aria-pressed", "true"); await expect(button).toHaveAttribute("aria-busy", "false"); expect( await page.evaluate( () => window.__originalMusicSource === window.__rad.audio.source, ), ).toBe(true); await page.reload(); await page.clock.runFor(32); await expect(button).toHaveAttribute("aria-pressed", "true"); await expect(button).toHaveAttribute("aria-busy", "false"); expect((await state()).context).toBe(null); await page.locator("#start").click(); await page.clock.runFor(32); await expect.poll(async () => (await state()).playing).toBe(true); // A run ending stops the soundtrack while retaining the user's preference. await page.evaluate(() => { const { game } = window.__rad; game.state.misses = 2; game.placement = null; }); await page.clock.fastForward(10100); await page.clock.runFor(120); await expect.poll(async () => (await state()).context).toBe("suspended"); expect((await state()).enabled).toBe(true); await page.screenshot({ path: "/tmp/rad-cohesion-music-toggle.png" });});
test("placement sound plays once per successful lock, including clears and automatic locks, and respects mute", async ({ page,}) => { const button = page.getByRole("button", { name: "Audio", exact: true }); await button.click(); await expect(button).toHaveAttribute("aria-busy", "false"); await expect .poll(async () => page.evaluate(() => window.__rad.audio.playing)) .toBe(true); await page.evaluate(() => { const { audio } = window.__rad; window.__effectStarts = []; window.__themeSource = audio.source; const context = audio.context;
if (!context) throw new Error("Expected audio to have an active context"); const original = context.createBufferSource.bind(context); context.createBufferSource = () => { const source = original(), start = source.start.bind(source);
source.start = (...args: number[]) => { if (source.buffer && source.buffer === audio.placementBuffer) window.__effectStarts.push({ duration: source.buffer.duration, loop: source.loop, });
return start(...args); };
return source; }; });
const effectCount = () => page.evaluate(() => window.__effectStarts.length);
const placeAtEmpty = async (clear = false) => { await page.mouse.move(0, 0); await page.evaluate((clear) => { const { game } = window.__rad;
const p = game.index.byPiece[game.current].find( (p) => p.cellIds.every((id: number) => !game.state.occupied.has(id)) && (!clear || new Set( p.cellIds.map((id: number) => game.board.cells[id].parentFaceId), ).size === 1), );
if (!p) throw new Error("No placement matches the test setup");
game.move(p.rootCellId); game.placement = p;
if (clear) for (const id of game.board.faces[ game.board.cells[p.cellIds[0]].parentFaceId ]) if (!p.cellIds.includes(id)) game.state.occupied.add(id); game.revision++; }, clear); };
await page.locator("#start").click(); await page.clock.runFor(32); await page.keyboard.press("Space"); await page.clock.runFor(32); expect(await effectCount()).toBe(1); const effect = await page.evaluate(() => window.__effectStarts[0]); expect(effect.duration).toBeGreaterThan(0); expect(effect.duration).toBe( await page.evaluate(() => { const buffer = window.__rad.audio.placementBuffer;
if (!buffer) throw new Error("Expected the placement sound to be loaded");
return buffer.duration; }), ); expect(effect.loop).toBe(false); await page.clock.runFor(150); await button.click(); await page.clock.runFor(120); await placeAtEmpty(); await page.keyboard.press("Space"); await page.clock.runFor(32); expect(await effectCount()).toBe(1); await page.clock.runFor(150); await button.click(); await expect .poll(async () => page.evaluate(() => window.__rad.audio.playing)) .toBe(true); expect(await effectCount()).toBe(1); // Muted placements must not replay on unmute. await placeAtEmpty(); await page.clock.fastForward(10100); await page.clock.runFor(32); expect(await effectCount()).toBe(2); await page.clock.runFor(150); await placeAtEmpty(); await page.evaluate(() => { const { game } = window.__rad;
if (!game.placement) throw new Error("Expected an active placement"); game.state.occupied.add(game.placement.cellIds[0]); }); await page.keyboard.press("Space"); await page.clock.runFor(32); expect(await effectCount()).toBe(2); await page.clock.runFor(350); await placeAtEmpty(true); await page.keyboard.press("Space"); await page.clock.runFor(32); expect(await effectCount()).toBe(3); await expect(page.locator("#feedback")).toContainText("cleared"); expect( await page.evaluate( () => window.__themeSource === window.__rad.audio.source, ), ).toBe(true);});
test("invalid manual placement plays a rejection sound and preserves the piece until timeout", async ({ page,}) => { const button = page.getByRole("button", { name: "Audio", exact: true }); await button.click(); await expect(button).toHaveAttribute("aria-busy", "false"); await expect .poll(() => page.evaluate(() => window.__rad.audio.playing)) .toBe(true); await page.locator("#start").click(); await page.clock.runFor(32);
const before = await page.evaluate(() => { const { game, audio } = window.__rad;
if (!game.placement) throw new Error("Expected an active placement"); game.state.occupied.add(game.placement.cellIds[0]); game.revision++; window.__invalidStarts = 0; const context = audio.context;
if (!context) throw new Error("Expected audio to have an active context"); const original = context.createBufferSource.bind(context); context.createBufferSource = () => { const source = original(), start = source.start.bind(source);
source.start = (...args: number[]) => { if (source.buffer === audio.invalidBuffer) window.__invalidStarts++;
return start(...args); };
return source; };
return { current: game.current, queue: [...game.queue], occupied: [...game.state.occupied], remaining: game.remaining, }; });
await page.keyboard.press("Space"); await page.clock.runFor(32); expect( await page.evaluate(() => { const { game } = window.__rad;
return { current: game.current, queue: game.queue, occupied: [...game.state.occupied], misses: game.state.misses, phase: game.phase, starts: window.__invalidStarts, }; }), ).toEqual({ current: before.current, queue: before.queue, occupied: before.occupied, misses: 0, phase: "playing", starts: 1, });
const remaining = await page.evaluate( () => window.__rad.game.remaining, );
expect(remaining).toBeLessThanOrEqual(before.remaining); expect(remaining).toBeGreaterThan(before.remaining - 0.1); await page.clock.fastForward(10100); expect( await page.evaluate(() => window.__rad.game.state.misses), ).toBe(1); expect(await page.evaluate(() => window.__invalidStarts)).toBe(1);});
test("planar picking round-trips to the flat surface and preserves the 3D camera", async ({ page,}) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.locator("#start").click(); await page.clock.runFor(32); const cameraBefore = (await inspect(page)).camera; await page.locator("#projection-mode").click(); await page.clock.runFor(220); await expect(page.locator("#scene .planar-canvas")).toBeVisible(); await expect(page.locator("#projection-mode")).toHaveText("2D");
const pick = await page.evaluate(() => { const { view } = window.__rad;
const hit = view.planarView.pickAt( view.planarView.canvas.clientWidth / 2, view.planarView.canvas.clientHeight / 2, );
if (!hit) throw new Error("Expected a planar board hit");
return hit; });
const rect = (await page.locator("#scene .planar-canvas").boundingBox())!; await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); await page.clock.runFor(32);
const hovered = await page.evaluate(() => { const { game, view } = window.__rad;
return { anchor: game.anchor, point: game.pointerPosition, camera: view.camera.position.toArray(), }; });
expect(hovered.anchor).toBe(pick.cellId); expect(hovered.point?.[0]).toBeCloseTo(pick.point[0], 8); expect(hovered.point?.[1]).toBeCloseTo(pick.point[1], 8); expect(hovered.point?.[2]).toBeCloseTo(pick.point[2], 8); expect(hovered.camera).toEqual(cameraBefore);
await page.locator("#projection-mode").click(); await page.clock.runFor(32); await expect(page.locator("#scene .planar-canvas")).toBeHidden(); expect((await inspect(page)).camera).toEqual(cameraBefore); expect(errors).toEqual([]);});
test("planar clicks place and reject on the picked cell while piece controls still work", async ({ page,}) => { await page.goto("/tests/fixture.html?k=5&mode=free&seed=1234"); await page.locator("#restart").click(); await page.getByRole("button", { name: "Select Moniamond", exact: true }).click(); await page.locator("#projection-mode").click(); await page.clock.runFor(32);
const canvas = page.locator("#scene .planar-canvas"), rect = (await canvas.boundingBox())!, point = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, expectedCell = await page.evaluate(() => { const { planarView } = window.__rad.view;
return planarView.pickAt( planarView.canvas.clientWidth / 2, planarView.canvas.clientHeight / 2, )!.cellId; });
await page.mouse.click(point.x, point.y); await page.clock.runFor(150); const placed = await inspect(page); expect(placed.occupied).toEqual([expectedCell]); expect(placed.current).toBe(5);
const rejectedBefore = await page.evaluate( () => window.__rad.game.rejectedPlacements, );
await page.mouse.click(point.x, point.y); await page.clock.runFor(32); expect((await inspect(page)).occupied).toEqual([expectedCell]); expect( await page.evaluate(() => window.__rad.game.rejectedPlacements), ).toBe(rejectedBefore + 1);
await page.locator("#rotate-left").click(); await page.locator("#flip-piece").click(); const transformed = await inspect(page); expect(transformed.rotation).toBe(5); expect(transformed.mirrored).toBe(true);});
test("center aim refreshes the active projection without cross-mode picking", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32);
const hits = await page.evaluate(() => { const { view } = window.__rad;
// SAFETY: pick only reads x and y to cast the center ray. const perspective = view["pick"]( { x: 0, y: 0 } as Parameters<typeof view["pick"]>[0], );
if (!perspective) throw new Error("Expected a 3D center hit");
return perspective; });
await page.locator("#center-aim").click(); await page.clock.runFor(32); const inThree = await inspect(page); expect(inThree.anchor).toBe(hits.cellId); expect(inThree.point?.[0]).toBeCloseTo(hits.point[0], 8); expect(inThree.point?.[1]).toBeCloseTo(hits.point[1], 8); expect(inThree.point?.[2]).toBeCloseTo(hits.point[2], 8);
await page.locator("#projection-mode").click(); await page.clock.runFor(32);
const planar = await page.evaluate(() => { const { planarView } = window.__rad.view;
return planarView.pickAt( planarView.canvas.clientWidth / 2, planarView.canvas.clientHeight / 2, ); });
if (!planar) throw new Error("Expected a planar center hit");
const inStereo = await inspect(page); expect(inStereo.anchor).toBe(planar.cellId); expect(inStereo.point?.[0]).toBeCloseTo(planar.point[0], 8); expect(inStereo.point?.[1]).toBeCloseTo(planar.point[1], 8); expect(inStereo.point?.[2]).toBeCloseTo(planar.point[2], 8);});
test("automatic planar projection follows the highlighted face and pins the hit", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(32);
const canvas = page.locator("#scene .planar-canvas"), rect = (await canvas.boundingBox())!;
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2); await page.mouse.wheel(0, -180); await page.clock.runFor(32);
// Keep real-time frame callbacks from finishing the transition between assertions. const pauseAt = await page.evaluate(() => Date.now() + 1_000); await page.clock.pauseAt(pauseAt);
const target = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const sourceFaceId = game.board.cells[game.anchor].parentFaceId; const scale = planar["baseScale"] * planar["zoom"];
for (const cell of game.board.cells) { if (cell.parentFaceId === sourceFaceId) continue;
const corners = cell.vertexIds.map((id) => game.board.vertices[id]);
const point: [number, number, number] = [ (corners[0][0] + corners[1][0] + corners[2][0]) / 3, (corners[0][1] + corners[1][1] + corners[2][1]) / 3, (corners[0][2] + corners[1][2] + corners[2][2]) / 3, ];
const projected = planar.map.project(point);
if (!projected) continue;
const x = planar.canvas.clientWidth / 2 + planar["pan"].x + projected[0] * scale, y = planar.canvas.clientHeight / 2 + planar["pan"].y - projected[1] * scale;
if ( x < 8 || y < 8 || x >= planar.canvas.clientWidth - 8 || y >= planar.canvas.clientHeight - 8 ) continue;
const rect = planar.canvas.getBoundingClientRect();
const clientX = Math.round(rect.left + x), clientY = Math.round(rect.top + y), localX = clientX - rect.left, localY = clientY - rect.top, hit = planar.pickAt(localX, localY);
if (hit && game.board.cells[hit.cellId].parentFaceId !== sourceFaceId) { planar.canvas.dispatchEvent( new PointerEvent("pointermove", { pointerId: 91, pointerType: "mouse", isPrimary: true, clientX, clientY, }), );
return { x: localX, y: localY, clientX, clientY, faceId: game.board.cells[hit.cellId].parentFaceId, hit, sourceFaceId, zoom: planar["zoom"], camera: view.camera.position.toArray(), occupied: [...game.state.occupied], }; } }
throw new Error("Expected a visible hit in another parent face"); });
await page.clock.runFor(32);
const intermediate = await page.evaluate( ({ x, y }) => { const { game, view } = window.__rad; const planar = view.planarView; const projected = game.pointerPosition && planar.map.project(game.pointerPosition); const scale = planar["baseScale"] * planar["zoom"];
return { animating: planar["projectionTransition"] !== null, mapPole: [...planar.map.pole], targetPole: planar["projectionTransition"] ? [...planar["projectionTransition"].target.pole] : null, pinnedPixel: projected ? { x: planar.canvas.clientWidth / 2 + planar["pan"].x + projected[0] * scale, y: planar.canvas.clientHeight / 2 + planar["pan"].y - projected[1] * scale, } : null, pickedCell: planar.pickAt(x, y)?.cellId, }; }, { x: target.x, y: target.y }, );
expect(intermediate.animating).toBe(true); expect(intermediate.mapPole).not.toEqual(intermediate.targetPole); expect(intermediate.pinnedPixel?.x).toBeCloseTo(target.x, 0); expect(intermediate.pinnedPixel?.y).toBeCloseTo(target.y, 0); expect(intermediate.pickedCell).toBe(target.hit.cellId);
const pauseButton = await page.locator("#pause").boundingBox();
if (!pauseButton) throw new Error("Expected the pause button to be visible");
// The clock is paused, so frame callbacks only run through explicit runFor calls. await page.mouse.click( pauseButton.x + pauseButton.width / 2, pauseButton.y + pauseButton.height / 2, );
const pausedTransition = await page.evaluate(() => { const planar = window.__rad.view.planarView; const transition = planar["projectionTransition"]!; const map = planar.map;
planar["advanceProjectionTransition"](0);
return { map: [...planar.map.pole], sameMapAfterZeroDt: planar.map === map, elapsed: transition.elapsed, }; });
await page.clock.runFor(100);
const frozenTransition = await page.evaluate(() => { const planar = window.__rad.view.planarView; const transition = planar["projectionTransition"]!; const map = planar.map;
planar["advanceProjectionTransition"](0);
return { map: [...planar.map.pole], sameMapAfterZeroDt: planar.map === map, elapsed: transition.elapsed, }; });
expect(pausedTransition.sameMapAfterZeroDt).toBe(true); expect(frozenTransition).toEqual(pausedTransition); await page.locator("#pause").click(); await page.clock.runFor(220);
const after = await page.evaluate( ({ hit }) => { const { game, view } = window.__rad; const planar = view.planarView; const firstMap = planar.map; const firstRevision = game.revision; const firstPoint = [...(game.pointerPosition ?? [])];
for (let frame = 0; frame < 5; frame++) view.render(0);
const selectedFaceId = game.board.cells[game.anchor].parentFaceId; const face = game.board.baseFaces[selectedFaceId];
const faceCenter: [number, number, number] = [ face.reduce( (sum, vertexId) => sum + game.board.baseVertices[vertexId][0], 0, ), face.reduce( (sum, vertexId) => sum + game.board.baseVertices[vertexId][1], 0, ), face.reduce( (sum, vertexId) => sum + game.board.baseVertices[vertexId][2], 0, ), ];
const faceCenterLength = Math.hypot(...faceCenter);
const normalizedFaceCenter: [number, number, number] = [ faceCenter[0] / faceCenterLength, faceCenter[1] / faceCenterLength, faceCenter[2] / faceCenterLength, ];
const projectedFaceCenter = planar.map.project(normalizedFaceCenter);
const projectedPoint = planar.map.project(hit.point);
const scale = planar["baseScale"] * planar["zoom"];
const pinnedPixel = projectedPoint ? { x: planar.canvas.clientWidth / 2 + planar["pan"].x + projectedPoint[0] * scale, y: planar.canvas.clientHeight / 2 + planar["pan"].y - projectedPoint[1] * scale, } : null;
return { sameMap: planar.map === firstMap, sameRevision: game.revision === firstRevision, firstPoint, anchor: game.anchor, faceId: selectedFaceId, point: game.pointerPosition, poleCellId: planar.map.poleCellId, poleFaceId: game.board.cells[planar.map.poleCellId].parentFaceId, projectedFaceCenter, pinnedPixel, zoom: planar["zoom"], camera: view.camera.position.toArray(), occupied: [...game.state.occupied], phase: game.phase, }; }, { x: target.x, y: target.y, hit: target.hit }, );
expect(after.phase).toBe("playing"); expect(after.anchor).toBe(target.hit.cellId); expect(after.faceId).toBe(target.faceId); expect(after.point?.[0]).toBeCloseTo(target.hit.point[0], 8); expect(after.point?.[1]).toBeCloseTo(target.hit.point[1], 8); expect(after.point?.[2]).toBeCloseTo(target.hit.point[2], 8); expect(after.poleFaceId).not.toBe(after.faceId); expect(after.projectedFaceCenter?.[0]).toBeCloseTo(0, 8); expect(after.projectedFaceCenter?.[1]).toBeCloseTo(0, 8); expect(after.pinnedPixel?.x).toBeCloseTo(target.x, 7); expect(after.pinnedPixel?.y).toBeCloseTo(target.y, 7); expect(after.zoom).toBe(target.zoom); expect(after.camera).toEqual(target.camera); expect(after.occupied).toEqual(target.occupied); expect(after.sameMap).toBe(true); expect(after.sameRevision).toBe(true); expect(after.firstPoint[0]).toBeCloseTo(target.hit.point[0], 8); expect(after.firstPoint[1]).toBeCloseTo(target.hit.point[1], 8); expect(after.firstPoint[2]).toBeCloseTo(target.hit.point[2], 8);
await page.locator("#pause").click();
const paused = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const map = planar.map; const anchor = game.anchor; const point = [...(game.pointerPosition ?? [])]; const occupied = [...game.state.occupied]; const zoom = planar["zoom"]; const rect = planar.canvas.getBoundingClientRect();
planar.canvas.dispatchEvent( new PointerEvent("pointermove", { pointerId: 89, pointerType: "mouse", isPrimary: true, clientX: rect.left + planar.canvas.clientWidth - 10, clientY: rect.top + planar.canvas.clientHeight - 10, }), ); view.render(0);
return { sameMap: planar.map === map, anchorUnchanged: game.anchor === anchor, pointUnchanged: game.pointerPosition?.every((value, axis) => value === point[axis]), occupiedUnchanged: [...game.state.occupied].join(",") === occupied.join(","), zoomUnchanged: planar["zoom"] === zoom, }; });
expect(paused).toEqual({ sameMap: true, anchorUnchanged: true, pointUnchanged: true, occupiedUnchanged: true, zoomUnchanged: true, });});
test("same-face pointer movement retargets the transition pin without restarting it", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(220);
const result = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const sourceFaceId = game.board.cells[game.anchor].parentFaceId;
const findHit = ( predicate: (hit: NonNullable<ReturnType<typeof planar.pickAt>>) => boolean, ) => { for (let y = 12; y < planar.canvas.clientHeight; y += 16) for (let x = 12; x < planar.canvas.clientWidth; x += 16) { const hit = planar.pickAt(x, y);
if (hit && predicate(hit)) return { hit, pixel: { x, y } }; }
return null; };
const first = findHit( (hit) => game.board.cells[hit.cellId].parentFaceId !== sourceFaceId, );
if (!first) throw new Error("Expected a visible hit in another face");
const rect = planar.canvas.getBoundingClientRect();
planar.canvas.dispatchEvent( new PointerEvent("pointermove", { pointerId: 193, pointerType: "mouse", isPrimary: true, clientX: rect.left + first.pixel.x, clientY: rect.top + first.pixel.y, }), );
const transition = planar["projectionTransition"];
if (!transition) throw new Error("Expected a face transition");
view.render(0.06);
const elapsedBeforeMove = transition.elapsed;
const second = findHit((hit) => game.board.cells[hit.cellId].parentFaceId === game.board.cells[first.hit.cellId].parentFaceId && hit.cellId !== first.hit.cellId && Math.hypot( hit.point[0] - first.hit.point[0], hit.point[1] - first.hit.point[1], hit.point[2] - first.hit.point[2], ) > 0.08, );
if (!second) throw new Error("Expected a separate hit in the target face");
planar.canvas.dispatchEvent( new PointerEvent("pointermove", { pointerId: 193, pointerType: "mouse", isPrimary: true, clientX: rect.left + second.pixel.x, clientY: rect.top + second.pixel.y, }), );
const movedTransition = planar["projectionTransition"]!; const sameTransition = movedTransition === transition; const sameTarget = movedTransition.target === transition.target; const movedPin = [...movedTransition.surfacePoint]; const movedElapsed = movedTransition.elapsed; const movedPixel = { ...movedTransition.pixel };
const stableFrames: Array<{ anchor: number; point: number[]; pinError: number; elapsed: number; }> = [];
for (let frame = 0; frame < 5; frame++) { view.render(0.02); const point = game.pointerPosition!; const projected = planar.map.project(point)!; const scale = planar["baseScale"] * planar["zoom"];
const pixel = { x: planar.canvas.clientWidth / 2 + planar["pan"].x + projected[0] * scale, y: planar.canvas.clientHeight / 2 + planar["pan"].y - projected[1] * scale, };
stableFrames.push({ anchor: game.anchor, point: [...point], pinError: Math.hypot(pixel.x - movedPixel.x, pixel.y - movedPixel.y), elapsed: transition.elapsed, }); }
const activeAt160ms = planar["projectionTransition"] === transition;
view.render(0.02);
const settledMapIsTarget = planar.map === transition.target; const settled = game.pointerPosition!; const settledProjected = planar.map.project(settled)!; const settledScale = planar["baseScale"] * planar["zoom"];
const settledPixel = { x: planar.canvas.clientWidth / 2 + planar["pan"].x + settledProjected[0] * settledScale, y: planar.canvas.clientHeight / 2 + planar["pan"].y - settledProjected[1] * settledScale, };
return { firstCell: first.hit.cellId, movedCell: second.hit.cellId, sameTransition, sameTarget, elapsedBeforeMove, movedElapsed, movedPin, expectedPin: second.hit.point, movedPixel, expectedPixel: second.pixel, stableFrames, activeAt160ms, settledMapIsTarget, transitionCleared: planar["projectionTransition"] === null, settledAnchor: game.anchor, settledPoint: [...settled], settledPixel, }; });
expect(result.movedCell).not.toBe(result.firstCell); expect(result.sameTransition).toBe(true); expect(result.sameTarget).toBe(true); expect(result.elapsedBeforeMove).toBeCloseTo(60, 8); expect(result.movedElapsed).toBeCloseTo(60, 8); result.movedPin.forEach((value, axis) => expect(value).toBeCloseTo(result.expectedPin[axis], 8), ); expect(result.movedPixel.x).toBeCloseTo(result.expectedPixel.x, 8); expect(result.movedPixel.y).toBeCloseTo(result.expectedPixel.y, 8); expect(result.activeAt160ms).toBe(true); expect(result.stableFrames).toHaveLength(5);
for (const [index, frame] of result.stableFrames.entries()) { expect(frame.anchor).toBe(result.movedCell); expect(frame.elapsed).toBeCloseTo(80 + index * 20, 8); expect(frame.pinError).toBeLessThan(1e-6); frame.point.forEach((value, axis) => expect(value).toBeCloseTo(result.expectedPin[axis], 7), ); }
expect(result.settledMapIsTarget).toBe(true); expect(result.transitionCleared).toBe(true); expect(result.settledAnchor).toBe(result.movedCell); result.settledPoint.forEach((value, axis) => expect(value).toBeCloseTo(result.expectedPin[axis], 7), ); expect(result.settledPixel.x).toBeCloseTo(result.expectedPixel.x, 7); expect(result.settledPixel.y).toBeCloseTo(result.expectedPixel.y, 7);});
test("rapid planar face changes retarget from the displayed frame", async ({ page }) => { await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(220);
const result = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; game.phase = "over";
const findHit = (excluded: ReadonlySet<number>, changeOrientation = false) => { for (let y = 12; y < planar.canvas.clientHeight; y += 24) for (let x = 12; x < planar.canvas.clientWidth; x += 24) { const hit = planar.pickAt(x, y);
if (hit) { const faceId = game.board.cells[hit.cellId].parentFaceId;
// A face whose normal is perpendicular to axisX leaves that axis // unchanged. Choose a target that exercises orientation transport. const normalDotAxis = game.board.baseFaces[faceId].reduce( (sum, vertexId) => sum + game.board.baseVertices[vertexId].reduce( (dot, value, axis) => dot + value * planar.map.axisX[axis], 0, ), 0, );
if (changeOrientation && Math.abs(normalDotAxis) < 1e-5) continue;
if (!excluded.has(faceId)) return { hit, faceId, pixel: { x, y } }; } }
return null; };
const initialFace = game.board.cells[game.anchor].parentFaceId, first = findHit(new Set([initialFace]), true);
if (!first) throw new Error("Expected a visible hit in another face");
planar["projectFace"](first.faceId, first.hit.point, first.pixel, planar.map.axisX);
const firstTarget = planar["projectionTransition"]!.target;
planar["advanceProjectionTransition"](0.09);
const displayedMidpoint = planar.map, second = findHit(new Set([first.faceId]));
if (!second) throw new Error("Expected a visible hit in a third face");
const displayedPan = { ...planar["pan"] };
planar["projectFace"](second.faceId, second.hit.point, second.pixel, displayedMidpoint.axisX);
const retarget = planar["projectionTransition"]!, sameMapOnRetarget = planar.map === displayedMidpoint, samePanOnRetarget = displayedPan.x === planar["pan"].x && displayedPan.y === planar["pan"].y, noQueuedTime = retarget.elapsed === 0, pin = retarget.surfacePoint, targetPole = retarget.target.pole, hintDot = firstTarget.axisX.reduce( (sum, value, axis) => sum + value * targetPole[axis], 0, ), expectedAxis = firstTarget.axisX.map( (value, axis) => value - targetPole[axis] * hintDot, ), axisLength = Math.hypot(...expectedAxis), pinnedBefore = planar.map.project(pin), scale = planar["baseScale"] * planar["zoom"];
planar["advanceProjectionTransition"](0.09);
const intermediateMap = planar.map, projectedPin = intermediateMap.project(pin), picked = planar.pickAt(second.pixel.x, second.pixel.y);
const intermediatePixel = projectedPin ? { x: planar.canvas.clientWidth / 2 + planar["pan"].x + projectedPin[0] * scale, y: planar.canvas.clientHeight / 2 + planar["pan"].y - projectedPin[1] * scale, } : null;
const activeMidway = planar["projectionTransition"] !== null;
planar["advanceProjectionTransition"](0.2);
const finalMap = planar.map, finalProjection = finalMap.project(pin), finalPixel = finalProjection ? { x: planar.canvas.clientWidth / 2 + planar["pan"].x + finalProjection[0] * scale, y: planar.canvas.clientHeight / 2 + planar["pan"].y - finalProjection[1] * scale, } : null;
return { transitionStartedFromDisplayed: retarget.from === displayedMidpoint, mapStayedAtRetarget: sameMapOnRetarget, panStayedAtRetarget: samePanOnRetarget, noQueuedTime, targetTransportedFromLogicalTarget: retarget.target.axisX.map( (value) => value, ).every((value, axis) => Math.abs(value - expectedAxis[axis] / axisLength) < 1e-10), logicalAndDisplayedOrientationDiffer: firstTarget.axisX.some( (value, axis) => Math.abs(value - displayedMidpoint.axisX[axis]) > 1e-5, ), pinWasVisible: pinnedBefore !== null, intermediatePin: intermediatePixel, intermediatePick: picked?.cellId, expectedCell: second.hit.cellId, transitionActiveMidway: activeMidway, finalMapIsTarget: finalMap === retarget.target, finalTransitionCleared: planar["projectionTransition"] === null, finalPin: finalPixel, expectedPixel: second.pixel, }; });
expect(result.transitionStartedFromDisplayed).toBe(true); expect(result.mapStayedAtRetarget).toBe(true); expect(result.panStayedAtRetarget).toBe(true); expect(result.noQueuedTime).toBe(true); expect(result.targetTransportedFromLogicalTarget).toBe(true); expect(result.logicalAndDisplayedOrientationDiffer).toBe(true); expect(result.pinWasVisible).toBe(true); expect(result.intermediatePin?.x).toBeCloseTo(result.expectedPixel.x, 8); expect(result.intermediatePin?.y).toBeCloseTo(result.expectedPixel.y, 8); expect(result.intermediatePick).toBe(result.expectedCell); expect(result.transitionActiveMidway).toBe(true); expect(result.finalMapIsTarget).toBe(true); expect(result.finalTransitionCleared).toBe(true); expect(result.finalPin?.x).toBeCloseTo(result.expectedPixel.x, 8); expect(result.finalPin?.y).toBeCloseTo(result.expectedPixel.y, 8);});
test("entering planar mode favors the saved anchor when its old pixel is offscreen", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32);
const saved = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const hit = planar.map.locate(1.8, -0.6);
// A saved pan can leave a finite Lambert point outside the viewport. planar["pan"] = { x: planar["width"] * 2, y: planar["height"] * 2 };
if (!hit) throw new Error("Expected a hit near the old pole");
game.move(hit.cellId, hit.point); const projected = planar.map.project(hit.point);
if (!projected) throw new Error("Expected a finite point near the old pole");
return { anchor: game.anchor, point: game.pointerPosition, projectedPixel: { x: planar["width"] / 2 + planar["pan"].x + projected[0] * planar["baseScale"], y: planar["height"] / 2 + planar["pan"].y - projected[1] * planar["baseScale"], }, width: planar["width"], height: planar["height"], }; });
expect(saved.projectedPixel.x < 0 || saved.projectedPixel.x > saved.width).toBe(true); expect(saved.projectedPixel.y < 0 || saved.projectedPixel.y > saved.height).toBe(true);
await page.locator("#projection-mode").click(); await page.clock.runFor(220);
const after = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const center = planar.pickAt(planar.canvas.clientWidth / 2, planar.canvas.clientHeight / 2); const faceId = game.board.cells[game.anchor].parentFaceId; const poleFaceId = game.board.cells[planar.map.poleCellId].parentFaceId; const face = game.board.baseFaces[faceId];
const faceCenter: [number, number, number] = [ face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][0], 0), face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][1], 0), face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][2], 0), ];
const faceCenterLength = Math.hypot(...faceCenter);
const normalizedFaceCenter: [number, number, number] = [ faceCenter[0] / faceCenterLength, faceCenter[1] / faceCenterLength, faceCenter[2] / faceCenterLength, ];
return { anchor: game.anchor, point: game.pointerPosition, center, faceId, poleFaceId, projectedFaceCenter: planar.map.project(normalizedFaceCenter), pan: planar["pan"], width: planar.canvas.clientWidth, height: planar.canvas.clientHeight, }; });
expect(after.anchor).toBe(saved.anchor); expect(after.point).toEqual(saved.point); expect(after.center?.cellId).toBe(saved.anchor); expect(after.center?.point[0]).toBeCloseTo(saved.point![0], 7); expect(after.center?.point[1]).toBeCloseTo(saved.point![1], 7); expect(after.center?.point[2]).toBeCloseTo(saved.point![2], 7); expect(after.poleFaceId).not.toBe(after.faceId); expect(after.projectedFaceCenter?.[0]).toBeCloseTo(0, 8); expect(after.projectedFaceCenter?.[1]).toBeCloseTo(0, 8); expect(Math.abs(after.pan.x)).toBeLessThan(after.width * 2); expect(Math.abs(after.pan.y)).toBeLessThan(after.height * 2);});
test("reduced motion settles the automatic planar projection immediately", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(32);
const state = await page.evaluate(() => { const { game, view } = window.__rad;
const planar = view.planarView;
const faceId = game.board.cells[game.anchor].parentFaceId, face = game.board.baseFaces[faceId];
const center: [number, number, number] = [0, 0, 0];
for (const vertexId of face) for (let axis = 0; axis < 3; axis++) center[axis] += game.board.baseVertices[vertexId][axis] / 3;
const length = Math.hypot(...center), normalized: [number, number, number] = [ center[0] / length, center[1] / length, center[2] / length, ], projected = planar.map.project(normalized);
return { transition: planar["projectionTransition"], projected }; });
expect(state.transition).toBeNull(); expect(state.projected?.[0]).toBeCloseTo(0, 8); expect(state.projected?.[1]).toBeCloseTo(0, 8);});
test("2D view supports anchored zoom, pan, and narrow-screen controls", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(32);
await page.setViewportSize({ width: 320, height: 640 }); await page.clock.runFor(32); const arena = (await page.locator(".arena").boundingBox())!; const button = (await page.locator("#projection-mode").boundingBox())!; expect(button.x).toBeGreaterThanOrEqual(arena.x); expect(button.x + button.width).toBeLessThanOrEqual(arena.x + arena.width); const recenter = (await page.locator("#recenter-view").boundingBox())!; expect(recenter.x).toBeGreaterThanOrEqual(arena.x); expect(recenter.x + recenter.width).toBeLessThanOrEqual(arena.x + arena.width);
await page.reload(); await page.clock.runFor(32); await page.locator("#start").click(); await page.locator("#projection-mode").click(); await page.clock.runFor(250); const canvas = page.locator("#scene .planar-canvas"); const rect = (await canvas.boundingBox())!; const pointer = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
await page.mouse.move(pointer.x, pointer.y); await page.clock.runFor(250);
const before = await page.evaluate(() => { const { view } = window.__rad;
return view.planarView.pickAt( view.planarView.canvas.clientWidth / 2, view.planarView.canvas.clientHeight / 2, )?.cellId; });
await page.mouse.move(pointer.x, pointer.y); await page.mouse.wheel(0, -300); await page.clock.runFor(32);
const afterZoom = await page.evaluate(() => { const { view } = window.__rad;
return view.planarView.pickAt( view.planarView.canvas.clientWidth / 2, view.planarView.canvas.clientHeight / 2, )?.cellId; });
expect(afterZoom).toBe(before);
const occupiedBefore = (await inspect(page)).occupied; await page.mouse.move(pointer.x, pointer.y); await page.mouse.down();
for (let step = 1; step <= 20; step++) await page.mouse.move(pointer.x + step, pointer.y + step * 0.5);
await page.mouse.up(); await page.clock.runFor(32); expect((await inspect(page)).phase).toBe("playing"); expect((await inspect(page)).occupied).toEqual(occupiedBefore);});
test("planar recenter and reset favor the active parent face", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32); await expect(page.locator("#recenter-view")).toBeHidden(); await page.locator("#projection-mode").click(); await page.clock.runFor(32); await expect(page.locator("#recenter-view")).toBeVisible();
const before = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const outer = planar.map.locate(1.95, 0);
if (!outer) throw new Error("Expected a hit in the disk rim");
game.move(outer.cellId, outer.point);
return { anchor: game.anchor, point: game.pointerPosition, placement: game.placement?.cellIds, rotation: game.rotation, mirrored: game.mirrored, occupied: [...game.state.occupied], camera: view.camera.position.toArray(), }; });
await page.locator("#recenter-view").click(); await page.clock.runFor(32);
const afterButton = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const center = planar.pickAt(planar.canvas.clientWidth / 2, planar.canvas.clientHeight / 2);
return { poleCellId: planar.map.poleCellId, center, anchor: game.anchor, point: game.pointerPosition, placement: game.placement?.cellIds, rotation: game.rotation, mirrored: game.mirrored, occupied: [...game.state.occupied], camera: view.camera.position.toArray(), zoom: planar["zoom"], }; });
expect(afterButton.poleCellId).not.toBe(0); expect(afterButton.center?.cellId).toBe(before.anchor); expect(afterButton.center?.point[0]).toBeCloseTo(before.point![0], 7); expect(afterButton.center?.point[1]).toBeCloseTo(before.point![1], 7); expect(afterButton.center?.point[2]).toBeCloseTo(before.point![2], 7); expect(afterButton.anchor).toBe(before.anchor); expect(afterButton.point).toEqual(before.point); expect(afterButton.placement).toEqual(before.placement); expect(afterButton.rotation).toBe(before.rotation); expect(afterButton.mirrored).toBe(before.mirrored); expect(afterButton.occupied).toEqual(before.occupied); expect(afterButton.camera).toEqual(before.camera); expect(afterButton.zoom).toBe(1);
await page.locator("#reset-view").click(); await page.clock.runFor(32);
const reset = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const faceId = game.board.cells[game.anchor].parentFaceId; const face = game.board.baseFaces[faceId];
const faceCenter: [number, number, number] = [ face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][0], 0), face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][1], 0), face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][2], 0), ];
const faceCenterLength = Math.hypot(...faceCenter);
const normalizedFaceCenter: [number, number, number] = [ faceCenter[0] / faceCenterLength, faceCenter[1] / faceCenterLength, faceCenter[2] / faceCenterLength, ];
const center = planar.pickAt(planar.canvas.clientWidth / 2, planar.canvas.clientHeight / 2);
return { poleCellId: planar.map.poleCellId, poleFaceId: game.board.cells[planar.map.poleCellId].parentFaceId, faceId, projectedFaceCenter: planar.map.project(normalizedFaceCenter), center, anchor: game.anchor, point: game.pointerPosition, zoom: planar["zoom"], }; });
expect(reset.zoom).toBe(1); expect(reset.poleFaceId).not.toBe(reset.faceId); expect(reset.projectedFaceCenter?.[0]).toBeCloseTo(0, 8); expect(reset.projectedFaceCenter?.[1]).toBeCloseTo(0, 8); expect(reset.center?.cellId).toBe(reset.anchor); expect(reset.center?.point[0]).toBeCloseTo(reset.point![0], 7); expect(reset.center?.point[1]).toBeCloseTo(reset.point![1], 7); expect(reset.center?.point[2]).toBeCloseTo(reset.point![2], 7);
const keyboardTarget = await page.evaluate(() => { const { game, view } = window.__rad;
const cell = game.board.cells.find((candidate) => candidate.neighborIds.some( (neighborId) => game.board.cells[neighborId].parentFaceId !== candidate.parentFaceId, ), )!;
const corners = cell.vertexIds.map((id) => game.board.vertices[id]);
const point: [number, number, number] = [ (corners[0][0] + corners[1][0] + corners[2][0]) / 3, (corners[0][1] + corners[1][1] + corners[2][1]) / 3, (corners[0][2] + corners[1][2] + corners[2][2]) / 3, ];
game.move(cell.id, point); view.planarView["zoom"] = 2.5; view.planarView["pan"] = { x: 31, y: -19 };
return { cellId: game.anchor, point, placement: game.placement?.cellIds }; });
await page.keyboard.press("c"); await page.clock.runFor(32);
const afterKeyboard = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const center = planar.pickAt(planar.canvas.clientWidth / 2, planar.canvas.clientHeight / 2);
return { poleCellId: planar.map.poleCellId, center, zoom: planar["zoom"], anchor: game.anchor, point: game.pointerPosition, placement: game.placement?.cellIds, }; });
expect(afterKeyboard.zoom).toBe(1); expect(afterKeyboard.poleCellId).not.toBe(0); expect(afterKeyboard.center?.cellId).toBe(keyboardTarget.cellId); expect(afterKeyboard.center?.point[0]).toBeCloseTo(keyboardTarget.point[0], 7); expect(afterKeyboard.center?.point[1]).toBeCloseTo(keyboardTarget.point[1], 7); expect(afterKeyboard.center?.point[2]).toBeCloseTo(keyboardTarget.point[2], 7); expect(afterKeyboard.anchor).toBe(keyboardTarget.cellId); expect(afterKeyboard.point).toEqual(keyboardTarget.point); expect(afterKeyboard.placement).toEqual(keyboardTarget.placement);
const highlight = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const canvas = planar.canvas; const context = canvas.getContext("2d");
if (!context) throw new Error("Expected a planar canvas context");
game.phase = "over"; game.placement = null; game.revision++; view.render(0);
const pixel = (cellId: number) => { const cell = game.board.cells[cellId]; const corners = cell.vertexIds.map((id) => game.board.vertices[id]);
const point: [number, number, number] = [ (corners[0][0] + corners[1][0] + corners[2][0]) / 3, (corners[0][1] + corners[1][1] + corners[2][1]) / 3, (corners[0][2] + corners[1][2] + corners[2][2]) / 3, ];
const projected = planar.map.project(point);
if (!projected) throw new Error("Expected a finite cell center");
const scale = planar["baseScale"] * planar["zoom"];
const x = canvas.clientWidth / 2 + planar["pan"].x + projected[0] * scale;
const y = canvas.clientHeight / 2 + planar["pan"].y - projected[1] * scale;
return context.getImageData( Math.round(x * (canvas.width / canvas.clientWidth)), Math.round(y * (canvas.height / canvas.clientHeight)), 1, 1, ).data.slice(0, 3); };
const selectedCell = game.board.cells[game.anchor];
const otherCellId = selectedCell.neighborIds.find( (id) => game.board.cells[id].parentFaceId !== selectedCell.parentFaceId && id !== planar.map.poleCellId, )!;
return { selected: [...pixel(selectedCell.id)], other: [...pixel(otherCellId)], }; });
expect(highlight.selected).not.toEqual(highlight.other);});
test("touch center aim recenters without changing the active footprint", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(32);
const before = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const rect = planar.canvas.getBoundingClientRect();
planar.canvas.dispatchEvent( new PointerEvent("pointermove", { pointerId: 27, pointerType: "touch", isPrimary: true, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, }), );
const outerCell = game.board.cells[0]; const corners = outerCell.vertexIds.map((id) => game.board.vertices[id]);
const point: [number, number, number] = [ (corners[0][0] + corners[1][0] + corners[2][0]) / 3, (corners[0][1] + corners[1][1] + corners[2][1]) / 3, (corners[0][2] + corners[1][2] + corners[2][2]) / 3, ];
const projected = planar.map.project(point);
if (!projected) throw new Error("Expected a finite point inside the outer cell");
planar["pan"] = { x: -projected[0] * planar["baseScale"], y: projected[1] * planar["baseScale"], }; view.render(0);
return { centerAim: view.usesCenterAim, anchor: game.anchor, point: game.pointerPosition, placement: game.placement?.cellIds, rotation: game.rotation, mirrored: game.mirrored, occupied: [...game.state.occupied], camera: view.camera.position.toArray(), }; });
expect(before.centerAim).toBe(true); await page.locator("#recenter-view").click(); await page.clock.runFor(32);
const after = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView;
const center = planar.pickAt( planar.canvas.clientWidth / 2, planar.canvas.clientHeight / 2, );
return { centerAim: view.usesCenterAim, center, anchor: game.anchor, point: game.pointerPosition, placement: game.placement?.cellIds, rotation: game.rotation, mirrored: game.mirrored, occupied: [...game.state.occupied], camera: view.camera.position.toArray(), }; });
expect(after.centerAim).toBe(true); expect(after.center?.cellId).toBe(before.anchor); expect(after.center?.point[0]).toBeCloseTo(before.point![0], 7); expect(after.center?.point[1]).toBeCloseTo(before.point![1], 7); expect(after.center?.point[2]).toBeCloseTo(before.point![2], 7); expect(after.anchor).toBe(before.anchor); expect(after.point).toEqual(before.point); expect(after.placement).toEqual(before.placement); expect(after.rotation).toBe(before.rotation); expect(after.mirrored).toBe(before.mirrored); expect(after.occupied).toEqual(before.occupied); expect(after.camera).toEqual(before.camera);});
test("planar switching and reset preserve a paused game", async ({ page }) => { await page.locator("#start").click(); await page.clock.runFor(500); await page.locator("#projection-mode").click(); const canvas = (await page.locator("#scene .planar-canvas").boundingBox())!; const center = { x: canvas.x + canvas.width / 2, y: canvas.y + canvas.height / 2 }; await page.mouse.move(center.x, center.y); await page.mouse.wheel(0, -120); await page.mouse.move(center.x, center.y); await page.mouse.down(); await page.mouse.move(center.x + 24, center.y + 12); await page.mouse.up(); expect( await page.evaluate(() => ({ zoom: window.__rad.view.planarView["zoom"], pan: window.__rad.view.planarView["pan"], })), ).not.toEqual({ zoom: 1, pan: { x: 0, y: 0 } });
await page.locator("#pause").click(); const paused = await inspect(page); await page.locator("#projection-mode").click(); await page.locator("#projection-mode").click(); expect( await page.evaluate( () => window.__rad.view.planarView.canvas.hidden, ), ).toBe(false); await expect(page.locator("#scene")).toHaveCSS("visibility", "hidden"); await page.locator("#reset-view").click(); await page.clock.runFor(32);
const reset = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const faceId = game.board.cells[game.anchor].parentFaceId; const face = game.board.baseFaces[faceId];
const faceCenter: [number, number, number] = [ face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][0], 0), face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][1], 0), face.reduce((sum, vertexId) => sum + game.board.baseVertices[vertexId][2], 0), ];
const faceCenterLength = Math.hypot(...faceCenter);
const normalizedFaceCenter: [number, number, number] = [ faceCenter[0] / faceCenterLength, faceCenter[1] / faceCenterLength, faceCenter[2] / faceCenterLength, ];
return { paused: game.paused, planar: view.planar, zoom: planar["zoom"], poleFaceId: game.board.cells[planar.map.poleCellId].parentFaceId, faceId, projectedFaceCenter: planar.map.project(normalizedFaceCenter), }; });
expect( reset, ).toMatchObject({ paused: true, planar: true, zoom: 1 }); expect(reset.poleFaceId).not.toBe(reset.faceId); expect(reset.projectedFaceCenter?.[0]).toBeCloseTo(0, 8); expect(reset.projectedFaceCenter?.[1]).toBeCloseTo(0, 8); await expect(page.locator("#scene")).toHaveCSS("visibility", "hidden");
await page.locator("#start").click(); await page.clock.runFor(32); const resumed = await inspect(page); expect(resumed.phase).toBe("playing"); expect(resumed.occupied).toEqual(paused.occupied); expect(resumed.remaining).toBeLessThanOrEqual(paused.remaining); expect(resumed.remaining).toBeGreaterThan(paused.remaining - 0.1);
await page.locator("#projection-mode").click(); await page.clock.runFor(32); expect((await inspect(page)).camera).toEqual(paused.camera);});
test("planar touch drag pans and two-touch gestures pinch without locking", async ({ page,}) => { await page.locator("#start").click(); await page.clock.runFor(32); await page.locator("#projection-mode").click(); await page.clock.runFor(32);
const gesture = await page.evaluate(() => { const { game, view } = window.__rad; const planar = view.planarView; const canvas = planar.canvas; const rect = canvas.getBoundingClientRect(); const zoomBefore = planar["zoom"]; const panBefore = { ...planar["pan"] };
canvas.setPointerCapture = () => {};
const send = ( type: string, pointerId: number, x: number, y: number, isPrimary: boolean, ) => { canvas.dispatchEvent( new PointerEvent(type, { pointerId, pointerType: "touch", isPrimary, clientX: x, clientY: y, button: 0, }), ); };
const x = rect.left + rect.width / 2, y = rect.top + rect.height / 2;
send("pointerdown", 1, x - 60, y, true); send("pointermove", 1, x - 20, y + 10, true); send("pointerup", 1, x - 20, y + 10, true); send("pointerdown", 1, x - 50, y, true); send("pointerdown", 2, x + 50, y, false); send("pointermove", 1, x - 70, y, true); send("pointermove", 2, x + 70, y, false); send("pointerup", 2, x + 70, y, false); send("pointerup", 1, x - 70, y, true);
return { zoomBefore, zoomAfter: planar["zoom"], panBefore, panAfter: { ...planar["pan"] }, phase: game.phase, occupied: [...game.state.occupied], }; });
expect(gesture.zoomAfter).toBeGreaterThan(gesture.zoomBefore); expect(gesture.panAfter.x).not.toBe(gesture.panBefore.x); expect(gesture.phase).toBe("playing"); expect(gesture.occupied).toEqual([]);});
for (const width of [1440, 390]) { test(`2D mode supports placement, navigation, and switching at width ${width}`, async ({ page }) => { const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message)); await page.setViewportSize({ width, height: 960 }); await page.locator("#start").click(); await page.clock.runFor(32); const camera = (await inspect(page)).camera;
await page.locator("#projection-mode").click(); await page.clock.runFor(220); await expect(page.locator("#projection-mode")).toHaveText("2D"); const canvas = page.locator("#scene .planar-canvas");
await expect(canvas).toBeVisible(); await expect(canvas).toHaveAttribute("aria-label", /2D board/); await page.locator("#recenter-view").click(); await page.clock.runFor(32); const rect = (await canvas.boundingBox())!; const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
await page.mouse.move(center.x, center.y); await page.clock.runFor(32); const before = await inspect(page);
expect(before.legal).toBe(true); await page.mouse.click(center.x, center.y); await page.clock.runFor(150); expect((await inspect(page)).occupied).toEqual(expect.arrayContaining([...before.cells!])); await page.mouse.wheel(0, -120); await page.clock.runFor(32); expect(await page.evaluate(() => window.__rad.view.planarView["zoom"])).toBeGreaterThan(1); await page.mouse.move(center.x, center.y); await page.mouse.down(); await page.mouse.move(center.x + 30, center.y + 20, { steps: 3 }); await page.mouse.up(); await page.clock.runFor(220); const placed = (await inspect(page)).occupied;
await page.keyboard.press("c"); await page.clock.runFor(32); expect(await page.evaluate(() => window.__rad.view.planarView["zoom"])).toBe(1); expect((await inspect(page)).occupied).toEqual(placed);
const background = await page.evaluate(() => { const view = window.__rad.view.planarView; const x = view.canvas.clientWidth / 2 + view["pan"].x + 2.1 * view["baseScale"]; const y = view.canvas.clientHeight / 2 + view["pan"].y;
view["draw"](); const ratio = view.canvas.width / view.canvas.clientWidth; const pixel = view.canvas.getContext("2d")!.getImageData(Math.floor(x * ratio), Math.floor(y * ratio), 1, 1).data;
return { hit: view.pickAt(x, y), alpha: pixel[3] }; });
expect(background).toEqual({ hit: null, alpha: 0 }); await page.keyboard.press("p"); await page.locator("#projection-mode").click(); await page.clock.runFor(32); expect((await inspect(page)).camera).toEqual(camera); expect((await inspect(page)).occupied).toEqual(placed); await expect(canvas).toBeHidden(); await page.locator("#projection-mode").click(); await page.clock.runFor(32); expect(await page.evaluate(() => window.__rad.game.paused)).toBe(true); await expect(canvas).toBeHidden(); await page.keyboard.press("p"); await page.clock.runFor(32); await expect(canvas).toBeVisible(); expect(errors).toEqual([]); });}