arena_config.lua
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