Gododin Gododin Documentation

Linked repository source

examples/logistics_post/subsystem/logistics.odin

This read-only view comes from the file in the Gododin repository. It retains its design, example or evidence context.

Download the original file · Return to the guide

// Package logistics is the logistics_post example's pure Odin kernel: a small
// depot-logistics simulation. It imports neither Godot nor Gododin — only
// Odin's core memory package — and owns every byte it needs, so a warm
// post_tick performs no allocation.
//
// The simulation is deterministic. Workers claim the nearest pending job by
// breadth-first road distance with index-order tie-breaks, walk one cell per
// fixed tick along their computed path, pick up at the job's source, and
// deliver at its destination. The same seed, map, and job sequence replay
// exactly; time is never read. The GDScript oracle in the project mirrors
// these rules for a tiny reference workload.
//
// Ownership: one block carries the struct, the map copy, workers, and job
// slots; the caller's cells are copied once at creation, never retained.
package logistics

import "core:mem"

MIN_DIMENSION :: 8
MAX_DIMENSION :: 96
MAX_CELLS :: MAX_DIMENSION * MAX_DIMENSION
MAX_WORKERS :: 64
MAX_JOBS :: 4096

// Cell kinds, exactly the bytes the scene authors.
CELL_EMPTY :: u8(0)
CELL_ROAD :: u8(1)
CELL_SOURCE :: u8(2)
CELL_DEST :: u8(3)

Worker_State :: enum u8 {
	Idle,
	To_Source,
	To_Dest,
}

Job_State :: enum u8 {
	Pending,
	Assigned,
	Delivered,
}

Post :: struct {
	live:        bool,
	allocator:   mem.Allocator,
	width:       i32,
	height:      i32,
	cells:       []u8,
	worker_count: i32,
	workers:     []Worker,
	jobs:        []Job,
	job_count:   i64,
	next_job_id: i64,
	seed:        u32,
	ticks:       u64,
	deliveries:  i64,
	dist:        []i32,
	queue:       []i32,
	// The job ids delivered by the most recent tick, for the one coarse
	// event per delivery; bounded because a tick's deliveries are bounded by
	// the worker count.
	last_delivered:      [MAX_WORKERS]i64,
	last_delivered_count: i64,
}

Worker :: struct {
	id:       i32,
	x, y:     i32,
	state:    Worker_State,
	job:      i64, // the claimed job id, -1 while idle
	// The claimed route, consumed front to back. A BFS path on a connected
	// map visits each cell once, so capacity MAX_CELLS bounds any route.
	path_x:   [MAX_CELLS]i16,
	path_y:   [MAX_CELLS]i16,
	path_len: i32,
	path_at:  i32,
}

Job :: struct {
	id:     i64,
	source: i32, // cell index
	dest:   i32,
	state:  Job_State,
}

