forked from Squamousness/Dfhack-Testing-Bin
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathammo.lua
More file actions
1328 lines (1192 loc) · 53.3 KB
/
ammo.lua
File metadata and controls
1328 lines (1192 loc) · 53.3 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
-- ammo.lua
-- Magazine/Belt ammunition system: multi-round ammo containers returned to shooter on impact.
-- Each shot launches a real bullet projectile; the magazine is redirected to drop in place
-- and returned to the shooter until its round count is exhausted.
--
-- All mutable state lives in _G.__ammo so that enable/disable/debug commands can be
-- issued as separate script invocations and still share the same runtime state.
local eventful = require('plugins.eventful')
local repeatUtil = require('repeat-util')
local utils = require('utils')
local CALLBACK_ID = "ammo"
local CALLBACK_ID_CLEANUP = "ammo:cleanup"
local CLEANUP_INTERVAL = 1200
-- Persistent state shared across ALL invocations of this script
local S = _G.__ammo or {}
_G.__ammo = S
if S.enabled == nil then S.enabled = false end
if S.DEBUG == nil then S.DEBUG = false end
if S.intercepted == nil then S.intercepted = {} end
if S.rounds_remaining == nil then S.rounds_remaining = {} end
if S.bullet_subtype_cache == nil then S.bullet_subtype_cache = {} end
if S.reloading == nil then S.reloading = {} end
if S.pellet_subtype_cache == nil then S.pellet_subtype_cache = {} end
-- S.log_file is intentionally not persisted (file handles can't survive re-init)
---------------------------------------------------------------------------
-- CONFIG (loaded from config_gun.lua)
---------------------------------------------------------------------------
local GCfg = _G.__GunConfig
if not GCfg or not GCfg._loaded then
dfhack.printerr("[ammo] FATAL: __GunConfig not set — bw_autoload must run first")
return
end
local MAGAZINE_CAPACITY = GCfg.MAGAZINE_CAPACITY
local BULLET_FOR_MAGAZINE = GCfg.BULLET_FOR_MAGAZINE
local RELOAD_PENALTY_TICKS = GCfg.RELOAD_PENALTY_TICKS
local RELOAD_PENALTY_TICKS_ADV = GCfg.RELOAD_PENALTY_TICKS_ADV
local SHELL_CONFIG = GCfg.SHELL_CONFIG
local SHOTGUN_SHELL_TYPES = GCfg.SHOTGUN_SHELL_TYPES
local SUPPRESS_RELOAD_MESSAGE = GCfg.SUPPRESS_RELOAD_MESSAGE
local NO_HITS_FRIENDS_WEAPONS = GCfg.NO_HITS_FRIENDS_WEAPONS
local MULTIBARREL_WEAPONS = GCfg.MULTIBARREL_WEAPONS
local BULLET_MATERIAL_OVERRIDE = GCfg.BULLET_MATERIAL_OVERRIDE
-- Extra ticks of dead-time imposed on the firer after each magazine is exhausted.
-- Simulates the reload being slower than the between-shot cycling time.
--
-- Two sets of values: fortress/arena mode and adventure mode.
-- Fortress/arena: 1 tick = 72 in-game seconds. 15-25 ticks is a noticeable pause.
-- Adventure mode: 1 tick = 0.5 in-game seconds (144× more granular). A typical
-- player action costs ~100 ticks, so penalties must be in that range to register.
local RELOAD_PENALTY_DEFAULT = 200 -- fortress/arena: box/detachable magazines
local RELOAD_PENALTY_DEFAULT_ADV = 50 -- adventure mode: box/detachable magazines
local function is_adventure_mode()
local ok, result = pcall(function()
return df.global.gamemode == df.game_mode.ADVENTURE
end)
return ok and result
end
local function get_reload_penalty(sub_id)
if is_adventure_mode() then
return RELOAD_PENALTY_TICKS_ADV[sub_id] or RELOAD_PENALTY_DEFAULT_ADV
end
return RELOAD_PENALTY_TICKS[sub_id] or RELOAD_PENALTY_DEFAULT
end
---------------------------------------------------------------------------
-- INVENTORY MODE SHIM (same pattern as sidearm.lua)
---------------------------------------------------------------------------
local MODE_HAULED
local ok_new, inv_role = pcall(function() return df.inv_item_role_type end)
local ok_old, t_mode = pcall(function() return df.unit_inventory_item.T_mode end)
if ok_new and inv_role then
MODE_HAULED = inv_role.Hauled
elseif ok_old and t_mode then
MODE_HAULED = t_mode.Hauled
else
print("[ammo] WARNING: Cannot resolve inventory mode enum!")
MODE_HAULED = 0
end
---------------------------------------------------------------------------
-- DEBUG LOGGING
---------------------------------------------------------------------------
local function log(fmt, ...)
if not S.DEBUG then return end
local ok, msg = pcall(string.format, fmt, ...)
if not ok then msg = fmt end
local frame = -1
pcall(function() frame = df.global.world.frame_counter end)
local line = ("[frame=%d] %s"):format(frame, msg)
-- Console output so we can see even if file write fails
print("[ammo] " .. line)
if S.log_file then
S.log_file:write(line .. "\n")
S.log_file:flush()
end
end
local function open_log()
local path = "ammo_debug.txt"
S.log_file = io.open(path, "w")
if S.log_file then
S.log_file:write("=== ammo.lua debug log ===\n")
S.log_file:flush()
print("[ammo] Debug log: " .. dfhack.getDFPath() .. "\\" .. path)
else
print("[ammo] WARNING: Could not open " .. path .. " for writing")
end
end
local function close_log()
if S.log_file then
S.log_file:close()
S.log_file = nil
end
end
---------------------------------------------------------------------------
-- HELPERS
---------------------------------------------------------------------------
local function count_table(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
local function cleanup_intercepted()
if not S.enabled then return end
pcall(function()
local live = {}
for _, proj in utils.listpairs(df.global.world.projectiles.all) do
if proj and proj:getType() == df.projectile_type.Item then
live[proj.id] = true
end
end
for pid in pairs(S.intercepted) do
if not live[pid] then
S.intercepted[pid] = nil
end
end
end)
end
-- Returns true if item_id is still owned by an active proj_itemst.
local function item_is_projectile(item_id)
local proj_list = df.global.world.projectiles
if proj_list then
for _, proj in ipairs(proj_list.all) do
if proj.item and proj.item.id == item_id then
return true
end
end
end
return false
end
-- Find the ID of the unit currently holding the weapon with the given item ID.
local function find_firer_id(bow_id)
if bow_id < 0 then return -1 end
for _, unit in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(unit) and not dfhack.units.isDead(unit) then
for _, inv_item in ipairs(unit.inventory) do
if inv_item.item.id == bow_id then
return unit.id
end
end
end
end
return -1
end
-- Find the quiver in a unit's inventory.
local function get_quiver(unit)
for _, inv_item in ipairs(unit.inventory) do
if inv_item.item:getType() == df.item_type.QUIVER then
return inv_item.item
end
end
return nil
end
-- Find the first ammo stack inside a quiver whose subtype and material match.
-- Quiver contains items via CONTAINS_ITEM general_refs.
local function find_mag_stack_in_quiver(quiver, sub, mat_t, mat_i)
local found = nil
pcall(function()
for _, ref in ipairs(quiver.general_refs) do
if ref:getType() == df.general_ref_type.CONTAINS_ITEM then
local c = df.item.find(ref.item_id)
if c and c:getType() == df.item_type.AMMO
and c:getSubtype() == sub
and c.mat_type == mat_t
and c.mat_index == mat_i then
found = c
return -- break out of pcall (Lua doesn't have break for ipairs)
end
end
end
end)
return found
end
-- Find matching ammo stack anywhere on a unit: quiver, direct inventory, or any
-- container they're carrying (bag, backpack, etc.). Excludes exclude_id so we
-- never accidentally match the projectile item itself.
local function find_mag_stack_on_unit(unit, sub, mat_t, mat_i, exclude_id)
local found = nil
pcall(function()
for _, inv_item in ipairs(unit.inventory) do
local it = inv_item.item
if it and it.id ~= exclude_id then
-- Direct ammo item in inventory
if it:getType() == df.item_type.AMMO
and it:getSubtype() == sub
and it.mat_type == mat_t
and it.mat_index == mat_i then
found = it
return
end
-- Container (quiver, bag, etc.): search its contents
local nested = find_mag_stack_in_quiver(it, sub, mat_t, mat_i)
if nested and nested.id ~= exclude_id then
found = nested
return
end
end
end
end)
return found
end
-- True if unit is the player-controlled adventurer.
local function is_player_unit(unit)
local adv = dfhack.world.getAdventurer()
return adv ~= nil and adv.id == unit.id
end
-- Apply a reload delay to the firer after their magazine is exhausted.
-- sub_idx/mat_t/mat_i: when provided, the next ammo stack in the quiver is
-- temporarily zeroed so DF's combat AI won't queue ShootRangedWeapon during the
-- reload window, allowing setPathGoal(ArcherReposition) to execute naturally.
local function apply_reload_penalty(round_key, firer_unit_id, sub_id, sub_idx, mat_t, mat_i)
if not sub_id then return end
local penalty = get_reload_penalty(sub_id)
if penalty <= 0 then return end
local firer = df.unit.find(firer_unit_id)
if not firer or dfhack.units.isDead(firer) then return end
-- Close the 1-tick gap before maneuver.lua's repeat-util callback runs.
-- Prevents the AI from queuing a new action in the first tick of the reload window.
pcall(function()
if not dfhack.units.isPlayerControlled(firer) then
firer.counters.think_counter = math.max(2, firer.counters.think_counter)
end
end)
local until_frame = df.global.world.frame_counter + penalty
S.reloading[round_key] = until_frame
log("apply_reload_penalty: unit=%d penalty=%d ticks (until frame %d)",
firer_unit_id, penalty, until_frame)
-- Hide the next ammo stack so DF's combat AI can't shoot during reload.
if sub_idx and mat_t and mat_i then
local quiver = get_quiver(firer)
if quiver then
local stack = find_mag_stack_in_quiver(quiver, sub_idx, mat_t, mat_i)
if stack then
local saved = stack:getStackSize()
if saved > 0 then
pcall(function() stack:setStackSize(0) end)
log("apply_reload_penalty: hid ammo stack %d (saved=%d)", stack.id, saved)
local stack_id = stack.id
dfhack.timeout(penalty, "ticks", function()
if not S.enabled then return end
local s = df.item.find(stack_id)
if s and s:getStackSize() == 0 then
pcall(function() s:setStackSize(saved) end)
log("apply_reload_penalty: restored ammo stack %d to %d", stack_id, saved)
end
end)
end
end
end
end
-- Notify maneuver.lua so it can start repositioning this unit.
if _G.__maneuver and _G.__maneuver.on_reload_start then
_G.__maneuver.on_reload_start(firer_unit_id, round_key, until_frame)
end
end
-- Cached lookup: matinfo token string → { mat_type, mat_idx }.
local bullet_mat_override_cache = {}
local function resolve_mat_override(token)
if bullet_mat_override_cache[token] then
return table.unpack(bullet_mat_override_cache[token])
end
local mi = dfhack.matinfo.find(token)
if mi then
bullet_mat_override_cache[token] = { mi.type, mi.index }
return mi.type, mi.index
end
return nil, nil
end
-- Cached lookup: bullet ID string → ammo subtype index.
local function get_bullet_subtype(bullet_id)
if S.bullet_subtype_cache[bullet_id] then
return S.bullet_subtype_cache[bullet_id]
end
local ammo_defs = df.global.world.raws.itemdefs.ammo
for i = 0, #ammo_defs - 1 do
local def = ammo_defs[i]
if def and def.id == bullet_id then
S.bullet_subtype_cache[bullet_id] = i
return i
end
end
return -1
end
-- Returns barrel count from the weapon that fired this shot, or 1.
local function get_barrel_count(bow_id)
if bow_id < 0 then return 1 end
local bow = df.item.find(bow_id)
if not bow then return 1 end
local ok, weapon_id = pcall(function()
return dfhack.items.getSubtypeDef(df.item_type.WEAPON, bow:getSubtype()).id
end)
if not ok or not weapon_id then return 1 end
return MULTIBARREL_WEAPONS[weapon_id] or 1
end
-- Returns true if bullets from this weapon should get hits_friends=true,
-- meaning they can hit neutrals and allies in the line of fire.
-- Weapons in NO_HITS_FRIENDS_WEAPONS are excluded (enemies only).
local function bullet_hits_friends(bow_id)
if bow_id < 0 then return true end
local bow = df.item.find(bow_id)
if not bow then return true end
local ok, weapon_id = pcall(function()
return dfhack.items.getSubtypeDef(df.item_type.WEAPON, bow:getSubtype()).id
end)
if not ok or not weapon_id then return true end
return not NO_HITS_FRIENDS_WEAPONS[weapon_id]
end
-- Cached lookup: pellet ID string → ammo subtype index.
local function get_pellet_subtype(pellet_id)
if S.pellet_subtype_cache[pellet_id] then
return S.pellet_subtype_cache[pellet_id]
end
local ammo_defs = df.global.world.raws.itemdefs.ammo
for i = 0, #ammo_defs - 1 do
local def = ammo_defs[i]
if def and def.id == pellet_id then
S.pellet_subtype_cache[pellet_id] = i
return i
end
end
return -1
end
---------------------------------------------------------------------------
-- CONE SPREAD (shell ammo)
-- ammo.lua owns SHOT/FLECHETTE/SLUG magazine behavior; shotgun.lua owns SHELL4.
---------------------------------------------------------------------------
local function compute_spread(cx, cy, tx, ty, tz, count, cone_tan)
local dx, dy = tx - cx, ty - cy
local range = math.sqrt(dx * dx + dy * dy)
if range < 0.5 then range = 0.5 end
local px, py = -dy / range, dx / range
local spread_radius = range * cone_tan
local targets = {}
for _ = 1, count do
local r = (math.random() + math.random() - 1) * spread_radius
local offx = math.floor(px * r + 0.5)
local offy = math.floor(py * r + 0.5)
table.insert(targets, { x = tx + offx, y = ty + offy, z = tz })
end
return targets
end
local function launch_pellets(spawn)
local pellet_idx = get_pellet_subtype(spawn.pellet_id)
log("[ammo/shell] launch_pellets: %s -> subtype_idx=%d count=%d barrel_count=%d",
spawn.pellet_id, pellet_idx, spawn.count, spawn.barrel_count or 1)
if pellet_idx < 0 then
log("[ammo/shell] launch_pellets: pellet type not found in raws, aborting")
return
end
local total = spawn.count * (spawn.barrel_count or 1)
local pellet_mat_type, pellet_mat_idx = spawn.mat_type, spawn.mat_idx
local pellet_mat_tok = BULLET_MATERIAL_OVERRIDE[spawn.pellet_id]
if pellet_mat_tok then
local ot, oi = resolve_mat_override(pellet_mat_tok)
if ot and oi then
pellet_mat_type, pellet_mat_idx = ot, oi
log("[ammo/shell] launch_pellets: mat overridden to %s (%d/%d)", pellet_mat_tok, ot, oi)
else
log("[ammo/shell] launch_pellets: mat override '%s' not found in raws, using container mat", pellet_mat_tok)
end
end
local creator = df.unit.find(spawn.firer_unit_id)
if not creator then
for _, u in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(u) and not dfhack.units.isDead(u) then
creator = u
break
end
end
end
local cx, cy, cz = spawn.cur_x, spawn.cur_y, spawn.cur_z
local origin = xyz2pos(cx, cy, cz)
local targets = compute_spread(cx, cy,
spawn.target_x, spawn.target_y, spawn.target_z,
total, spawn.cone_tan)
local launched = 0
-- Pre-scan: collect non-allied units within the shot cone once per shot.
-- Per-pellet checks then iterate only this small list rather than all
-- active units, mirroring the geometry used in no-friendly-fire.lua.
local cone_units = {}
local cone_ok, cone_err = pcall(function()
local fu = df.unit.find(spawn.firer_unit_id)
if not fu then fu = dfhack.world.getAdventurer() end
if fu then
local dx = spawn.target_x - cx
local dy = spawn.target_y - cy
local shot_dist = math.sqrt(dx * dx + dy * dy)
if shot_dist >= 0.5 then
local nx = dx / shot_dist
local ny = dy / shot_dist
local c_tan = spawn.cone_tan or 0
for _, u in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(u) and not dfhack.units.isDead(u)
and u.id ~= fu.id
and math.abs(u.pos.z - cz) <= 1 then
local is_ally =
(fu.civ_id ~= -1 and fu.civ_id == u.civ_id)
or (fu.military.squad_id ~= -1 and fu.military.squad_id == u.military.squad_id)
or (dfhack.units.isCitizen(fu) and dfhack.units.isCitizen(u))
if not is_ally then
local ux = u.pos.x - cx
local uy = u.pos.y - cy
local along = ux * nx + uy * ny
if along >= 1 and along <= shot_dist + 3 then
local perp = math.abs(ux * ny - uy * nx)
if perp <= along * c_tan + 1 then
table.insert(cone_units, u)
end
end
end
end
end
end
end
end)
if not cone_ok then
dfhack.printerr("[ammo/shell] cone_units scan error: " .. tostring(cone_err))
end
log("[ammo/shell] launch_pellets: cone_units=%d", #cone_units)
-- Proactively assign one pellet per cone unit based on best angular match.
-- The old per-pellet ray check (perp<=0.5) almost never fires because random
-- spread tiles rarely pass within 0.5 tiles of a nearby neutral. Pre-assignment
-- guarantees each non-allied unit in the cone gets at least one pellet aimed
-- directly at their tile with spec_target_unit set.
local assignments = {} -- targets[] index → { id, pos }
for _, u in ipairs(cone_units) do
local ux = u.pos.x - cx
local uy = u.pos.y - cy
local ud = math.sqrt(ux * ux + uy * uy)
if ud >= 0.5 then
local unx = ux / ud
local uny = uy / ud
local best_idx = nil
local best_cross = math.huge
for i, tgt in ipairs(targets) do
if not assignments[i] then
local tdx = tgt.x - cx
local tdy = tgt.y - cy
local td = math.sqrt(tdx * tdx + tdy * tdy)
if td >= 0.5 then
local cross = math.abs(unx * (tdy / td) - uny * (tdx / td))
if cross < best_cross then
best_cross = cross
best_idx = i
end
end
end
end
if best_idx then
assignments[best_idx] = { id = u.id, pos = u.pos }
log("[ammo/shell] assigned pellet %d to unit %d (angular_err=%.3f)", best_idx, u.id, best_cross)
end
end
end
for i, tgt in ipairs(targets) do
local ok, err = pcall(function()
local items = dfhack.items.createItem(
creator, df.item_type.AMMO, pellet_idx, pellet_mat_type, pellet_mat_idx)
local pellet = items and items[1]
if not pellet then return end
pellet:setStackSize(1)
dfhack.items.moveToGround(pellet, origin)
local proj = dfhack.items.makeProjectile(pellet)
if not proj then return end
proj.origin_pos.x = cx; proj.origin_pos.y = cy; proj.origin_pos.z = cz
proj.prev_pos.x = cx; proj.prev_pos.y = cy; proj.prev_pos.z = cz
proj.cur_pos.x = cx; proj.cur_pos.y = cy; proj.cur_pos.z = cz
proj.target_pos.x = tgt.x
proj.target_pos.y = tgt.y
proj.target_pos.z = tgt.z
-- Set directional speed components so DF moves this projectile
-- tile-by-tile. makeProjectile() leaves speed_x/y/z = 0; without
-- them the pellet never traverses tiles and hit detection never fires.
pcall(function()
local pdx = tgt.x - cx
local pdy = tgt.y - cy
local pdist = math.sqrt(pdx * pdx + pdy * pdy)
if pdist < 0.5 then pdist = 0.5 end
local spd = spawn.orig_speed
proj.speed_x = math.floor(pdx / pdist * spd + 0.5)
proj.speed_y = math.floor(pdy / pdist * spd + 0.5)
proj.speed_z = 0
end)
proj.velocity = spawn.velocity
proj.accel_x = spawn.accel_x
proj.accel_y = spawn.accel_y
proj.accel_z = spawn.accel_z
proj.hit_rating = spawn.hit_rating
proj.hit_chance_modifier = spawn.hit_chance_modifier
proj.min_hit_distance = spawn.min_hit_distance
proj.bow_id = spawn.bow_id
proj.distance_flown = 1
if spawn.fall_threshold then
pcall(function() proj.fall_threshold = spawn.fall_threshold end)
end
pcall(function()
local firer = df.unit.find(spawn.firer_unit_id)
if not firer then firer = dfhack.world.getAdventurer() end
proj.firer = firer
end)
-- Allow this pellet to hit neutral units (traders, wildlife, etc.)
-- not just declared enemies. Without this flag DF skips hit checks
-- for anyone who isn't an active combat target of the firer.
pcall(function() proj.flags.hits_friends = true end)
-- Use the pre-assigned unit for this pellet slot (if any).
-- Assigned pellets are redirected to the unit's actual tile so DF
-- performs a hit-roll; unassigned pellets fly free to their spread tile.
pcall(function()
local assign = assignments[i]
local effective = assign and { x = assign.pos.x, y = assign.pos.y, z = assign.pos.z } or tgt
proj.target_pos.x = effective.x
proj.target_pos.y = effective.y
proj.target_pos.z = effective.z
local pdx = effective.x - cx
local pdy = effective.y - cy
local pdist = math.sqrt(pdx * pdx + pdy * pdy)
if pdist >= 0.5 then
local spd = spawn.orig_speed
proj.speed_x = math.floor(pdx / pdist * spd + 0.5)
proj.speed_y = math.floor(pdy / pdist * spd + 0.5)
end
proj.spec_target_unit = assign and assign.id or -1
proj.target_bp = -1
end)
-- Mark in ammo.lua's intercepted set to prevent re-interception.
S.intercepted[proj.id] = true
launched = launched + 1
end)
if not ok then
log("[ammo/shell] launch_pellets: error — %s", tostring(err))
end
end
log("[ammo/shell] launch_pellets: launched %d/%d from (%d,%d,%d)",
launched, total, cx, cy, cz)
end
-- Consume `count` extra ammo from the firer's inventory (for extra barrels).
-- Reuses ammo.lua's existing inventory-search functions.
local function consume_extra_ammo(firer_id, sub_idx, mat_t, mat_i, exclude_id, count)
local firer = df.unit.find(firer_id)
if not firer or dfhack.units.isDead(firer) then
log("[ammo/shell] consume_extra_ammo: firer_id=%d not found or dead", firer_id)
return
end
local stack = nil
local quiver = get_quiver(firer)
if quiver then
stack = find_mag_stack_in_quiver(quiver, sub_idx, mat_t, mat_i)
if stack and stack.id == exclude_id then stack = nil end
end
if not stack then
stack = find_mag_stack_on_unit(firer, sub_idx, mat_t, mat_i, exclude_id)
end
if not stack then
log("[ammo/shell] consume_extra_ammo: no stack found sub=%d mat=%d/%d", sub_idx, mat_t, mat_i)
return
end
local sz = 0
pcall(function() sz = stack:getStackSize() end)
log("[ammo/shell] consume_extra_ammo: stack_id=%d size=%d consuming=%d", stack.id, sz, count)
if sz <= count then
pcall(dfhack.items.remove, stack)
else
pcall(function() stack:setStackSize(sz - count) end)
end
end
---------------------------------------------------------------------------
-- BULLET SUBSTITUTION
---------------------------------------------------------------------------
-- `spawn` is a plain data table captured on the magazine's first movement tick.
-- Bullet creation is deferred to a timeout callback so we never modify the
-- projectile list while DF is iterating it.
local function launch_bullet(spawn)
local bullet_id = spawn.bullet_id
if not bullet_id then
log("launch_bullet: no bullet_id mapping")
return
end
local bullet_idx = get_bullet_subtype(bullet_id)
log("launch_bullet: %s -> subtype_idx=%d", bullet_id, bullet_idx)
if bullet_idx < 0 then
log("launch_bullet: bullet type not found in raws, aborting")
return
end
-- createItem requires a real unit as creator; nil can cause errors.
local creator = df.unit.find(spawn.firer_unit_id)
if not creator then
-- Fallback: any active unit will do (just for item creation credit)
for _, u in ipairs(df.global.world.units.active) do
if dfhack.units.isActive(u) and not dfhack.units.isDead(u) then
creator = u
break
end
end
end
log("launch_bullet: creator unit=%s mat_type=%d mat_idx=%d",
creator and tostring(creator.id) or "NIL",
spawn.mat_type, spawn.mat_idx)
-- Step 1: create the item
local use_mat_type, use_mat_idx = spawn.mat_type, spawn.mat_idx
local mat_tok = BULLET_MATERIAL_OVERRIDE[bullet_id]
if mat_tok then
local ot, oi = resolve_mat_override(mat_tok)
if ot and oi then
use_mat_type, use_mat_idx = ot, oi
log("launch_bullet: mat overridden to %s (%d/%d)", mat_tok, ot, oi)
else
log("launch_bullet: mat override '%s' not found in raws, using container mat", mat_tok)
end
end
local bullet_item
local ok1, err1 = pcall(function()
local items = dfhack.items.createItem(
creator, df.item_type.AMMO, bullet_idx, use_mat_type, use_mat_idx)
bullet_item = items and items[1]
end)
if not ok1 then
log("launch_bullet: createItem ERROR - %s", tostring(err1))
dfhack.printerr("[ammo] createItem error: " .. tostring(err1))
return
end
if not bullet_item then
log("launch_bullet: createItem returned empty list")
return
end
log("launch_bullet: item created id=%d", bullet_item.id)
pcall(function() bullet_item:setStackSize(1) end)
-- Step 2: place on ground
local gpos = xyz2pos(spawn.cur_x, spawn.cur_y, spawn.cur_z)
local ok2, err2 = pcall(function()
dfhack.items.moveToGround(bullet_item, gpos)
end)
if not ok2 then
log("launch_bullet: moveToGround ERROR - %s", tostring(err2))
else
log("launch_bullet: moveToGround OK at (%d,%d,%d)", spawn.cur_x, spawn.cur_y, spawn.cur_z)
end
-- Step 3: make projectile
local bullet_proj
local ok3, err3 = pcall(function()
bullet_proj = dfhack.items.makeProjectile(bullet_item)
end)
if not ok3 then
log("launch_bullet: makeProjectile ERROR - %s", tostring(err3))
dfhack.printerr("[ammo] makeProjectile error: " .. tostring(err3))
return
end
if not bullet_proj then
log("launch_bullet: makeProjectile returned nil")
return
end
log("launch_bullet: projectile created proj_id=%d", bullet_proj.id)
-- Step 4: set trajectory and targeting
local ok4, err4 = pcall(function()
bullet_proj.origin_pos.x = spawn.cur_x
bullet_proj.origin_pos.y = spawn.cur_y
bullet_proj.origin_pos.z = spawn.cur_z
bullet_proj.prev_pos.x = spawn.cur_x
bullet_proj.prev_pos.y = spawn.cur_y
bullet_proj.prev_pos.z = spawn.cur_z
bullet_proj.cur_pos.x = spawn.cur_x
bullet_proj.cur_pos.y = spawn.cur_y
bullet_proj.cur_pos.z = spawn.cur_z
bullet_proj.target_pos.x = spawn.target_x
bullet_proj.target_pos.y = spawn.target_y
bullet_proj.target_pos.z = spawn.target_z
-- Set directional speed so DF can move this bullet/shell tile-by-tile.
-- makeProjectile() leaves speed_x/y/z = 0; without them the projectile
-- never traverses tiles (wall/terrain hits never resolve, gas never fires).
local pdx = spawn.target_x - spawn.cur_x
local pdy = spawn.target_y - spawn.cur_y
local pdist = math.sqrt(pdx * pdx + pdy * pdy)
if pdist >= 0.5 then
local spd = spawn.orig_speed
bullet_proj.speed_x = math.floor(pdx / pdist * spd + 0.5)
bullet_proj.speed_y = math.floor(pdy / pdist * spd + 0.5)
bullet_proj.speed_z = 0
end
bullet_proj.velocity = spawn.velocity
bullet_proj.accel_x = spawn.accel_x
bullet_proj.accel_y = spawn.accel_y
bullet_proj.accel_z = spawn.accel_z
-- Combat roll: must match the magazine's value or the bullet always misses
bullet_proj.hit_rating = spawn.hit_rating
bullet_proj.hit_chance_modifier = spawn.hit_chance_modifier
bullet_proj.min_hit_distance = spawn.min_hit_distance
bullet_proj.bow_id = spawn.bow_id
-- Prevents collision with shooter on the first movement check
bullet_proj.distance_flown = 1
if spawn.fall_threshold then
bullet_proj.fall_threshold = spawn.fall_threshold
end
end)
if not ok4 then
log("launch_bullet: trajectory ERROR - %s", tostring(err4))
else
log("launch_bullet: vel=%d target=(%d,%d,%d)",
spawn.velocity, spawn.target_x, spawn.target_y, spawn.target_z)
end
-- Step 5: link firer and targeting (each in its own pcall — field names vary by version)
pcall(function()
-- firer is a unit pointer, not an ID
local firer = df.unit.find(spawn.firer_unit_id)
if not firer then firer = dfhack.world.getAdventurer() end
bullet_proj.firer = firer
end)
pcall(function()
-- Aimed-shot targeting: which unit and body part we're aiming at
bullet_proj.spec_target_unit = spawn.spec_target_unit
bullet_proj.target_bp = spawn.target_bp
end)
-- Step 6: friendly fire — allow bullet to hit neutrals and allies in the
-- line of fire, unless the weapon is in NO_HITS_FRIENDS_WEAPONS.
local hf = bullet_hits_friends(spawn.bow_id)
if hf then
pcall(function() bullet_proj.flags.hits_friends = true end)
end
log("launch_bullet: firer=%d spec_target=%d bp=%d hits_friends=%s",
spawn.firer_unit_id, spawn.spec_target_unit, spawn.target_bp, tostring(hf))
end
---------------------------------------------------------------------------
-- RETURN ITEM TO SHOOTER
---------------------------------------------------------------------------
local function return_item(info, item)
local firer = info.firer_id >= 0 and df.unit.find(info.firer_id) or nil
if firer and dfhack.units.isActive(firer) and not dfhack.units.isDead(firer) then
local quiver = get_quiver(firer)
if quiver then
-- pcall returns (status, fn_result); we need BOTH to confirm success
local pcall_ok, moved = pcall(dfhack.items.moveToContainer, item, quiver)
log("return_item: moveToContainer(quiver) pcall_ok=%s moved=%s", tostring(pcall_ok), tostring(moved))
if pcall_ok and moved then return end
else
log("return_item: no quiver on firer %d", info.firer_id)
end
local pcall_ok, moved = pcall(dfhack.items.moveToInventory, item, firer, MODE_HAULED, -1)
log("return_item: moveToInventory(hauled) pcall_ok=%s moved=%s", tostring(pcall_ok), tostring(moved))
if pcall_ok and moved then return end
else
log("return_item: firer_id=%d not found or dead", info.firer_id)
end
log("return_item: ground fallback at (%d,%d,%d)",
info.origin_pos.x, info.origin_pos.y, info.origin_pos.z)
pcall(dfhack.items.moveToGround, item, info.origin_pos)
end
---------------------------------------------------------------------------
-- DEFERRED MAGAZINE RETURN
-- Scheduled directly from on_proj_move; no background polling loop needed.
---------------------------------------------------------------------------
-- Polls until the magazine item is no longer a projectile, then processes the return.
--
-- info fields:
-- item_id - the flying magazine item
-- firer_id - unit who fired
-- remaining - pre-computed rounds left (decremented at shot time in on_proj_move)
-- sub - ammo subtype index
-- mat_t - mat_type
-- mat_i - mat_index
-- origin_pos - fallback ground position
--
-- Strategy: increment the existing quiver stack by 1 and destroy the landed magazine
-- item, so no new stack appears. On the last shot (remaining==0) we skip the
-- increment — one physical magazine is consumed from the quiver stack naturally.
local function try_return(info, attempt)
if not S.enabled then return end
local item = df.item.find(info.item_id)
if not item then
log("try_return: item_id=%d destroyed after %d attempts", info.item_id, attempt)
return
end
local still_proj = false
pcall(function() still_proj = item_is_projectile(info.item_id) end)
if still_proj then
log("try_return: item_id=%d still projectile, attempt=%d", info.item_id, attempt)
if attempt <= 30 then
dfhack.timeout(1, "ticks", function() try_return(info, attempt + 1) end)
else
log("try_return: item_id=%d timed out", info.item_id)
end
return
end
log("try_return: item_id=%d landed after %d attempts, remaining=%d",
info.item_id, attempt, info.remaining)
if info.remaining > 0 then
-- Rounds left: find the quiver stack and add 1 back to it (reversing DF's
-- decrement when it fired), then destroy the magazine item so there is no
-- second stack in the quiver.
local firer = df.unit.find(info.firer_id)
local incremented = false
if firer and dfhack.units.isActive(firer) and not dfhack.units.isDead(firer) then
local quiver = get_quiver(firer)
if quiver then
local stack = find_mag_stack_in_quiver(quiver, info.sub, info.mat_t, info.mat_i)
if stack then
pcall(function()
stack:setStackSize(stack:getStackSize() + 1)
end)
incremented = true
log("try_return: quiver stack %d size +1 -> %d", stack.id, stack:getStackSize())
else
log("try_return: no matching quiver stack found")
end
else
log("try_return: firer has no quiver")
end
else
log("try_return: firer %d gone or dead", info.firer_id)
end
if not incremented then
-- Fallback: search the full inventory (direct items + containers).
-- Covers units with no quiver (arena fighters, adventurers, etc.)
local stack = find_mag_stack_on_unit(firer, info.sub, info.mat_t, info.mat_i, info.item_id)
if stack then
pcall(function() stack:setStackSize(stack:getStackSize() + 1) end)
incremented = true
log("try_return: inventory/container stack %d size +1 -> %d", stack.id, stack:getStackSize())
else
log("try_return: no matching stack found anywhere on unit")
end
end
if not incremented then
-- Last resort: physically return the item rather than losing it.
return_item(info, item)
return
end
else
log("try_return: magazine expended — quiver stack decrements naturally")
local firer = df.unit.find(info.firer_id)
local has_ammo = firer and dfhack.units.isActive(firer) and not dfhack.units.isDead(firer)
and find_mag_stack_on_unit(firer, info.sub, info.mat_t, info.mat_i, info.item_id) ~= nil
if has_ammo then
apply_reload_penalty(info.round_key, info.firer_id, info.sub_id, info.sub, info.mat_t, info.mat_i)
if info.firer_is_player
and not SUPPRESS_RELOAD_MESSAGE[info.sub_id] then
local penalty = get_reload_penalty(info.sub_id)
dfhack.gui.showAnnouncement("Reloading!", 4, true) -- COLOR_RED
dfhack.timeout(penalty, "ticks", function()
if S.enabled then
dfhack.gui.showAnnouncement("Reloaded!", 10, true) -- COLOR_LIGHTGREEN
end
end)
end
else
log("try_return: firer %d has no ammo remaining", info.firer_id)
if info.firer_is_player then
dfhack.gui.showAnnouncement("No Ammo!", 4, true) -- COLOR_RED
end
end
end
-- Destroy the landed magazine item (it has been merged into the quiver stack
-- or was the last round of this magazine — either way it must not litter).
local ok, err = pcall(dfhack.items.remove, item)
if not ok then
log("try_return: remove failed (%s), leaving on ground", tostring(err))
-- Leave it; it's cosmetic litter at worst
end
end
---------------------------------------------------------------------------
-- PROJECTILE TRACKING
---------------------------------------------------------------------------
local function on_proj_move(projectile)
local item = projectile.item
if not item then
log("on_proj_move: proj_id=%d no item", projectile.id)
return
end
-- Skip non-ammo projectiles silently (thrown rocks, enemy bolts, etc.)
if item:getType() ~= df.item_type.AMMO then return end
local ok, sub_id = pcall(function()
return dfhack.items.getSubtypeDef(df.item_type.AMMO, item:getSubtype()).id
end)
if not ok or not sub_id then
log("on_proj_move: proj_id=%d getSubtypeDef failed ok=%s sub=%s",
projectile.id, tostring(ok), tostring(sub_id))
return
end
local capacity = MAGAZINE_CAPACITY[sub_id]
if not capacity then
return -- regular bullet or other non-magazine ammo: pass through silently
end
local shell_cfg = SHELL_CONFIG[sub_id]