http-nu 0.17.2

The surprisingly performant, Nushell-scriptable, cross.stream-powered, Datastar-ready HTTP server that fits in your back pocket.
Documentation
# Pure 2048 game logic + the replay-pipeline state machine.
#
# No store dependencies (.cat, .last, .append) live here -- those go in
# mod.nu, which `use`s this module and adds them. The pure layer is what
# the xs snapshot-actor uses via `use game *` (modules registered to xs
# topics get parsed before xs's store-binding commands are available, so
# they have to stay pure).

# --- game logic -----------------------------------------------------------

# Deterministic random "roll" -- a pure function of (game_id, state, key).
# Same inputs always yield the same {idx, value}, so the whole game replays
# identically and (undo + same direction) reproduces the original spawn.
# Frames carry only the intent and game_id; replay re-derives every spawn.
export def roll [game_id: string, state: record, key: string]: nothing -> record {
  let payload = $"($game_id)|($key)|" + ($state | to json --raw)
  let h = $payload | hash sha256
  # Prepend "0x" so `into int` parses as hex. `--radix 16` would mis-read
  # hashes starting with "0b" / "0o" as binary / octal literals and error.
  {
    idx: ($h | str substring 0..8 | $"0x($in)" | into int)
    value: (($h | str substring 8..10 | $"0x($in)" | into int) mod 10)
  }
}

