diff --git a/CHANGELOG.md b/CHANGELOG.md index 3481826..a767dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Notable changes to **pixelcat**. All art and sound are original/procedural (no asset files). +## [Unreleased] + +### Dog +- **Fetch on its own** - step away and a dog starts its own game rather than waiting on a butterfly: it noses a ball out, chases it down and carries it home. Cats still get the butterfly. One toggle governs both, and work mode, reduced motion and low power suppress both. + +### Fixes +- **The dog stops re-fetching the ball it just delivered.** It dropped the ball at its own feet, was back inside the grab radius on the next frame, and looped pickup/deliver at frame rate: 254 heart-and-chirp bursts in 72 seconds and a pant timer that never expired. Only the first throw ever looked right. +- **Tray wording matches what the tray actually does.** A dog was offered "Give a treat 🦴" and then thrown a tennis ball; it now reads "Throw the ball 🎾", alongside "Ball to chase" and "Work mode (stay put, no ball)". +- **`npm run lint` works on a real working copy again** - it was walking local-only paths that `.gitignore` already excludes and reporting ~1.5k errors from code this repo does not own. + ## [0.2.0] - 2026-07-25 ### Stay on track diff --git a/README.md b/README.md index 47c0f3b..3157244 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ coat choice, so switching back and forth never loses your pick. | Resting | loafs into a "cat bread" | curls nose-to-tail into a ring | | Excited | hunting crouch, ears back | **play bow**: chest down, rump up, tail flagged | | Tail | slow rolling S-curve, tip flicks | fast wag from the base, shaped per breed (curl / plume / feather / stub / straight) | -| Play | bats a butterfly | **fetch**: chases the thrown ball, carries it home, drops it | +| Play | bats a butterfly | **fetch**: chases the ball down, carries it home, drops it | +| Left alone | a butterfly flutters in | starts its own game of fetch | | After exertion | grooms | pants, tongue out | | Scroll | climbs the yarn rope hand over hand | hauls itself up the same rope, ears and snout intact | | Reward | a fish treat | a tennis ball | @@ -165,7 +166,7 @@ to your cursor, to your typing, and to its own internal mood. | **Move your cursor** | The cat watches it and blinks now and then. Flick the cursor fast and it crouches, stalks, and pounces. A sudden jolt startles it: it puffs up, freezes, then bolts or creeps back. | | **Type in any app** | It leans onto two big keys and kneads them with its paws. Type fast enough and it overheats, turning red with steam, then cools down. | | **Scroll anywhere** | Grabs a yarn rope and climbs it hand over hand, up when you scroll up and down when you scroll down, with a ball of yarn anchored on the floor. | -| **Wait for a visitor** | Once in a while a butterfly flutters in. The cat tracks it, swats at it, and occasionally pounces and catches it between its paws before it flutters off. Step away from the keyboard and a butterfly comes out on its own, so the cat always has something to play with. | +| **Wait for a visitor** | Once in a while a butterfly flutters in. The cat tracks it, swats at it, and occasionally pounces and catches it between its paws before it flutters off. Step away from the keyboard and one comes out on its own, so the pet always has something to play with. Keep a dog instead and it starts its own game of fetch rather than waiting on a butterfly. | | **Leave it be** | Left alone it keeps itself busy: it bats a drifting leaf with a paw, washes its face, loafs, and its whiskers twitch. Lively without ever getting in your way. | | **Come back** | Return after being away and the cat notices you: happy eyes, a little heart, and a friendly chirp hello. | | **Give it a treat** | Pick "Give a treat" from the tray and a little fish drops in. The cat trots over and noms it with hearts and a happy chirp. | diff --git a/eslint.config.js b/eslint.config.js index 3e398ea..17d758a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,7 +32,17 @@ const sharedOverlay = { const CONSUMER_OVERLAY = ['src/renderer.js', 'src/settings-renderer.js', 'src/cat-preview.js']; module.exports = [ - { ignores: ['node_modules/**', 'dist/**', 'site/**', 'src/climb-frames.js', '_*/**'] }, + // Keep this in step with .gitignore. Without the local-only entries, a working + // copy that has picked up scratch files or the unrelated coursework folder sends + // `npm run lint` walking a vendored python venv, and the one command that should + // surface real bugs drowns in ~1.5k phantom errors. CI checks out a clean tree + // and never sees them, which is exactly why this rots unnoticed. + { + ignores: [ + 'node_modules/**', 'dist/**', 'out/**', 'site/**', 'src/climb-frames.js', + '_*', '_*/**', 'previews/**', '.playwright-mcp/**', '.codegraph/**', 'CODEPATH Proj/**', + ], + }, js.configs.recommended, diff --git a/src/main.js b/src/main.js index 2706776..5b357a7 100644 --- a/src/main.js +++ b/src/main.js @@ -401,7 +401,7 @@ function rebuildTrayMenu() { tray.setContextMenu(Menu.buildFromTemplate([ { label: 'Settings…', click: openSettings }, { label: 'Start break now', click: triggerBreak }, - { label: sp.treatLabel, click: giveTreat }, + { label: sp.giveLabel, click: giveTreat }, { label: 'Recent notifications', submenu: recentItems }, { label: 'Snooze last reminder', submenu: [ { label: '5 minutes', click: () => snoozeLast(5) }, @@ -413,7 +413,7 @@ function rebuildTrayMenu() { { label: sp.coatNoun, submenu: coatItems }, { label: 'Follow cursor', type: 'checkbox', checked: !!(cfg && cfg.followCursor), click: () => persistAndBroadcast({ ...cfg, followCursor: !cfg.followCursor }) }, { label: 'Mouse hunt', type: 'checkbox', checked: !!(cfg && cfg.huntOn), click: () => persistAndBroadcast({ ...cfg, huntOn: !cfg.huntOn }) }, - { label: 'Butterfly visits', type: 'checkbox', checked: !(cfg && cfg.butterflyOn === false), click: () => persistAndBroadcast({ ...cfg, butterflyOn: !(cfg && cfg.butterflyOn !== false) }) }, + { label: sp.playToggleLabel, type: 'checkbox', checked: !(cfg && cfg.butterflyOn === false), click: () => persistAndBroadcast({ ...cfg, butterflyOn: !(cfg && cfg.butterflyOn !== false) }) }, { label: 'Mood reactions', type: 'checkbox', checked: !(cfg && cfg.moodOn === false), click: () => persistAndBroadcast({ ...cfg, moodOn: !(cfg && cfg.moodOn !== false) }) }, { label: 'Startle at cursor', type: 'checkbox', checked: !(cfg && cfg.startleOn === false), click: () => persistAndBroadcast({ ...cfg, startleOn: !(cfg && cfg.startleOn !== false) }) }, { label: 'Mood', submenu: [ @@ -433,7 +433,7 @@ function rebuildTrayMenu() { ] }, { label: 'Always on top', type: 'checkbox', checked: !(cfg && cfg.onTop === false), click: () => persistAndBroadcast({ ...cfg, onTop: !(cfg && cfg.onTop !== false) }) }, { label: 'Wander', type: 'checkbox', checked: !(cfg && cfg.roamOn === false), click: () => persistAndBroadcast({ ...cfg, roamOn: !(cfg && cfg.roamOn !== false) }) }, - { label: 'Work mode (stay put, no butterfly)', type: 'checkbox', checked: !!(cfg && cfg.workMode), click: () => persistAndBroadcast({ ...cfg, workMode: !(cfg && cfg.workMode) }) }, + { label: `Work mode (stay put, no ${sp.playNoun})`, type: 'checkbox', checked: !!(cfg && cfg.workMode), click: () => persistAndBroadcast({ ...cfg, workMode: !(cfg && cfg.workMode) }) }, { label: 'Rest corner', submenu: [ { label: 'Bottom-left', type: 'radio', checked: !!(cfg && cfg.restSide === 'left'), click: () => persistAndBroadcast({ ...cfg, restSide: 'left' }) }, { label: 'Bottom-right', type: 'radio', checked: !(cfg && cfg.restSide === 'left'), click: () => persistAndBroadcast({ ...cfg, restSide: 'right' }) }, @@ -487,8 +487,9 @@ function triggerBreak() { function giveTreat() { if (!win || win.isDestroyed()) return; // Same tray slot, species-appropriate payload: a cat is handed a fish, a dog - // gets a tennis ball thrown for it to chase down and bring back. - win.webContents.send(speciesOf(cfg && cfg.species).id === 'dog' ? 'ball' : 'treat'); + // gets a tennis ball thrown for it to chase down and bring back. The channel + // comes from the same registry entry as the menu label, so the two cannot drift. + win.webContents.send(speciesOf(cfg && cfg.species).giveChannel); } // ---- Pomodoro: focus/break loops. Main owns the phase clock (the renderer may diff --git a/src/pets.js b/src/pets.js index 05ad22f..9cce112 100644 --- a/src/pets.js +++ b/src/pets.js @@ -29,10 +29,15 @@ const SPECIES = { coats: CAT_COATS, coatNoun: 'Coat', defaultCoat: 'Tuxedo', - // tray/menu wording differs per species so the app never says "treat" at a dog - // that is actually being handed a ball - treatLabel: 'Give a treat 🐟', - playLabel: 'Send a butterfly 🦋', + // The tray's single "give" slot: what it is called AND which payload it sends. + // They live together so the menu can never say "treat" at a dog that is actually + // being handed a ball, which is precisely what happened while the label and the + // channel were chosen in two different files. + giveLabel: 'Give a treat 🐟', + giveChannel: 'treat', + // The companion the pet plays with on its own once you step away. + playNoun: 'butterfly', + playToggleLabel: 'Butterfly visits', }, dog: { id: 'dog', @@ -41,8 +46,10 @@ const SPECIES = { coats: DOG_COATS, coatNoun: 'Breed', defaultCoat: 'Golden Retriever', - treatLabel: 'Give a treat 🦴', - playLabel: 'Throw the ball 🎾', + giveLabel: 'Throw the ball 🎾', + giveChannel: 'ball', + playNoun: 'ball', + playToggleLabel: 'Ball to chase', }, }; diff --git a/src/renderer.js b/src/renderer.js index f5ef322..3188b13 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -1290,7 +1290,7 @@ function updateBall(t, dt, f) { roamTo = { x: home, y: floorLockOn() ? restingY() : pos.y }; roamDur = clamp(Math.abs(pos.x - home) * 2.4, 400, 1700); roamUntil = t + roamDur; nextRoam = t + 20000; } else { // delivered: drop it, wag, pant, ask for another - ball.phase = 'rest'; ball.restAt = t; ball.y = floor - BALL_R; + ball.phase = 'rest'; ball.delivered = true; ball.restAt = t; ball.y = floor - BALL_R; wagBoost = 1.2; pantUntil = t + 4200; addEnergy(10); popLove(t, pos.x, pos.y - SH * 0.8, 2, 16); if (config && config.soundOn) playChirp(); @@ -1301,6 +1301,12 @@ function updateBall(t, dt, f) { // 'rest': lying on the floor. The dog goes and gets it. if (f.grabbing || f.hunting || f.startleActive || f.typing || paperLen > 1) return; if (t - ball.restAt > 45000) { ball = null; return; } // forgotten after a while + // A ball the dog has already carried home stays where it was dropped, waiting for + // you to throw it again. Without this the dog drops it at its own feet, is back + // inside FETCH_GRAB on the very next frame, picks it up, walks nowhere, "delivers" + // again... looping pickup/deliver every frame and spraying hearts, chirps and a + // permanently refreshed pant. The first throw looks perfect, which is why it shipped. + if (ball.delivered) return; const dist = Math.abs(pos.x - ballApproachX()); if (dist <= FETCH_GRAB) { ball.phase = 'carry'; ball.side = Math.sign(ball.x - pos.x) || 1; @@ -1750,6 +1756,35 @@ function startBflyVisit(t) { bfDriftCx = pos.x; bfDriftCy = clamp(pos.y - SH - 30, BF_TOP, viewH - BF_EDGE); bfDriftTX = bfDriftCx; bfDriftTY = bfDriftCy; bfPhase = Math.random() * Math.PI * 2; bfNextDrift = t + 500; } +// --- Self-play: what the pet does with itself once you step away. A cat gets a +// butterfly to stalk. A dog would rather have something thrown, and it already +// knows how to fetch, so it noses its own ball out and carries it back. Both +// species run off the same idle gates and the same tray toggle, so switching +// play off switches all of it off. +function updateSelfPlay(t, dt, step, f) { + if (!isDog()) { updateButterflyDesk(t, dt, step, f); return; } + // Swapped to a dog mid-visit: the butterfly is still DRAWN for as long as bfOn is + // set, so it has to be flown off properly rather than abandoned frozen in mid-air. + if (bfOn) { bfMode = 'out'; updateButterflyDesk(t, dt, step, f); return; } + updateDogFetchUrge(t, f); +} +// A dog with nothing to chase talks itself into a game of fetch, on the same +// schedule that earns a cat a butterfly visit. There is no separate "leaving" +// phase: updateBall() forgets an untouched ball after 45s, which re-arms this. +function updateDogFetchUrge(t, f) { + if (SHOT || ball || !pos) return; // already something in play + const allow = f.follow && !lowPower && !(config && config.reducedMotion) + && !(config && config.butterflyOn === false) && !workModeOn() && !f.grabbing && !f.typing; + if (!allow || !f.calm) return; + if ((t - lastCursorMove) < BF_MOUSE_QUIET_MS) return; // never while the mouse is in use + const idleMs = t - Math.max(lastCursorMove, lastKeyAt); + if (!(t > bfNextVisit || (idleMs > IDLE_BUTTERFLY_MS && t > bfIdleNextVisit))) return; + throwBall(); + // Re-arm on the cadence a departing butterfly uses, so the dog does not start a + // fresh game the instant the last ball is forgotten. + bfNextVisit = t + 50000 + Math.random() * 50000; + bfIdleNextVisit = t + 14000 + Math.random() * 10000; +} // Flight + cat reaction. f = { follow, grabbing, hunting, typing, petting, startleActive, calm }. function updateButterflyDesk(t, dt, step, f) { const force = SHOT && qp.get('bfly') === '1'; @@ -2299,7 +2334,7 @@ function draw(t) { if (lookTarget && t > lookTargetUntil) lookTarget = null; if (t > leanUntil) leanTarget = 0; lean += (leanTarget - lean) * 0.09 * step; - updateButterflyDesk(t, dt, step, { follow, grabbing, hunting, typing, petting, startleActive, calm }); + updateSelfPlay(t, dt, step, { follow, grabbing, hunting, typing, petting, startleActive, calm }); updateTreat(t, { grabbing, hunting, typing, startleActive }); updateBall(t, dt, { grabbing, hunting, typing, startleActive }); updateDogVitals(t, dt); diff --git a/tests/fetch.test.js b/tests/fetch.test.js new file mode 100644 index 0000000..22b6b00 --- /dev/null +++ b/tests/fetch.test.js @@ -0,0 +1,91 @@ +// Fetch (dogs only) tests. +// +// The tray throws a tennis ball, the dog chases it down, carries it home and drops +// it. That whole cycle runs off one `ball` object moving through fly -> rest -> +// carry -> rest, driven entirely by the rAF loop, so it is easy to get a state +// machine that looks right for one throw and then never settles. +// +// It shipped doing exactly that: the dog dropped the ball at its own feet, was +// back inside the grab radius on the next frame, picked it up, "delivered" it +// without moving, and looped forever at frame rate. Over 72 simulated seconds +// that was 511 phase flips and 254 heart-and-chirp bursts, with the pant timer +// refreshed every frame so the dog never stopped panting. These tests pin the +// shape of one complete fetch and the fact that it ENDS. +const test = require('node:test'); +const assert = require('node:assert'); +const { loadOverlay } = require('../scripts/overlay-vm.js'); + +const STEP = 60; // > the idle-fps throttle in draw(), so no frame gets skipped + +// Drive the real loop and record every ball phase change along the way. +function runFetch(h, ms) { + const flips = []; + let prev = h.run('ball ? ball.phase : null'); + for (let t = STEP; t <= ms; t += STEP) { + h.run(`draw(${t})`); + const phase = h.run('ball ? ball.phase : null'); + if (phase !== prev) { flips.push({ t, from: prev, to: phase }); prev = phase; } + } + return flips; +} + +function dogOverlay() { + const h = loadOverlay(); + h.run('setSpecies("dog")'); + // Self-play off, so these tests see exactly one ball: the one they throw. An idle + // dog otherwise starts a game of its own partway through the longer runs here and + // the counts stop meaning anything. Who STARTS a game is self-play.test.js's job. + h.ipc('onConfig', { species: 'dog', soundOn: false, followCursor: true, floorLock: true, butterflyOn: false }); + // Count the celebration bursts: one per delivery is right, one per frame is the bug. + h.run('var __love = 0; var __popLove = popLove; popLove = function () { __love++; return __popLove.apply(null, arguments); };'); + return h; +} + +test('a thrown ball is chased down, carried home and delivered exactly once', () => { + const h = dogOverlay(); + h.run('throwBall()'); + assert.strictEqual(h.run('ball.phase'), 'fly', 'the throw should put the ball in the air'); + const flips = runFetch(h, 20000); + + const seq = flips.map((f) => f.to); + assert.deepEqual(seq.slice(0, 3), ['rest', 'carry', 'rest'], + `expected land -> pick up -> deliver, got ${JSON.stringify(seq)}`); + + const deliveries = flips.filter((f) => f.from === 'carry' && f.to === 'rest'); + assert.equal(deliveries.length, 1, `the dog delivered ${deliveries.length} times for one throw`); + assert.equal(h.run('__love'), 1, 'one delivery should be one burst of hearts, not one per frame'); +}); + +test('the dog leaves a delivered ball alone instead of looping on it', () => { + // The regression itself. The ball lands ~6px from the dog on delivery, well + // inside FETCH_GRAB (22px), so without a "already brought this back" marker the + // rest branch re-grabs it immediately and the cycle never terminates. + const h = dogOverlay(); + h.run('throwBall()'); + const flips = runFetch(h, 40000); + + assert.ok(flips.length <= 6, `one fetch should be a handful of phase changes, got ${flips.length}`); + assert.ok(h.run('__love') <= 1, `hearts fired ${h.run('__love')} times for a single fetch`); + assert.strictEqual(h.run('ball && ball.delivered'), true, 'the delivered ball should be marked, not re-chased'); + assert.strictEqual(h.run('ball && ball.phase'), 'rest', 'a delivered ball stays on the floor'); +}); + +test('the pant timer stops being refreshed once the fetch is over', () => { + // Panting is driven by `pantUntil`, which the delivery branch pushes forward. + // While the loop ran it was re-armed every frame, so the dog panted permanently. + const h = dogOverlay(); + h.run('throwBall()'); + runFetch(h, 30000); + const pantUntil = h.run('pantUntil'); + assert.ok(pantUntil < 30000, `pantUntil (${pantUntil}) is still being pushed forward after the fetch ended`); +}); + +test('a delivered ball is eventually forgotten so the floor does not stay littered', () => { + const h = dogOverlay(); + h.run('throwBall()'); + const flips = runFetch(h, 60000); + const forgotten = flips.find((f) => f.to === null); + assert.ok(forgotten, 'the ball should be cleared once it has sat untouched'); + assert.ok(forgotten.t > 40000, `forgotten too eagerly at ${forgotten.t}ms`); + assert.strictEqual(h.run('ball'), null); +}); diff --git a/tests/pets.test.js b/tests/pets.test.js index 4636694..2a6b473 100644 --- a/tests/pets.test.js +++ b/tests/pets.test.js @@ -1,5 +1,7 @@ const test = require('node:test'); const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); const cat = require('../src/cat-sprite.js'); const dog = require('../src/dog-sprite.js'); @@ -79,6 +81,33 @@ test('species registry agrees with the sprite modules', () => { assert.ok(pets.defaultCoatIndex('cat') >= 0); }); +test('every species describes its give slot and its self-play companion', () => { + const preload = fs.readFileSync(path.join(__dirname, '..', 'src', 'preload.js'), 'utf8'); + for (const id of pets.SPECIES_IDS) { + const sp = pets.speciesOf(id); + for (const k of ['giveLabel', 'giveChannel', 'playNoun', 'playToggleLabel']) { + assert.ok(sp[k], `${id} is missing ${k}, so the tray would render "undefined"`); + } + // main.js sends sp.giveChannel verbatim. If preload never subscribes to that + // channel the tray item is a no-op with nothing in the log to explain it. + assert.ok(preload.includes(`sub('${sp.giveChannel}')`), + `preload.js never subscribes to '${sp.giveChannel}', so ${id}'s give slot would do nothing`); + } +}); + +test('the give-slot wording matches the payload it actually sends', () => { + // Regression: the dog's slot read "Give a treat" (with a bone) while the app + // threw a tennis ball, because the label and the channel were picked in two + // different files. They now come from one registry entry. + const dogSp = pets.speciesOf('dog'); + assert.equal(dogSp.giveChannel, 'ball'); + assert.match(dogSp.giveLabel, /ball/i, 'a dog that gets a ball must not be offered a "treat"'); + const catSp = pets.speciesOf('cat'); + assert.equal(catSp.giveChannel, 'treat'); + assert.match(catSp.giveLabel, /treat/i); + assert.notEqual(dogSp.playNoun, catSp.playNoun, 'each species plays with its own thing'); +}); + test('config normalises species and keeps a coat per species', () => { // config.js pulls in electron for app.getPath, so exercise the pure helpers via // the same rules rather than booting Electron here. diff --git a/tests/self-play.test.js b/tests/self-play.test.js new file mode 100644 index 0000000..98b4701 --- /dev/null +++ b/tests/self-play.test.js @@ -0,0 +1,77 @@ +// Self-play tests: what the pet does with itself once you step away. +// +// A cat gets a butterfly to stalk. A dog gets a game of fetch, because it already +// knows how to chase a ball down and carry it home and a dog stalking a butterfly +// reads as a recoloured cat. Both hang off the same idle gates and the same tray +// toggle, so these tests check the ROUTING (right species, right companion) and +// the gating (off means off), not the flight or fetch mechanics themselves. +const test = require('node:test'); +const assert = require('node:assert'); +const { loadOverlay } = require('../scripts/overlay-vm.js'); + +const STEP = 60; + +function petOverlay(species, cfg = {}) { + const h = loadOverlay(); + h.run(`setSpecies(${JSON.stringify(species)})`); + h.ipc('onConfig', { species, soundOn: false, followCursor: true, floorLock: true, ...cfg }); + return h; +} + +// Idle the pet (no cursor or key events) and report when each companion first appeared. +function idle(h, ms, from = 0) { + let ball = null, bfly = null; + for (let t = from + STEP; t <= from + ms; t += STEP) { + h.run(`draw(${t})`); + if (ball === null && h.run('!!ball')) ball = t; + if (bfly === null && h.run('!!bfOn')) bfly = t; + } + return { ball, bfly }; +} + +test('a dog left alone starts its own game of fetch', () => { + const { ball, bfly } = idle(petOverlay('dog'), 30000); + assert.ok(ball, 'an idle dog should nose a ball out on its own'); + assert.equal(bfly, null, 'a dog should never be sent a butterfly'); +}); + +test('a cat left alone still gets a butterfly and never a ball', () => { + const { ball, bfly } = idle(petOverlay('cat'), 30000); + assert.ok(bfly, 'an idle cat should still get its butterfly visit'); + assert.equal(ball, null, 'a cat has no use for a tennis ball'); +}); + +test('both species reach for their companion on the same idle schedule', () => { + // The dog path reuses the butterfly timers deliberately. If they ever drift + // apart, one species starts feeling noticeably livelier than the other. + const dog = idle(petOverlay('dog'), 30000); + const cat = idle(petOverlay('cat'), 30000); + assert.equal(dog.ball, cat.bfly, `dog played at ${dog.ball}ms but cat played at ${cat.bfly}ms`); +}); + +test('turning play off silences both species', () => { + // One toggle ("Butterfly visits" / "Ball to chase") governs both. + const dog = idle(petOverlay('dog', { butterflyOn: false }), 30000); + const cat = idle(petOverlay('cat', { butterflyOn: false }), 30000); + assert.equal(dog.ball, null, 'play is off, the dog should not fetch'); + assert.equal(cat.bfly, null, 'play is off, the cat should get no butterfly'); +}); + +test('work mode and reduced motion both stop a dog starting a game', () => { + assert.equal(idle(petOverlay('dog', { workMode: true }), 30000).ball, null, 'work mode means stay put'); + assert.equal(idle(petOverlay('dog', { reducedMotion: true }), 30000).ball, null, 'reduced motion means no self-play'); +}); + +test('a butterfly in flight leaves properly when the pet becomes a dog', () => { + // The butterfly is DRAWN for as long as bfOn is set. Routing dogs straight to + // fetch without flying it off would freeze it mid-air on screen forever. + const h = petOverlay('cat'); + idle(h, 9000); + assert.strictEqual(h.run('bfOn'), true, 'expected a butterfly mid-visit before the swap'); + + h.run('setSpecies("dog")'); + h.ipc('onConfig', { species: 'dog', soundOn: false, followCursor: true, floorLock: true }); + idle(h, 4000, 9000); + + assert.strictEqual(h.run('bfOn'), false, 'the butterfly should fly off, not freeze on screen'); +});