hex_world source

← hex_world · API reference · version 0.2.3

4 file(s), 929 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
Cell struct22HXW-00534 81 82 88 138 158 … (48 total)
Chunk struct31—46 87 90 309
World struct45HXW-00494 95 111 124 138 158 … (12 total)
chunk_idx_32 fn62HXW-003112 113 125 126 139 140 … (26 total)
hex_idx_32 fn70HXW-003141 142 162 163 43 44 … (17 total)
cell_empty fn81HXW-00189 148 151 275 349 124
build_chunk fn87—130
world_empty fn94—55 60 75 82 97 99 … (29 total)
has_chunk fn111HXW-00130 35 132 133
ensure_chunk fn124HXW-001159 389 33 34
get_cell fn138HXW-001197 198 199 200 201 202 … (24 total)
set_cell fn158HXW-001275 61 63 64 67 70 … (42 total)
cell_count fn179HXW-00556 62 65 68 71 103 … (19 total)
neighbour_count fn195HXW-002236 78 91 58 84 85 … (11 total)
tick_and_decay fn218—146 147 148 61 64 86 … (11 total)
world_save fn318—98 109 127 150 161 105 … (7 total)
world_load fn366—100 111 129 152 163 175 … (11 total)

src/hex_world.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// hex_world — a sparse world of single-layer hex cells in 32x32 chunks
// (32-wide chunks, floor-division for negative coords), each Cell packed
// to exactly 4 bytes:
//   [c_color: u8] [c_height: u8] [c_age: u16-LE]

// ── Types ───────────────────────────────────────────────────────────

// 4-byte cell.  c_color uses the u8 typedef (integer limit(0,255)
// size(1)); c_age uses u16 (size(2)).  Storage packs to exactly 4
// bytes per cell — the projector ships them through the binary
// surface in chunk-major order.
//
// c_color = 0 means "empty" (no crystal, no TTT mark).  Both clients
// treat 0 as background; non-zero as live content.  It is a SENTINEL, not a
// colour: a colour-0 cell stores its other fields but is invisible to
// cell_count, neighbour_count, the decay pass and world_save.  Start a palette
// at 1.
// Example: @HXW-005
pub struct Cell {
  pub c_color:  u8,
  pub c_height: u8,
  pub c_age:    u16,
}

// 32x32 grid at chunk coords (ck_cx, ck_cz).  Index into ck_cells:
// hx * 32 + hz where hx, hz ∈ [0, 31].  Same shape as moros_map's
// Chunk minus the cy layer (this world is single-layer).
pub struct Chunk {
  ck_cx:    integer,
  ck_cz:    integer,
  ck_cells: vector<Cell>,
}

// Top-level container.  Sparse: only chunks with at least one
// non-empty cell live in `chunks`; empty chunks are removed by the
// tick loop's GC pass — that pass is the CALLER's.  Nothing in this
// library frees a chunk: `set_cell` says so, `tick_and_decay` empties cells
// without touching `chunks`, and the only reclaim it offers is a
// `world_save` / `world_load` round trip, which writes non-empty chunks only.
// `tick` is monotonic per server lifetime.
// Example: @HXW-004
pub struct World {
  pub chunks: vector<Chunk>,
  pub tick:   integer,
}

// ── Addressing helpers ────────────────────────────────────────────
//
// Public so code that packs cells by chunk can route world (q, r)
// coordinates to the right chunk + intra-chunk slot without
// re-implementing the floor-division dance for negatives.

// floor(v / 32) — handles negative v correctly (C-style `/` rounds
// toward zero, which silently maps cell (-1, 0) to chunk 0 instead
// of chunk -1).  Use this rather than `v / 32` for any coordinate that
// can go west or north of the origin, which in a world that grows both
// ways is half of them.
// Example: @HXW-003
pub fn chunk_idx_32(v: integer) -> integer {
  vr = v % 32;
  if vr < 0 { (v - vr - 32) / 32 } else { (v - vr) / 32 }
}

// v mod 32, always in [0, 31].  `v % 32` is NOT this: it answers a negative
// slot for a negative v, which then indexes nothing.
// Example: @HXW-003
pub fn hex_idx_32(v: integer) -> integer {
  vr = v % 32;
  if vr < 0 { vr + 32 } else { vr }
}

// ── Constructors ──────────────────────────────────────────────────

// Default empty cell.  All fields zero; serialises to four 0x00
// bytes on the wire.  Also what `get_cell` answers for a cell that is not
// there at all — the two are deliberately indistinguishable.
// Example: @HXW-001, @HXW-005
pub fn cell_empty() -> Cell {
  Cell { c_color: 0, c_height: 0, c_age: 0 }
}

// Build a 32x32 chunk filled with empty cells at chunk coords
// (cx, cz).
pub fn build_chunk(cx: integer, cz: integer) -> Chunk {
  cells: vector<Cell> = [];
  for _ in 0..1024 { cells += [cell_empty()]; }
  Chunk { ck_cx: cx, ck_cz: cz, ck_cells: cells }
}

// New empty world, tick 0, no chunks.
pub fn world_empty() -> World {
  World { chunks: [], tick: 0 }
}

// ── Cell access ───────────────────────────────────────────────────
//
// Linear scan through `chunks` for the matching (cx, cz).  Sparse
// worlds keep `chunks` small (typically <100), so the scan is cheap
// — matches moros_map's design choice and avoids a separate
// hash-by-chunk-coord index.  If profiling shows a hot site, the
// indexed variant lives in moros_map's pattern and can be lifted
// here.

