Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/desktop/src/components/pet/roam-behavior.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'

import { chooseMove, dwellMs, type DwellRange, HOP_CHANCE, pickStrollTarget, REST_CHANCE, type Rng } from './roam-behavior'
import type { Ledge } from './roam-geometry'

// Deterministic rng that replays a fixed sequence (last value sticks).
const seq =
(...vals: number[]): Rng =>
() =>
vals.shift() ?? vals[vals.length - 1] ?? 0

const RANGE: DwellRange = { maxMs: 13000, meanMs: 4000, minMs: 1500 }
const ledge = (left: number, right: number, y = 0): Ledge => ({ left, right, y })

describe('dwellMs', () => {
it('clamps the degenerate draws to the floor and ceiling', () => {
// rng→0 ⇒ u=1 ⇒ -ln(1)·mean = 0, raised to the floor.
expect(dwellMs(RANGE, () => 0)).toBe(RANGE.minMs)
// rng→~1 ⇒ u→0 ⇒ -ln(u) blows up, capped at the ceiling.
expect(dwellMs(RANGE, () => 1 - 1e-9)).toBe(RANGE.maxMs)
})

it('returns the mean at the exponential median point', () => {
// rng = 1 - 1/e ⇒ u = 1/e ⇒ -ln(u) = 1 ⇒ exactly the mean.
expect(dwellMs(RANGE, () => 1 - 1 / Math.E)).toBeCloseTo(RANGE.meanMs, 6)
})

it('stays within [min, max] across the whole rng domain', () => {
let state = 0.123456789

for (let i = 0; i < 5000; i++) {
state = (state * 9301 + 0.49297) % 1 // cheap deterministic walk
const ms = dwellMs(RANGE, () => state)
expect(ms).toBeGreaterThanOrEqual(RANGE.minMs)
expect(ms).toBeLessThanOrEqual(RANGE.maxMs)
}
})
})

describe('chooseMove', () => {
it('rests whenever the first draw lands under restChance — even where it could hop', () => {
expect(chooseMove(true, seq(0))).toBe('rest')
expect(chooseMove(false, seq(REST_CHANCE - 1e-9))).toBe('rest')
})

it('strolls when moving with nowhere to hop', () => {
expect(chooseMove(false, seq(0.99))).toBe('stroll')
})

it('hops only when moving, a ledge is reachable, and the second draw says so', () => {
expect(chooseMove(true, seq(0.99, HOP_CHANCE - 1e-9))).toBe('hop')
expect(chooseMove(true, seq(0.99, HOP_CHANCE))).toBe('stroll')
})

it('treats restChance as a strict lower bound (boundary stays a move)', () => {
expect(chooseMove(false, seq(REST_CHANCE))).toBe('stroll')
})

it('loafs far more than it roams over a long run (the whole point)', () => {
let state = 0.314159
const rng: Rng = () => (state = (state * 16807 + 0.5) % 1)
let rests = 0
const N = 20000

for (let i = 0; i < N; i++) {
if (chooseMove(true, rng) === 'rest') {
rests++
}
}

// ~62% rests; assert the contract (majority loafing), not the exact rate.
expect(rests / N).toBeGreaterThan(0.5)
})
})