// post_create validates the map, copies it, and spawns the workers spread by
// seed across the walkable cells. One block owns everything; the worker path
// buffers dominate its size and are reused across jobs.
post_create :: proc(
	width, height: i32,
	cells: []u8,
	worker_count: i32,
	seed: u32,
	allocator: mem.Allocator,
) -> (^Post, bool) {
	if width < MIN_DIMENSION || width > MAX_DIMENSION do return nil, false
	if height < MIN_DIMENSION || height > MAX_DIMENSION do return nil, false
	if len(cells) != int(width) * int(height) do return nil, false
	if worker_count < 1 || worker_count > MAX_WORKERS do return nil, false
	for cell in cells {
		if cell != CELL_EMPTY && cell != CELL_ROAD && cell != CELL_SOURCE && cell != CELL_DEST {
			return nil, false
		}
	}

	worker_bytes := int(worker_count) * size_of(Worker)
	block, block_error := mem.alloc(
		size_of(Post) + len(cells) + worker_bytes + MAX_JOBS * size_of(Job) +
		MAX_CELLS * (size_of(i32) * 2),
		mem.DEFAULT_ALIGNMENT, allocator)
	if block_error != nil || block == nil do return nil, false
	post := cast(^Post)block
	post.live = true
	post.allocator = allocator
	post.width = width
	post.height = height
	post.seed = seed
	post.job_count = 0
	post.next_job_id = 1
	post.ticks = 0
	post.deliveries = 0
	post.worker_count = worker_count
	backing := (cast([^]byte)block)[size_of(Post):]
	post.cells = mem.slice_ptr(cast(^u8)backing, len(cells))
	copy(post.cells, cells)
	backing = backing[len(cells):]
	post.workers = mem.slice_ptr(cast(^Worker)backing, int(worker_count))
	backing = backing[worker_bytes:]
	post.jobs = mem.slice_ptr(cast(^Job)backing, MAX_JOBS)
	backing = backing[MAX_JOBS * size_of(Job):]
	post.dist = mem.slice_ptr(cast(^i32)backing, MAX_CELLS)
	backing = backing[MAX_CELLS * size_of(i32):]
	post.queue = mem.slice_ptr(cast(^i32)backing, MAX_CELLS)

	// Spawn: walkable cells in row-major order, chosen by the seeded LCG so
	// the same seed spreads workers identically.
	walkable_count := 0
	for cell_index in 0..<len(post.cells) {
		if post.cells[cell_index] != CELL_EMPTY {
			post.queue[walkable_count] = i32(cell_index)
			walkable_count += 1
		}
	}
	if walkable_count < int(worker_count) {
		_ = mem.free(block, allocator)
		return nil, false
	}
	rng_state := seed & 0xffffffff
	for index in 0..<int(worker_count) {
		rng_state = (rng_state * 1664525 + 1013904223) & 0xffffffff
		pick := int(rng_state >> 8) % walkable_count
		worker := &post.workers[index]
		worker.id = i32(index)
		worker.x = post.queue[pick] % width
		worker.y = post.queue[pick] / width
		worker.state = .Idle
		worker.job = -1
		worker.path_len = 0
		worker.path_at = 0
	}
	return post, true
}

post_destroy :: proc(post: ^^Post) -> bool {
	if post == nil || post^ == nil do return false
	owner := post^
	if !owner.live do return false
	if mem.free(rawptr(owner), owner.allocator) != nil do return false
	post^ = nil
	return true
}

// walkable reports whether a cell index can be occupied or routed through:
// any non-empty cell. Sources and destinations are walkable — they are the
// pickup and delivery points.
walkable :: proc(post: ^Post, index: i32) -> bool {
	if index < 0 || int(index) >= len(post.cells) do return false
	return post.cells[index] != CELL_EMPTY
}

// bfs_distance fills post.dist with the road distance from `start` to every
// reachable cell (-1 unreachable), 4-neighbourhood, index-ordered queue so
// ties resolve deterministically. False when the start itself is unwalkable.
bfs_distance :: proc(post: ^Post, start: i32) -> bool {
	if !walkable(post, start) do return false
	dist := post.dist
	for i in 0..<len(dist) do dist[i] = -1
	head, tail := 0, 0
	post.queue[tail] = start
	tail += 1
	dist[start] = 0
	w := post.width
	for head < tail {
		here := post.queue[head]
		head += 1
		// Left and right neighbours must not wrap across rows.
		if here % w != 0 && walkable(post, here-1) && dist[int(here-1)] == -1 {
			dist[int(here-1)] = dist[here] + 1
			post.queue[tail] = here - 1
			tail += 1
		}
		if (here+1) % w != 0 && walkable(post, here+1) && dist[int(here+1)] == -1 {
			dist[int(here+1)] = dist[here] + 1
			post.queue[tail] = here + 1
			tail += 1
		}
		if here-w >= 0 && walkable(post, here-w) && dist[int(here-w)] == -1 {
			dist[int(here-w)] = dist[here] + 1
			post.queue[tail] = here - w
			tail += 1
		}
		if here+w < i32(len(dist)) && walkable(post, here+w) && dist[int(here+w)] == -1 {
			dist[int(here+w)] = dist[here] + 1
			post.queue[tail] = here + w
			tail += 1
		}
	}
	return true
}