# Drop a 2 (90%) or 4 (10%) into an empty cell, picked by a roll.
#
# Empty cells are derived as integer indices (0..15, where idx = r*4 + c)
# rather than {r, c} records, to avoid constructing 16 fresh records every
# spawn -- profiling showed that grid-build dominated this function.
export def spawn-tile [seeds: record]: record -> record {
  let s = $in
  let occupied = $s.tiles | each {|t| $t.r * 4 + $t.c }
  let empties = 0..15 | where {|i| $i not-in $occupied }
  if ($empties | is-empty) { return $s }
  let pick_idx = $empties | get ($seeds.idx mod ($empties | length))
  let value = if ($seeds.value == 0) { 4 } else { 2 }
  $s
  | update tiles { append {id: $s.next_id r: ($pick_idx // 4) c: ($pick_idx mod 4) value: $value merged: false spawned: true} }
  | update next_id { $in + 1 }
}

# Initial board for a game. Two tiles spawned via roll() with key "@init".
export def initial-state [game_id: string]: nothing -> record {
  let s0 = {tiles: [] ghosts: [] next_id: 1 score: 0 game_over: false}
  let s1 = $s0 | spawn-tile (roll $game_id $s0 "@init")
  $s1 | spawn-tile (roll $game_id $s1 "@init")
}

# Slide one row left, preserving tile identity. When two adjacent tiles
# merge, the leading tile keeps its id (and doubles its value); the
# trailing tile becomes a ghost at the merge cell -- it carries the
# consumed tile's id so the renderer can emit it with the same
# view-transition-name, letting the browser pair the old visible tile
# with the new (invisible) ghost and slide it into the merge position
# before fading. Returns {tiles, ghosts, score}.
#
# Imperative `mut`+`while` rather than `reduce`: profiling showed reduce was
# ~75% slower in this hot path because every step paid closure-call cost
# plus an immutable record copy for the accumulator.
export def slide-row-tiles [row_idx: int]: list -> record {
  let in_row = $in | sort-by c
  mut out = []
  mut ghosts = []
  mut score = 0
  mut col = 0
  mut i = 0
  let n = $in_row | length
  while $i < $n {
    let cur = $in_row | get $i
    let has_next = ($i + 1) < $n
    let nxt = if $has_next { $in_row | get ($i + 1) } else { null }
    if $has_next and $cur.value == $nxt.value {
      let merged = $cur.value * 2
      $out = $out | append {id: $cur.id r: $row_idx c: $col value: $merged merged: true spawned: false}
      $ghosts = $ghosts | append {id: $nxt.id r: $row_idx c: $col value: $nxt.value}
      $score = $score + $merged
      $col = $col + 1
      $i = $i + 2
    } else {
      $out = $out | append {id: $cur.id r: $row_idx c: $col value: $cur.value merged: false spawned: false}
      $col = $col + 1
      $i = $i + 1
    }
  }
  {tiles: $out ghosts: $ghosts score: $score}
}

# Group tiles by row once, slide each row, flatten back. The previous form
# called `where r == X` four times over the full tile list -- redundant
# work that profiling showed cost ~40% of this function's time.
export def slide-tiles-left []: list -> record {
  let by_row = $in | group-by r
  let rows = 0..3 | each {|r|
    ($by_row | get -o ($r | into string) | default []) | slide-row-tiles $r
  }
  {
    tiles: ($rows | each { $in.tiles } | flatten)
    ghosts: ($rows | each { $in.ghosts } | flatten)
    score: ($rows | each { $in.score } | math sum)
  }
}

# Reflect tiles over the vertical axis (c -> 3 - c).
export def reflect-cols []: list -> list {
  $in | each {|t| $t | upsert c (3 - $t.c) }
}

# Swap r and c (transpose over the diagonal).
export def transpose-tiles []: list -> list {
  $in | each {|t| $t | upsert r $t.c | upsert c $t.r }
}

# All four directions reuse `slide-tiles-left` by reflecting/transposing in
# and back out -- so the merge logic lives in exactly one place. Direction
# is data: each dir maps to {pre, post} closures that frame slide-tiles-left.
# The post-transform is the inverse of the pre-transform (reflect is its own
# inverse; transpose is its own inverse; the composition reverses on the way
# out).
export def slide-tiles [dir: string]: list -> record {
  let plan = {
    h: {pre: {|| $in}                              post: {|| $in}}
    l: {pre: {|| reflect-cols}                     post: {|| reflect-cols}}
    k: {pre: {|| transpose-tiles}                  post: {|| transpose-tiles}}
    j: {pre: {|| transpose-tiles | reflect-cols}   post: {|| reflect-cols | transpose-tiles}}
  } | get -o $dir
  if $plan == null { return {tiles: $in ghosts: [] score: 0} }
  let r = $in | do $plan.pre | slide-tiles-left
  {tiles: ($r.tiles | do $plan.post) ghosts: ($r.ghosts | do $plan.post) score: $r.score}
}

export def tiles-equal [a: list b: list]: nothing -> bool {
  # Compare on game-meaningful fields only; ignore animation hints like
  # merged/spawned which apply-move stamps on every tile.
  let proj = {|ts| $ts | each {|t| {id: $t.id r: $t.r c: $t.c value: $t.value} } | sort-by id }
  (do $proj $a) == (do $proj $b)
}

export def is-game-over []: record -> bool {
  let s = $in
  if ($s.tiles | length) < 16 { return false }
  let mergeable = $s.tiles | any {|t|
      let right = $t.c < 3 and ($s.tiles | where r == $t.r and c == ($t.c + 1) | first | get value) == $t.value
      let down = $t.r < 3 and ($s.tiles | where r == ($t.r + 1) and c == $t.c | first | get value) == $t.value
      $right or $down
    }
  not $mergeable
}

export def apply-move [dir: string, game_id: string]: record -> record {
  # Ghosts, merged, and spawned flags live for one snapshot only -- the
  # move that produced them. Clear at the start so they don't carry over.
  let s = $in
    | upsert ghosts []
    | update tiles { each {|t| $t | upsert merged false | upsert spawned false } }
  let r = $s.tiles | slide-tiles $dir
  if $s.game_over or (tiles-equal $s.tiles $r.tiles) { return $s }
  let next = ($s
    | update tiles { $r.tiles }
    | update ghosts { $r.ghosts }
    | update score { $in + $r.score }
    | spawn-tile (roll $game_id $s $dir))
  $next | upsert game_over ($next | is-game-over)
}

# --- replay pipeline ------------------------------------------------------

# Static topic filter: admits this player's games index, any game's move
# topic, and the xs.threshold marker. Everything else (other players'
# topics, unrelated streams) is dropped. impulses-to-states still does
# the dynamic per-game filtering -- this stage is the cheap pre-filter.
export def filter-for-player [games_topic: string] {
  where {|f| (
    $f.topic == $games_topic
    or ($f.topic | str starts-with "game.move.")
    or $f.topic == "xs.threshold"
    or $f.topic == "xs.pulse"
  ) }
}

# Takes xs frames, yields {state, threshold?} records. One emit per
# move frame.
export def impulses-to-states [initial: record] {
  # s = {stack: [state, ...], game_id, games_topic}
  # Stack discipline: every move that actually changes the board pushes the
  # resulting state. An undo frame pops the top, exposing the previous state.
  # The "current" state is always the top of the stack. game_id seeds spawn
  # determinism so (state, dir) -> same spawn within a game; undo + same-dir
  # = same outcome. A frame on the player's games_topic starts a new game:
  # game_id becomes the frame's id, stack resets to a fresh initial-state.
  # Move frames are only processed for the current game's topic; everything
  # else is dropped. Legacy slam-X intents in old logs fall through to the
  # noop-echo arm at the bottom.
  generate {|frame s|
    let cur = $s.stack | last
    if $frame.topic == "xs.threshold" {
      # Replay caught up to live. Also emit a signals patch with how long
      # the replay took -- useful as a quick debug indicator client-side.
      let started = $s | get started? | default (date now)
      let elapsed = ((date now) - $started) / 1ms | math round
      return {out: [
        {state: $cur, threshold: true}
        {signals: {replayMs: $elapsed}}
      ], next: $s}
    }
    if $frame.topic == "xs.pulse" {
      # SSE keepalive -- emit a pulse marker; downstream stages turn it into
      # a datastar-patch-signals no-op so the client sees a sign of life.
      return {out: [{pulse: true}], next: $s}
    }
    if $frame.topic == $s.games_topic {
      # New game (or first game) for this player. Reset to a fresh board.
      # Carry req_id from the originating reset POST so the client's pending
      # RTT probe finds a matching mutation and resolves.
      let new_game_id = $frame.id
      let new_state = initial-state $new_game_id
      let req_id = $frame | get meta? | default {} | get req_id? | default ""
      return {
        out: [{state: $new_state, threshold: false, req_id: $req_id}]
        next: ($s | update stack [$new_state] | update game_id $new_game_id)
      }
    }
    if $frame.topic != $"game.move.($s.game_id)" {
      # Some other player's game, or our own old game. Drop.
      return {next: $s}
    }
    let kind = $frame.meta | get kind? | default "move"
    let req_id = $frame.meta | get req_id? | default ""
    if $kind == "undo" {
      # Pop one entry. If only the initial state is on the stack, echo.
      if ($s.stack | length) <= 1 {
        return {out: [{state: $cur, req_id: $req_id, threshold: false}], next: $s}
      }
      let popped = $s.stack | drop 1
      let new_top = $popped | last
      return {out: [{state: $new_top, direction: "undo", changed: true, req_id: $req_id, threshold: false, move_id: ($frame | get id? | default "")}], next: ($s | update stack $popped)}
    }
    let intent = $frame.meta | get intent? | default ""
    if $intent in [h j k l] {
      let new_state = $cur | apply-move $intent $s.game_id
      let changed = not (tiles-equal $cur.tiles $new_state.tiles)
      # No-op moves don't push -- nothing to undo if the board didn't change.
      let new_stack = if $changed { $s.stack | append $new_state } else { $s.stack }
      return {out: [{state: $new_state, direction: $intent, changed: $changed, req_id: $req_id, threshold: false, move_id: ($frame | get id? | default "")}], next: ($s | update stack $new_stack)}
    }
    # Any other intent (empty ping, legacy slam-X, unrecognised) emits a no-op state
    # echo. The client uses the resulting mutation to measure RTT and to
    # clear the "lit edge" -- if the edge stays lit, the server is slow.
    return {out: [{state: $cur, req_id: $req_id, threshold: false}], next: $s}
  } $initial
  | flatten
}

# Pipeline form: fold a stream of move frames for one game into final state.
# game_id is required -- spawn rolls are seeded from it, so without it the
# replay would diverge from what the live server produced.
#
#   .cat -T $"game.move.($id)" | project-game $id | to json
export def project-game [game_id: string]: list -> record {
  # Single-statement body: a `let` between a custom command's input and a
  # downstream `generate` (inside impulses-to-states) breaks the implicit
  # pipeline -- generate ends up with no input and calls its closure in
  # single-arg mode, leaving the `s` parameter unbound.
  #
  # The reduce tracks the most-recent state record while letting non-state
  # records (signals, pulses) pass without disturbing the running answer.
  # On empty input the fold seed -- a fresh initial-state -- is returned
  # unchanged.
  impulses-to-states {
    stack: [(initial-state $game_id)]
    game_id: $game_id
    games_topic: ""
  }
  | reduce --fold (initial-state $game_id) {|item acc| $item | get state? | default $acc }
}

# Authorize a move frame against a game's owner. The HTTP-layer
# `/move` handler stamps `meta.user_id` from the resolved session; the
# snapshot-actor compares against the owner before applying the move.
# Returns true if:
#   - the game has no recorded owner (empty owner_id), OR
#   - the frame's stamped user_id matches the owner
# Anonymous or mismatched stamps return false -- the actor no-ops.
export def move-authorized [frame: record, owner_id: string]: nothing -> bool {
  if ($owner_id | is-empty) { return true }
  let user = $frame.meta | get user_id? | default ""
  $user == $owner_id
}