// True iff a chunk exists at the global (q, r).  The only way to distinguish
// "no chunk here" from "chunk here, cell empty" — `get_cell` answers the same
// thing for both, on purpose.
// Example: @HXW-001
pub fn has_chunk(self: World, q: integer, r: integer) -> boolean {
  cx = chunk_idx_32(q);
  cz = chunk_idx_32(r);
  for hc_c in self.chunks {
    if hc_c.ck_cx == cx && hc_c.ck_cz == cz { return true; }
  }
  false
}

// Ensure a chunk exists at the global (q, r); creates a 32x32 of
// empty cells if absent — 1024 cells per first touch, and never freed again
// by this library.
// Example: @HXW-001, @HXW-004
pub fn ensure_chunk(self: &World, q: integer, r: integer) {
  cx = chunk_idx_32(q);
  cz = chunk_idx_32(r);
  for ec_c in self.chunks {
    if ec_c.ck_cx == cx && ec_c.ck_cz == cz { return; }
  }
  self.chunks += [build_chunk(cx, cz)];
}

// Read the cell at global (q, r).  Returns `cell_empty()` when the
// chunk does not exist (sparse-world convention — empty chunks are
// implicitly all-empty).  Reading never allocates, so a read sweep over any
// region is free however wide; ask `has_chunk` if you need the difference.
// Example: @HXW-001
pub fn get_cell(self: World, q: integer, r: integer) -> Cell {
  cx = chunk_idx_32(q);
  cz = chunk_idx_32(r);
  hx = hex_idx_32(q);
  hz = hex_idx_32(r);
  idx = hx * 32 + hz;
  for gc_c in self.chunks {
    if gc_c.ck_cx == cx && gc_c.ck_cz == cz {
      cells = gc_c.ck_cells;
      if idx < len(cells) { return cells[idx]; }
      return cell_empty();
    }
  }
  cell_empty()
}

// Write a cell at global (q, r).  Creates the containing chunk on
// demand; does NOT GC empty chunks (the tick loop's end-of-tick
// pass owns that — and it is the caller's loop, not this library's).
// Example: @HXW-001, @HXW-004
pub fn set_cell(self: &World, q: integer, r: integer, value: Cell) {
  ensure_chunk(self, q, r);
  cx = chunk_idx_32(q);
  cz = chunk_idx_32(r);
  hx = hex_idx_32(q);
  hz = hex_idx_32(r);
  idx = hx * 32 + hz;
  for sc_c in self.chunks {
    if sc_c.ck_cx == cx && sc_c.ck_cz == cz {
      cells = sc_c.ck_cells;
      if idx < len(cells) { cells[idx] = value; }
      return;
    }
  }
}

// ── Aggregates + neighbour counting ────────────────────────────────

// Total number of non-empty cells across every chunk.  "Non-empty" is
// `c_color != 0`, so this can answer 0 for a world holding heights and ages.
// Example: @HXW-005
pub fn cell_count(self: World) -> integer {
  cct_total = 0;
  for cct_c in self.chunks {
    for cct_cell in cct_c.ck_cells {
      if cct_cell.c_color != 0 { cct_total += 1; }
    }
  }
  cct_total
}

// Count filled axial-hex neighbours of the cell at global (q, r).
// Returns 0..=6.  Empty centre cell is fine — neighbours are read
// independently of the centre's state.  AXIAL: the six deltas carry no parity
// term, so these (q, r) are not the odd-r offset pair `hex_grid` uses, though
// both are `(integer, integer)` spelled q and r.  "Filled" is `c_color != 0`.
// Example: @HXW-002, @HXW-005
pub fn neighbour_count(self: World, q: integer, r: integer) -> integer {
  nc_count = 0;
  if get_cell(self, q + 1, r    ).c_color != 0 { nc_count += 1; }
  if get_cell(self, q - 1, r    ).c_color != 0 { nc_count += 1; }
  if get_cell(self, q,     r + 1).c_color != 0 { nc_count += 1; }
  if get_cell(self, q,     r - 1).c_color != 0 { nc_count += 1; }
  if get_cell(self, q + 1, r - 1).c_color != 0 { nc_count += 1; }
  if get_cell(self, q - 1, r + 1).c_color != 0 { nc_count += 1; }
  nc_count
}

// ── Tick + decay ───────────────────────────────────────────────────
//
// One world step: advances `tick`, ages every non-empty cell by 1,
// then removes any cell whose `c_age` has crossed
// `effective_lifetime + decay_window` where
// `effective_lifetime = base_lifetime + neighbour_lease *
// filled_neighbour_count`.  Neighbour counts are computed against
// the PRE-TICK state — a single tick can remove a connected blob
// of cells, but the cascade (centre losing its neighbour support)
// only fires on the NEXT tick.  This keeps the simulation
// deterministic per-step and prevents one-tick complete collapse
// of large clusters.
pub fn tick_and_decay(self: &World, base_lifetime: integer,
                       neighbour_lease: integer, decay_window: integer) {
  self.tick += 1;

  // Pass 1 — decide removals using PRE-TICK state.  Two parallel
  // vectors record (q, r) pairs to flush; using parallel vectors
  // sidesteps the tuple-vector codegen path that's still fragile
  // across some loft compiler shapes.
  rm_q: vector<integer> = [];
  rm_r: vector<integer> = [];
  for tc in self.chunks {
    for thx in 0..32 {
      for thz in 0..32 {
        tidx = thx * 32 + thz;
        tcell = tc.ck_cells[tidx];
        if tcell.c_color == 0 { continue; }
        tq = tc.ck_cx * 32 + thx;
        tr = tc.ck_cz * 32 + thz;
        tnbr = neighbour_count(self, tq, tr);
        teff = base_lifetime + neighbour_lease * tnbr;
        tnew_age = tcell.c_age + 1;
        if tnew_age > 65535 { tnew_age = 65535; }
        if tnew_age >= teff + decay_window {
          rm_q += [tq];
          rm_r += [tr];
        }
      }
    }
  }

  // Pass 2 — increment c_age on every non-empty cell.  Cells
  // scheduled for removal still get their age bumped here so a
  // post-mortem peek (debugging hooks) sees a coherent number;
  // the immediate `set_cell(... cell_empty())` below clears them.
  for ic in self.chunks {
    for ihx in 0..32 {
      icells = ic.ck_cells;
      for ihz in 0..32 {
        iidx = ihx * 32 + ihz;
        if iidx < len(icells) {
          icell = icells[iidx];
          if icell.c_color == 0 { continue; }
          inew_age = icell.c_age + 1;
          if inew_age > 65535 { inew_age = 65535; }
          icells[iidx] = Cell {
            c_color:  icell.c_color,
            c_height: icell.c_height,
            c_age:    (inew_age & 65535) as u16,
          };
        }
      }
    }
  }

  // Pass 3 — apply removals.
  rm_n = len(rm_q);
  for rm_i in 0..rm_n {
    set_cell(self, rm_q[rm_i], rm_r[rm_i], cell_empty());
  }
}