// route writes the walk path from the worker's route start to `goal`
// (inclusive of goal, exclusive of the caller's own cell) by greedy descent
// over a precomputed distance field: at each backwards step it takes the
// neighbour with the smallest remaining distance, index order breaking ties.
// The path is stored reversed-in-place so index 0 is the first step.
route :: proc(post: ^Post, worker: ^Worker, goal: i32) -> i32 {
	dist := post.dist
	if dist[goal] < 0 do return -1
	worker.path_len = 0
	worker.path_at = 0
	here := goal
	w := post.width
	for {
		worker.path_x[worker.path_len] = i16(here % w)
		worker.path_y[worker.path_len] = i16(here / w)
		worker.path_len += 1
		if dist[here] == 0 do break
		best := i32(-1)
		if here % w != 0 && walkable(post, here-1) && dist[int(here-1)] >= 0 {
			best = here - 1
		}
		if (here+1) % w != 0 && walkable(post, here+1) && dist[int(here+1)] >= 0 &&
			(best == -1 || dist[int(here+1)] < dist[best]) {
			best = here + 1
		}
		if here-w >= 0 && walkable(post, here-w) && dist[int(here-w)] >= 0 &&
			(best == -1 || dist[int(here-w)] < dist[best]) {
			best = here - w
		}
		if here+w < i32(len(dist)) && walkable(post, here+w) && dist[int(here+w)] >= 0 &&
			(best == -1 || dist[int(here+w)] < dist[best]) {
			best = here + w
		}
		if best == -1 do return -1
		here = best
	}
	left, right := i32(0), worker.path_len - 1
	for left < right {
		tmp_x := worker.path_x[left]
		worker.path_x[left] = worker.path_x[right]
		worker.path_x[right] = tmp_x
		tmp_y := worker.path_y[left]
		worker.path_y[left] = worker.path_y[right]
		worker.path_y[right] = tmp_y
		left += 1
		right -= 1
	}
	return worker.path_len
}

// post_submit_job registers one source→destination delivery and returns its
// stable id, or -1 when the post is full or either cell is the wrong kind.
post_submit_job :: proc(post: ^Post, source, dest: i32) -> i64 {
	if post == nil || !post.live do return -1
	if post.job_count >= MAX_JOBS do return -1
	if !walkable(post, source) || !walkable(post, dest) do return -1
	if post.cells[source] != CELL_SOURCE || post.cells[dest] != CELL_DEST do return -1
	job := &post.jobs[post.job_count]
	job.id = post.next_job_id
	job.source = source
	job.dest = dest
	job.state = .Pending
	post.job_count += 1
	post.next_job_id += 1
	return job.id
}

// assign walks the idle workers in id order; each claims the nearest pending
// job by road distance from its own cell, scanning jobs in id order so a
// distance tie keeps the earlier job. A claim whose route fails stays idle.
assign :: proc(post: ^Post) {
	for w_index in 0..<len(post.workers) {
		worker := &post.workers[w_index]
		if worker.state != .Idle do continue
		here := worker.y * post.width + worker.x
		if !walkable(post, here) do continue
		if !bfs_distance(post, here) do continue
		best := i64(-1)
		best_distance := i32(-1)
		for job_index in 0..<int(post.job_count) {
			job := &post.jobs[job_index]
			if job.state != .Pending do continue
			d := post.dist[job.source]
			if d < 0 do continue
			if best == -1 || d < best_distance {
				best = job.id
				best_distance = d
			}
		}
		if best == -1 do continue
		job := &post.jobs[best - 1]
		if route(post, worker, job.source) < 0 do continue
		job.state = .Assigned
		worker.job = job.id
		worker.state = .To_Source
	}
}

