-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathadvhunger.lua
More file actions
508 lines (449 loc) · 20.5 KB
/
advhunger.lua
File metadata and controls
508 lines (449 loc) · 20.5 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
-- advhunger.lua
-- Tracks hunger, thirst, and sleep needs for adventure mode party companions.
-- Auto-consumes food/drink from party packs when needs become critical.
-- Forces collapse when companions are exhausted. Syncs rest with player sleep.
local repeatUtil = require("repeat-util")
local eventful = require("plugins.eventful")
local CALLBACK_ID = "advhunger"
---------------------------------------------------------------------------
-- PERSISTENT STATE (survives script reloads, following maneuver.lua pattern)
---------------------------------------------------------------------------
local S = _G.__advhunger or {}
_G.__advhunger = S
if S.enabled == nil then S.enabled = false end
if S.last_announced == nil then S.last_announced = {} end
if S.collapse_offset == nil then S.collapse_offset = {} end
if S.was_sleeping == nil then S.was_sleeping = false end
if S.sleep_start_tick == nil then S.sleep_start_tick = 0 end
if S.DEBUG == nil then S.DEBUG = false end
-- S.log_file intentionally not persisted (file handles can't survive re-init)
-- But if debug was on, re-open the log in append mode so logging continues.
S.log_file = nil
if S.DEBUG then
S.log_file = io.open("advhunger_debug.txt", "a")
end
-- Cancel any orphaned callbacks from a previous load
repeatUtil.cancel(CALLBACK_ID)
eventful.onJobCompleted[CALLBACK_ID] = nil
---------------------------------------------------------------------------
-- CONSTANTS
---------------------------------------------------------------------------
local CHECK_INTERVAL = 200 -- ticks between main polling checks
local HUNGER_WARN = 50000 -- announce "[Name] is getting hungry"
local HUNGER_EAT = 75000 -- auto-consume food from pack
local HUNGER_DEATH = 100000 -- kill if still no food (matches vanilla fat-depletion onset)
local THIRST_WARN = 25000 -- announce "[Name] is thirsty"
local THIRST_DRINK = 50000 -- auto-consume drink from pack
local THIRST_DEATH = 75000 -- kill if still no drink (matches vanilla death threshold)
local SLEEP_WARN = 100000 -- announce "[Name] is exhausted"
local SLEEP_COLLAPSE = 175000 -- force unconscious
local COLLAPSE_DURATION = 3000 -- ticks spent unconscious (~3 DF hours)
local COLLAPSE_VARIANCE = 25000 -- max random extra ticks before collapse (per unit)
local ANNOUNCE_COOLDOWN = 2000 -- min ticks between repeat announcements per unit
local FOOD_CATS = { "MEAT", "FISH", "FISH_RAW", "EGG", "FOOD", "PLANT", "PLANT_GROWTH", "CHEESE", "VERMIN" }
---------------------------------------------------------------------------
-- 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 tick = -1
pcall(function() tick = df.global.cur_year_tick end)
local line = ("[tick=%d] %s"):format(tick, msg)
print("[advhunger] " .. line)
if S.log_file then
S.log_file:write(line .. "\n")
S.log_file:flush()
end
end
local function open_log()
local path = "advhunger_debug.txt"
S.log_file = io.open(path, "w")
if S.log_file then
S.log_file:write("=== advhunger.lua debug log ===\n")
S.log_file:write("Columns per companion: hunger_timer | thirst_timer | sleepiness_timer\n")
S.log_file:write("Watch for jumps/resets after zone transitions to answer the map-load question.\n\n")
S.log_file:flush()
print("[advhunger] Debug log: " .. dfhack.getDFPath() .. "\\" .. path)
else
print("[advhunger] 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 cur_tick()
return df.global.cur_year_tick
end
local function unit_name(unit)
return dfhack.units.getReadableName(unit)
end
-- Returns true if enough ticks have elapsed since last announcement of this type.
local function can_announce(uid, need_type)
local tick = cur_tick()
if not S.last_announced[uid] then
S.last_announced[uid] = {}
end
local last = S.last_announced[uid][need_type] or 0
if (tick - last) > ANNOUNCE_COOLDOWN then
S.last_announced[uid][need_type] = tick
return true
end
return false
end
-- Returns companions (units to check needs for) and carrier_ids (set of unit IDs
-- that may carry supplies, including the adventurer).
local function get_party_units()
if not dfhack.world.isAdventureMode() then return {}, {} end
local adv = dfhack.world.getAdventurer()
if not adv then return {}, {} end
local interactions = df.global.adventure.interactions
local companions = {}
local carrier_ids = { [adv.id] = true } -- adventurer also carries supplies
local hf_lists = {
interactions.party_core_members,
interactions.party_extra_members,
interactions.party_pets,
}
for _, hf_list in ipairs(hf_lists) do
for _, hf_id in ipairs(hf_list) do
local hf = df.historical_figure.find(hf_id)
if hf then
local unit = df.unit.find(hf.unit_id)
if unit and dfhack.units.isActive(unit) and unit.id ~= adv.id then
table.insert(companions, unit)
carrier_ids[unit.id] = true
end
end
end
end
return companions, carrier_ids
end
-- Find a food item carried (directly or in a container) by any party member.
local function find_food_in_party(carrier_ids)
for _, cat in ipairs(FOOD_CATS) do
local vec = df.global.world.items.other[cat]
if vec then
for _, item in ipairs(vec) do
if not item.flags.garbage_collect and not item.flags.in_job then
local ref = dfhack.items.getOuterContainerRef(item)
if ref and ref.type == df.specific_ref_type.UNIT then
if carrier_ids[ref.object.id] then
return item
end
end
end
end
end
end
end
-- Find a drink item carried (directly or in a container) by any party member.
local function find_drink_in_party(carrier_ids)
for _, drink in ipairs(df.global.world.items.other.DRINK) do
if not drink.flags.garbage_collect and not drink.flags.in_job then
local ref = dfhack.items.getOuterContainerRef(drink)
if ref and ref.type == df.specific_ref_type.UNIT then
if carrier_ids[ref.object.id] then
return drink
end
end
end
end
end
-- Reduce companion sleepiness timers proportional to how long the player slept.
local function sync_companion_sleep(companions, sleep_ticks)
if sleep_ticks <= 0 or #companions == 0 then return end
local reduction = math.floor(sleep_ticks * 0.8)
log("sleep sync: slept=%d ticks, reduction=%d", sleep_ticks, reduction)
for _, unit in ipairs(companions) do
local before = unit.counters2.sleepiness_timer
unit.counters2.sleepiness_timer = math.max(0, before - reduction)
log(" %s sleepiness %d -> %d", unit_name(unit), before, unit.counters2.sleepiness_timer)
end
print(("[advhunger] Party rested — reduced sleepiness by %d ticks"):format(reduction))
end
-- Kill a companion from starvation or dehydration, leaving a corpse.
-- Uses blood drain (triggers engine death + corpse/history processing).
-- Falls back to flags2.killed for bloodless units.
local function kill_from_need(unit, cause)
local name = unit_name(unit)
local msg = cause == "hunger"
and ("%s has starved to death!"):format(name)
or ("%s has died of thirst!"):format(name)
log("DEATH %s: %s (hunger=%d thirst=%d)", cause, name,
unit.counters2.hunger_timer, unit.counters2.thirst_timer)
if unit.body.blood_max > 0 then
unit.body.blood_count = 0
else
unit.flags2.killed = true
end
dfhack.gui.showAnnouncement(msg, COLOR_RED, true)
end
---------------------------------------------------------------------------
-- EVENTFUL: instant sleep-wake sync
-- Fires the moment the player's Sleep job completes, rather than waiting
-- up to CHECK_INTERVAL ticks for the polling loop.
---------------------------------------------------------------------------
local function on_job_completed(job)
if job.job_type ~= df.job_type.Sleep then return end
if not dfhack.world.isAdventureMode() then return end
local adv = dfhack.world.getAdventurer()
if not adv then return end
-- Verify this is the adventurer's job via position match
if job.pos.x ~= adv.pos.x or job.pos.y ~= adv.pos.y then return end
-- Compute how long the player slept (sleep_start_tick set by polling loop)
local sleep_ticks = 0
if S.sleep_start_tick > 0 then
sleep_ticks = cur_tick() - S.sleep_start_tick
if sleep_ticks < 0 then sleep_ticks = 0 end -- guard: year boundary rollover
end
log("eventful: Sleep job completed, sleep_ticks=%d", sleep_ticks)
local companions, _ = get_party_units()
sync_companion_sleep(companions, sleep_ticks)
-- Reset sleep tracking so polling loop fallback doesn't double-sync
S.sleep_start_tick = 0
S.was_sleeping = false
end
---------------------------------------------------------------------------
-- MAIN POLLING TICK (every CHECK_INTERVAL ticks)
---------------------------------------------------------------------------
local function tick_fn()
if not dfhack.world.isAdventureMode() then return end
local adv = dfhack.world.getAdventurer()
if not adv then return end
local companions, carrier_ids = get_party_units()
-- Track sleep start time for eventful sync calculation
local adv_job = adv.job.current_job
local is_sleeping = adv_job ~= nil and adv_job.job_type == df.job_type.Sleep
if is_sleeping and not S.was_sleeping then
-- Player just went to sleep
S.sleep_start_tick = cur_tick()
log("player sleep start at tick=%d", S.sleep_start_tick)
elseif not is_sleeping and S.was_sleeping and S.sleep_start_tick > 0 then
-- Fallback: eventful missed the wake (race condition or disabled event)
local sleep_ticks = cur_tick() - S.sleep_start_tick
if sleep_ticks < 0 then sleep_ticks = 0 end
log("fallback sleep sync triggered (eventful may have missed wake)")
sync_companion_sleep(companions, sleep_ticks)
S.sleep_start_tick = 0
end
S.was_sleeping = is_sleeping
if #companions == 0 then
log("tick: no companions in party")
return
end
-- Per-tick timer dump (the key data for the map-transition question)
if S.DEBUG then
log("tick: %d companion(s)", #companions)
for _, unit in ipairs(companions) do
log(" %-30s hunger=%-6d thirst=%-6d sleep=%-6d unconscious=%d",
unit_name(unit),
unit.counters2.hunger_timer,
unit.counters2.thirst_timer,
unit.counters2.sleepiness_timer,
unit.counters.unconscious)
end
end
-- Process each companion's needs
for _, unit in ipairs(companions) do
local uid = unit.id
local name = unit_name(unit)
local caste_raw = dfhack.units.getCasteRaw(unit)
local caste_flags = caste_raw and caste_raw.flags or {}
-- TICK TIMERS (game does not process these for NPC companions) ------
-- Hunger/thirst accumulate even while unconscious (sleep doesn't cure hunger).
-- Sleepiness only accumulates while awake.
if not caste_flags.NO_EAT then
unit.counters2.hunger_timer = unit.counters2.hunger_timer + CHECK_INTERVAL
end
if not caste_flags.NO_DRINK then
unit.counters2.thirst_timer = unit.counters2.thirst_timer + CHECK_INTERVAL
end
if unit.counters.unconscious == 0 then
unit.counters2.sleepiness_timer = unit.counters2.sleepiness_timer + CHECK_INTERVAL
end
-- HUNGER -----------------------------------------------------------
if not caste_flags.NO_EAT then
local hunger = unit.counters2.hunger_timer
if hunger >= HUNGER_EAT then
local food = find_food_in_party(carrier_ids)
if food then
local desc = dfhack.items.getDescription(food, 0)
log("ACTION eat: %s consumes '%s' (hunger was %d)", name, desc, hunger)
dfhack.items.remove(food)
unit.counters2.hunger_timer = 0
dfhack.gui.showAnnouncement(
("%s eats %s."):format(name, desc), COLOR_YELLOW, false)
elseif hunger >= HUNGER_DEATH then
kill_from_need(unit, "hunger")
goto next_companion
else
log("WARN starving: %s hunger=%d, no food found", name, hunger)
if can_announce(uid, "starving") then
dfhack.gui.showAnnouncement(
("%s is starving and there is no food!"):format(name),
COLOR_RED, true)
end
end
elseif hunger >= HUNGER_WARN then
if can_announce(uid, "hungry") then
dfhack.gui.showAnnouncement(
("%s is getting hungry."):format(name), COLOR_YELLOW, false)
end
end
end
-- THIRST -----------------------------------------------------------
if not caste_flags.NO_DRINK then
local thirst = unit.counters2.thirst_timer
if thirst >= THIRST_DRINK then
local drink = find_drink_in_party(carrier_ids)
if drink then
local desc = dfhack.items.getDescription(drink, 0)
log("ACTION drink: %s consumes '%s' (thirst was %d)", name, desc, thirst)
dfhack.items.remove(drink)
unit.counters2.thirst_timer = 0
dfhack.gui.showAnnouncement(
("%s drinks %s."):format(name, desc), COLOR_CYAN, false)
elseif thirst >= THIRST_DEATH then
kill_from_need(unit, "thirst")
goto next_companion
else
log("WARN dehydrated: %s thirst=%d, no drink found", name, thirst)
if can_announce(uid, "dehydrated") then
dfhack.gui.showAnnouncement(
("%s is dehydrated and there is nothing to drink!"):format(name),
COLOR_RED, true)
end
end
elseif thirst >= THIRST_WARN then
if can_announce(uid, "thirsty") then
dfhack.gui.showAnnouncement(
("%s is thirsty."):format(name), COLOR_YELLOW, false)
end
end
end
-- EXHAUSTION / SLEEP -----------------------------------------------
local sleepiness = unit.counters2.sleepiness_timer
-- Assign a per-unit random collapse offset when first crossing warn threshold.
-- This staggers collapses after fast travel so everyone doesn't drop at once.
if sleepiness >= SLEEP_WARN and S.collapse_offset[uid] == nil then
S.collapse_offset[uid] = math.random(0, COLLAPSE_VARIANCE)
log("assigned collapse offset %d to %s", S.collapse_offset[uid], name)
end
local collapse_thresh = SLEEP_COLLAPSE + (S.collapse_offset[uid] or 0)
if sleepiness >= collapse_thresh and unit.counters.unconscious == 0 then
log("ACTION collapse: %s sleep=%d >= thresh=%d", name, sleepiness, collapse_thresh)
unit.counters.unconscious = COLLAPSE_DURATION
unit.counters2.sleepiness_timer = math.max(0, sleepiness - 50000)
S.collapse_offset[uid] = nil -- reset for next time
dfhack.gui.showAnnouncement(
("%s collapses from exhaustion!"):format(name), COLOR_RED, true)
elseif sleepiness >= SLEEP_WARN then
if can_announce(uid, "sleepy") then
dfhack.gui.showAnnouncement(
("%s is exhausted and needs rest."):format(name), COLOR_YELLOW, false)
end
else
-- Sleepiness recovered — reset collapse offset for next cycle
S.collapse_offset[uid] = nil
end
::next_companion::
end
end
---------------------------------------------------------------------------
-- ENABLE / DISABLE / STATUS
---------------------------------------------------------------------------
local function do_enable()
if S.enabled then
print("[advhunger] Already enabled")
return
end
repeatUtil.scheduleEvery(CALLBACK_ID, CHECK_INTERVAL, "ticks", tick_fn)
eventful.enableEvent(eventful.eventType.JOB_COMPLETED, 5)
eventful.onJobCompleted[CALLBACK_ID] = on_job_completed
S.enabled = true
print("[advhunger] Enabled — tracking party hunger, thirst, and sleep")
end
local function do_disable()
if not S.enabled then
print("[advhunger] Already disabled")
return
end
repeatUtil.cancel(CALLBACK_ID)
eventful.onJobCompleted[CALLBACK_ID] = nil
S.enabled = false
print("[advhunger] Disabled")
end
local function do_status()
print(("[advhunger] Status: %s"):format(S.enabled and "ENABLED" or "DISABLED"))
print(("[advhunger] Debug: %s"):format(S.DEBUG and "ON" or "OFF"))
print(("[advhunger] CHECK_INTERVAL = %d ticks"):format(CHECK_INTERVAL))
print(("[advhunger] Hunger: warn=%d, eat=%d"):format(HUNGER_WARN, HUNGER_EAT))
print(("[advhunger] Thirst: warn=%d, drink=%d"):format(THIRST_WARN, THIRST_DRINK))
print(("[advhunger] Sleep: warn=%d, collapse=%d (+0..%d random offset)"):format(
SLEEP_WARN, SLEEP_COLLAPSE, COLLAPSE_VARIANCE))
print(("[advhunger] Collapse duration = %d ticks"):format(COLLAPSE_DURATION))
if dfhack.world.isAdventureMode() then
local companions, _ = get_party_units()
print(("[advhunger] Party companions tracked: %d"):format(#companions))
if S.DEBUG and #companions > 0 then
print("[advhunger] Current timers:")
for _, unit in ipairs(companions) do
print(("[advhunger] %-30s hunger=%-6d thirst=%-6d sleep=%-6d"):format(
unit_name(unit),
unit.counters2.hunger_timer,
unit.counters2.thirst_timer,
unit.counters2.sleepiness_timer))
end
end
end
end
local function toggle_debug()
if S.DEBUG then
S.DEBUG = false
close_log()
print("[advhunger] Debug logging OFF")
else
S.DEBUG = true
open_log()
print(("[advhunger] Debug ON — enabled=%s, eventful hook=%s"):format(
tostring(S.enabled),
tostring(eventful.onJobCompleted[CALLBACK_ID] ~= nil)))
print("[advhunger] Every poll tick will log all companion timers.")
print("[advhunger] Travel to another site and back; watch for timer jumps/resets.")
end
end
---------------------------------------------------------------------------
-- RE-REGISTER CALLBACKS ON RELOAD
-- The cancel at the top clears any orphaned callback, but if the script is
-- re-invoked by a non-enable command (e.g. "advhunger debug" while running),
-- we need to put the callbacks back immediately.
---------------------------------------------------------------------------
if S.enabled then
repeatUtil.scheduleEvery(CALLBACK_ID, CHECK_INTERVAL, "ticks", tick_fn)
eventful.enableEvent(eventful.eventType.JOB_COMPLETED, 5)
eventful.onJobCompleted[CALLBACK_ID] = on_job_completed
end
---------------------------------------------------------------------------
-- ARGUMENT PARSING
---------------------------------------------------------------------------
local args = { ... }
local command = args[1] or "enable"
if command == "enable" then
do_enable()
elseif command == "disable" then
do_disable()
elseif command == "status" then
do_status()
elseif command == "debug" then
toggle_debug()
else
print("[advhunger] Usage: advhunger [enable|disable|status|debug]")
end