z_mirror/EvenDistributionLite

git clone https://git.raiguard.me/z_mirror/EvenDistributionLite

Caleb HeuerFix freeze when trying to insert items with conflicting health valuesf71c37b

trunk
20.6 KiB672 linesraw
1--- @class DragState
2--- @field balance boolean
3--- @field entities LuaEntity[]
4--- @field item {name: string, quality: string}
5--- @field labels table<uint, LuaRenderObject>
6--- @field last_tick uint
7--- @field player LuaPlayer
8
9--- @class LastSelectedState
10--- @field cursor_count uint
11--- @field entity LuaEntity
12--- @field hand_location ItemStackLocation
13--- @field item ItemStackDefinition
14--- @field tick uint
15
16--- @type table<string, Color>
17local colors = {
18  red = { r = 1 },
19  white = { r = 1, g = 1, b = 1 },
20  yellow = { r = 1, g = 1 },
21}
22
23--- @type table<string, defines.inventory[]>
24local entity_transfer_inventories = {
25  ["agricultural-tower"] = {
26    defines.inventory.crafter_input,
27    defines.inventory.crafter_modules,
28    defines.inventory.fuel,
29  },
30  ["ammo-turret"] = { defines.inventory.turret_ammo },
31  ["artillery-turret"] = { defines.inventory.artillery_turret_ammo },
32  ["artillery-wagon"] = { defines.inventory.artillery_wagon_ammo },
33  ["assembling-machine"] = {
34    defines.inventory.crafter_input,
35    defines.inventory.crafter_modules,
36    defines.inventory.fuel,
37  },
38  ["asteroid-collector"] = { defines.inventory.asteroid_collector_output },
39  ["beacon"] = { defines.inventory.beacon_modules, defines.inventory.fuel },
40  ["boiler"] = { defines.inventory.fuel },
41  ["burner-generator"] = { defines.inventory.fuel },
42  ["car"] = { defines.inventory.car_ammo, defines.inventory.car_trunk, defines.inventory.fuel },
43  ["cargo-landing-pad"] = { defines.inventory.cargo_landing_pad_main },
44  ["cargo-wagon"] = { defines.inventory.cargo_wagon },
45  ["character"] = {
46    defines.inventory.character_ammo,
47    defines.inventory.character_armor,
48    defines.inventory.character_guns,
49    defines.inventory.character_main,
50    defines.inventory.character_vehicle,
51  },
52  ["container"] = { defines.inventory.chest },
53  ["furnace"] = { defines.inventory.crafter_input, defines.inventory.crafter_modules, defines.inventory.fuel },
54  ["fusion-reactor"] = { defines.inventory.fuel },
55  ["inserter"] = { defines.inventory.fuel },
56  ["lab"] = { defines.inventory.lab_input, defines.inventory.lab_modules, defines.inventory.fuel },
57  ["locomotive"] = { defines.inventory.fuel },
58  ["logistic-container"] = { defines.inventory.chest },
59  ["mining-drill"] = { defines.inventory.mining_drill_modules, defines.inventory.fuel },
60  ["roboport"] = { defines.inventory.roboport_material, defines.inventory.roboport_robot, defines.inventory.fuel },
61  ["rocket-silo"] = {
62    defines.inventory.crafter_input,
63    defines.inventory.crafter_modules,
64    defines.inventory.fuel,
65    defines.inventory.rocket_silo_rocket,
66  },
67  ["space-platform-hub"] = { defines.inventory.hub_main },
68  ["spidertron"] = { defines.inventory.spider_ammo, defines.inventory.spider_trunk, defines.inventory.fuel },
69}
70
71--- @type table<defines.controllers, defines.inventory[]>
72local player_transfer_inventories = {
73  [defines.controllers.character] = {
74    -- defines.inventory.character_ammo,
75    defines.inventory.character_armor,
76    defines.inventory.character_guns,
77    defines.inventory.character_main,
78    defines.inventory.character_vehicle,
79  },
80  [defines.controllers.cutscene] = {},
81  [defines.controllers.editor] = {
82    -- defines.inventory.editor_ammo,
83    defines.inventory.editor_armor,
84    defines.inventory.editor_guns,
85    defines.inventory.editor_main,
86  },
87  [defines.controllers.ghost] = {},
88  [defines.controllers.god] = { defines.inventory.god_main },
89  [defines.controllers.remote] = {},
90  [defines.controllers.spectator] = {},
91}
92
93local complex_items = {
94  ["item-with-entity-data"] = true,
95  ["armor"] = true,
96  ["spidertron-remote"] = true,
97  ["blueprint"] = true,
98  ["blueprint-book"] = true,
99  ["upgrade-planner"] = true,
100  ["deconstruction-planner"] = true,
101}
102
103--- @param target LuaEntity|LuaPlayer
104--- @param inventory_defines defines.inventory[]?
105--- @return LuaInventory[]
106local function build_inventories(target, inventory_defines)
107  if not inventory_defines then
108    return {}
109  end
110
111  local out = {}
112  for _, inventory_type in pairs(inventory_defines) do
113    -- Use table.insert here just in case get_inventory() returns nil.
114    table.insert(out, target.get_inventory(inventory_type))
115  end
116  return out
117end
118
119--- @alias TransferTarget LuaPlayer|LuaEntity|LuaInventory
120
121--- @param target TransferTarget
122--- @return LuaInventory[]
123local function get_transfer_inventories(target)
124  if target.object_name == "LuaEntity" then
125    return build_inventories(target, entity_transfer_inventories[target.type])
126  elseif target.object_name == "LuaInventory" then
127    return { target }
128  elseif target.object_name == "LuaPlayer" then
129    return build_inventories(target, player_transfer_inventories[target.controller_type])
130  else
131    error("Invalid transfer target type " .. target.object_name) --- @diagnostic disable-line: undefined-field
132  end
133end
134
135--- @param target TransferTarget
136--- @return fun(): LuaInventory?
137local function inventory_iterator(target)
138  local inventories = get_transfer_inventories(target)
139  local i = 0
140  return function()
141    i = i + 1
142    return inventories[i]
143  end
144end
145
146--- @param from TransferTarget
147--- @param to TransferTarget
148--- @param spec ItemStackDefinition
149--- @return uint transferred
150local function transfer(from, to, spec)
151  if spec.count < 0 then
152    spec.count = math.abs(spec.count)
153    local temp = from
154    from = to
155    to = temp
156  end
157
158  local from_inventories = inventory_iterator(from)
159  local to_inventories = inventory_iterator(to)
160
161  local from_cursor_stack, to_cursor_stack
162  local from_cursor_stack_exhausted, to_cursor_stack_exhausted
163  if from.object_name == "LuaPlayer" then
164    from_cursor_stack = from.cursor_stack
165  end
166  if to.object_name == "LuaPlayer" then
167    to_cursor_stack = to.cursor_stack
168  end
169
170  local transferred = 0
171  local id = { name = spec.name, quality = spec.quality }
172
173  local from_inventory = from_inventories()
174  local to_inventory = to_inventories()
175  while from_inventory and to_inventory and transferred < spec.count do
176    local source_stack
177    if from_cursor_stack and not from_cursor_stack_exhausted then
178      source_stack = from_cursor_stack
179    else
180      source_stack = from_inventory.find_item_stack(id)
181    end
182
183    if
184      source_stack
185      and not from_cursor_stack_exhausted
186      and (
187        not source_stack.valid_for_read or (source_stack.name ~= spec.name or source_stack.quality.name ~= spec.quality)
188      )
189    then
190      from_cursor_stack_exhausted = true
191      goto continue
192    end
193
194    if not source_stack then
195      from_inventory = from_inventories()
196      goto continue
197    end
198
199    local to_transfer = math.min(source_stack.count, spec.count - transferred)
200
201    if to_cursor_stack and not to_cursor_stack_exhausted then
202      if to_cursor_stack.valid_for_read then
203        if
204          to_cursor_stack.type == source_stack.type
205          and to_cursor_stack.name == source_stack.name
206          and to_cursor_stack.quality == source_stack.quality
207          and to_cursor_stack.count < to_cursor_stack.prototype.stack_size
208        then
209          local count_before = to_cursor_stack.count
210          to_cursor_stack.transfer_stack(source_stack, to_transfer)
211          transferred = transferred + to_cursor_stack.count - count_before
212          if not source_stack.valid_for_read then
213            goto continue
214          end
215        end
216      elseif to_cursor_stack.transfer_stack(source_stack, to_transfer) then
217        transferred = transferred + to_cursor_stack.count
218        to_cursor_stack_exhausted = true
219        if source_stack == from_cursor_stack and not source_stack.valid_for_read then
220          from_cursor_stack_exhausted = true
221        end
222        goto continue
223      end
224    end
225
226    if not to_inventory.can_insert(id) then
227      to_inventory = to_inventories()
228      goto continue
229    end
230
231    if complex_items[source_stack.type] then
232      local empty_slot = to_inventory.find_empty_stack(id)
233      if not empty_slot then
234        to_inventory = to_inventories()
235        goto continue
236      end
237      assert(empty_slot.transfer_stack(source_stack), "Transfer of full stack failed")
238      transferred = transferred + empty_slot.count
239    else
240      --- @type ItemStackDefinition
241      local this_spec = {
242        name = source_stack.name,
243        quality = source_stack.quality.name,
244        count = to_transfer,
245        health = source_stack.health,
246        durability = source_stack.type == "tool" and source_stack.durability or nil,
247        ammo = source_stack.type == "ammo" and source_stack.ammo or nil,
248        tags = source_stack.type == "item-with-tags" and source_stack.tags or nil,
249        custom_description = source_stack.type == "item-with-tags" and source_stack.custom_description or nil,
250        spoil_percent = source_stack.spoil_percent,
251      }
252      if this_spec.ammo and this_spec.ammo < 1 then
253        -- Apparently the game will sometimes give us a LuaItemStack with an ammo of zero, despite the game attempting
254        -- to enforce that this can never happen. Since I can't reproduce the issue, let's just set the ammo to 1 and
255        -- call it a day.
256        this_spec.ammo = 1
257      end
258      local this_transferred = to_inventory.insert(this_spec)
259      if this_transferred > 0 then
260        source_stack.count = source_stack.count - this_transferred
261        transferred = transferred + this_transferred
262      else
263        -- We weren't actually able to insert anything into this inventory, so move to the next one
264        to_inventory = to_inventories()
265        goto continue
266      end
267    end
268
269    if source_stack == from_cursor_stack and not source_stack.valid_for_read then
270      from_cursor_stack_exhausted = true
271    end
272
273    ::continue::
274  end
275
276  return transferred
277end
278
279--- @param entity LuaEntity
280--- @param item ItemIDAndQualityIDPair
281--- @return uint
282local function get_entity_item_count(entity, item)
283  local total = 0
284  local inventories = entity_transfer_inventories[entity.type]
285  if not inventories then
286    return 0
287  end
288  for _, inventory_type in pairs(inventories) do
289    local inventory = entity.get_inventory(inventory_type)
290    if inventory then
291      total = total + inventory.get_item_count(item)
292    end
293  end
294  return total
295end
296
297--- @class EntityAndCount
298--- @field entity LuaEntity
299--- @field count integer
300
301--- @param a EntityAndCount
302--- @param b EntityAndCount
303local function sort_by_count(a, b)
304  return a.count < b.count
305end
306
307--- @param entities LuaEntity[]
308--- @param item ItemIDAndQualityIDPair
309--- @param player_total uint
310--- @return EntityAndCount[]
311local function get_balanced_distribution(entities, item, player_total)
312  local num_entities = #entities
313
314  -- Determine total and individual entity contents
315  --- @type uint[]
316  local entity_counts = {}
317  local total = player_total
318  for i = 1, num_entities do
319    local count = get_entity_item_count(entities[i], item)
320    entity_counts[i] = count
321    total = total + count
322  end
323
324  -- Get balanced distribution and calculate deltas for each entity
325  local balanced = math.floor(total / num_entities)
326  local remainder = total % num_entities
327  --- @type EntityAndCount[]
328  local out = {}
329  for i = 1, num_entities do
330    local entity_count = entity_counts[i]
331    local target_count = balanced
332    if remainder > 0 then
333      remainder = remainder - 1
334      target_count = target_count + 1
335    end
336    out[i] = { entity = entities[i], count = target_count - entity_count }
337  end
338  table.sort(out, sort_by_count)
339  return out
340end
341
342--- @param total uint
343--- @param entities LuaEntity[]
344--- @return EntityAndCount[]
345local function get_even_distribution(total, entities)
346  local num_entities = #entities
347  local base = math.floor(total / num_entities)
348  local remainder = total % num_entities
349  --- @type EntityAndCount[]
350  local out = {}
351  for i = 1, num_entities do
352    local count = base
353    if remainder > 0 then
354      remainder = remainder - 1
355      count = count + 1
356    end
357    out[i] = { entity = entities[i], count = count }
358  end
359  table.sort(out, sort_by_count)
360  return out
361end
362
363--- @param inventory LuaInventory
364--- @param cursor_stack LuaItemStack
365--- @param item ItemIDAndQualityIDPair
366--- @return uint
367local function get_item_count(inventory, cursor_stack, item)
368  local count = inventory.get_item_count(item)
369  if cursor_stack.valid_for_read and cursor_stack.name == item.name and cursor_stack.quality.name == item.quality then
370    count = count + cursor_stack.count
371  end
372  return count
373end
374
375--- @param drag_state DragState
376local function validate_entities(drag_state)
377  local entities = {}
378  local i = 0
379  for _, entity in pairs(drag_state.entities) do
380    if entity.valid then
381      i = i + 1
382      entities[i] = entity
383    end
384  end
385  drag_state.entities = entities
386end
387
388script.on_init(function()
389  --- @type table<uint, DragState>
390  storage.drag = {}
391  --- @type table<uint, LastSelectedState>
392  storage.last_selected = {}
393end)
394
395script.on_event(defines.events.on_selected_entity_changed, function(e)
396  local player = game.get_player(e.player_index)
397  if not player or not player.valid then
398    return
399  end
400
401  local selected = player.selected
402  local cursor_stack = player.cursor_stack
403  if
404    not selected
405    or selected.type == "loader"
406    or selected.type == "loader-1x1"
407    or not cursor_stack
408    or not cursor_stack.valid_for_read
409  then
410    storage.last_selected[e.player_index] = nil
411    return
412  end
413  local main_inventory = player.get_main_inventory()
414  if not main_inventory then
415    return
416  end
417
418  --- @type LastSelectedState
419  storage.last_selected[e.player_index] = {
420    cursor_count = cursor_stack.count,
421    entity = selected,
422    hand_location = player.hand_location,
423    item = {
424      name = cursor_stack.name,
425      quality = cursor_stack.quality.name,
426      count = get_item_count(
427        main_inventory,
428        cursor_stack,
429        { name = cursor_stack.name, quality = cursor_stack.quality.name }
430      ),
431    },
432    tick = game.tick,
433  }
434end)
435
436script.on_event(defines.events.on_player_fast_transferred, function(e)
437  if not e.from_player then
438    return
439  end
440
441  local entity = e.entity
442  if not entity.valid then
443    return
444  end
445
446  local selected_state = storage.last_selected[e.player_index]
447  if
448    not selected_state
449    or selected_state.tick ~= game.tick
450    or not selected_state.entity.valid
451    or selected_state.entity ~= entity
452  then
453    return
454  end
455
456  local player = game.get_player(e.player_index)
457  if not player or not player.valid then
458    return
459  end
460
461  local cursor_stack = player.cursor_stack
462  if not cursor_stack then
463    return
464  end
465
466  local main_inventory = player.get_main_inventory()
467  if not main_inventory then
468    return
469  end
470
471  -- Get the number of items that were inserted by the fast transfer
472  local new_count = get_item_count(main_inventory, cursor_stack, selected_state.item --[[@as ItemIDAndQualityIDPair]])
473  local inserted = selected_state.item.count - new_count
474  if inserted > 0 then
475    -- Remove items from the destination and restore the player's inventory state
476    local item = { name = selected_state.item.name, quality = selected_state.item.quality, count = inserted }
477    transfer(entity, player, item)
478  elseif
479    get_entity_item_count(entity, { name = selected_state.item.name, quality = selected_state.item.quality }) == 0
480  then
481    -- This item can't be inserted at all
482    return
483  end
484
485  -- Create or retrieve drag state
486  local drag_state = storage.drag[e.player_index]
487  if not drag_state then
488    --- @type DragState
489    drag_state = {
490      balance = e.is_split ~= settings.get_player_settings(player)["edl-swap-balance"].value,
491      entities = {},
492      item = { name = selected_state.item.name, quality = selected_state.item.quality },
493      last_tick = game.tick,
494      labels = {},
495      player = player,
496    }
497    storage.drag[e.player_index] = drag_state
498  end
499
500  drag_state.last_tick = game.tick
501
502  -- Remove game-generated flying text.
503  player.clear_local_flying_texts()
504
505  validate_entities(drag_state)
506
507  local entities = drag_state.entities
508  local labels = drag_state.labels
509  local unit_number = entity.unit_number
510  --- @cast unit_number -?
511
512  if not labels[unit_number] then
513    table.insert(entities, entity)
514  end
515
516  -- Update item counts
517  local total = selected_state.item.count
518  --- @cast total -?
519  if drag_state.balance then
520    for i = 1, #entities do
521      total = total + get_entity_item_count(entities[i], drag_state.item)
522    end
523  end
524  local distribution = get_even_distribution(total, entities)
525  for _, data in pairs(distribution) do
526    local this_entity = data.entity
527    local this_unit_number = this_entity.unit_number
528    --- @cast this_unit_number -?
529    local label = labels[this_unit_number]
530    if not label or not label.valid then
531      local color = colors.white
532      if drag_state.balance then
533        color = colors.yellow
534      end
535      label = rendering.draw_text({
536        color = color,
537        players = { e.player_index },
538        surface = this_entity.surface,
539        target = this_entity,
540        text = "",
541      })
542      labels[this_unit_number] = label
543    end
544    label.text = data.count
545  end
546end)
547
548--- @param drag_state DragState
549local function finish_drag(drag_state)
550  if not drag_state.player.valid then
551    return
552  end
553
554  -- Destroy labels
555  for _, label in pairs(drag_state.labels) do
556    if label.valid then
557      label.destroy()
558    end
559  end
560
561  validate_entities(drag_state)
562
563  local entities = drag_state.entities
564  local item = drag_state.item
565  local item_localised_name = prototypes.item[item.name].localised_name
566  if item.quality ~= "normal" then
567    item_localised_name = { "", item_localised_name, " (", prototypes.quality[item.quality].localised_name, ")" }
568  end
569
570  local player = drag_state.player
571  local cursor_stack = player.cursor_stack
572  if not cursor_stack then
573    return
574  end
575  local main_inventory = player.get_main_inventory()
576  if not main_inventory then
577    return
578  end
579
580  -- Calculate entity deltas
581  local distribution
582  local player_total = get_item_count(main_inventory, cursor_stack, item)
583  if drag_state.balance then
584    distribution = get_balanced_distribution(entities, item, player_total)
585  else
586    distribution = get_even_distribution(player_total, entities)
587  end
588
589  local max_total = player_total
590  for _, data in pairs(distribution) do
591    if data.count < 0 then
592      max_total = max_total + -data.count
593    end
594  end
595
596  local required_stacks = math.ceil(max_total / prototypes.item[item.name].stack_size) * 2 -- Just in case we have a bunch of non-joinable item stacks.
597  local work_inventory = game.create_inventory(required_stacks)
598
599  --- First, transfer everything from the player to the work inventory.
600  assert(
601    transfer(player, work_inventory, { name = item.name, quality = item.quality, count = player_total }) == player_total,
602    "Failed to transfer all items to work inventory."
603  )
604
605  for _, data in pairs(distribution) do
606    local entity = data.entity
607    local to_insert = data.count
608
609    local item_spec = { name = item.name, count = to_insert, quality = item.quality }
610    local transferred = transfer(work_inventory, entity, item_spec)
611
612    -- Show flying text
613    local color = colors.white
614    if transferred == 0 then
615      color = colors.red
616    elseif transferred ~= math.abs(to_insert) then
617      color = colors.yellow
618    end
619    --- @diagnostic disable-next-line missing-field
620    player.create_local_flying_text({
621      text = { "", to_insert > 0 and "-" or "+", transferred, " [item=", item_spec.name, "] ", item_localised_name },
622      position = entity.position,
623      color = color,
624    })
625  end
626
627  local remainder = work_inventory.get_item_count(item)
628  if remainder > 0 then
629    local transferred =
630      transfer(work_inventory, player, { name = item.name, quality = item.quality, count = remainder })
631    if transferred < remainder then
632      player.surface.spill_inventory({
633        position = player.physical_position,
634        inventory = work_inventory,
635        force = player.force,
636        allow_belts = false,
637      })
638    end
639  end
640
641  assert(work_inventory.is_empty(), "Failed to empty work inventory.")
642  work_inventory.destroy()
643end
644
645script.on_event(defines.events.on_player_cursor_stack_changed, function(e)
646  local drag_state = storage.drag[e.player_index]
647  if not drag_state then
648    return
649  end
650
651  local cursor_stack = drag_state.player.cursor_stack
652  local cursor_item = cursor_stack and cursor_stack.valid_for_read and cursor_stack.name
653  if drag_state.item.name == cursor_item then
654    return
655  end
656
657  storage.drag[e.player_index] = nil
658  finish_drag(drag_state)
659end)
660
661script.on_event(defines.events.on_tick, function()
662  for player_index, drag_state in pairs(storage.drag) do
663    local clear_cursor = drag_state.player.mod_settings["edl-clear-cursor"].value --[[@as boolean]]
664    if not clear_cursor then
665      local ticks = drag_state.player.mod_settings["edl-ticks"].value --[[@as int]]
666      if drag_state.last_tick + ticks <= game.tick then
667        storage.drag[player_index] = nil
668        finish_drag(drag_state)
669      end
670    end
671  end
672end)