// Behavior engine: a finite state machine that drives the mascot's
// autonomous motion + animation. To add a behavior:
//   1. Add its id to `BehaviorId` in types.ts.
//   2. Add a `BehaviorDef` entry to BEHAVIORS below.
//   3. (Optional) Give it a `weight` to make it idle-selectable.
// `stepMascot()` and the weighted-random idle selector consume
// BEHAVIORS generically — no engine change is needed for a new behavior.
//
// Behaviors split into two groups:
//   - Self-selecting (idle/wander/peck/sleep): pickable by the weighted
//     random idle selector when the current behavior expires.
//   - Forced (dragged/falling/react/land): entered only via forceBehavior()
//     from the pointer code, gravity logic, or the stimulus bus.
//
// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom
// edge, == the taskbar's top edge). When above ground and not dragged,
// the mascot falls with a slow flutter terminal velocity — but the fall
// is alive: panic-flap wing-beats slow the descent on a speed-scaled,
// jittered cycle, faster-than-terminal tosses decay under drag, hard
// impacts bounce once and skid, and hard sideways throws ricochet off
// the surface's side bounds. On landing, a brief `land` behavior plays
// (the squash/spring render layer keys off rt.squashAt/impactVy), then
// idle. Dragging is always honored — pointer code calls
// forceBehavior('dragged'), which wins over any autonomous behavior or
// non-drag-breaking reaction.

import type { AnimName, BehaviorId, MascotRuntime } from './types'
import type { MascotModel } from './state.svelte'

// ─── tuning constants ────────────────────────────────────────────────────

const GRAVITY = 1400 // px/s^2 (gentle)
const TERMINAL_VY = 320 // px/s (slow flutter fall)
// A fall can exceed TERMINAL_VY (a hard downward toss); instead of a
// hard clamp, drag pulls it back toward terminal at this rate, so a
// fling reads fast-then-settling instead of unnaturally capped.
const SUPER_TERMINAL_DRAG = 1000 // px/s^2
const WALK_SPEED = 36 // px/s
const MARGIN = 24 // px before the surface edge where wander flips facing

// Falling is a series of glide/flap sub-phases, not a flat monotonic drop:
// every flapCycleMs, a wing-beat impulse briefly cuts the descent speed
// (a real, if losing, attempt at flight), and the animation swaps to `flap`
// for the FLAP_BURST_MS right after each impulse. The cycle is DYNAMIC —
// the faster the descent, the more frantic the flapping (scheduleFlap
// below), with jitter so it never reads metronomic. A gentle sine wobble
// adds horizontal drift so the fall isn't perfectly vertical either. See
// stepMascot()'s 'falling' case and the `falling` BehaviorDef below.
const FLAP_CYCLE_MIN_MS = 330 // frantic (fast descent)
const FLAP_CYCLE_MAX_MS = 600 // lazy (slow flutter)
const FLAP_BURST_MS = 160
const FLAP_IMPULSE = 260 // px/s shaved off vy at the start of each cycle
const FLAP_MAX_LIFT = -150 // px/s — how negative (upward) a flap may push vy
const WOBBLE_VX = 22 // px/s amplitude of the sideways drift while falling
const VX_DECAY_PER_S = 1.4 // exponential decay rate for toss/drift vx

// Impact bounce: a round fluffy body, not a rock. A hard-enough impact
// bounces once (diminished), then lands. The contact squash is rendered
// by Mascot.svelte's spring from rt.squashAt/impactVy.
const BOUNCE_MIN_VY = 250 // px/s impact below which there's no bounce
const BOUNCE_RESTITUTION = 0.34
const BOUNCE_MAX = 1
// Landing skid: real sideways momentum survives touchdown as a short
// friction slide instead of the old dead stop.
const SKID_MIN_VX = 140 // px/s — slower sideways landings just stop
const SKID_ENTRY_MAX = 420 // px/s — cap on carried-in skid speed
const SKID_KEEP = 0.5 // fraction of touchdown vx kept as skid
const SKID_FRICTION = 900 // px/s^2
// Wall ricochet: hard sideways throws bounce off the surface's side
// bounds mid-fall; a gentle drift still just stops at the margin.
const WALL_BOUNCE_MIN_VX = 150 // px/s
const WALL_BOUNCE_RESTITUTION = 0.45
// Hop: a small autonomous forward hop (chickens hop!) — real projectile
// motion, no flapping. If the ground drops out mid-hop (hopped off a
// window edge), stepMascot hands off to a real fall.
const HOP_VY = 210 // px/s takeoff speed
const HOP_VX = 70 // px/s forward speed
const HOP_BACKSTOP_MS = 900 // behaviorUntil backstop; real exit is on landing