// post_tick advances one fixed step: assignment, then one cell of movement
// per walking worker; a worker that reaches its source routes onward to the
// destination in the same tick, and one that reaches its destination
// delivers. Returns the number of deliveries completed this tick.
post_tick :: proc(post: ^Post) -> i64 {
	if post == nil || !post.live do return 0
	deliveries := i64(0)
	post.last_delivered_count = 0
	assign(post)
	for w_index in 0..<len(post.workers) {
		worker := &post.workers[w_index]
		if worker.state == .Idle do continue
		if worker.path_at < worker.path_len {
			worker.x = i32(worker.path_x[worker.path_at])
			worker.y = i32(worker.path_y[worker.path_at])
			worker.path_at += 1
		}
		if worker.path_at < worker.path_len do continue
		// The route is consumed: the worker stands on its current target.
		job := &post.jobs[worker.job - 1]
		if worker.state == .To_Source {
			here := worker.y * post.width + worker.x
			if bfs_distance(post, here) && route(post, worker, job.dest) >= 0 {
				worker.state = .To_Dest
				continue
			}
			worker.state = .Idle
			worker.job = -1
			job.state = .Pending
			continue
		}
		job.state = .Delivered
		worker.state = .Idle
		worker.job = -1
		post.deliveries += 1
		if post.last_delivered_count < MAX_WORKERS {
			post.last_delivered[post.last_delivered_count] = job.id
			post.last_delivered_count += 1
		}
		deliveries += 1
	}
	post.ticks += 1
	return deliveries
}

// last_delivered_ids copies the job ids delivered by the most recent tick
// into `out`; returns how many were written.
last_delivered_ids :: proc(post: ^Post, out: []i64) -> i64 {
	if post == nil || !post.live do return -1
	written := i64(0)
	for index in 0..<min(post.last_delivered_count, i64(len(out))) {
		out[index] = post.last_delivered[index]
		written += 1
	}
	return written
}

// worker_snapshot writes five f32s per worker — id, cell x, cell y, state,
// claimed job id — in one bounded batch for the scene's presentation.
worker_snapshot :: proc(post: ^Post, out: []f32) -> i64 {
	if post == nil || !post.live do return -1
	if len(out) < len(post.workers) * 5 do return -1
	for w_index in 0..<len(post.workers) {
		worker := &post.workers[w_index]
		base := w_index * 5
		out[base] = f32(worker.id)
		out[base+1] = f32(worker.x)
		out[base+2] = f32(worker.y)
		out[base+3] = f32(worker.state)
		out[base+4] = f32(worker.job)
	}
	return i64(len(post.workers))
}

// job_state reports one job's lifecycle state by stable id, or -1 unknown.
job_state :: proc(post: ^Post, id: i64) -> i64 {
	if post == nil || !post.live do return -1
	if id < 1 || id > post.job_count do return -1
	return i64(post.jobs[id - 1].state)
}

// state_digest fingerprints the whole simulation for replay checks.
state_digest :: proc(post: ^Post) -> u64 {
	if post == nil || !post.live do return 0
	hash := u64(0xcbf29ce484222325)
	hash = (hash ~ u64(post.ticks)) * 0x100000001b3
	hash = (hash ~ u64(post.deliveries)) * 0x100000001b3
	for w_index in 0..<len(post.workers) {
		worker := &post.workers[w_index]
		hash = (hash ~ u64(worker.x)) * 0x100000001b3
		hash = (hash ~ u64(worker.y)) * 0x100000001b3
		hash = (hash ~ u64(worker.state)) * 0x100000001b3
		hash = (hash ~ u64(worker.job)) * 0x100000001b3
	}
	for job_index in 0..<int(post.job_count) {
		hash = (hash ~ u64(post.jobs[job_index].state)) * 0x100000001b3
	}
	return hash
}