// ── Persistence — binary snapshot save / load ──────────────────────
//
// Whole-world dump.  Single-shot full-state snapshot; the file is
// truncated on each save.
//
// File layout (little-endian, version 1):
//   [4B u32  : magic 0x57524C44 'WRLD']
//   [4B u32  : format version = 1]
//   [4B i32  : tick]
//   [4B u32  : chunk count N]
//   N × {
//     [4B i32: ck_cx]
//     [4B i32: ck_cz]
//     [4B u32: filled-cell count K in this chunk]
//     K × {
//       [2B u16: cell idx within chunk (0..1023)]
//       [1B u8 : c_color]
//       [1B u8 : c_height]
//       [2B u16: c_age]
//     }
//   }
//
// Costs: O(total cells) per save, ~6 bytes per filled cell + 12 bytes
// per non-empty chunk + 16 bytes header.  A 1000-cell world fits in
// ~6 KB.

WORLD_FILE_MAGIC   = 0x57524C44;       // 'WRLD'
WORLD_FILE_VERSION = 1;

// Count the filled (c_color != 0) cells in a single chunk.
fn chunk_filled_count(c: Chunk) -> integer {
  cfc_k = 0;
  for cfc_cell in c.ck_cells {
    if cfc_cell.c_color != 0 { cfc_k += 1; }
  }
  cfc_k
}

// Save the world to `path`, replacing whatever was there.
pub fn world_save(self: World, path: text) {
  // Pre-count non-empty chunks so the header is correct without a
  // second pass over `chunks` after the file is open.
  ws_n_chunks = 0;
  for ws_pc in self.chunks {
    if chunk_filled_count(ws_pc) > 0 { ws_n_chunks += 1; }
  }
  {
    f = file(path);
    f#format = LittleEndian;
    // Snapshot semantics: each save replaces the previous file.  Make
    // the truncation explicit so the sequence keeps working once `+=`
    // is append-only (rather than truncating on first reopen).
    f.set_file_size(0);
    // Explicit `as i32` casts force 4-byte writes — without them, a
    // bare `integer` value writes 8 bytes (loft stores integers as
    // i64 internally; the file API takes the storage width unless
    // overridden by a cast).  c_color/c_height/c_age are written at
    // their declared u8/u16 widths automatically because they're
    // strongly typed struct fields.
    f += (WORLD_FILE_MAGIC   as i32);
    f += (WORLD_FILE_VERSION as i32);
    f += (self.tick          as i32);
    f += (ws_n_chunks        as i32);
    for ws_c in self.chunks {
      ws_k = chunk_filled_count(ws_c);
      if ws_k == 0 { continue; }
      f += (ws_c.ck_cx as i32);
      f += (ws_c.ck_cz as i32);
      f += (ws_k       as i32);
      for ws_i in 0..1024 {
        ws_cell = ws_c.ck_cells[ws_i] ?? cell_empty();
        if ws_cell.c_color == 0 { continue; }
        f += ((ws_i & 65535) as u16);
        f += ws_cell.c_color as u8;
        f += ws_cell.c_height as u8;
        f += ws_cell.c_age as u16;
      }
    }
    f.sync();
  }
}

// Load the world from `path`.  Mutates `self` in place; pre-existing
// cells in `self` are left untouched unless the snapshot overwrites
// the same (q, r).  Returns the number of cells loaded.  Returns 0 if
// the file does not exist, has the wrong magic, or carries an
// unsupported version — never panics.
pub fn world_load(self: &World, path: text) -> integer {
  // `f#read as T` reads exactly T's byte width — no size argument
  // needed.  For the per-cell payload reads, the LHS-driven form
  // is even shorter: the field's declared type (`u8` / `u16`) IS the
  // width, so no `as T` at all.
  if !exists(path) { return 0; }
  f = file(path);
  f#format = LittleEndian;
  wl_magic = f#read as i32;
  if wl_magic != (WORLD_FILE_MAGIC as i32) { return 0; }
  wl_version = f#read as i32;
  if wl_version != (WORLD_FILE_VERSION as i32) { return 0; }
  self.tick = f#read as i32;
  wl_n_chunks = f#read as i32;
  wl_total = 0;
  wl_i_chunk = 0;
  while wl_i_chunk < wl_n_chunks {
    wl_cx = f#read as i32;
    wl_cz = f#read as i32;
    wl_k  = f#read as i32;
    // Ensure the target chunk exists, then locate it for direct
    // index-into-ck_cells writes.  We pass a global (q, r) inside
    // the chunk's footprint so chunk_idx_32 reproduces (wl_cx, wl_cz).
    ensure_chunk(self, wl_cx * 32, wl_cz * 32);
    for wl_target in self.chunks {
      if wl_target.ck_cx != wl_cx { continue; }
      if wl_target.ck_cz != wl_cz { continue; }
      wl_j = 0;
      wl_cells = wl_target.ck_cells;
      while wl_j < wl_k {
        wl_idx = f#read as u16;     // explicit — `wl_idx` is a local with no type hint
        wl_cell = Cell{c_color: 0, c_height: 0, c_age: 0};
        wl_cell.c_color  = f#read;   // LHS-driven: c_color is u8 → 1 byte
        wl_cell.c_height = f#read;   //             c_height is u8 → 1 byte
        wl_cell.c_age    = f#read;   //             c_age is u16 → 2 bytes
        if wl_idx < len(wl_cells) { wl_cells[wl_idx] = wl_cell; }
        wl_total += 1;
        wl_j += 1;
      }
      break;
    }
    wl_i_chunk += 1;
  }
  wl_total
}