describe('pickStrollTarget', () => {
it('collapses to the left edge on a ledge too narrow to walk', () => {
expect(pickStrollTarget(ledge(100, 102), 100, seq(0))).toBe(100)
})

it('lands inside the ledge and clears the minimum travel distance', () => {
const wide = ledge(0, 1000)
const from = 500
const x = pickStrollTarget(wide, from, seq(0.5, 0))

expect(x).toBeGreaterThanOrEqual(wide.left)
expect(x).toBeLessThanOrEqual(wide.right)
expect(Math.abs(x - from)).toBeGreaterThanOrEqual(110) // STROLL_MIN_PX
})

it('heads toward the side with more room', () => {
// Pinned near the right wall, the roomier side is left. First draw clears the
// rare double-back coin ⇒ it commits to the roomy (left) side ⇒ target < x.
const x = pickStrollTarget(ledge(0, 1000), 950, seq(0.5, 0))
expect(x).toBeLessThan(950)
})
})
97 changes: 97 additions & 0 deletions apps/desktop/src/components/pet/roam-behavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Pure decision helpers for the floating pet's wander — the "what to do & when"
* layer, split out from the geometry (`roam-geometry.ts`) and the RAF/DOM loop
* (`use-pet-roam.ts`) so the *rhythm* of the roam is tunable in one place and
* unit testable (every function takes an injectable `rng`).
*
* The goal is a calm, believable critter rather than a fidgeting one. Two ideas
* from ambient game-AI carry the weight (see GameAIPro ch.36 "Breathing Life
* into Your Background Characters" + standard idle/wander state machines):
*
* 1. **Loaf, don't pace.** A background character that picks a new walk on
* every beat reads as nervous. Most decision beats just keep resting;
* movement is the exception, not the default (`REST_CHANCE`).
* 2. **Memoryless dwell times.** Uniform pauses feel metronomic. An
* exponential dwell — the classic model for idle durations — gives mostly
* short rests with the occasional long loaf, so the cadence never reads as a
* fixed pattern (`dwellMs` / `PAUSE_DWELL`).
*/

import type { Ledge } from './roam-geometry'

export type Rng = () => number

/** What the pet does when a rest beat ends. */
export type RoamMove = 'rest' | 'stroll' | 'hop'

export interface DwellRange {
/** Mean of the exponential draw — the "typical" rest length. */
meanMs: number
/** Floor, so a near-zero draw never produces a jittery micro-pause. */
minMs: number
/** Ceiling, so a fat-tail draw (or a throttled tab) can't freeze the pet. */
maxMs: number
}

// Rest length between beats: mostly short loafs, the occasional long one.
export const PAUSE_DWELL: DwellRange = { maxMs: 13000, meanMs: 4200, minMs: 1500 }
// Most beats the pet just keeps loafing — a critter that re-walks every beat
// reads as nervous, not alive.
export const REST_CHANCE = 0.62
// When it *does* move, chance it hops to another ledge vs. strolling this one.
export const HOP_CHANCE = 0.2
// Strolls should cover ground, not shuffle: travel at least this fraction of the
// ledge (or this many px, whichever is larger), up to the room available.
const STROLL_MIN_FRACTION = 0.45
const STROLL_MIN_PX = 110
// Bias toward the roomier side so the pet crosses the app instead of pacing one
// spot; the long tail of the coin still lets it double back now and then.
const STROLL_TOWARD_ROOM = 0.85

/**
* Exponential (memoryless) dwell time, clamped to `[minMs, maxMs]`. With rng→0
* this returns `minMs`; with rng→1 it saturates at `maxMs`; in between it's
* `-ln(u)·meanMs`, so short rests dominate and long loafs are rare but possible.
*/
export function dwellMs({ meanMs, minMs, maxMs }: DwellRange, rng: Rng = Math.random): number {
const u = 1 - rng() // map [0,1) → (0,1] so the log stays finite

return Math.min(maxMs, Math.max(minMs, -Math.log(u) * meanMs))
}

/**
* Decide a beat: rest (the common case), or — when the pet is actually going to
* move — hop to a reachable ledge if one exists and the dice say so, else stroll
* the current ledge. `canHop` is false when no neighbouring surface overlaps, so
* the pet never "hops" in place.
*/
export function chooseMove(canHop: boolean, rng: Rng = Math.random): RoamMove {
if (rng() < REST_CHANCE) {
return 'rest'
}

return canHop && rng() < HOP_CHANCE ? 'hop' : 'stroll'
}

/**
* A stroll destination (absolute x) on `ledge` that actually goes somewhere:
* lean toward the side with more room and guarantee a decent minimum travel, so
* the pet crosses the app rather than shuffling in place.
*/
export function pickStrollTarget(ledge: Ledge, fromX: number, rng: Rng = Math.random): number {
const span = ledge.right - ledge.left

if (span <= 4) {
return ledge.left
}

const roomLeft = fromX - ledge.left
const roomRight = ledge.right - fromX
// Usually head to the roomier side; the long tail of the coin doubles back.
const goRight = (rng() < STROLL_TOWARD_ROOM) === (roomRight >= roomLeft)
const room = Math.max(0, goRight ? roomRight : roomLeft)
const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX))
const dist = minDist + rng() * Math.max(0, room - minDist)

return goRight ? fromX + dist : fromX - dist
}
51 changes: 51 additions & 0 deletions apps/desktop/src/components/pet/roam-geometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'

import { GROUND_EPS, groundTop, type Ledge, overlapsX, resolveLedge } from './roam-geometry'

const ledge = (y: number, left = 0, right = 1000): Ledge => ({ left, right, y })

describe('groundTop', () => {
it('sinks the feet by the padding offset so they meet the surface', () => {
// y - petH + FEET_DROP_PX(4)
expect(groundTop(ledge(500), 100)).toBe(404)
})
})

describe('overlapsX', () => {
it('is true only when the walkable ranges share real width', () => {
expect(overlapsX(ledge(0, 0, 100), ledge(0, 50, 200))).toBe(true)
expect(overlapsX(ledge(0, 0, 100), ledge(0, 100, 200))).toBe(false) // touching, not overlapping
expect(overlapsX(ledge(0, 0, 100), ledge(0, 300, 400))).toBe(false)
})
})

