forked from DFHack/dfhack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathecosystem.lua
More file actions
480 lines (422 loc) · 17.8 KB
/
Copy pathecosystem.lua
File metadata and controls
480 lines (422 loc) · 17.8 KB
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
-- tests the quickfort ecosystem end-to-end via:
-- .csv -> quickfort/buildingplan/dig-now -> blueprint -> .csv
--
-- test cases are sets of .csv files in the
-- blueprints/library/test/ecosystem/in directory
--
-- test metadata is stored in an associated #notes blueprint:
-- description (required)
-- width (required)
-- height (required)
-- depth (default is 1)
-- start (cursor offset for input blueprints, default is 1,1)
-- extra_fn (the name of a function in the extra_fns table in this file to
-- run after applying all blueprints but before comparing results.
-- this function will get the map coordinate of the upper-left
-- corner of the test area passed to it)
--
-- depends on blueprint, buildingplan, and dig-now plugins (as well as the
-- quickfort script and anything else run in the extra_fns, of course)
--
-- note that this test harness cannot (yet) test #query blueprints that define
-- rooms since furniture is not actually built during the test. It also cannot
-- test blueprints that #build flooring and then #build a workshop on top, again
-- since the flooring is never actually built. We can support these features
-- once we figure out how to programmatically deconstruct buildlings without
-- crashing the game.
config.mode = 'fortress'
local argparse = require('argparse')
local gui = require('gui')
local guidm = require('gui.dwarfmode')
local utils = require('utils')
local blueprint = require('plugins.blueprint')
local confirm = require('plugins.confirm')
local assign_minecarts = reqscript('assign-minecarts')
local quantum = reqscript('gui/quantum')
local quickfort = reqscript('quickfort')
local quickfort_list = reqscript('internal/quickfort/list')
local quickfort_command = reqscript('internal/quickfort/command')
local blueprints_dir = 'blueprints/'
local input_dir = 'library/test/ecosystem/in/'
local golden_dir = 'library/test/ecosystem/golden/'
local output_dir = 'library/test/ecosystem/out/'
local phase_names = utils.invert(blueprint.valid_phases)
-- clear the output dir before each test run (but not after -- to allow
-- inspection of failed results)
local function test_wrapper(test_fn)
local outdir = blueprints_dir .. output_dir
if dfhack.filesystem.exists(outdir) then
for _, v in ipairs(dfhack.filesystem.listdir_recursive(outdir)) do
if not v.isdir then
os.remove(v.path)
end
end
end
test_fn()
end
config.wrapper = test_wrapper
local function bad_spec(expected, varname, basename, bad_value)
qerror(('expected %s for %s in "%s" test spec; got "%s"'):
format(expected, varname, basename, bad_value))
end
local function get_positive_int(numstr, varname, basename)
local num = tonumber(numstr)
if not num or num <= 0 or num ~= math.floor(num) then
bad_spec('positive integer', varname, basename, numstr)
end
return num
end
local function os_exists(path)
return os.rename(path, path) and true or false
end
local function get_blueprint_sets()
-- find test blueprints with `quickfort list`
local mock_print = mock.func()
mock.patch(quickfort_list, 'print', mock_print,
function()
dfhack.run_script('quickfort', 'list', '-l', input_dir)
end)
-- group blueprint sets
local sets = {}
for _,args in ipairs(mock_print.call_args) do
local line = args[1]
-- phase is the label or, if the label doesn't exist, the mode
local _,_,listnum,fname,phase = line:find('(%d+)%) (%S+).-[/(]([^ )]+)')
if listnum then
local _,_,file_part = fname:find('/([^/]+)$')
local _,_,basename = file_part:find('^([^-.]+)')
if not sets[basename] then sets[basename] = {spec={}, phases={}} end
local golden_path = golden_dir..file_part
if not os_exists(blueprints_dir..golden_path) then
golden_path = fname
end
sets[basename].phases[phase] = {
listnum=listnum,
golden_filepath=golden_path,
output_filepath=output_dir..file_part}
end
end
-- load test specs
for basename,set in pairs(sets) do
local spec, notes = set.spec, set.phases.notes
-- set defaults
spec.depth = '1'
-- read spec
mock.patch(quickfort_command, 'print',
function(text)
for line in text:gmatch('[^\n]*') do
local _,_,var,val = line:find('%*?%s*([^=]+)=(.*)')
if var then spec[var] = val end
end
end,
function()
dfhack.run_script('quickfort', 'run', '-q', notes.listnum)
end)
-- validate spec and convert number strings to numeric vars
if not spec.description or spec.description == '' then
qerror(('missing description in test spec for "%s"'):
format(basename))
end
spec.width = get_positive_int(spec.width, 'width', basename)
spec.height = get_positive_int(spec.height, 'height', basename)
spec.depth = get_positive_int(spec.depth, 'depth', basename)
if spec.start then
local start_spec = argparse.numberList(spec.start, basename, 2)
spec.start = {x=get_positive_int(start_spec[1], 'startx', basename),
y=get_positive_int(start_spec[2], 'starty', basename)}
end
end
return sets
end
local function is_usable_test_tile(pos)
local tiletype = dfhack.maps.getTileType(pos)
local tileattrs = df.tiletype.attrs[tiletype]
local good_material = tileattrs.material == df.tiletype_material.STONE or
tileattrs.material == df.tiletype_material.FEATURE or
tileattrs.material == df.tiletype_material.MINERAL
local good_shape = tileattrs.shape == df.tiletype_shape.WALL
return good_material and good_shape
end
local function get_test_area(area, spec)
-- return with success if our current area meets or exceeds requirements
if area.width >= spec.width and area.height >= spec.height and
area.depth >= spec.depth then
return true
end
-- return with failure if the test requirements cannot ever be satisfied by
-- the current map
if spec.width > df.global.world.map.x_count - 2 or
spec.height > df.global.world.map.y_count - 2 or
spec.depth > df.global.world.map.z_count then
print('map too small to accomodate test')
return false
end
-- keep this simple for now. just go down the layers and check the region
-- starting at the upper left corner of each level.
local startz = area.pos and area.pos.z or df.global.world.map.z_count-1
for z_start = startz,0,-1 do
local z_end = z_start - spec.depth + 1
if z_end < 1 then return false end
for z = z_start,z_end,-1 do
for y = 1,spec.height do
for x = 1,spec.width do
if not is_usable_test_tile(xyz2pos(x, y, z)) then
-- next check should start on the z-level below this one
z_start = z
goto continue
end
end
end
end
do
area.width, area.height, area.depth =
spec.width, spec.height, spec.depth
area.pos = {x=1, y=1, z=z_start}
area.endpos = {x=area.width, y=area.height, z=z_start-area.depth+1}
return true
end
::continue::
end
end
local function get_cursor_arg(pos, start)
start = start or {x=1, y=1}
return ('--cursor=%d,%d,%d'):format(pos.x+start.x-1, pos.y+start.y-1, pos.z)
end
local function quickfort_cmd(cmd, listnum_or_path, pos, start)
dfhack.run_script('quickfort', cmd, '-q', listnum_or_path,
get_cursor_arg(pos, start))
end
local function quickfort_run(listnum_or_path, pos, start)
quickfort_cmd('run', listnum_or_path, pos, start)
end
local function quickfort_undo(listnum_or_path, pos, start)
quickfort_cmd('undo', listnum_or_path, pos, start)
end
local function designate_area(pos, spec)
local endx, endy, endz = pos.x + spec.width - 1, pos.y + spec.height - 1,
pos.z - spec.depth + 1
for z = pos.z,endz,-1 do for y = pos.y,endy do for x = pos.x,endx do
dfhack.maps.getTileFlags(xyz2pos(x, y, z)).dig =
df.tile_dig_designation.Default
end end end
end
local function format_pos(pos)
return ('%s,%s,%s'):format(pos.x, pos.y, pos.z)
end
local function run_dig_now(area)
dfhack.run_command('dig-now', format_pos(area.pos),
format_pos(area.endpos), '--clean')
end
local function get_playback_start_arg(start)
if not start then return end
return ('--playback-start=%d,%d'):format(start.x, start.y)
end
local function run_blueprint(basename, spec, pos)
local args = {tostring(spec.width), tostring(spec.height),
tostring(-spec.depth), output_dir..basename,
get_cursor_arg(pos), '-tphase'}
local playback_start_arg = get_playback_start_arg(spec.start)
if playback_start_arg then
table.insert(args, playback_start_arg)
end
blueprint.run(table.unpack(args))
end
local function reset_area(area, spec)
-- include the area border tiles that can get unhidden
local width, height, depth = spec.width+2, spec.height+2, spec.depth
local commands = {
'f', 'any', ';',
'p', 'any', ';',
'p', 's', 'wall', ';',
'p', 'sp', 'normal', ';',
'p', 'h', '1', ';',
'r', tostring(width), tostring(height), tostring(depth)}
dfhack.run_command('tiletypes-command', table.unpack(commands))
local pos = copyall(area.pos)
-- include the border tiles
pos.x = pos.x - 1
pos.y = pos.y - 1
-- tiletypes goes up z's, so adjust starting zlevel accordingly
pos.z = pos.z - spec.depth + 1
dfhack.run_command('tiletypes-here', '--quiet', get_cursor_arg(pos))
-- patch up tiles where the material couldn't be automatically determined
-- we don't just set all tiles to 'stone' so we don't obliterate veins
commands = {
'f', 's', 'empty', ';',
'p', 'm', 'stone'}
dfhack.run_command('tiletypes-command', table.unpack(commands))
dfhack.run_command('tiletypes-here', '--quiet', get_cursor_arg(pos))
commands = {'f', 's', 'ramp_top'}
dfhack.run_command('tiletypes-command', table.unpack(commands))
dfhack.run_command('tiletypes-here', '--quiet', get_cursor_arg(pos))
end
local function do_phase(phase_data, area, spec)
quickfort_run(phase_data.listnum, area.pos, spec.start)
end
-- run a #dig blueprint (or just designate the whole block if there is no data)
-- and then run dig-now to materialize the designations
local function do_dig_phase(phase_data, area, spec)
if phase_data then
do_phase(phase_data, area, spec)
else
designate_area(area.pos, spec)
end
-- run dig-now to dig out designated tiles
run_dig_now(area)
end
local extra_fns = {}
function test.end_to_end()
-- read in test plan
local sets = get_blueprint_sets()
local area = {width=0, height=0, depth=0}
for basename,set in pairs(sets) do
local spec = set.spec
print(('running quickfort ecosystem test: "%s": %s'):
format(basename, spec.description))
-- find an unused area of the map that meets requirements, else skip
if not get_test_area(area, spec) then
print(('cannot find unused map area to test set "%s"; skipping'):
format(basename))
goto continue
end
local phases = set.phases
do_dig_phase(phases.dig, area, spec)
if phases.smooth then do_dig_phase(phases.smooth, area, spec) end
if phases.carve then do_dig_phase(phases.carve, area, spec) end
if phases.build then do_phase(phases.build, area, spec) end
if phases.place then do_phase(phases.place, area, spec) end
if phases.zone then do_phase(phases.zone, area, spec) end
if phases.query then do_phase(phases.query, area, spec) end
-- run any extra commands, if defined by the blueprint spec
if spec.extra_fn then
extra_fns[spec.extra_fn](area.pos)
end
-- run blueprint to generate files in output dir
run_blueprint(basename, spec, area.pos)
-- quickfort undo blueprints (order shouldn't matter)
for _,phase_name in ipairs(phase_names) do
if phases[phase_name] then
quickfort_undo(phases[phase_name].golden_filepath,
area.pos, spec.start)
end
end
-- run tiletypes to reset tiles in area to hidden walls
reset_area(area, set.spec)
-- compare md5sum of input and output files
local md5File = dfhack.internal.md5File
for phase,phase_data in pairs(phases) do
if phase == 'notes' then goto continue end
print((' verifying phase: %s'):format(phase))
local golden_filepath = blueprints_dir..phase_data.golden_filepath
local output_filepath = blueprints_dir..phase_data.output_filepath
local input_hash, input_size = md5File(golden_filepath)
local output_hash, output_size = md5File(output_filepath)
expect.eq(input_hash, output_hash,
'compare blueprint contents to input: '..output_filepath)
expect.eq(input_size, output_size,
'compare blueprint length to input: '..output_filepath)
if not output_hash then goto continue end
if input_hash ~= output_hash or input_size ~= output_size then
-- show diff
local input, output =
io.open(golden_filepath, 'r'), io.open(output_filepath, 'r')
local input_lines, output_lines = {}, {}
for l in input:lines() do table.insert(input_lines, l) end
for l in output:lines() do table.insert(output_lines, l) end
input:close()
output:close()
expect.table_eq(input_lines, output_lines)
return nil
end
::continue::
end
::continue::
end
end
local function send_keys(...)
local keys = {...}
for _,key in ipairs(keys) do
gui.simulateInput(dfhack.gui.getCurViewscreen(true), key)
end
end
function extra_fns.gui_quantum(pos)
local vehicles = assign_minecarts.get_free_vehicles()
local confirm_state = confirm.isEnabled()
local confirm_conf = confirm.get_conf_data()
local routes = df.global.ui.hauling.routes
local num_routes = #routes
local next_order_id = df.global.world.manager_order_next_id
return dfhack.with_finalize(
function()
-- unforbid the minecarts we forbade
for _,minecart in ipairs(vehicles) do
local item = df.item.find(minecart.item_id)
if not item then error('could not find item in list') end
item.flags.forbid = false
end
if confirm_state then
dfhack.run_command('enable confirm')
for _,c in pairs(confirm_conf) do
confirm.set_conf_state(c.id, c.enabled)
end
end
end,
function()
-- forbid all available minecarts
for _,minecart in ipairs(vehicles) do
local item = df.item.find(minecart.item_id)
if not item then error('could not find item in list') end
item.flags.forbid = true
end
dfhack.run_script('gui/quantum')
local view = quantum.view
view:onRender()
guidm.setCursorPos(pos)
-- select the feeder stockpile
send_keys('CURSOR_RIGHT', 'CURSOR_RIGHT', 'SELECT')
view:onRender()
-- deselect the feeder stockpile
send_keys('LEAVESCREEN')
view:onRender()
-- reselect the feeder stockpile
send_keys('SELECT')
view:onRender()
-- set a custom name
send_keys('CUSTOM_N')
view:onRender()
view:onInput({_STRING=string.byte('f')})
view:onInput({_STRING=string.byte('o')})
view:onInput({_STRING=string.byte('o')})
send_keys('SELECT')
-- rotate the dump direction to the south
send_keys('CUSTOM_D')
view:onRender()
-- move the cursor to the dump position
send_keys('CURSOR_DOWN', 'CURSOR_DOWN', 'CURSOR_DOWN')
view:onRender()
-- commit and dismiss the dialog
send_keys('SELECT', 'SELECT')
-- verify the created route
expect.eq(num_routes + 1, #routes)
local route = routes[#routes-1]
expect.eq(0, #route.vehicle_ids, 'minecart should not be assigned')
expect.eq(1, #route.stops, 'should have 1 stop')
expect.eq(1, #route.stops[0].stockpiles,
'should have 1 link')
-- verify the created order
expect.eq(next_order_id + 1, df.global.world.manager_order_next_id)
local orders = df.global.world.manager_orders
local order = orders[#orders - 1]
expect.eq(df.job_type.MakeTool, order.job_type)
-- if confirm is enabled, temporarily disable it so we can remove
-- the route and manager order via the ui (easier than walking the
-- structures and carefully deleting all the memory)
if confirm_state then
dfhack.run_command('disable confirm')
end
-- delete last route
quickfort.apply_blueprint{mode='config', data='h--x^'}
-- delete last manager order
quickfort.apply_blueprint{mode='config', data='jm{Up}r^^'}
end)
end