Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions modules/placement/api.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
--- Synced contract of the placement module: where a thing can legally stand.
---
--- No gadget and no state. Placement is a question about the map as it is right
--- now, so this file wires the pure search order to the engine-facing tests and
--- answers; there is nothing to own between calls, and nothing to get out of
--- sync.
---
--- local Placement = ModuleHandler.Get("placement")
--- local x, y, z, why = Placement.NearestValid(wantX, wantZ, {
--- radius = 400, -- how far to look, NOT how far to scatter
--- footprint = 64, -- what the thing actually occupies
--- surface = "land",
--- })
--- if x == nil then
--- Spring.Log(..., "nowhere to put it: " .. why)
--- end

local Search = VFS.Include("modules/placement/lib/search.lua")
local Ground = VFS.Include("modules/placement/spring/ground.lua")

--- position_checks reads the world the moment it loads (gaia's team, map size),
--- so it is pulled in on first USE rather than on include. A module that
--- explodes at require time takes every consumer's spec down with it, and
--- nothing here needs the map until somebody asks a question about it.
local ground
local function checks()
if ground == nil then
ground = Ground.New({
positionChecks = VFS.Include("luarules/utilities/damgam_lib/position_checks.lua"),
})
end
return ground
end

--- Default lattice spacing. Half a small structure: fine enough that "nearest"
--- means something, coarse enough that a 512-elmo search is tens of samples
--- rather than thousands.
local DEFAULT_STEP = 64

return {
---The nearest spot to (x, z) that the thing described by `opts` can stand
---on, or nil and a sentence saying what refused it.
---
---Deterministic: the walk is a fixed integer lattice, so the same request
---on the same map gives the same answer on every client, in a replay, and
---in a spec. Nothing here draws a random number.
---
---@param x number
---@param z number
---@param opts { radius: number|nil, step: number|nil, footprint: number|nil, surface: string|nil, flatness: number|nil, clearance: number|nil, limit: integer|nil }|nil
---@return number|nil x, number|nil y, number|nil z, string|nil why
NearestValid = function(x, z, opts)
opts = opts or {}
local want = {
footprint = opts.footprint or 64,
surface = opts.surface or "land",
flatness = opts.flatness,
clearance = opts.clearance,
}
local ground = checks()
local tally = ground.NewTally()
local found
local fx, fz = Search.Outward(x, z, {
radius = opts.radius or 0,
step = opts.step or DEFAULT_STEP,
limit = opts.limit,
}, function(cx, cz)
local ok, y = ground.Usable(cx, cz, want, tally)
if ok then
found = y
end
return ok
end)

if fx == nil then
return nil, nil, nil, ground.Explain(tally)
end
return fx, found, fz, nil
end,

---Is this exact spot usable? The question without the search, for a caller
---that wants to know rather than to be moved.
---@param x number
---@param z number
---@param opts table|nil
---@return boolean ok, number|nil y
IsValid = function(x, z, opts)
opts = opts or {}
return checks().Usable(x, z, {
footprint = opts.footprint or 64,
surface = opts.surface or "land",
flatness = opts.flatness,
clearance = opts.clearance,
}, nil)
end,

---Spread several things around a point without stacking them, in a fixed
---order. Each takes the nearest spot the ones before it have not taken,
---which is what makes a group land as a deliberate cluster rather than a
---scatter — and makes the result reproducible.
---@param x number
---@param z number
---@param count integer
---@param opts table|nil
---@return { x: number, y: number, z: number }[] spots may be shorter than count
Cluster = function(x, z, count, opts)
opts = opts or {}
local footprint = opts.footprint or 64
local ground = checks()
local taken = {}
local out = {}
local want = {
footprint = footprint,
surface = opts.surface or "land",
flatness = opts.flatness,
clearance = opts.clearance,
}
for _ = 1, count do
local placed
local px, pz = Search.Outward(x, z, {
radius = opts.radius or 0,
step = opts.step or DEFAULT_STEP,
limit = opts.limit,
}, function(cx, cz)
if taken[cx .. "," .. cz] then
return false
end
local ok, y = ground.Usable(cx, cz, want, nil)
if ok then
placed = y
end
return ok
end)
if px == nil then
break
end
taken[px .. "," .. pz] = true
out[#out + 1] = { x = px, y = placed, z = pz }
end
return out
end,
}
116 changes: 116 additions & 0 deletions modules/placement/lib/search.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
--- The search ORDER: which points to try, in what sequence, given a centre and
--- how far out we are willing to look. Pure — no Spring, no engine, no map — so
--- the thing that decides "nearest first" is specced directly rather than
--- inferred from where units ended up.
---
--- Integer lattice, not trigonometry. Candidates are offsets on a grid of
--- `step`, visited in rings of increasing Chebyshev distance, and sorted inside
--- each ring by true squared distance. Two reasons for the lattice over a
--- circle of sin/cos samples:
---
--- * every coordinate is exact integer arithmetic, so there is no floating
--- point to disagree about between clients, platforms or replays; and
--- * a ring at radius k has a bounded, knowable number of candidates, so a
--- caller can reason about the cost of the worst case instead of guessing.
---
--- Nearest-first matters because it is the whole contract. A caller asked for a
--- point; if that point is unusable they want the closest usable one, not a
--- random one within tolerance.

local Search = {}

---Candidate offsets at one ring, nearest first.
---
---Ring 0 is the requested point itself. Ring k is every lattice point whose
---Chebyshev distance is exactly k — the square shell — which is what makes the
---rings disjoint and the whole walk exhaustive.
---@param ring integer 0-based ring index
---@param step number lattice spacing in elmos
---@return { dx: number, dz: number }[]
function Search.Ring(ring, step)
assert(type(ring) == "number" and ring >= 0 and ring % 1 == 0, "Ring expects a non-negative integer ring")
assert(type(step) == "number" and step > 0, "Ring expects a positive step")

if ring == 0 then
return { { dx = 0, dz = 0 } }
end

local out = {}
for i = -ring, ring do
for j = -ring, ring do
-- The shell only: interior points belong to an earlier ring and
-- must not be offered twice.
if i == -ring or i == ring or j == -ring or j == ring then
out[#out + 1] = { dx = i * step, dz = j * step }
end
end
end

-- Nearest first inside the shell. The dx/dz tiebreak is arbitrary but
-- FIXED, which is the point: two candidates at the same distance must
-- always be offered in the same order.
table.sort(out, function(a, b)
local da = a.dx * a.dx + a.dz * a.dz
local db = b.dx * b.dx + b.dz * b.dz
if da ~= db then
return da < db
end
if a.dx ~= b.dx then
return a.dx < b.dx
end
return a.dz < b.dz
end)
return out
end

---How many rings a given reach needs at a given step.
---@param radius number how far out the caller will accept a spot
---@param step number lattice spacing
---@return integer
function Search.Rings(radius, step)
if radius <= 0 then
return 0
end
return math.ceil(radius / step)
end

---Walk outward from a point, offering each candidate to `accept` until one is
---taken. Returns the accepted position, or nil having tried everything.
---
---`accept` is where the engine lives; keeping it a parameter is what leaves
---this file pure and lets a spec drive the whole walk with a plain function.
---@param x number
---@param z number
---@param opts { radius: number, step: number|nil, limit: integer|nil }
---@param accept fun(x: number, z: number, ring: integer): boolean
---@return number|nil x, number|nil z, integer tried
function Search.Outward(x, z, opts, accept)
assert(type(accept) == "function", "Outward expects an accept function")
local step = opts.step or 64
local radius = opts.radius or 0
-- A ceiling on work, not on distance: a caller that asks to search a whole
-- map should get a bounded answer rather than a frame-long stall.
local limit = opts.limit or 512

local tried = 0
for ring = 0, Search.Rings(radius, step) do
for _, offset in ipairs(Search.Ring(ring, step)) do
-- The shell overshoots the circle at its corners; skip what the
-- caller did not ask for so "radius" means radius.
local dist2 = offset.dx * offset.dx + offset.dz * offset.dz
if dist2 <= radius * radius or ring == 0 then
tried = tried + 1
if tried > limit then
return nil, nil, tried - 1
end
local cx, cz = x + offset.dx, z + offset.dz
if accept(cx, cz, ring) then
return cx, cz, tried
end
end
end
end
return nil, nil, tried
end

return Search
22 changes: 22 additions & 0 deletions modules/placement/module.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
--- Manifest for the placement module: where a thing can legally stand.
---
--- Every system that puts a unit on the map answers the same question — is
--- this ground I can put something on, and if not, where is the nearest ground
--- that is? A wave director asks it for burrows, a mission roster asks it for
--- its opening world state, and a mission action asks it when it moves a unit.
--- Before this, each answered it separately or not at all: the spawner had a
--- careful cascade, the roster had none, and the move action teleported blind.
---
--- The answer is DETERMINISTIC by construction. No random sampling: the search
--- walks outward from the requested point in a fixed order and takes the first
--- spot that passes, so the same request gives the same answer on every client,
--- in a replay, and in a spec that asserts exact coordinates. That is also why
--- it is the closest possible match — a caller gets where they asked for, or
--- the nearest place the terrain allows, rather than somewhere random that
--- happened to be legal.
---@type ModuleManifestFile
return {
name = "placement",
description = "Where a unit can legally stand: deterministic nearest-valid ground",
requires = {},
}
Loading
Loading