Animate the whip: a crack at the strike point and a flinch on the target

Every whip click now lands visibly, wherever the target is standing:

- simulation.whip() always registers a WhipCrackEffect at the struck
  agent and starts their hit reaction; step() ages both away. The
  discipline rule is unchanged - only snack-bar/restroom slackers are
  sent hustling home; desk, CEO office, and meeting stay safe
- office-renderer draws the strike: a lash sweeping in from the upper
  right that collapses into an impact starburst with an expanding ring
  (~450ms), while the struck agent shakes with anime-style pain sparks
  over their head (~600ms), fading as the flinch drains
- The office e2e now collects pageerror during the run and lets the
  effect play out after a whip-mode click, so a throwing frame in the
  rAF loop fails the suite instead of dying silently
This commit is contained in:
ZacharyZhang-NY committed 2026-07-08 23:41:33 -04:00
1 parent 1f89ea7089
commit c50f17190c
6 files changed
+137 -8

No files matched your search

+9
View File
@@ -22,6 +22,10 @@ test("the office floor opens agent details on click", async () => {
try {
const page = await app.firstWindow();
// The office renders in a rAF loop; a throwing frame (e.g. a broken
// effect draw) surfaces only as a page error, so collect them.
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "Get started" }).click();
await page.getByLabel("API key").fill("office-e2e-key");
@@ -93,6 +97,9 @@ test("the office floor opens agent details on click", async () => {
await expect(whip).toHaveAttribute("aria-pressed", "true");
await page.mouse.click(clickX, clickY);
await expect(page.getByRole("dialog", { name: /Agent / })).not.toBeVisible();
// Let the crack flash and flinch play out a few frames - a throwing
// effect draw would land in pageErrors and fail the final assertion.
await page.waitForTimeout(700);
await page.keyboard.press("Escape");
await expect(whip).toHaveAttribute("aria-pressed", "false");
@@ -118,6 +125,8 @@ test("the office floor opens agent details on click", async () => {
const outDir = resolve("test-results/screenshots");
mkdirSync(outDir, { recursive: true });
await page.screenshot({ path: join(outDir, "19-office-populated.png"), fullPage: false });
expect(pageErrors).toEqual([]);
} finally {
await app.close();
}
+24
View File
@@ -239,6 +239,30 @@ describe("office simulation", () => {
expect(sim.whip("nobody")).toBe(false);
});
test("every whip strike plays a crack and a flinch, wherever the target is", () => {
const sim = new OfficeSimulation(() => 0);
sim.sync([agentInput("a")]);
const agent = sim.agents[0];
// Struck at the desk: no discipline effect, but the animation still fires.
expect(sim.whip("a")).toBe(false);
expect(agent.behavior.kind).toBe("seated");
expect(agent.hitReactMs).toBeGreaterThan(0);
expect(sim.effects).toHaveLength(1);
expect(sim.effects[0]).toMatchObject({ x: agent.x, ageMs: 0 });
// A second strike stacks another crack; a missing target adds nothing.
sim.whip("a");
expect(sim.effects).toHaveLength(2);
sim.whip("nobody");
expect(sim.effects).toHaveLength(2);
// Both the crack flash and the flinch drain away with time.
for (let i = 0; i < 50; i++) sim.step(16, IDLE);
expect(sim.effects).toHaveLength(0);
expect(agent.hitReactMs).toBe(0);
});
test("clicks hit the agent drawn at that spot, frontmost first", () => {
const sim = new OfficeSimulation();
sim.sync([agentInput("a"), agentInput("b")]);