mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:10:49 +02:00
198 lines
7.3 KiB
Lua
198 lines
7.3 KiB
Lua
-- data/battle_arenas.lua, edited in place by line.
|
|
--
|
|
-- The arena editor (tests/arena_editor.lua) writes this file back every time
|
|
-- a map is committed, and most of what is in it is PROSE: which alternatives
|
|
-- were rejected for each area and what was wrong with them. That argument is
|
|
-- the part a tool cannot reconstruct, so nothing here regenerates the file --
|
|
-- an entry that changed has its own lines replaced, a new one is appended,
|
|
-- and every other byte comes through exactly as it was.
|
|
--
|
|
-- Kept out of the driver so it can be tested without a game: a bug in here
|
|
-- writes a config file, and the failure mode of a bad one is silent -- every
|
|
-- map loses its authored spot and battles quietly start happening somewhere
|
|
-- else. `apply` therefore never decides on its own that its output is good;
|
|
-- it hands back the text and the caller parses it back and checks it says
|
|
-- what was asked for before anything is overwritten.
|
|
--
|
|
-- local Config = dofile("mods/DramaticShapeVoxelMod/tests/arena_config.lua")
|
|
-- local text, report = Config.apply(oldText, { ROUTE_1 = { x = 4, y = 14,
|
|
-- shape = "narrow" } })
|
|
--
|
|
-- An edit's value is an entry table, `false` for an authored refusal, or
|
|
-- Config.REMOVE to take the entry out altogether.
|
|
local Config = {}
|
|
|
|
Config.REMOVE = setmetatable({}, { __tostring = function() return "REMOVE" end })
|
|
|
|
-- The heading new maps are collected under, so a file worked over several
|
|
-- sessions grows one list rather than one heading per session.
|
|
Config.SECTION = " -- ------- added by tests/arena_editor"
|
|
|
|
-- Lossless both ways: a trailing newline becomes a trailing empty line, and
|
|
-- concat puts it back.
|
|
function Config.splitLines(text)
|
|
local out, pos = {}, 1
|
|
while true do
|
|
local a, b = text:find("\n", pos, true)
|
|
if not a then
|
|
out[#out + 1] = text:sub(pos)
|
|
break
|
|
end
|
|
out[#out + 1] = text:sub(pos, a - 1)
|
|
pos = b + 1
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- One entry, laid out the way the file already lays them out: on one line
|
|
-- where it fits inside the file's own margin, and wrapped under the brace
|
|
-- where it does not (which is how the cross-floor entries are written).
|
|
function Config.entryLines(id, entry)
|
|
if entry == false then
|
|
return { (" [%q] = false,"):format(id) }
|
|
end
|
|
local head = (" [%q] = { "):format(id)
|
|
local parts = {}
|
|
if entry.map then parts[#parts + 1] = ("map = %q"):format(entry.map) end
|
|
parts[#parts + 1] = ("x = %d"):format(entry.x)
|
|
parts[#parts + 1] = ("y = %d"):format(entry.y)
|
|
parts[#parts + 1] = ("shape = %q"):format(entry.shape or "wide")
|
|
if (entry.turn or 0) ~= 0 then
|
|
parts[#parts + 1] = ("turn = %d"):format(entry.turn)
|
|
end
|
|
if entry.cam then parts[#parts + 1] = ("cam = %q"):format(entry.cam) end
|
|
local one = head .. table.concat(parts, ", ") .. " },"
|
|
if #one <= 79 then return { one } end
|
|
local first = entry.map and 3 or 2
|
|
return {
|
|
head .. table.concat(parts, ", ", 1, first) .. ",",
|
|
(" "):rep(#head) .. table.concat(parts, ", ", first + 1) .. " },",
|
|
}
|
|
end
|
|
|
|
-- Where an id's entry starts and ends, plus how many comment lines sit
|
|
-- directly above it. The end is the first line that closes the table, so a
|
|
-- wrapped entry is found whole rather than half-replaced.
|
|
function Config.findEntry(lines, id)
|
|
local pat = '^%s*%["' .. id:gsub("(%W)", "%%%1") .. '"%]%s*='
|
|
for i = 1, #lines do
|
|
if lines[i]:find(pat) then
|
|
local note = 0
|
|
while i - note - 1 >= 1 and lines[i - note - 1]:find("^%s*%-%-") do
|
|
note = note + 1
|
|
end
|
|
if lines[i]:find("=%s*false%s*,?%s*$") then return i, i, note end
|
|
for j = i, #lines do
|
|
if lines[j]:find("}%s*,?%s*$") then return i, j, note end
|
|
end
|
|
return i, i, note
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- The `}` that closes the returned table: new entries go in above it.
|
|
function Config.closingLine(lines)
|
|
for i = #lines, 1, -1 do
|
|
if lines[i]:find("^}") then return i end
|
|
end
|
|
return #lines + 1
|
|
end
|
|
|
|
-- Apply `edits` (map id -> entry | false | REMOVE) to `text`.
|
|
--
|
|
-- Returns the new text and a report: `changed`, `added`, `removed` and
|
|
-- `stale` as lists of map ids. `stale` is the one worth acting on -- those
|
|
-- entries had comment lines directly above them, and a comment above an
|
|
-- entry that has just moved describes where it USED to be.
|
|
function Config.apply(text, edits)
|
|
local lines = Config.splitLines(text)
|
|
local report = { changed = {}, added = {}, removed = {}, stale = {} }
|
|
|
|
local ordered = {}
|
|
for id in pairs(edits) do ordered[#ordered + 1] = id end
|
|
table.sort(ordered)
|
|
|
|
local appended = {}
|
|
for _, id in ipairs(ordered) do
|
|
local entry = edits[id]
|
|
-- found again per edit rather than indexed once up front: each
|
|
-- replacement changes the line count under every index after it
|
|
local from, to, note = Config.findEntry(lines, id)
|
|
if entry == Config.REMOVE then
|
|
if from then
|
|
for _ = from, to do table.remove(lines, from) end
|
|
report.removed[#report.removed + 1] = id
|
|
if note > 0 then report.stale[#report.stale + 1] = id end
|
|
end
|
|
elseif from then
|
|
local new = Config.entryLines(id, entry)
|
|
for _ = from, to do table.remove(lines, from) end
|
|
for k = #new, 1, -1 do table.insert(lines, from, new[k]) end
|
|
report.changed[#report.changed + 1] = id
|
|
if note > 0 then report.stale[#report.stale + 1] = id end
|
|
else
|
|
appended[#appended + 1] = id
|
|
report.added[#report.added + 1] = id
|
|
end
|
|
end
|
|
|
|
if #appended > 0 then
|
|
local at, block = nil, {}
|
|
for i = 1, #lines do
|
|
if lines[i]:find("^%s*%-%-%s+%-+ added by tests/arena_editor") then
|
|
at = i + 1
|
|
end
|
|
end
|
|
if not at then
|
|
at = Config.closingLine(lines)
|
|
block[#block + 1] = ""
|
|
block[#block + 1] = Config.SECTION
|
|
end
|
|
for _, id in ipairs(appended) do
|
|
for _, l in ipairs(Config.entryLines(id, edits[id])) do
|
|
block[#block + 1] = l
|
|
end
|
|
end
|
|
for k = #block, 1, -1 do table.insert(lines, at, block[k]) end
|
|
end
|
|
|
|
return table.concat(lines, "\n"), report
|
|
end
|
|
|
|
-- Whether two entries say the same thing. `false` and nil are values here --
|
|
-- a refusal is not the absence of one -- so this is an equality test and not
|
|
-- a truthiness one.
|
|
function Config.same(a, b)
|
|
if a == b then return true end
|
|
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
|
return a.x == b.x and a.y == b.y
|
|
and (a.shape or "wide") == (b.shape or "wide")
|
|
and (a.turn or 0) == (b.turn or 0)
|
|
and a.cam == b.cam and a.map == b.map
|
|
end
|
|
|
|
-- Parse a config back and check it says what was committed. This is what
|
|
-- stands between a bug in the surgery above and a data file that no longer
|
|
-- loads, so it is run on the TEXT before that text replaces anything.
|
|
-- Returns the parsed table, or nil and why not.
|
|
function Config.verify(text, edits, chunkName)
|
|
local chunk, err = load(text, "@" .. (chunkName or "battle_arenas"))
|
|
if not chunk then return nil, "would not compile: " .. tostring(err) end
|
|
local ok, list = pcall(chunk)
|
|
if not (ok and type(list) == "table") then
|
|
return nil, "did not return a table: " .. tostring(list)
|
|
end
|
|
for id, want in pairs(edits) do
|
|
local got = list[id]
|
|
if want == Config.REMOVE then
|
|
if got ~= nil then return nil, id .. " is still in the file" end
|
|
elseif not Config.same(want, got) then
|
|
return nil, id .. " did not read back as it was committed"
|
|
end
|
|
end
|
|
return list
|
|
end
|
|
|
|
return Config
|