// Ground tracking: Mascot.svelte's tick() chases the ground line (the top
// of whatever window/surface is beneath the mascot) each frame. A small
// per-tick change (a window being dragged smoothly, with the mascot riding
// along) follows instantly; anything the ground drops away by more than
// this is treated as the surface disappearing — falling takes over instead
// of snapping. See GROUND_FOLLOW_MAX_STEP/GROUND_DROP_FALL_PX in Mascot.svelte.

// Default durations (ms) for self-selecting behaviors. Each BehaviorDef
// can override with its own minMs/maxMs.
const IDLE_MS = [1500, 4000] as const
const WANDER_MS = [2500, 5000] as const
const PECK_MS = [1200, 2200] as const
const SLEEP_MS = [6000, 12000] as const
const LAND_MS = 400
const REACT_DEFAULT_MS = 1800

// Idle chatter: very occasional, unprompted, purely cosmetic one-liners —
// no signal value, just personality. Rolled once each time `idle` is
// (re-)entered, gated by both a probability and a cooldown so it stays
// rare rather than firing on every idle cycle (idle gets re-picked often
// by the weighted-random selector). See the `idle` BehaviorDef below.
const IDLE_CHATTER_CHANCE = 0.12
const IDLE_CHATTER_COOLDOWN_MS = 25_000
const IDLE_CHATTER_DURATION_MS = 2200
const IDLE_CHATTER_LINES = [
  '🐔 Bawk.',
  "💭 Wonder what's up on hubris…",
  '🌾 Any seeds around?',
  '😌 Nice day for uptime.',
  '📦 So many containers…',
  '☁️ Backup time yet?',
  '🥚 Remember when I was an egg?',
  '🔧 *pecks at nothing in particular*',
  '🐧 Penguins are cool too, I guess.'
]
// Module-level (not per-runtime) since there's only ever one mascot —
// matches stimuli.ts's own module-level cooldown tracking.
let lastChatterAt = 0

// ─── BehaviorDef ─────────────────────────────────────────────────────────

export interface BehaviorDef {
  id: BehaviorId
  /** Animation to play while this behavior is active. May depend on runtime state (e.g. facing). */
  anim: (rt: MascotRuntime, model: MascotModel) => AnimName
  /** Called once when the behavior is entered (set up velocity, etc). */
  enter?: (rt: MascotRuntime) => void
  /** Per-frame physics/integration. `dt` is already clamped to <= 100ms by the loop. */
  tick: (rt: MascotRuntime, dt: number, now: number) => void
  /** Called when behaviorUntil has passed; returns the next behavior id, or null to trigger idle selection. */
  next: (rt: MascotRuntime, now: number) => BehaviorId | null
  /** Idle-selection weight (>0 = eligible). Undefined/0 = never auto-picked. */
  weight?: number
  /** Duration range in ms for this behavior when auto-selected. */
  minMs: number
  maxMs: number
}

function randRange(min: number, max: number): number {
  return min + Math.random() * (max - min)
}

function pickWeighted(candidates: BehaviorDef[]): BehaviorDef {
  const total = candidates.reduce((s, b) => s + (b.weight ?? 0), 0)
  let r = Math.random() * total
  for (const b of candidates) {
    r -= b.weight ?? 0
    if (r <= 0) return b
  }
  return candidates[0]
}

// ─── ground / bounds helpers ─────────────────────────────────────────────

/**
 * (Re)start a flap cycle: records `now` as the last wing-beat and picks
 * the next cycle length from the CURRENT descent speed — a fast fall
 * (hard toss) flaps frantically, a gentle flutter flaps lazily — plus
 * ±15% jitter so the rhythm never sounds like a metronome.
 */
function scheduleFlap(rt: MascotRuntime, now: number): void {
  rt.fallPhaseAt = now
  const speedFactor = Math.max(0, Math.min(1, rt.vy / TERMINAL_VY))
  const base = FLAP_CYCLE_MAX_MS - speedFactor * (FLAP_CYCLE_MAX_MS - FLAP_CYCLE_MIN_MS)
  rt.flapCycleMs = base * (0.85 + Math.random() * 0.3)
}