describe('resolveLedge', () => {
const floor = ledge(600)
const shelf = ledge(300, 100, 400)

it('returns the highest surface at or below the feet under the current x', () => {
// Standing on the shelf line, under the shelf's x-span ⇒ the shelf.
const petH = 100
const onShelf = resolveLedge([floor, shelf], 200, shelf.y - petH, petH)
expect(onShelf).toBe(shelf)
})

it('ignores surfaces the pet is not horizontally over', () => {
const petH = 100
// x=800 is past the shelf ⇒ only the floor qualifies.
const onFloor = resolveLedge([floor, shelf], 800, floor.y - petH, petH)
expect(onFloor).toBe(floor)
})

it('falls back to the floor (ledges[0]) when below everything', () => {
const petH = 100
const below = resolveLedge([floor, shelf], 200, 5000, petH)
expect(below).toBe(floor)
})

it('counts a surface within GROUND_EPS of the feet as standing on it', () => {
const petH = 100
const justAbove = resolveLedge([floor], 10, floor.y - petH - GROUND_EPS + 0.5, petH)
expect(justAbove).toBe(floor)
})
})
130 changes: 130 additions & 0 deletions apps/desktop/src/components/pet/roam-geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* The "where can it stand" layer of the floating pet's wander: it measures the
* live DOM for walkable surfaces and answers pure questions about them. Split
* from the decision logic (`roam-behavior.ts`) and the RAF/DOM loop
* (`use-pet-roam.ts`) so the loop reads as physics, not geometry, and the pure
* helpers (`overlapsX`, `resolveLedge`, `groundTop`) stay unit testable.
*/

import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'

/**
* A horizontal surface the pet can stand and walk on. `y` is the surface line
* (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the
* whole sprite stays on the ledge.
*/
export interface Ledge {
y: number
left: number
right: number
}

// Elements the pet can perch on top of, measured fresh each beat. The bottom
// floor is always a ledge; these add app furniture the pet can climb onto (the
// composer, the profile rail). Add a `data-slot` here to grow the playground.
const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]']

// A full-width bar pinned to the window bottom (the status bar). When present,
// the pet walks along its TOP edge instead of the window edge, so it stands on
// the bar rather than covering it.
const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]'

// Sprites carry a few px of transparent padding below the feet; sink the pet by
// this much so the visible feet meet the surface instead of hovering above it.
const FEET_DROP_PX = 4
// Snap distance: how close the feet must be to count as "on this ledge".
export const GROUND_EPS = 2

const vw = (): number => window.innerWidth || 800
const vh = (): number => window.innerHeight || 600

/** The y a pet of height `petH` rests at when standing on `ledge`. */
export const groundTop = (ledge: Ledge, petH: number): number => ledge.y - petH + FEET_DROP_PX

/**
* Do the pet's walkable x-ranges on two ledges overlap enough to step across?
* (Pure — the wander uses it to find hop-reachable neighbours.)
*/
export const overlapsX = (from: Ledge, to: Ledge): boolean =>
Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2

/**
* The highest surface at or below the pet's feet under its current x — i.e. what
* it's standing on, or what it would fall onto. Pure; falls back to the floor
* (always `ledges[0]`) if the pet is somehow below everything.
*/
export function resolveLedge(ledges: Ledge[], x: number, y: number, petH: number): Ledge {
const bottom = y + petH
let best: Ledge | null = null

for (const ledge of ledges) {
if (x < ledge.left - 2 || x > ledge.right + 2) {
continue
}

if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) {
best = ledge
}
}

return best ?? ledges[0]!
}

/** The bottom ground line: the top of the status bar if it's pinned full-width
* across the window bottom, otherwise the window edge. */
function floorY(width: number, height: number, petH: number): number {
const bar = document.querySelector(FLOOR_BAR_SELECTOR)

if (bar) {
const rect = bar.getBoundingClientRect()

if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) {
return rect.top
}
}

return height
}

/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen
* perch element with room above it for the pet to stand. */
export function snapshotLedges(petW: number, petH: number): Ledge[] {
const width = vw()
const height = vh()
const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }]

for (const selector of PERCH_SELECTORS) {
const el = document.querySelector(selector)

if (!el) {
continue
}

const rect = el.getBoundingClientRect()
const left = Math.max(0, rect.left)
const right = Math.min(width - petW, rect.right - petW)

// Skip surfaces that are too narrow for the pet, have no headroom above, or
// sit off-screen / flush with the floor (no daylight between them).
if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) {
continue
}

ledges.push({ left, right, y: rect.top })
}

return ledges
}

/**
* While a full-screen route overlay is up it's the only walkable surface: a
* single ledge at the overlay card's bottom inner edge. The card uses
* `OverlayView`'s equal inset on every side — `titlebar-height + padding` — so
* we derive it from that rather than measuring.
*/
export function overlayLedge(petW: number): Ledge {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem

return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset }
}
Loading
Loading