-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbring-the-army-home.lua
More file actions
1207 lines (974 loc) · 50 KB
/
bring-the-army-home.lua
File metadata and controls
1207 lines (974 loc) · 50 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
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--Speeds up map entry returning from missions. Requires setup.
--@module = false -- TODO
--@enabled = false
local utils = require('utils')
local eventful = require('plugins.eventful')
local do_profile = nil -- can be nil, false, "time", or "call". "call" is slow, potentially very slow.
-- profiling requires my fork of the Pepperfish profiler,
local debugging = true
local help = [====[
bring-the-army-home
===================
*Tags:* fort | auto | gameplay | military
After a raid, returning soldiers will enter the map quickly
instead of trickling in one at a time.
In addition, all spoils items will be dropped and forbidden.
This script requires a Fishing Zone with tiles on the edge
of the map.
Make the zone as big as 1x20 or even 1x30, on the map edge.
The zone can be disabled to prevent fishing jobs.
Only the earliest-created Fishing Zone with tiles on the map
edge will be used.
When squads return from a mission, squad members will have
their entry point set to a random location in the zone.
Usage:
bring-the-army-home
For manual use. Run this immediately after the "Squad Name
and others have returned." announcement.
bring-the-army-home start
The activation is new-unit and notification-based: Every
time a new unit is added to the map, the announcement queue
is checked for a "Squad Name and others have returned."
announcement. This triggers the script.
bring-the-army-home stop
Stop running the script, if previously started.
]====]
--[==[
DONE when dropping items, first remove any job. Store in stockpile/bookcase can be active.
TODO generalize to other report types (migrants).
DONE find out what happens with messengers.
Messengers get an army_controller with goal type 19,
with data containing the hfids of the workers being requested.
The army_controller does not have squads, but does have an
entity id (to our group) and an 'epp id' (into our group's
historical_entity.positions.assignments[], which also has a
backlink to the army_controller).
Messengers get an army when they leave the map. The army links
to the relevant army_controller, has one member and no squads,
and the army.flags are all false.
When messengers return, they trigger a 301 announcement, with
"have returned" text. It's handled just like an army return.
DONE When messengers return with a worker who happens to be a
merchant, that worker cannot be assigned to the military,
and seemingly isn't a full member of the fort in other ways.
Removed because makeown fixes it.
TODO When messengers return with workers, the _workers_ do not
own the clothing they're wearing. Fix that.
(later) same for the messengers, at least sometimes.
it's all similar to the handling of squaddies.
It's more obvious for the workers, as they immediately drop
all their clothing and walk naked to the new clothes they choose.
TODO When messengers return with a former site occupier, that
site occupier's weapon and shield are dropped and forbidden.
(Other former uniform items are handled normally by the game.)
(Sometimes. TODO more research.)
DONE catch map load / unload.
TODO maybe: Instead of random, equal units per tile ?
DONT: keep a cache of already-processed unit-ids and tick time, and don't double-process them.
I dealt with this issue in a different way.
TODO maybe: I just found that squad_order_raid_sitest is where the exit point is defined.
could override that on close of the world map? to force the army to exit on our zone.
note: squad return event is 301 GUEST_ARRIVAL
note: found out there are NO TICKS between the incoming units being placed
into units.active, and the announcement, and the first unit entering the map:
announcement GUEST_ARRIVAL at year 507 tick 77210
Squad4 and others have returned.
Triggered at tick 77210
At tick 77210, #units.active changed from 699 to 719
At this point, the squad leader was already on the map at the announcement.pos location.
(later) I also found that this is when the army is deleted. likely the army_controller too.
so you can't study the army/army_controller to cross-check the incoming units.
(could grab any relevant info on every trigger.)
There are 100 ticks between waves, that is, attempts to enter.
DONE: could frequency be set as low as 100? Is the 100 aligned to a boundary?
A: NO. Probably 10.
However, there are not necessarily 100 ticks between the first unit to enter, and the next wave.
DONE (testing): Babies should not be removed from their mothers.
Mother 11046 Thikut Uz, SQ4 ; Baby 17054 Rith Laz.
On-map, active babies have flags1.rider set.
They also have relationship_ids.RiderMount == their mother's id.
mount_type == 1 (CARRIED).
TODO check if that is still true of .inactive babies.
On-map, active units being 'ridden' have flags1.ridden set.
No specific-ref, no general-ref. No relationship_id for being ridden.
So if a unit has .rider, move them to the same square as their mount, I think.
Okay, another chance to resolve this. Bembul Nil, SQ4 has a baby, Lorbam Erith.
Bembul: flags1.ridden true.
Lorbam: profession 104 Baby, profession2 104 Baby, flags1.rider true, mood 8 Baby,
birth_year 514, birth_time 10284, relationship_ids.RiderMount mother's unit_id,
mount_type 1 CARRIED, 3 owned items (somehow).
DONE Babies. It turns out that riders (including babies) are handled by dfhack.units.teleport().
no wonder I couldn't ever get my rider code to trigger.
remove my special-handling rider code. find out what needs to happen, if anything.
TODO maybe: it turns out that eventful.onUnitNewActive is pretty slow, because it scans
the entire active units vector every n ticks (based on frequency of course).
Consider switching back to a report-based trigger.
(OTOH, so many reports come in that that's slow as well.)
flat-out callback every 10 ticks, and quickly check world.status.announcements?
TODO possibility: histfig.info.whereabouts.year and .year_tick.
verify that this is valid on army return, then use it to catch the arriving units which
have already entered and moved away from the entry tile.
TODO should we be watching armies / army controllers? to get the histfigs out of them?
we could report missing units.
rome wrote a plugin to do that, just use it instead.
TODO catch_newunits() is getting out of sync; it drifts from the preferred 10-tick boundary.
what can be done about that?
TODO instead of intercepting qerror(), run the entire script inside a dfhack.pcall(),
catch errors, stop_catching_newunits(), and re-throw it using error(). or qerror()?
--]==]
-- profiling requires my modified version of the Pepperfish profiler that has suspend/resume.
if profiler then
profiler:stop()
profiler = nil
end
if do_profile then
profiler = require('profiler').newProfiler(do_profile)
else
profiler = {
start = function() end,
stop = function() end,
suspend = function() end,
resume = function() end,
report = function() end,
}
end
local plotinfo = (function(a,b)for k,_ in pairs(df.global) do if k == a or k == b then return df.global[k]; end; end; error(); end)("ui", "plotinfo")
-- note: unlike normal C-style printf, this ends the line.
local function printf(...)
print(string.format(...))
end
-- This is basically debug-printf().
-- If a global or top-level local variable 'debugging' is false or does not exist, there is no output.
-- If 'debugging' is true, this uses dfhack.printerr() to both print to the console (in red),
-- and log to the stderr.log file.
-- The debug library is used to find both the filename and the function name.
--
-- note: we need to cache this because dfhack.current_script_name() doesn't work inside callbacks.
local dprintf_current_script_name = (type(dfhack.current_script_name()) == "string")
and dfhack.current_script_name():match( '([^/]*)$' ) or ""
function dprintf(format, ...)
if not debugging then return; end
-- unfortunately, even if we're not debugging, the script wastes time collecting
-- all of the info that would be printed. So don't do anything too slow.
-- Lua 5.3 Reference Manual 4.9 lua_Debug and lua_getinfo.
-- 2 = immediate caller's frame, n = name info, t = istailcall, l = currentline.
local info = debug.getinfo(2, "ntl")
or { namewhat = "{no debug info}", name = "{no debug info}", istailcall = false, currentline = 0 }
-- we assume that info always contains details about a function, because that's what we asked for.
-- Lua 5.3 Reference Manual 3.4.10:
-- "However, a tail call erases any debug information about the calling function."
info.name = info.name or ( (info.istailcall) and "{tail call}" or "{no function}" )
local message = string.format("%s:%d %s(): " .. format, dprintf_current_script_name, info.currentline, info.name, ...)
local oldcolor = dfhack.color(COLOR_LIGHTCYAN)
print(message)
dfhack.color(oldcolor)
io.stderr:write(message):write('\n')
end
local stop_catching_newunits -- declared here, defined as a function near the end of the script.
qerror = dfhack.BASE_G.qerror -- global to this script (but not require'd modules, be careful!)
local function __qerror(msg, lvl)
qerror = dfhack.BASE_G.qerror
stop_catching_newunits()
dfhack.BASE_G.qerror(msg, lvl) -- voodoo, call the original global qerror()
end
qerror = __qerror
---@alias coord {x:integer, y:integer, z:integer} # coercible into a `df.coord`.
---@alias strpos string # x,y,z coords formatted as a string: "12,34,56"
---@param x integer
---@param y integer
---@param z integer
---@return strpos
local function xyz2str(x,y,z)
x = math.tointeger(x); y = math.tointeger(y); z = math.tointeger(z)
if not x or not y or not z then
qerror("not enough parameters, or a parameter could not be converted to an integer.")
end
return(string.format("%d,%d,%d",x,y,z))
end
---accepts true `df.coord`s or any table with x, y, z fields.
---
---@param pos coord
---@return strpos
local function pos2str(pos)
return xyz2str(pos2xyz(pos))
end
---returns a list of all of the tiles which are actually in the building, considering extents.
---
---note: only tested with building type `df.building_civzonest`.
---
---@param building df.building
---@return coord[]
local function get_all_building_tiles(building)
---@type coord[]
local tiles = {}
local z = building.z
for x = building.x1, building.x2 do
for y = building.y1, building.y2 do
if dfhack.buildings.containsTile(building, x, y) then
table.insert(tiles, xyz2pos(x,y,z))
end
end
end
return tiles
end
---returns a list of the acceptable coordinates for incoming units to be teleported to.
---these coordinates are found from the first fishing zone which has tiles on the map edge.
---
---@return coord[] # a (possibly-empty) list of coords of teleportation targets.
--- # for reasons, there must be more than one target tile.
local function find_acceptable_tiles()
---@type { [string]: boolean } -- dictionary, i.e. a table<strpos, true>.
local is_on_map_edge_list = {} -- If the key exists, the strpos is on the (surface) map edge.
---@param x integer|coord|df.coord
---@param y integer?
---@param z integer?
---@return boolean
local function is_on_map_edge(x,y,z)
if y == nil and z == nil then x,y,z = pos2xyz(x); end
-- build the table, only once.
if #is_on_map_edge_list == 0 then
local source = plotinfo.map_edge
-- unfortunately, can't use builtin get_path_xyz() because the data is not set up as a path.
-- TODO: this would be a good use case for ipairs3() or ipairsN() (when I write it).
for i = 0, #source.surface_x-1 do
local xx, yy, zz = source.surface_x[i], source.surface_y[i], source.surface_z[i]
is_on_map_edge_list[xyz2str(xx,yy,zz)] = true
end
end
return ( is_on_map_edge_list[xyz2str(x,y,z)] == true )
end
---@type coord[]
local acceptable_tiles = {}
-- note: we do not cache this because e.g. trees can grow or buildings can be constructed.
for _,zone in ipairs( df.global.world.buildings.other.ZONE_FISHING_AREA ) do
acceptable_tiles = {}
for _, pos in ipairs(get_all_building_tiles(zone)) do
if is_on_map_edge(pos) == true
and dfhack.maps.getWalkableGroup(pos) > 0
and dfhack.buildings.checkFreeTiles(pos,{x=1,y=1},nil,false,true) == true -- redundant?
then
table.insert(acceptable_tiles, pos)
end
end
if #acceptable_tiles > 0 then break; end
end
-- this is necessary because we need to remove entrypos from the list if it overlaps,
-- so we need to have more than one acceptable target tile.
if #acceptable_tiles == 1 then acceptable_tiles = {}; end
return acceptable_tiles
end
---returns whether an item is assigned to a miner, woodcutter, or hunter.
---essentially works like dfhack.items.isSquadEquipment() .
-- TODO: Actually, isSquadEquipment() just checks plotinfo.equipment.items_assigned[][].
-- so this entire function is redundant.
---
---@param item df.item
---@return boolean
local function isMinerWoodcutterHunterEquipment(item)
-- actually this was not hard to check. nice.
return utils.linear_index(plotinfo.equipment.work_weapons, item.id) ~= nil
or utils.linear_index(plotinfo.equipment.ammo_items, item.id) ~= nil
end
---@param unit df.unit
local function drop_and_forbid_spoils(unit)
-- Spoils tests that are not performed are:
-- * Not owned by the unit. For some reason, returning soldiers don't own anything at all. Bug!
-- * Spoils item(s) are expected to be the last item(s) in the inventory.
--
---@param unit df.unit
---@param invitem df.unit_inventory_item
local function invitem_is_spoils(unit, invitem)
local item = invitem.item
if item:hasWriting() then return true; end -- for some reason, foreign books may not be tagged .foreign.
if not item.flags.foreign then return false; end
if dfhack.units.isActive(unit) and invitem.mode ~= df.inv_item_role_type.Hauled then return false; end
if not dfhack.units.isActive(unit) and invitem.mode ~= df.inv_item_role_type.Weapon then return false; end
-- TODO: see comment above re isSquadEquipment in defintion for isMinerWoodcutterHunterEquipment.
-- It would be much better to check the entire squad, or this soldier, to see if the item is assigned.
if dfhack.items.isSquadEquipment(item) then return false; end
if isMinerWoodcutterHunterEquipment(item) then return false; end -- could happen for Messengers.
return true
end
---@type coord
local unitpos = xyz2pos(dfhack.units.getPosition(unit))
---@type df.item[]
local items_to_drop = {}
-- collector.
for idx, invitem in ipairs(unit.inventory) do
local item = invitem.item
if invitem_is_spoils(unit, invitem) then
-- This happens when a unit both looted an artifact or book, and looted a
-- "Loot other items" such as a crutch or a nest box.
if (true) and (idx ~= #unit.inventory-1) then
dprintf("NOTICE: Unit %d %s spoils item %d %s is not the last inventory item. " ..
"So that happens.", unit.id, dfhack.units.getReadableName(unit),
item.id, dfhack.items.getReadableDescription(item) )
end
table.insert(items_to_drop, item)
end
end
-- processor.
for idx, item in ipairs(items_to_drop) do
if (true) and (#items_to_drop > 1) then
-- This happens when a unit both looted an artifact or book, and also looted a
-- "Loot other items" such as a crutch or a nest box.
dprintf("NOTICE: Unit %d %s was carrying %d spoils items: invitem #%d id %d %s.",
unit.id, dfhack.units.getReadableName(unit),
#items_to_drop, idx,
item.id, dfhack.items.getReadableDescription(item) )
end
local sref = dfhack.items.getSpecificRef(item, df.specific_ref_type.JOB)
if (true) and (sref) then
dprintf("NOTICE: Spoils item id %d %s was in job %d, type %s",
item.id, dfhack.items.getReadableDescription(item),
sref.data.job.id, df.job_type[sref.data.job.job_type] )
end
if (true) and (item.flags.in_job ~= (sref ~= nil)) then
dprintf("WARNING: Spoils item id %d %s: the job data is out of sync: .flags.in_job=%s, sref=%s",
item.id, dfhack.items.getReadableDescription(item),
(item.flags.in_job) and 'true' or 'false',
(sref) and 'exists' or 'nil' )
end
if (sref) then dfhack.job.removeJob(sref.data.job); end
local sref = dfhack.items.getSpecificRef(item, df.specific_ref_type.JOB)
if (true) and (sref) then
dprintf("WARNING: Failed to remove job for spoils item id %d %s", item.id,
dfhack.items.getReadableDescription(item))
end
local success = dfhack.items.moveToGround(item, unitpos)
if (true) or (not success) then
dprintf("Dropping and forbidding spoils item id %d %s at (%s)%s.",
item.id, dfhack.items.getReadableDescription(item),
pos2str(unitpos), (success) and '' or ' FAILED!' )
end
if item.flags.on_ground then
item.flags.forbid = true
end
end
end
---Teleport a unit to the special zone. The unit can be active (alive & on the map) or inactive.
---The unit's alive/dead status is not considered; this has not been tested with dead units.
---
---The unit should be in world.units.active[]; this is not tested.
---
-- TODO it would be better to deal with entrypos == nil in assign_incoming_units_to_tiles() .
--
---@param unit df.unit
---@param entrypos coord? # where are they coming from?
---@param acceptable_tiles coord[] # what tiles are valid incoming tiles? (in our zone and on the edge.)
local function teleport_unit_to_a_random_incoming_tile(unit, entrypos, acceptable_tiles)
if unit.flags1.rider then
dprintf("attempted to teleport a rider: %d %s", unit.id, Name(unit))
return
end
-- making a nil entrypos into a valid coord that is not on the map simplifies processing.
local had_an_entrypos = (entrypos ~= nil) -- currently only used for debugging.
entrypos = entrypos or xyz2pos(-30000, -30000, -30000)
---@type coord
local oldpos = xyz2pos(dfhack.units.getPosition(unit))
-- testing whether this works. if it works, it will avoid (most) double-processing.
-- note that if the unit is on entrypos, and entrypos happens to be in acceptable_tiles,
-- we do not skip the teleport.
if not (same_xyz(oldpos, entrypos)) then
for _, atile in ipairs(acceptable_tiles) do
if same_xyz(oldpos, atile) then
dprintf("NOTICE: unit %d 's current position is already in acceptable_tiles; " ..
"skipping teleport.", unit.id)
return
end
end
end
-- handle the case where the unit is not actually on the entrypos.
-- this shouldn't happen, but early experiments showed that it does.
-- later: probably the unit had moved away from the entrypos before the script ran.
-- even later: or the unit got double-processed because entrypos was in the acceptable tiles,
-- and two armies returned at nearly the same time.
-- converted to just a debugging notification.
if had_an_entrypos and not same_xyz(entrypos, oldpos) then
dprintf("NOTICE: Unit %d is is at (%s), not on the entrypos (%s). So that does happen.",
unit.id, pos2str(oldpos), pos2str(entrypos))
end
---@type coord
local pos
-- I had an issue where the game's chosen incoming tile was in acceptable_tiles[].
-- this manifested as: the units I assigned to that tile were not able to enter the map.
-- the cause of this was tracked down to occ.unit and occ.unit_grounded, but I could
-- not figure out why occ.unit kept being set.
-- anyway, we need to skip the special case.
-- (later: this may not have been properly diagnosed. there is something going on with occ.unit.)
-- TODO: the way to TEST this would be to force acceptable_tiles[] to contain the entrypos
-- and one other tile.
repeat
pos = acceptable_tiles[ math.random(#acceptable_tiles) ]
until not same_xyz(entrypos, pos)
dprintf("Teleporting %s unit %d to arrive at (%s)", (unit.flags1.inactive) and 'inactive' or 'active',
unit.id, pos2str(pos) )
-- .flags1.inactive units are assigned a map tile, but do not yet occupy it.
-- the teleport sets the occupancy flags per the normal case of an active unit.
-- we need to undo that.
---@type df.tile_occupancy
local occ = dfhack.maps.getTileBlock(pos).occupancy[pos.x % 16][pos.y % 16]
local old_occ_unit = occ.unit
local old_occ_unit_grounded = occ.unit_grounded
if (false) and (unit.flags1.inactive and unit.flags1.on_ground) then
-- I think this only happens when a unit is double-processed.
dprintf("NOTICE: Before teleport, inactive unit %d had flags1.on_ground set.", unit.id)
end
local success = dfhack.units.teleport(unit, pos)
if (false) and (unit.flags1.inactive and unit.flags1.on_ground) then
-- this happens fairly often, and is harmless.
dprintf("NOTICE: After teleport, inactive unit %d has flags1.on_ground set.", unit.id)
end
if success then
-- restore the tile's unit occupancy flags, if necessary.
if unit.flags1.inactive then
occ.unit = old_occ_unit
occ.unit_grounded = old_occ_unit_grounded
unit.flags1.on_ground = false
end
drop_and_forbid_spoils(unit)
else
dprintf("WARNING: Teleport failed! Unit %d oldpos %s target %s", unit.id, pos2str(oldpos), pos2str(pos))
end
end
---@param pos coord
---@return df.item[]
local function get_items_on_this_tile(pos)
local itemlist = {}
local block = dfhack.maps.getTileBlock(pos)
if not (block) then return {}; end
for _, id in ipairs(block.items) do
local item = df.item.find(id)
if (item) and same_xyz(xyz2pos(dfhack.items.getPosition(item)), pos) then
table.insert(itemlist, item)
end
end
return itemlist
end
---@param itemlist df.item[]
---@param acceptable_tiles coord[]
local function move_items_to_a_random_incoming_tile(itemlist, acceptable_tiles)
local pos = acceptable_tiles[ math.random(#acceptable_tiles) ]
for _, item in ipairs(itemlist) do
local oldpos = xyz2pos(dfhack.items.getPosition(item))
local success = dfhack.items.moveToGround(item, pos)
dprintf("Moving and forbidding just-dropped item %d at (%s) to (%s).%s",
item.id, pos2str(oldpos), pos2str(pos), (success) and '' or ' FAILED!' )
-- TODO maybe, consider it: only forbid if it's not a fort-created item.
if item.flags.on_ground then item.flags.forbid = true; end
end
end
---@param entrypos coord? # the original entrance location, parsed from the announcement.
---@return integer # the number of units which were teleported.
--- # does not count babies and other riders.
local function assign_incoming_units_to_tiles(entrypos)
local acceptable_tiles = find_acceptable_tiles()
if #acceptable_tiles == 0 then
dprintf("find_acceptable_tiles() returned 0 tiles.")
printf("bring-the-army-home could not locate any tiles to place the returning army on!")
printf("Please create a Fishing Zone on the edge of the map, in the location where the")
printf("army should return. (The Fishing Zone can be disabled to prevent fishing jobs.)")
return 0
end
-- TODO maybe: there are issues when the entrypos is a member of the acceptable_tiles set.
-- it would simplifiy logic elsewhere if we just removed entrypos from the set.
-- it would get rid of some special-case handling and nested or convoluted logic.
-- debug logging, chasing an issue. kind of slow, but only if debugging, so it's okay.
-- (later) I think I've resolved this; turning off debug logging.
for _,unit in ipairs(df.global.world.units.active) do
if (true) or (not debugging) then break; end
if (not dfhack.units.isKilled(unit) and unit.flags1.inactive and unit.flags1.on_ground) then
dprintf("NOTICE: Before any teleports, inactive unit %d has flags1.on_ground set.", unit.id)
dprintf(" isFortControlled=%s %s",
dfhack.units.isFortControlled(unit) and 'true ' or 'false',
dfhack.units.getReadableName(unit))
end
end
-- debug logging, chasing an issue. slow, but only if debugging, so it's okay.
-- (later) I think I've resolved this; turning off debug logging.
for _, pos in ipairs(acceptable_tiles) do
if (true) or (not debugging) then break; end
local occ = dfhack.maps.getTileBlock(pos).occupancy[pos.x % 16][pos.y % 16]
if occ.unit_grounded then
dprintf("NOTICE: Before any teleports, tile (%s) has occ.unit_grounded set.", pos2str(pos))
local a, ang, ag = 0, 0, 0
for _, unit in ipairs(df.global.world.units.active) do
local unitpos = xyz2pos(dfhack.units.getPosition(unit))
if same_xyz(unitpos, pos) then
dprintf("\tUnit %d is on this tile. .flags1.inactive = %s, .flags1.on_ground = %s.",
unit.id, (unit.flags1.inactive) and 'true ' or 'false',
(unit.flags1.on_ground) and 'true ' or 'false' )
end
a = a + ((not unit.flags1.inactive) and 1 or 0)
ang = ang + ((not unit.flags1.inactive and not unit.flags1.on_ground) and 1 or 0)
ag = ag + ((not unit.flags1.inactive and unit.flags1.on_ground) and 1 or 0)
end
dprintf("\tThis tile had %d active, %d nongrounded, %d grounded units.", a, ang, ag)
end
end
local processed = 0
for _,unit in ipairs(df.global.world.units.active) do
-- okay, as a catch-all, we're going to process all .incoming, .inactive,
-- not .killed, fort-controlled units (whether or not they're at entrypos),
-- TODO maybe: remove this catch-all case? it causes double-teleporting when two
-- arrivals occur in quick succession.
-- and ALSO all units at entrypos (if given),
-- even if they're on the map and active (i.e. the first unit to enter),
-- even if they're not fort-controlled (e.g. freed prisoners).
--
-- we do NOT care about military squad. war animals don't have a squad.
--
if ( unit.flags1.incoming and unit.flags1.inactive and not unit.flags2.killed
and dfhack.units.isFortControlled(unit) )
or ( (entrypos) and same_xyz(xyz2pos(dfhack.units.getPosition(unit)), entrypos) )
then
if not unit.flags1.rider then -- dfhack.units.teleport deals with riders.
teleport_unit_to_a_random_incoming_tile(unit, entrypos, acceptable_tiles)
--[[ removed because makeown does this.
-- (HACK) if an arriving unit is a Merchant, make them an Administrator instead.
-- This is because if a newly-arriving unit is a Merchant, they cannot be assigned
-- to the military and seemingly aren't a full member of the fort in other ways.
-- Administrator chosen semi-arbitrarily as a profession that won't interfere with
-- work assignments and will be overridden upon leveling a different skill.
if unit.profession == df.profession.MERCHANT then
unit.profession = df.profession.ADMINISTRATOR
end
--]]
processed = processed + 1
end
end
end
-- special case: soldiers who already entered the map may have already dropped their spoils.
if (entrypos) then
move_items_to_a_random_incoming_tile( get_items_on_this_tile(entrypos), acceptable_tiles )
end
-- debug logging, chasing an issue. kind of slow, but only if debugging, so it's okay.
-- (later) I think I've resolved this; turning off debug logging.
for _,unit in ipairs(df.global.world.units.active) do
if (true) or (not debugging) then break; end
if (not dfhack.units.isKilled(unit) and unit.flags1.inactive and unit.flags1.on_ground) then
dprintf("NOTICE: After all teleports, inactive unit %d has flags1.on_ground set.", unit.id)
dprintf(" isFortControlled=%s %s",
dfhack.units.isFortControlled(unit) and 'true ' or 'false',
dfhack.units.getReadableName(unit))
end
end
-- debug logging, chasing an issue. slow, but only if debugging, so it's okay.
-- (later) I think I've resolved this; turning off debug logging.
for _, pos in ipairs(acceptable_tiles) do
if (true) or (not debugging) then break; end
local occ = dfhack.maps.getTileBlock(pos).occupancy[pos.x % 16][pos.y % 16]
if occ.unit_grounded then
dprintf("NOTICE: After all teleports, tile (%s) has occ.unit_grounded set.", pos2str(pos))
local a, ang, ag = 0, 0, 0
for _, unit in ipairs(df.global.world.units.active) do
local unitpos = xyz2pos(dfhack.units.getPosition(unit))
if same_xyz(unitpos, pos) then
dprintf("\tUnit %d is on this tile. .flags1.inactive = %s, .flags1.on_ground = %s.",
unit.id, (unit.flags1.inactive) and 'true ' or 'false',
(unit.flags1.on_ground) and 'true ' or 'false' )
end
a = a + ((not unit.flags1.inactive) and 1 or 0)
ang = ang + ((not unit.flags1.inactive and not unit.flags1.on_ground) and 1 or 0)
ag = ag + ((not unit.flags1.inactive and unit.flags1.on_ground) and 1 or 0)
end
dprintf("\tThis tile had %d active, %d nongrounded, %d grounded units.", a, ang, ag)
end
end
if processed > 0 then
dprintf("processed %d incoming %s.", processed, (processed == 1) and 'unit' or 'units')
end
return processed
end
last_announcement_id = last_announcement_id or -1 -- global, persistant.
-- TODO this needs to be cleared on fort-unload.
-- in the case of a NEW announcement type GUEST_ARRIVAL subtype "have returned.",
-- this returns the entry-point as a coord .
-- otherwise it returns nil.
--
---@return coord?
local function check_for_our_announcement()
-- scan through all announcements, starting from the bottom.
-- we expect that there are very few announcements, so no performance hit.
-- TODO consider scanning backwards until we hit last_announcement_id,
-- then forward from the id after that.
for _, report in ipairs(df.global.world.status.announcements) do
-- have we ever seen this report before?
if report.id > last_announcement_id then
last_announcement_id = report.id
if (false) and (debugging) then
dprintf("new announcement: id %d year %d tick %d type %s text %s",
report.id, report.year, report.time, df.announcement_type[report.type],
report.text)
end
if report.type == df.announcement_type.GUEST_ARRIVAL then
if (true) and (debugging) then
dprintf("announcement: id %d year %d tick %d type %s text %s",
report.id, report.year, report.time, df.announcement_type[report.type],
report.text)
end
-- note: even the singular case gets plural text: "Squad1 have returned."
-- however, the text " has returned." is in the binary, so better safe than sorry.
-- aha, it does happen, when a single unit returns.
-- DONE: what happens with messengers? A: treated just the same as an army return.
if string.match(report.text, "has returned.$")
or string.match(report.text, "have returned.$")
then
---@type coord
local pos = report.pos
dprintf("It is an army return! at (%s) tick %d", pos2str(pos), report.time)
if (report.time % 10) ~= 0 then
-- test a hypothesis:
dprintf("NOTICE: the announcement's time is not divisible by 10. So that happens.")
end
return pos
end
end
end
end
return nil
end
catch_newunits_enabled = catch_newunits_enabled or false -- global, persistant.
local frequency = 10
local debugging_modulus = 0
local function catch_newunits(unit_id) --asynchronous callback
local _ENV = _ENV -- pull in to ensure access to globals.
local debugging = debugging -- pull in to support dprintf.
profiler:resume()
if (debugging) then
local current_modulus = (dfhack.world.ReadCurrentTick() % frequency)
if current_modulus ~= 0 and current_modulus ~= debugging_modulus then
dprintf("NOTICE: ticks modulo %d is not 0; value is %d; suppressing further reports.",
frequency, current_modulus)
debugging_modulus = current_modulus
end
end
-- TODO counting for status reporting.
-- TODO I don't think it's worth testing these.
if not catch_newunits_enabled then
stop_catching_newunits()
dprintf("unexpectedly triggered with catch_newunits_enabled==false")
return
end
if not dfhack.isWorldLoaded() then
stop_catching_newunits()
dprintf("unexpectedly triggered without a world loaded.")
return
end
if not dfhack.isMapLoaded() then
stop_catching_newunits()
dprintf("unexpectedly triggered without a map loaded.")
return
end
if not dfhack.isSiteLoaded() then
stop_catching_newunits()
dprintf("unexpectedly triggered without a player fort loaded.")
return
end
if (false) and (debugging) then
dprintf("Caught a new unit: id %d tick %d", unit_id, dfhack.world.ReadCurrentTick())
end
---@type df.coord?
local pos = check_for_our_announcement()
-- if not nil, then we should trigger.
if (pos) then
dfhack.world.SetPauseState(true)
print("\a") -- ring the bell
-- TODO this would be the place to check that all squad members arrived home safely.
-- (or, you know, were legitimately killed horribly by a demon....)
-- DONE should we redo find_acceptable_tiles() on each arrival?
-- in case of new constructions, bridges, newly-grown trees, etc....
-- yes, did this in assign_incoming_units_to_tiles()
if assign_incoming_units_to_tiles(pos) == 0 then
-- TODO report that no units could be teleported?
-- TODO somehow report if any teleports failed?
end
end
profiler:suspend()
end
-- note: onUnitNewActive is UNDOCUMENTED.
--
local current_script_name = dfhack.current_script_name()
local bring_the_army_home_KEY = current_script_name -- globally unique across all scripts.
local synchronization_timer_id = nil
local function start_catching_newunits()
-- DONE: we don't need to check every tick; we just need to do our stuff before the
-- first-to-arrive unit (i.e. already active on the map) takes their first step.
-- (however, consider the fastdwarf module.)
-- note: preserve-rooms checks every 109 ticks.
--
-- DONE: it looks like arrivals only happen on ticks where ticks % 10 == 0. Needs more testing.
-- DONE: looks true, so synchronize ourself to a 10-tick boundary.
-- TODO: we're losing synchronization for unknown reasons, should we re-sync in catch_newunits? how?
dprintf("on entry, current tick is %d; tick modulo frequency is %d.",
dfhack.world.ReadCurrentTick(), (dfhack.world.ReadCurrentTick() % frequency) )
if (dfhack.world.ReadCurrentTick() % frequency) ~= 0 then -- skip a bit, brother.
local delay = frequency - (dfhack.world.ReadCurrentTick() % frequency)
dprintf("setting a timeout to call this function again in %d ticks.", delay)
synchronization_timer_id = dfhack.timeout( delay, 'ticks',
function() start_catching_newunits(); end )
return
end
synchronization_timer_id = nil -- if we reach this point, either we never invoked a timer
-- or the timer just triggered and therefore expired.
-- 2nd parameter is frequency. 1 == every tick, 16 == every 16 ticks.
-- 16 ticks is too many; we can miss the first incoming unit.
eventful.enableEvent(eventful.eventType.UNIT_NEW_ACTIVE, frequency)
eventful['onUnitNewActive'][bring_the_army_home_KEY] =
function(unit_id) catch_newunits(unit_id); end
catch_newunits_enabled = true
profiler:start()
profiler:suspend()
end
--[[ local function ]] stop_catching_newunits = function()
-- note: the odd declaration syntax is because we pre-declared the local variable
-- stop_catching_newunits, so that my replacement qerror() could reference it.
-- now we are defining it as a function. see:
-- https://stackoverflow.com/questions/12291203/lua-how-to-call-a-function-prior-to-it-being-defined
-- note: this routine MUST not call qerror(), because it is called by my replacement qerror().
-- DONE Q: how to disable?
-- A: apparently you disable the callback by setting the key to nil.
-- A2: you cannot disable the timer, though. it keeps running.
-- A3: you can't even change the frequency to make the timer trigger less often.
eventful['onUnitNewActive'][bring_the_army_home_KEY] = nil
-- turn off the timer if it's running.
dfhack.timeout_active(synchronization_timer_id, nil)
synchronization_timer_id = nil
catch_newunits_enabled = false
profiler:resume()
profiler:stop()
if do_profile then
-- TODO it would be best to use the script's full path. But we don't know its base path.
local outfile = io.open( current_script_name:match( '([^/]*)$' ) .. ".txt", "w+" )
success, err = pcall( function() profiler:report( outfile );end )
if not success then
dprintf("Error calling profiler:report()\n%s", err)
end
outfile:close()
end
end
-- note: the eventful C++ code shuts down the world-specific exports at world-unload.
-- so we don't really _need_ to catch fort/map/world unloads.
-- unless we're caching stuff that should be reset. which we are. so do catch it.
--
local function catch_events(event_id)
if event == SC_MAP_UNLOADED then
dprintf("handling SC_MAP_UNLOADED.")
stop_catching_newunits()
dfhack.onStateChange[GLOBAL_KEY] = nil
last_announcement_id = -1
end
end
-- TODO function setEnabled(boolean)
enabled = enabled or false
function isEnabled()
return enabled
end
local function main(...)
-- TODO real parsing.
local cmd = ...
if cmd == 'stop' then -- TODO after module-izing, this will be redundant.
stop_catching_newunits()
elseif cmd == 'start' then
start_catching_newunits()
else
local pos = check_for_our_announcement() -- may return nil
if assign_incoming_units_to_tiles(pos) == 0 then -- if pos is nil? run it anyway.
print('There were no incoming units!')
end
end
end
if dfhack_flags.module and dfhack_flags.enable_state then
dprintf("running as an enabled module. installing hooks.")
enabled = true
dfhack.onStateChange[GLOBAL_KEY] = function(event_id) catch_events(event_id); end
start_catching_newunits()
elseif dfhack_flags.module and not dfhack_flags.enable_state then
dprintf("running as a disabled module. disabling hooks.")
enabled = false
dfhack.onStateChange[GLOBAL_KEY] = nil
stop_catching_newunits()
else
dprintf("running from commandline.")
main(...)
end
--[[
I had one case where a soldier got all set up properly, but was not inserted into units.active.
I couldn't get him to move onto the map until I inserted the unit manually.
TODO try to catch this case. At least complain, if not try to fix it.
--]]
--[[
1593856 69 D_MIGRANTS_ARRIVAL Some migrants have arrived. 508 57410
1593855 344 MONARCH_ARRIVAL Your ruler has arrived with a full entourage. Your thriving
site is now the capital, and with continued fortune and toil, the legend of
a true Mountainhome may yet be written. 508 57410
two of those migrants or entourage were soldiers who were out on a mission.
they were unassigned from their squad. ISTR they lost all their weapons/armor as well.
--]]
--[[
I sent 10 squads on a mission; about 95 units. No war animals.
5 did not return. Exploring this with DFHack:
A returner's HF has
.info.whereabouts:
.state 1 Settler
.site_id 1851 my site
.subregion_id -1
.feature_layer_id -1
.army_id -1
.cz_id (some world_object_data, all 0's)
.cz_bld_num -1