function groundY(rt: MascotRuntime): number {
  // rt.groundY is recomputed each tick by Mascot.svelte from the window
  // state — it's the top edge of the highest window beneath the mascot,
  // or rt.bounds.h (surface bottom) when no window is beneath.
  return rt.groundY
}

function clampX(rt: MascotRuntime): void {
  const minX = MARGIN / 2
  const maxX = rt.bounds.w - MARGIN / 2
  if (rt.x < minX) {
    rt.x = minX
    rt.facing = 1
  }
  if (rt.x > maxX) {
    rt.x = maxX
    rt.facing = -1
  }
}

// ─── BEHAVIORS registry ───────────────────────────────────────────────────

export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
  egg: {
    id: 'egg',
    anim: () => 'egg-idle',
    tick: () => {
      // Egg doesn't move on its own.
    },
    next: () => 'egg',
    minMs: 0,
    maxMs: 0
  },

  idle: {
    id: 'idle',
    // Periodic blink: enter() sets blinkUntil to the START of the next
    // blink window (2–6s away). anim() returns 'blink' when we're past
    // that start but within 150ms of it. A chatter bubble (see enter()
    // below) takes over the sprite for as long as it's showing — the
    // mouth-flap 'talk' loop reads as the mascot actually saying the line
    // instead of just standing there while text happens to appear above it.
    anim: (rt) => {
      if (rt.bubbleText) return 'talk'
      const now = performance.now()
      if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
      return 'idle'
    },
    enter: (rt) => {
      rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000
      // Idle chatter: rare, cosmetic-only bubble line (see the constants
      // above). Doesn't touch the FSM/behavior at all, just the bubble.
      const now = performance.now()
      if (now - lastChatterAt > IDLE_CHATTER_COOLDOWN_MS && Math.random() < IDLE_CHATTER_CHANCE) {
        lastChatterAt = now
        rt.bubbleText = IDLE_CHATTER_LINES[Math.floor(Math.random() * IDLE_CHATTER_LINES.length)]
        rt.bubbleUntil = now + IDLE_CHATTER_DURATION_MS
      }
    },
    tick: () => {
      // Standing still.
    },
    next: () => null,
    weight: 3,
    minMs: IDLE_MS[0],
    maxMs: IDLE_MS[1]
  },

  wander: {
    id: 'wander',
    anim: () => 'walk',
    enter: (rt) => {
      rt.vx = rt.facing * WALK_SPEED
    },
    tick: (rt) => {
      rt.x += rt.vx * (1 / 60) // dt is in seconds via the loop below; but tick receives ms — see stepMascot
      // Actually the loop calls tick with dt in seconds; but to keep the
      // BehaviorDef.tick signature consistent with the plan's `(rt, dt, now)`
      // where dt is seconds-clamped, we'll re-derive below. The wander
      // integration is redone in stepMascot to use dt correctly.
    },
    next: () => null,
    weight: 4,
    minMs: WANDER_MS[0],
    maxMs: WANDER_MS[1]
  },

  peck: {
    id: 'peck',
    anim: () => 'peck',
    tick: () => {
      // Stationary peck animation.
    },
    next: () => null,
    weight: 2,
    minMs: PECK_MS[0],
    maxMs: PECK_MS[1]
  },

  hop: {
    id: 'hop',
    // A little forward hop (chickens hop!). Real projectile motion —
    // enter() throws it up-and-forward and stepMascot's 'hop' case
    // integrates gravity until touchdown, which forces idle with a small
    // landing squash (impactVy ≈ HOP_VY: light, no poof).
    anim: () => 'flap',
    enter: (rt) => {
      rt.vy = -HOP_VY
      rt.vx = rt.facing * HOP_VX
    },
    tick: () => {
      // Integration happens in stepMascot (needs dt in seconds).
    },
    next: () => null,
    weight: 2,
    minMs: HOP_BACKSTOP_MS,
    maxMs: HOP_BACKSTOP_MS * 2
  },

  sleep: {
    id: 'sleep',
    anim: () => 'sleep',
    tick: () => {
      // Asleep.
    },
    next: () => null,
    weight: 1,
    minMs: SLEEP_MS[0],
    maxMs: SLEEP_MS[1]
  },

  dragged: {
    id: 'dragged',
    anim: () => 'dragged',
    tick: () => {
      // Position is owned by the pointer handler; nothing to do here.
    },
    next: () => null, // exited only via forceBehavior from pointerup
    minMs: 0,
    maxMs: 0
  },

  falling: {
    id: 'falling',
    // Flap briefly right after each wing-beat impulse (see stepMascot),
    // glide the rest of the cycle.
    anim: (rt) => (performance.now() - rt.fallPhaseAt < FLAP_BURST_MS ? 'flap' : 'fall-flutter'),
    enter: (rt) => {
      rt.bounceCount = 0
      scheduleFlap(rt, performance.now())
      // vx/vy are deliberately NOT reset here — they carry over from
      // drag-release toss momentum (set by Mascot.svelte's onPointerUp)
      // when falling starts from a throw, or stay at 0 when it starts from
      // walking off an edge / a surface disappearing underfoot.
    },
    tick: () => {
      // Integration happens in stepMascot (needs dt in seconds).
    },
    next: () => null, // exited via stepMascot when y reaches ground
    minMs: 0,
    maxMs: 0
  },

  land: {
    id: 'land',
    anim: () => 'land',
    tick: () => {
      // Brief squash animation.
    },
    // Routes to 'peck' instead of 'idle' when the drag that led here was
    // released on top of a desktop icon (Mascot.svelte's onPointerUp sets
    // investigateOnLand) — a little "investigate" reaction, whether the
    // landing was immediate or came after a fall. Consumed once.
    next: (rt) => {
      if (rt.investigateOnLand) {
        rt.investigateOnLand = false
        return 'peck'
      }
      return 'idle'
    },
    minMs: LAND_MS,
    maxMs: LAND_MS
  },

  react: {
    id: 'react',
    anim: (rt) => rt.reactAnim ?? 'idle',
    tick: () => {
      // Reaction plays its animation; no motion.
    },
    next: () => 'idle',
    minMs: REACT_DEFAULT_MS,
    maxMs: REACT_DEFAULT_MS
  },

  // Continuous engagement with the focused task window's active turn — not
  // a timed pulse like `react` above. Entered/exited directly by
  // MascotLayer's busy-state callback (see stimuli.ts's attachStimuli,
  // second callback), which also keeps rt.busyTalking current every time
  // the phase flips. No `weight` — never auto-picked by the idle selector,
  // same as dragged/falling/land.
  busy: {
    id: 'busy',
    anim: (rt) => (rt.busyTalking ? 'talk' : 'react-think'),
    tick: () => {
      // Stationary — just displays whichever sprite busyTalking selects.
    },
    // Only reached if something calls next() on it directly, which nothing
    // does in practice: MascotLayer forces 'idle' itself the moment
    // stimuli.ts reports the turn ended. Falling back to 'idle' here is
    // just a safe default, not the real exit path.
    next: () => 'idle',
    minMs: 0,
    maxMs: 0
  }
}