tests/02-persist.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Regression tests for lib/hex_world's save/load + chunk addressing
// surface (lib_plans/12 Phase 6t Tier 5).  The companion smoke test
// (hex_world.loft) covers get/set + chunk count + negative-coord get.
// This file pins:
//   * world_save / world_load binary round-trip (single cell, many
//     cells across chunks, negative coords, tick preserved, age
//     preserved);
//   * world_load failure modes (missing file → 0, wrong magic → 0,
//     wrong version → 0) — never panics;
//   * chunk_idx_32 / hex_idx_32 contract for negative inputs;
//   * cell_count + neighbour_count aggregates.
//
// Round-trip tests write to `_tmp_*.bin` beside this file (relative paths
// resolve from the test file's directory under `loft test`); those names are
// gitignored.  Each test re-creates its tmp file, so leftover
// artefacts from a previous run are harmless.

use hex_world::*;

// ── chunk addressing ─────────────────────────────────────────────

// chunk_idx_32 returns floor(v / 32), correct for negative v.
fn test_chunk_idx_positive() {
  assert(chunk_idx_32(0)  == 0,  "0 → 0");
  assert(chunk_idx_32(31) == 0,  "31 → 0");
  assert(chunk_idx_32(32) == 1,  "32 → 1");
  assert(chunk_idx_32(63) == 1,  "63 → 1");
  assert(chunk_idx_32(64) == 2,  "64 → 2");
}

fn test_chunk_idx_negative() {
  assert(chunk_idx_32(-1)  == -1, "-1 → -1 (floor, not trunc)");
  assert(chunk_idx_32(-31) == -1, "-31 → -1");
  assert(chunk_idx_32(-32) == -1, "-32 → -1 (boundary)");
  assert(chunk_idx_32(-33) == -2, "-33 → -2");
  assert(chunk_idx_32(-64) == -2, "-64 → -2 (boundary)");
}

fn test_hex_idx_in_range() {
  assert(hex_idx_32(0)   == 0,  "0 → 0");
  assert(hex_idx_32(31)  == 31, "31 → 31");
  assert(hex_idx_32(32)  == 0,  "32 wraps to 0");
  assert(hex_idx_32(33)  == 1,  "33 → 1");
  assert(hex_idx_32(-1)  == 31, "-1 → 31 (positive remainder)");
  assert(hex_idx_32(-32) == 0,  "-32 → 0");
  assert(hex_idx_32(-33) == 31, "-33 → 31");
}

// ── aggregates ──────────────────────────────────────────────────

fn test_cell_count_empty() {
  w = world_empty();
  assert(cell_count(w) == 0, "empty world has 0 cells");
}

fn test_cell_count_after_set() {
  w = world_empty();
  set_cell(w, 0, 0, Cell{c_color: 1, c_height: 0, c_age: 0});
  assert(cell_count(w) == 1, "1 set cell counted");
  set_cell(w, 5, 7, Cell{c_color: 2, c_height: 0, c_age: 0});
  set_cell(w, -3, 4, Cell{c_color: 3, c_height: 0, c_age: 0});
  assert(cell_count(w) == 3, "3 set cells counted, got {cell_count(w)}");
  // overwrite — count unchanged
  set_cell(w, 0, 0, Cell{c_color: 9, c_height: 0, c_age: 0});
  assert(cell_count(w) == 3, "overwrite leaves count at 3");
  // clear by setting c_color = 0
  set_cell(w, 5, 7, Cell{c_color: 0, c_height: 0, c_age: 0});
  assert(cell_count(w) == 2, "clearing brings count to 2");
}

fn test_neighbour_count_isolated() {
  w = world_empty();
  set_cell(w, 0, 0, Cell{c_color: 1, c_height: 0, c_age: 0});
  // Isolated cell — 0 filled neighbours.
  assert(neighbour_count(w, 0, 0) == 0, "isolated cell: 0 neighbours");
}

fn test_neighbour_count_full() {
  w = world_empty();
  // Centre at (0,0); fill all six axial-hex neighbours.
  set_cell(w,  1,  0, Cell{c_color: 1, c_height: 0, c_age: 0});
  set_cell(w, -1,  0, Cell{c_color: 1, c_height: 0, c_age: 0});
  set_cell(w,  0,  1, Cell{c_color: 1, c_height: 0, c_age: 0});
  set_cell(w,  0, -1, Cell{c_color: 1, c_height: 0, c_age: 0});
  set_cell(w,  1, -1, Cell{c_color: 1, c_height: 0, c_age: 0});
  set_cell(w, -1,  1, Cell{c_color: 1, c_height: 0, c_age: 0});
  // Centre stays empty — neighbour_count reads neighbours, not centre.
  assert(neighbour_count(w, 0, 0) == 6, "all 6 axial neighbours filled");
}

