<!-- SOLID CITY · part 1993 -->
stores both on a refusal — a refused step never moves the
 * ship. Attitude is left as the core leaves it: it is the yoke, not
 * the ship, and a frozen yoke would deadlock every go-around at the
 * floor. (2) ATMOSPHERE: the core's air is
 * sea-level air at every height; the machine keeps the world's own
 * ceiling (420 m) as the envelope — above it she is not allowed to
 * climb, and the pilot is told the air is thin. The core's recipe is
 * the packet's light craft (top ~84 m/s, stall ~27 m/s); the city's
 * big arcade ships keep their own law elsewhere — two families, two
 * purposes, never conflated.
 *
 * LAUNCH and LANDING are the machine's own kinematics: the core has
 * no ground roll (VECTOR, Gate 4), and the adapter's sweep holds
 * every craft 2.2 m above the fabric, so the first and last metres
 * belong to the berth. The launch is a cradle throw — the spaceport's
 * rail hurls her along her heading on a 10-degree slope to flying
 * speed; the landing is a roll-out on the surface she chose. */
export function createShipMachine(core, world) {
  var M = {
    mode: 'PARKED', t: 0, thrTarget: 0, clock: null, craft: null,
    emerg: 0, emergWindow: 0, phaseT: 0, rollV: 0,
    saidCeiling: 0, saidStall: 0, saidPullup: 0,
    lastStall: false, stallTime: 0, mushTime: 0, recActive: false
  };
  function snap(c) {
    return { pos: { x: c.pos.x, y: c.pos.y, z: c.pos.z }, vel: { x: c.vel.x, y: c.vel.y, z: c.vel.z } };
  }
  function restore(c, s) {
    /* position and velocity are restored entire — a refused step never
     * moves the ship, never tunnels, never keeps stolen speed. The
     * attitude scalars are NOT restored: the core assigns velocity only
     * after its sweep passes, so pos/vel are the whole hazard; pitch and
     * bank are the pilot's yoke, and a yoke that moved during a refused
     * second stays moved — that is what lets a go-around's nose rise
     * across refused frames instead of freezing at the floor. */
    c.pos = { x: s.pos.x, y: s.pos.y, z: s.pos.z }; c.vel = { x: s.vel.x, y: s.vel.y, z: s.vel.z };
  }
  function surfBelow(c) { var h = world.supportAt(c.pos.x, c.pos.z, 'SPACE_SHIP'); return h ? h.y : null; }
  function gear(c) { var s = surfBelow(c); return s === null ? 999 : c.pos.y - s; }
  function speed(c) { return Math.hypot(c.vel.x, c.vel.y, c.vel.z); }
  function fwd(c) { return { x: Math.sin(c.yaw), z: Math.cos(c.yaw) }; }

  function board(craft, pos, yaw) {
    M.craft = craft; M.mode = 'PARKED'; M.thrTarget = 0; M.emerg = 0; M.phaseT = 0;
    craft.pos = { x: pos.x, y: pos.y, z: pos.z }; craft.yaw = yaw;
    craft.vel = { x: 0, y: 0, z: 0 }; craft.pitch = 0; craft.bank = 0;
    craft.pitchRate = 0; craft.rollRate = 0; craft.throttle = 0;
    M.clock = core.createCraftClock(core.stepSpaceShip, craft, world);
    return M;
  }

  function emergency(events, why) {
    M.mode = 'EMERGENCY'; M.phaseT = 0;
    M.emerg++; M.emergWindow = 10;
    events.push(why === 'hot' ? 'too-hot' : 'emergency');
  }

  function flyTick(dt, input, events) {
    /* one frame of core flight with a transactional snapshot */
    var c = M.craft, s = snap(c);
    var res = M.clock(dt, input);
    if (!res.accepted) { restore(c, s); return false; }
    M.lastStall = !!res.stall;
    /* the shudder flickers at the boundary of the stall — evidence is
     * accumulated with a decay, not reset by one clean frame */
    M.stallTime = res.stall ? M.stallTime + dt : Math.max(0, M.stallTime - 2 * dt);
    if (res.stall && M.t - M.saidStall > 6) { M.saidStall = M.t; events.push('stall'); }
    return true;
  }

  function tick(dt, input) {
    var c = M.craft, events = [];
    if (!c) return { mode: M.mode, events: events };
    M.t += dt;
    if (M.emergWindow > 0) { M.emergWindow -= dt; if (M.emergWindow <= 0) M.emerg = 0; }
    var x = Math.max(-1, Math.min(1, input.x || 0));
    var y = Math.max(-1, Math.min(1, input.y || 0));
    if (input.thrDelta) M.thrTarget = Math.max(0, Math.min(1, M.thrTarget + input.thrDelta * 0.55 * dt));

    if (M.mode === 'PARKED') {
      var s0 = surfBelow(c); if (s0 !== null) c.pos.y = s0;
      c.vel = { x: 0, y: 0, z: 0 }; c.throttle = 0; M.thrTarget = 0;
      if (input.action) { M.mode = 'LAUNCH'; M.phaseT = 0; events.push('launch'); }
      return { mode: M.mode, events: events };
    }

    if (M.mode === 'LAUNCH') {
      /* the cradle throw: 1.6 s along the heading on a 10-degree slope,
       * 0 -> 34 m/s, then the core takes a flying ship */
      M.phaseT += dt;
      var k = Math.min(1, M.phaseT / 1.6), v = 34 * k, f = fwd(c);
      var climb = Math.sin(10 * Math.PI / 180), run = Math.cos(10 * Math.PI / 180);
      c.pos.x += f.x * run * v * dt; c.pos.z += f.z * run * v * dt; c.pos.y += climb * v * dt;
      c.vel = { x: f.x * run * v, y: climb * v, z: f.z * run * v };
      c.pitch = 10 * Math.PI / 180 * k;
      if (k >= 1) {
        c.throttle = 0.8; M.thrTarget = 0.8; M.mode = 'FREE'; events.push('airborne')