// ─── stepMascot: the per-frame driver ────────────────────────────────────

/** Force a behavior. Used by pointer code (dragged), gravity (falling), stimuli (react). */
export function forceBehavior(
  rt: MascotRuntime,
  id: BehaviorId,
  opts?: { anim?: AnimName; durationMs?: number }
): void {
  rt.behavior = id
  if (opts?.anim) {
    if (id === 'react') rt.reactAnim = opts.anim
    else {
      // For non-react behaviors, override the anim by setting animStart on a custom anim.
      rt.anim = opts.anim
      rt.animStart = performance.now()
    }
  }
  if (id === 'react' && opts?.anim) {
    rt.anim = opts.anim
    rt.animStart = performance.now()
  }
  const def = BEHAVIORS[id]
  if (opts?.durationMs) {
    rt.behaviorUntil = performance.now() + opts.durationMs
  } else if (def.maxMs > 0) {
    rt.behaviorUntil = performance.now() + randRange(def.minMs, def.maxMs)
  } else {
    rt.behaviorUntil = Number.POSITIVE_INFINITY
  }
  def.enter?.(rt)
}

/** Step the FSM by `dt` ms (already clamped by the loop to <= 100ms). */
export function stepMascot(rt: MascotRuntime, model: MascotModel, now: number, dt: number): void {
  const dts = dt / 1000
  const def = BEHAVIORS[rt.behavior]

  // Per-behavior physics integration. Done here (not in def.tick) so the
  // dt semantics stay consistent — the BehaviorDef.tick is reserved for
  // any bespoke per-frame logic a future behavior needs.
  switch (rt.behavior) {
    case 'wander': {
      rt.x += rt.vx * dts
      // Flip at margins.
      if (rt.x < MARGIN / 2) {
        rt.x = MARGIN / 2
        rt.facing = 1
        rt.vx = WALK_SPEED
      } else if (rt.x > rt.bounds.w - MARGIN / 2) {
        rt.x = rt.bounds.w - MARGIN / 2
        rt.facing = -1
        rt.vx = -WALK_SPEED
      }
      // If the mascot walks off a window edge (ground dropped below
      // current y), switch to falling — it flutters down to the next
      // surface beneath (another window, or the desktop bottom).
      if (rt.y < groundY(rt) - 1) {
        forceBehavior(rt, 'falling')
      }
      break
    }
    case 'idle': {
      // Same edge-detection as wander: a window can close/move under the
      // mascot while it's idling, dropping the ground out from under it.
      if (rt.y < groundY(rt) - 1) {
        forceBehavior(rt, 'falling')
      }
      break
    }
    case 'falling': {
      // Wing-beat: every flapCycleMs (dynamic — see scheduleFlap), cut
      // the descent speed sharply: a real (if losing) attempt at flight
      // rather than a flat drop.
      if (now - rt.fallPhaseAt >= rt.flapCycleMs) {
        rt.vy = Math.max(FLAP_MAX_LIFT, rt.vy - FLAP_IMPULSE)
        scheduleFlap(rt, now)
      }
      rt.vy += GRAVITY * dts
      // Soft terminal: a fall moving faster than terminal (a hard
      // downward toss) decays back toward it under drag instead of being
      // hard-clamped mid-air.
      if (rt.vy > TERMINAL_VY) {
        rt.vy = Math.max(TERMINAL_VY, rt.vy - SUPER_TERMINAL_DRAG * dts)
      }
      // Toss/drift horizontal velocity decays so it doesn't carry forever,
      // plus a gentle sideways wobble so even a straight-down drop isn't
      // perfectly vertical.
      rt.vx *= Math.max(0, 1 - VX_DECAY_PER_S * dts)
      const wobble = Math.sin(now / 260) * WOBBLE_VX
      rt.x += (rt.vx + wobble) * dts
      rt.y += rt.vy * dts
      // Face the direction of travel on real sideways tosses.
      if (Math.abs(rt.vx) > 40) rt.facing = rt.vx > 0 ? 1 : -1
      // Ricochet off the surface's side bounds on hard sideways throws
      // (a gentle drift still just stops at the margin, via clampX).
      const minX = MARGIN / 2
      const maxX = rt.bounds.w - MARGIN / 2
      if (rt.x <= minX && rt.vx < -WALL_BOUNCE_MIN_VX) {
        rt.x = minX
        rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION
      } else if (rt.x >= maxX && rt.vx > WALL_BOUNCE_MIN_VX) {
        rt.x = maxX
        rt.vx = -rt.vx * WALL_BOUNCE_RESTITUTION
      }
      const gy = groundY(rt)
      // Touchdown only while actually descending (vy > 0): a flap
      // impulse or a post-bounce rise can briefly carry it upward at/below
      // the ground line (or a window rising underneath can catch up to
      // it) — those must not read as impacts.
      if (rt.y >= gy && rt.vy > 0) {
        rt.y = gy
        const impact = rt.vy
        if (impact >= BOUNCE_MIN_VY && rt.bounceCount < BOUNCE_MAX) {
          // Hard impact: one soft, diminished bounce. The contact squash
          // renders from squashAt/impactVy in Mascot.svelte; vx keeps
          // decaying in the air for the second descent.
          rt.bounceCount++
          rt.vy = -impact * BOUNCE_RESTITUTION
          rt.impactVy = impact * 0.8
          rt.squashAt = now
        } else {
          rt.vy = 0
          rt.impactVy = impact
          rt.squashAt = now
          // Carry real sideways momentum into a short friction skid
          // instead of the old dead stop.
          const av = Math.abs(rt.vx)
          rt.vx =
            av >= SKID_MIN_VX ? Math.sign(rt.vx) * Math.min(av, SKID_ENTRY_MAX) * SKID_KEEP : 0
          forceBehavior(rt, 'land', { durationMs: LAND_MS })
        }
      }
      break
    }
    case 'hop': {
      // A small forward hop — plain projectile integration, no flapping
      // (too short). If the ground drops out mid-hop (it hopped off a
      // window edge), hand off to a real fall.
      rt.vy += GRAVITY * dts
      rt.x += rt.vx * dts
      rt.y += rt.vy * dts
      const gy = groundY(rt)
      if (rt.vy > 0 && gy - rt.y > 48) {
        forceBehavior(rt, 'falling')
      } else if (rt.y >= gy && rt.vy > 0) {
        rt.y = gy
        rt.impactVy = rt.vy
        rt.squashAt = now
        rt.vy = 0
        rt.vx = 0
        forceBehavior(rt, 'idle')
      }
      break
    }
    case 'land': {
      // Skid: leftover horizontal momentum from a sideways touchdown
      // (set in the falling→land transition above) decays under friction.
      if (rt.vx !== 0) {
        rt.x += rt.vx * dts
        const dec = SKID_FRICTION * dts
        rt.vx = Math.abs(rt.vx) <= dec ? 0 : rt.vx - Math.sign(rt.vx) * dec
      }
      break
    }
    case 'dragged': {
      // Position owned by pointer; just keep y clamped above ground so
      // release-from-ground doesn't immediately enter falling.
      break
    }
    default:
      break
  }

  // Keep x in bounds for any behavior (defensive).
  if (rt.behavior !== 'dragged') clampX(rt)

  // Sync anim from the active behavior (unless it was overridden by a
  // react/dragged force; reactAnim holds the override for `react`).
  if (rt.behavior === 'react' && rt.reactAnim) {
    rt.anim = rt.reactAnim
  } else {
    const a = def.anim(rt, model)
    if (a !== rt.anim) {
      rt.anim = a
      rt.animStart = now
    }
  }

  // Transition: only self-expiring behaviors (next() consult). dragged,
  // falling, and hop manage their own exits (pointerup / touchdown).
  if (rt.behavior === 'dragged' || rt.behavior === 'falling' || rt.behavior === 'hop') return
  if (now < rt.behaviorUntil) return

  const next = def.next(rt, now)
  if (next) {
    forceBehavior(rt, next)
  } else {
    // Idle-select a new behavior via weighted random over eligible entries.
    const eligible = (Object.values(BEHAVIORS) as BehaviorDef[]).filter((b) => (b.weight ?? 0) > 0)
    if (eligible.length > 0) {
      const picked = pickWeighted(eligible)
      forceBehavior(rt, picked.id)
    }
  }
}