// ── save / load round-trip ───────────────────────────────────────

fn test_save_load_empty_world() {
  w = world_empty();
  world_save(w, "_tmp_empty.bin");
  loaded = world_empty();
  n = world_load(loaded, "_tmp_empty.bin");
  assert(n == 0, "empty world loads 0 cells");
  assert(loaded.tick == 0, "tick preserved (0)");
  assert(cell_count(loaded) == 0, "loaded has 0 cells");
}

fn test_save_load_single_cell() {
  w = world_empty();
  set_cell(w, 5, 7, Cell{c_color: 42, c_height: 9, c_age: 100});
  world_save(w, "_tmp_single.bin");
  loaded = world_empty();
  n = world_load(loaded, "_tmp_single.bin");
  assert(n == 1, "1 cell loaded, got {n}");
  c = get_cell(loaded, 5, 7);
  assert(c.c_color == 42,  "color preserved, got {c.c_color}");
  assert(c.c_height == 9,  "height preserved, got {c.c_height}");
  assert(c.c_age == 100,   "age preserved, got {c.c_age}");
}

fn test_save_load_many_cells_across_chunks() {
  w = world_empty();
  // 4 cells in 4 different chunks (q crosses 32, r crosses 32, and
  // negatives).
  set_cell(w,   0,   0, Cell{c_color: 1, c_height: 0, c_age: 1});
  set_cell(w,  35,   0, Cell{c_color: 2, c_height: 0, c_age: 2});
  set_cell(w,   0,  35, Cell{c_color: 3, c_height: 0, c_age: 3});
  set_cell(w, -10, -10, Cell{c_color: 4, c_height: 0, c_age: 4});
  world_save(w, "_tmp_many.bin");
  loaded = world_empty();
  n = world_load(loaded, "_tmp_many.bin");
  assert(n == 4, "4 cells loaded, got {n}");
  c1 = get_cell(loaded,   0,   0);
  c2 = get_cell(loaded,  35,   0);
  c3 = get_cell(loaded,   0,  35);
  c4 = get_cell(loaded, -10, -10);
  assert(c1.c_color == 1 && c1.c_age == 1, "(0,0)   color=1 age=1");
  assert(c2.c_color == 2 && c2.c_age == 2, "(35,0)  color=2 age=2");
  assert(c3.c_color == 3 && c3.c_age == 3, "(0,35)  color=3 age=3");
  assert(c4.c_color == 4 && c4.c_age == 4, "(-10,-10) color=4 age=4");
}

fn test_save_load_tick_preserved() {
  w = world_empty();
  set_cell(w, 0, 0, Cell{c_color: 1, c_height: 0, c_age: 0});
  // Advance tick by running tick_and_decay once.  base_lifetime large
  // so nothing decays — we only care that `tick` increments.
  tick_and_decay(w, 1000, 0, 100);
  tick_and_decay(w, 1000, 0, 100);
  tick_and_decay(w, 1000, 0, 100);
  assert(w.tick == 3, "tick advanced to 3, got {w.tick}");
  world_save(w, "_tmp_tick.bin");
  loaded = world_empty();
  n = world_load(loaded, "_tmp_tick.bin");
  assert(n == 1, "1 cell loaded after tick");
  assert(loaded.tick == 3, "tick preserved, got {loaded.tick}");
}

fn test_save_load_negative_coords() {
  w = world_empty();
  set_cell(w, -50, -50, Cell{c_color: 5, c_height: 0, c_age: 50});
  set_cell(w, -1, -1, Cell{c_color: 6, c_height: 0, c_age: 60});
  world_save(w, "_tmp_neg.bin");
  loaded = world_empty();
  n = world_load(loaded, "_tmp_neg.bin");
  assert(n == 2, "2 cells loaded, got {n}");
  ca = get_cell(loaded, -50, -50);
  cb = get_cell(loaded, -1, -1);
  assert(ca.c_color == 5, "(-50,-50) color=5, got {ca.c_color}");
  assert(cb.c_color == 6, "(-1,-1) color=6, got {cb.c_color}");
}

// ── world_load failure modes ─────────────────────────────────────

fn test_load_missing_returns_zero() {
  loaded = world_empty();
  n = world_load(loaded, "tests/no_such_world_a8b3c7d.bin");
  assert(n == 0, "missing file returns 0, no panic");
  assert(loaded.tick == 0, "loaded world unchanged");
  assert(cell_count(loaded) == 0, "no cells loaded");
}

fn test_load_wrong_magic_returns_zero() {
  // Write a 16-byte garbage header (wrong magic) and confirm
  // world_load bails without panicking.
  {
    f = file("_tmp_badmagic.bin");
    f#format = LittleEndian;
    f.set_file_size(0);
    f += (-559038737 as i32);   // wrong magic (0xDEADBEEF bit pattern, signed form fits i32)
    f += (1 as i32);
    f += (0 as i32);
    f += (0 as i32);
    f.sync();
  }
  loaded = world_empty();
  n = world_load(loaded, "_tmp_badmagic.bin");
  assert(n == 0, "wrong magic returns 0, no panic");
}

fn test_load_wrong_version_returns_zero() {
  // Correct magic, but unsupported version.
  {
    f = file("_tmp_badver.bin");
    f#format = LittleEndian;
    f.set_file_size(0);
    f += (0x57524C44 as i32);   // 'WRLD'
    f += (99 as i32);            // version 99 — not supported
    f += (0 as i32);
    f += (0 as i32);
    f.sync();
  }
  loaded = world_empty();
  n = world_load(loaded, "_tmp_badver.bin");
  assert(n == 0, "wrong version returns 0, no panic");
}

