DramaticShapeVoxelMod archival mirror · read-only
tests/arena_config.lua

arena_config.lua

tests/arena_config.lua7.3 KB100644c72a258157
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
-- 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