/** Helper: is the mascot currently in an interruptible autonomous behavior (not dragged)? */
export function isInterruptible(rt: MascotRuntime): boolean {
  return rt.behavior !== 'dragged'
}

/** Helper: is the mascot currently asleep (used by stimuli to check interruptsSleep)? */
export function isAsleep(rt: MascotRuntime): boolean {
  return rt.behavior === 'sleep'
}

/**
 * Release from a drag: land immediately if already at/below ground, or
 * start falling otherwise. `rt.vx`/`rt.vy` are expected to already hold the
 * release's toss velocity (set by Mascot.svelte's onPointerUp from recent
 * pointer-move samples) — they're carried into `falling`, not reset here.
 */
export function releaseFromDrag(rt: MascotRuntime): void {
  const gy = groundY(rt)
  if (rt.y >= gy) {
    rt.y = gy
    rt.vy = 0
    rt.vx = 0
    rt.impactVy = 0 // set down gently — no squash spring, no feather poof
    rt.squashAt = performance.now()
    forceBehavior(rt, 'land', { durationMs: LAND_MS })
  } else {
    forceBehavior(rt, 'falling')
  }
}

/** Recompute ground clamp on resize: if the mascot was at the old ground, snap to the new ground. */
export function reground(rt: MascotRuntime, oldH: number): void {
  const gy = groundY(rt)
  if (rt.y >= oldH - 1) {
    rt.y = gy
    rt.vy = 0
  } else if (rt.y > gy) {
    rt.y = gy
    rt.vy = 0
  }
  if (rt.behavior !== 'dragged') clampX(rt)
}