tests/hex_world.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// world: smoke test for the sparse 32x32-chunk World data model.
// Validates that get/set round-trips, chunks are created on demand,
// and the floor-division addressing handles negative coordinates.
use hex_world::*;

fn main() {
  // Empty world has no chunks.
  w = hex_world::world_empty();
  assert(len(w.chunks) == 0, "fresh world has no chunks");
  assert(w.tick == 0, "fresh world tick=0");

  // Reading a missing cell returns the empty default (no panic).
  c = w.get_cell(5, 7);
  assert(c.c_color == 0, "missing cell color = 0");
  assert(c.c_height == 0, "missing cell height = 0");
  assert(c.c_age == 0, "missing cell age = 0");

  // Writing creates the chunk on demand.
  w.set_cell(5, 7, hex_world::Cell { c_color: 3, c_height: 12, c_age: 100 });
  assert(len(w.chunks) == 1, "one chunk after first write");
  c2 = w.get_cell(5, 7);
  assert(c2.c_color == 3, "round-trip color");
  assert(c2.c_height == 12, "round-trip height");
  assert(c2.c_age == 100, "round-trip age");

  // Different (q, r) in the same chunk doesn't add a new chunk.
  w.set_cell(6, 8, hex_world::Cell { c_color: 5, c_height: 0, c_age: 0 });
  assert(len(w.chunks) == 1, "second write in same chunk reuses it");

  // Negative coordinates land in a different chunk.
  w.set_cell(-1, 0, hex_world::Cell { c_color: 7, c_height: 0, c_age: 0 });
  assert(len(w.chunks) == 2, "negative coord lands in chunk -1, fresh chunk");
  c3 = w.get_cell(-1, 0);
  assert(c3.c_color == 7, "negative round-trip works");

  // chunk_idx_32 / hex_idx_32 — boundary spot-checks.
  assert(hex_world::chunk_idx_32(0) == 0, "0 → chunk 0");
  assert(hex_world::chunk_idx_32(31) == 0, "31 → chunk 0");
  assert(hex_world::chunk_idx_32(32) == 1, "32 → chunk 1");
  assert(hex_world::chunk_idx_32(-1) == -1, "-1 → chunk -1");
  assert(hex_world::chunk_idx_32(-32) == -1, "-32 → chunk -1");
  assert(hex_world::chunk_idx_32(-33) == -2, "-33 → chunk -2");

  assert(hex_world::hex_idx_32(0) == 0, "hex 0");
  assert(hex_world::hex_idx_32(31) == 31, "hex 31");
  assert(hex_world::hex_idx_32(32) == 0, "hex 32 wraps to 0");
  assert(hex_world::hex_idx_32(-1) == 31, "hex -1 wraps to 31");

  // ── tick + decay ──────────────────────────────────────────────
  // Fresh world for the timing checks so the earlier set-cell calls
  // don't pollute neighbour counts.
  td_w = hex_world::world_empty();
  td_w.set_cell(100, 100, hex_world::Cell { c_color: 1, c_height: 0, c_age: 0 });
  assert(td_w.cell_count() == 1, "isolated cell placed");
  assert(td_w.neighbour_count(100, 100) == 0, "isolated cell: 0 neighbours");
  // base=10, lease=10, window=5 → effective_lifetime=10, removed at age 15.
  for _ in 0..14 {
    td_w.tick_and_decay(10, 10, 5);
  }
  assert(td_w.cell_count() == 1, "isolated still alive at age 14 (within decay window)");
  td_w.tick_and_decay(10, 10, 5);   // 15 ticks total → age 15 → removed
  assert(td_w.cell_count() == 0, "isolated removed at age 15");

  // Cluster: centre + 6 axial neighbours.  In hex topology each
  // surrounding cell shares two neighbours with the centre's other
  // surroundings — so each surrounding has 3 filled neighbours
  // (centre + two adjacent surroundings).  Centre has 6 neighbours.
  // base=10, lease=10, window=5:
  //   surrounding eff = 10 + 10*3 = 40 → removed at age 45
  //   centre      eff = 10 + 10*6 = 70 → removed at age 75 if all
  //               neighbours stay alive
  cl_w = hex_world::world_empty();
  cl_w.set_cell(50, 50, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  cl_w.set_cell(51, 50, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  cl_w.set_cell(49, 50, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  cl_w.set_cell(50, 51, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  cl_w.set_cell(50, 49, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  cl_w.set_cell(51, 49, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  cl_w.set_cell(49, 51, hex_world::Cell { c_color: 2, c_height: 0, c_age: 0 });
  assert(cl_w.cell_count() == 7, "cluster: 7 cells placed");
  assert(cl_w.neighbour_count(50, 50) == 6, "centre has 6 neighbours");
  assert(cl_w.neighbour_count(51, 50) == 3, "surrounding has 3 neighbours");
  for _ in 0..44 { cl_w.tick_and_decay(10, 10, 5); }
  assert(cl_w.cell_count() == 7, "cluster intact at age 44 (just under threshold 45)");
  cl_w.tick_and_decay(10, 10, 5);
  assert(cl_w.cell_count() == 1, "only centre remains at age 45");
  cl_w.tick_and_decay(10, 10, 5);
  assert(cl_w.cell_count() == 0, "centre removed once neighbours gone");

  // ── Persistence — save + load round-trip ─────────────────────────
  // Build a small world spanning two chunks + a negative-coord chunk
  // so save/load exercises both the file-format header counters and
  // the chunk_idx_32 negative path.
  sv_w = hex_world::world_empty();
  sv_w.tick = 17;
  sv_w.set_cell(5, 7,    hex_world::Cell { c_color: 3, c_height: 12, c_age: 100 });
  sv_w.set_cell(6, 8,    hex_world::Cell { c_color: 5, c_height: 0,  c_age: 0   });
  sv_w.set_cell(100, 0,  hex_world::Cell { c_color: 9, c_height: 4,  c_age: 42  });
  sv_w.set_cell(-1, 0,   hex_world::Cell { c_color: 7, c_height: 3,  c_age: 200 });

  sv_path = "/tmp/loft_world_test.bin";
  sv_w.world_save(sv_path);

  // Load into a fresh world and round-trip every value.
  ld_w = hex_world::world_empty();
  ld_n = ld_w.world_load(sv_path);
  assert(ld_n == 4, "loaded 4 cells");
  assert(ld_w.tick == 17, "loaded tick=17");

  ld_a = ld_w.get_cell(5, 7);
  assert(ld_a.c_color == 3 && ld_a.c_height == 12 && ld_a.c_age == 100, "round-trip (5,7)");
  ld_b = ld_w.get_cell(6, 8);
  assert(ld_b.c_color == 5, "round-trip (6,8) color");
  ld_c = ld_w.get_cell(100, 0);
  assert(ld_c.c_color == 9 && ld_c.c_height == 4 && ld_c.c_age == 42, "round-trip (100,0)");
  ld_d = ld_w.get_cell(-1, 0);
  assert(ld_d.c_color == 7 && ld_d.c_height == 3 && ld_d.c_age == 200, "negative-chunk round-trip");

  // Missing file → 0 cells, no panic.
  miss_w = hex_world::world_empty();
  miss_n = miss_w.world_load("/tmp/loft_world_does_not_exist.bin");
  assert(miss_n == 0, "missing file loads 0 cells");
  assert(miss_w.tick == 0, "missing file leaves tick unchanged");

  println("world smoke ok");
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `hex_world` (@PLN141).  Each `test_*` below is a REAL,
// run-every-CI demonstration of a contract the signature cannot state; the
// function documents itself by CITING the tag with `// Example: @HXW-00N`.
// Teaching examples, distinct from the assertion suite in `hex_world.loft`.
//
// This is a SPARSE container, and every contract it has comes from that one
// word.  A sparse store has to answer three questions a dense one never faces —
// what a read of an absent region means, when a region starts existing, and when
// it stops — and none of the three is visible in a signature that says
// `get_cell(self, q, r) -> Cell`.
use hex_world::*;

// @HXW-001 — `get_cell` cannot tell you "there is no chunk here".  It answers
// `cell_empty()` both for a cell that was never written and for a chunk that
// does not exist, which is the sparse-world convention working as intended: an
// absent chunk IS all-empty.  The consequence is the part a caller has to know:
// reading never allocates, so a read loop over a large region stays free, while
// `set_cell` allocates a whole 32x32 chunk on first touch.  `has_chunk` is the
// only way to ask the question `get_cell` is deliberately not answering.
fn test_reading_an_absent_chunk_is_not_an_error_and_not_a_read() {
  w = hex_world::world_empty();

  // An absent chunk reads as empty and stays absent — no allocation, no error.
  c = hex_world::get_cell(w, 5, 5);
  assert(c.c_color == 0, "an absent chunk reads as an empty cell");
  assert(len(w.chunks) == 0, "…and reading did NOT create it");
  assert(hex_world::has_chunk(w, 5, 5) == false, "has_chunk is the real question");

  // Existence is a separate act, and it does not change what `get_cell` answers.
  w.ensure_chunk(5, 5);
  assert(len(w.chunks) == 1, "ensure_chunk allocates the 32x32");
  assert(hex_world::has_chunk(w, 5, 5), "now it exists");
  assert(hex_world::get_cell(w, 5, 5).c_color == 0, "and reads exactly the same");

  // So a sweep that only reads is cheap however wide it is, and a sweep that
  // writes is 1024 cells per chunk touched.  Neither call site says so.
  assert(hex_world::get_cell(w, 4000, 4000).c_color == 0, "far away: still free");
  assert(len(w.chunks) == 1, "…and still one chunk");
}

// @HXW-002 — the `(q, r)` here are AXIAL, and `hex_grid`'s `(q, r)` in this same
// repository are ODD-R OFFSET.  `neighbour_count` walks the six axial deltas
// with no parity term at all — which is correct for axial and wrong for offset,
// where four of the six shift by a column between even and odd rows.  Both are
// `(integer, integer)` named `q` and `r`, in one monorepo, so nothing catches a
// coordinate handed from one to the other.
fn test_the_q_r_here_are_axial_not_the_offset_pair_hex_grid_uses() {
  w = hex_world::world_empty();
  // The six axial neighbours of the origin.
  fill(w, 1, 0);
  fill(w, -1, 0);
  fill(w, 0, 1);
  fill(w, 0, -1);
  fill(w, 1, -1);
  fill(w, -1, 1);
  assert(hex_world::neighbour_count(w, 0, 0) == 6, "all six axial neighbours");

  // (1,1) and (-1,-1) are NOT among them — in axial coordinates those are two
  // steps away.  Fill them and the count does not move.
  fill(w, 1, 1);
  fill(w, -1, -1);
  assert(hex_world::neighbour_count(w, 0, 0) == 6,
         "(1,1) and (-1,-1) are two steps away in AXIAL, so they do not count");

  // And the deltas do not depend on the row's parity, which is the giveaway.
  // An offset lattice needs a parity term; this one has none, on any row.
  ok = true;
  for r in 0..4 {
    v = hex_world::world_empty();
    v.set_cell(0, r + 1, hex_world::Cell { c_color: 1, c_height: 0, c_age: 0 });
    if hex_world::neighbour_count(v, 0, r) != 1 { ok = false; }
  }
  assert(ok, "(q, r+1) is a neighbour on EVERY row — an offset lattice cannot say that");
}

fn fill(fl_w: hex_world::World, fl_q: integer, fl_r: integer) {
  fl_w.set_cell(fl_q, fl_r, hex_world::Cell { c_color: 1, c_height: 0, c_age: 0 });
}

// @HXW-003 — `chunk_idx_32` and `hex_idx_32` are not conveniences, they are the
// contract.  loft's `/` and `%` round toward ZERO, so the obvious `q / 32` and
// `q % 32` put every negative coordinate in the wrong chunk and then index it
// with a NEGATIVE slot.  West and north of the origin is not an edge case in a
// world that grows in all four directions — it is half of it.
fn test_negative_coordinates_need_the_helpers_not_slash_and_percent() {
  assert(hex_world::chunk_idx_32(-1) == -1, "cell -1 lives in chunk -1");
  assert((-1) / 32 == 0, "…but plain division says chunk 0 — the wrong chunk");
  assert(hex_world::hex_idx_32(-1) == 31, "and it is slot 31 of that chunk");
  assert((-1) % 32 == -1, "…where plain modulo hands back a negative index");

  // The floor/mod pair is what makes the addressing continuous across zero:
  // stepping one cell west must step one slot, never jump a chunk boundary twice.
  ok = true;
  for i in 0..64 {
    v = i - 32;
    cx = hex_world::chunk_idx_32(v);
    hx = hex_world::hex_idx_32(v);
    if cx * 32 + hx != v { ok = false; }
  }
  assert(ok, "chunk*32 + slot reconstructs the coordinate, for negatives too");

  // Which the naive pair does not do:
  assert(((-1) / 32) * 32 + ((-1) % 32) == -1, "the naive pair happens to reconstruct…");
  assert(((-1) % 32) < 0, "…but only by carrying a negative slot no vector can index");
}

// @HXW-004 — nothing in this library ever removes an empty chunk.  The doc
// comment on `World` says empty chunks are removed by "the tick loop's GC pass",
// and that pass belongs to the CALLER: `set_cell` says it does not GC, and
// `tick_and_decay` empties cells without touching `chunks`.  So an in-memory
// world grows monotonically — a cell written once and erased leaves its 32x32
// behind forever, and `has_chunk` keeps answering true over a region that is
// entirely empty.  `world_save` is what compacts, because it writes only the
// non-empty chunks; a save/load round trip is the library's only reclaim.
fn test_an_emptied_chunk_stays_until_a_save_load_round_trip() {
  w = hex_world::world_empty();
  fill(w, 100, 100);
  assert(len(w.chunks) == 1 && hex_world::cell_count(w) == 1, "one chunk, one cell");

  // Erase the only cell.  The chunk remains.
  w.set_cell(100, 100, hex_world::cell_empty());
  assert(hex_world::cell_count(w) == 0, "no cells left");
  assert(len(w.chunks) == 1, "…and the chunk is still there");

  // Ticking does not collect it either, however many times you tick.
  w.tick_and_decay(1, 0, 1);
  w.tick_and_decay(1, 0, 1);
  assert(len(w.chunks) == 1, "tick_and_decay empties cells; it does not free chunks");
  assert(hex_world::has_chunk(w, 100, 100),
         "has_chunk still says yes over a wholly empty region");

  // The save path counts non-empty chunks for its header, so the round trip is
  // what actually reclaims.
  path = "zz-hxw-004.world";
  hex_world::world_save(w, path);
  v = hex_world::world_empty();
  v.world_load(path);
  assert(len(v.chunks) == 0, "a save/load round trip drops the empty chunk");
  assert(hex_world::cell_count(v) == 0, "with no cells lost — there were none");
  delete(path);
}

// @HXW-005 — `c_color == 0` is the EMPTY sentinel, not a colour.  A cell whose
// colour is 0 stores its other fields faithfully and is invisible to every
// aggregate in the library: `cell_count` skips it, `neighbour_count` does not
// see it, `tick_and_decay` never ages it, and `world_save` will not write the
// chunk it is alone in.  So a palette whose entry 0 is a real colour has an
// eraser for its first swatch, and the write that looks like it worked is the
// one that removed the cell.
fn test_colour_zero_is_the_empty_sentinel_not_a_colour() {
  w = hex_world::world_empty();

  // The write lands — the height comes back — and the cell counts for nothing.
  w.set_cell(2, 2, hex_world::Cell { c_color: 0, c_height: 9, c_age: 0 });
  assert(hex_world::get_cell(w, 2, 2).c_height == 9, "the other fields are stored");
  assert(hex_world::cell_count(w) == 0, "…but the cell is not counted");

  // Nor seen as a neighbour, so a colour-0 cell cannot support the ones around it.
  w.set_cell(3, 2, hex_world::Cell { c_color: 0, c_height: 9, c_age: 0 });
  assert(hex_world::neighbour_count(w, 2, 2) == 0, "a colour-0 neighbour is no neighbour");

  // One non-zero colour is all it takes for the same cell to become real.
  w.set_cell(3, 2, hex_world::Cell { c_color: 1, c_height: 9, c_age: 0 });
  assert(hex_world::neighbour_count(w, 2, 2) == 1, "colour 1 is a neighbour");
  assert(hex_world::cell_count(w) == 1, "and counts");

  // The rule to carry: reserve 0, and start a palette at 1 — which is what the
  // TTT and demo consumers both do (1 = X, 2 = O; 1-9 = palette colours).
  assert(live_colour(0) == false, "0 never means a colour");
  assert(live_colour(1), "a palette starts at 1");
}

fn live_colour(lc_c: integer) -> boolean { lc_c != 0 }