← 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.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Cell struct | 22 | HXW-005 | 34 81 82 88 138 158 … (48 total) |
Chunk struct | 31 | — | 46 87 90 309 |
World struct | 45 | HXW-004 | 94 95 111 124 138 158 … (12 total) |
chunk_idx_32 fn | 62 | HXW-003 | 112 113 125 126 139 140 … (26 total) |
hex_idx_32 fn | 70 | HXW-003 | 141 142 162 163 43 44 … (17 total) |
cell_empty fn | 81 | HXW-001 | 89 148 151 275 349 124 |
build_chunk fn | 87 | — | 130 |
world_empty fn | 94 | — | 55 60 75 82 97 99 … (29 total) |
has_chunk fn | 111 | HXW-001 | 30 35 132 133 |
ensure_chunk fn | 124 | HXW-001 | 159 389 33 34 |
get_cell fn | 138 | HXW-001 | 197 198 199 200 201 202 … (24 total) |
set_cell fn | 158 | HXW-001 | 275 61 63 64 67 70 … (42 total) |
cell_count fn | 179 | HXW-005 | 56 62 65 68 71 103 … (19 total) |
neighbour_count fn | 195 | HXW-002 | 236 78 91 58 84 85 … (11 total) |
tick_and_decay fn | 218 | — | 146 147 148 61 64 86 … (11 total) |
world_save fn | 318 | — | 98 109 127 150 161 105 … (7 total) |
world_load fn | 366 | — | 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 }