Skip to main content

banished_game/work/
stock.rs

1//! StorageComponent ledgers and town storage search (`Game`). See docs/CITIZEN-AI.md and
2//! docs/RESOURCE-PRODUCTION-FLOW.md.
3// Game +0xf0
4
5use crate::random::Random;
6
7/// RawMaterialDescription: a template shared by ledger entries, not a stored entity.
8#[derive(Debug, Clone, Default)]
9pub struct RawMaterial {
10    /// The resource reference.
11    pub reference: String,
12    // +0x48
13    /// Weighted storage units per item.
14    pub size: i32,
15    // +0x5c
16    /// Category flags (distinct from the search's category index).
17    pub flags: u32,
18    // +0x54; sent to Citizen message 1 by FUN_1400c2540.
19    /// How many uses a tool has (for tools).
20    pub tool_uses: i32,
21    // +0x58; FUN_140094410 prefers the last eligible entry with this set.
22    /// Whether a food search prefers this material.
23    pub preferred: bool,
24    // +0x4c
25    /// A crop field leaves a pile on every this-many cells.
26    ///
27    /// Original: `FUN_14011f7e0`.
28    pub pile_spacing: i32,
29    // +0x40
30    /// The size of a pasture's product pile.
31    ///
32    /// Original: `FUN_14012bcb0`.
33    pub pile_amount: i32,
34}
35
36// +0x28
37/// A StorageComponent entry (16 bytes): the raw material, the count and the reserved count.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Item {
40    /// The raw material.
41    pub material: usize,
42    /// How many are stored.
43    pub count: i32,
44    /// How many are reserved.
45    pub reserved: i32,
46}
47
48/// A storage: its capacity and the goods in it, with their reservations.
49#[derive(Debug, Clone, Default)]
50pub struct Storage {
51    // +0x38; +0x3c
52    /// The capacity.
53    pub capacity: i32,
54    /// The flags of the goods it accepts.
55    pub accepts: u32,
56    /// The goods in it.
57    pub items: Vec<Item>,
58}
59
60impl Storage {
61    /// Weighted occupancy (includes reserved goods).
62    ///
63    /// Original: `FUN_1401323e0`.
64    pub fn used(&self, materials: &[RawMaterial]) -> i32 {
65        self.items.iter().fold(0i32, |n, e| {
66            n.wrapping_add(materials[e.material].size.wrapping_mul(e.count))
67        })
68    }
69
70    /// Whether `count` units of a material fit in the storage.
71    ///
72    /// Original: `FUN_140132250`, `FUN_140131a30`.
73    pub fn fits(&self, materials: &[RawMaterial], material: usize, count: i32) -> bool {
74        self.accepts & materials[material].flags != 0
75            && materials[material]
76                .size
77                .wrapping_mul(count)
78                .wrapping_add(self.used(materials))
79                <= self.capacity
80    }
81
82    /// The entry of a material.
83    pub fn entry(&self, material: usize) -> Option<&Item> {
84        self.items.iter().find(|e| e.material == material)
85    }
86
87    /// Quantity available for another task.
88    ///
89    /// Original: `FUN_14011bc90`.
90    pub fn available(&self, material: usize) -> i32 {
91        self.entry(material)
92            .map_or(0, |e| e.count.wrapping_sub(e.reserved))
93    }
94
95    /// Merge in place, otherwise append with no reservation.
96    ///
97    /// Original: `FUN_1401324d0`.
98    pub fn add(&mut self, material: usize, count: i32) {
99        if let Some(e) = self.items.iter_mut().find(|e| e.material == material) {
100            e.count = e.count.wrapping_add(count);
101        } else {
102            self.items.push(Item {
103                material,
104                count,
105                reserved: 0,
106            });
107        }
108    }
109
110    /// Subtract, erase exactly-zero entries preserving order; no clamp.
111    ///
112    /// Original: `FUN_140132620`.
113    pub fn remove(&mut self, material: usize, count: i32) {
114        if let Some(i) = self.items.iter().position(|e| e.material == material) {
115            self.items[i].count = self.items[i].count.wrapping_sub(count);
116            if self.items[i].count == 0 {
117                self.items.remove(i);
118            }
119        }
120    }
121
122    /// Reserve when building a task, not on arrival.
123    ///
124    /// Original: `FUN_1400cb980`, `FUN_1400cf700`.
125    pub fn reserve(&mut self, material: usize, count: i32) {
126        if let Some(e) = self.items.iter_mut().find(|e| e.material == material) {
127            e.reserved = e.reserved.wrapping_add(count);
128        }
129    }
130
131    /// Release a reservation, when a task is cancelled or before the goods are used or moved.
132    ///
133    /// Original: `FUN_1400cb300`, `FUN_1400cf140` (cancel).
134    pub fn unreserve(&mut self, material: usize, count: i32) {
135        if let Some(e) = self.items.iter_mut().find(|e| e.material == material) {
136            e.reserved = e.reserved.wrapping_sub(count).max(0);
137        }
138    }
139
140    /// Search category 0 food, 1 fuel, 2 tool, 3 herb, 4 clothing. Even a single eligible
141    /// unpreferred material draws Rand(1).
142    ///
143    /// Original: `FUN_140094410`.
144    pub fn find_item(
145        &self,
146        materials: &[RawMaterial],
147        category: u32,
148        rng: &mut Random,
149    ) -> Option<usize> {
150        let bit = *[5, 6, 9, 13, 14].get(category as usize)?;
151        let eligible = |e: &&Item| {
152            materials[e.material].flags & (1 << bit) != 0 && e.count.wrapping_sub(e.reserved) > 0
153        };
154        let count = self.items.iter().filter(eligible).count();
155        if count == 0 {
156            return None;
157        }
158        let preferred = self
159            .items
160            .iter()
161            .filter(eligible)
162            .enumerate()
163            .filter(|(_, e)| materials[e.material].preferred)
164            .map(|(i, _)| i)
165            .last();
166        let index = preferred.unwrap_or_else(|| rng.rand_below(count as u32) as usize);
167        self.items
168            .iter()
169            .filter(eligible)
170            .nth(index)
171            .map(|e| e.material)
172    }
173}
174
175// +0x10/+0x18
176/// Registration in the manager's ordinary (`+0/+8`) or market list.
177#[derive(Debug, Clone)]
178pub struct StorageSite {
179    /// The entity.
180    pub entity: usize,
181    /// Its storage.
182    pub storage: usize,
183    // +0x20; +0x3c
184    /// StorageLocationDescription; zero uses StorageComponent.
185    pub location_flags: u32,
186    // +0x28
187    /// RadiusDescription; Some marks a market-list entry.
188    pub radius: Option<i32>,
189}
190
191/// The result of a storage search: the storage and what it offers.
192#[derive(Debug, Clone, PartialEq)]
193pub struct StorageMatch {
194    /// The storage site.
195    pub site: usize,
196    /// The storage.
197    pub storage: usize,
198    /// The raw material it offers.
199    pub material: usize,
200    /// The point to walk to.
201    pub target: [f32; 3],
202}
203
204/// What the storage search needs from the map: distances, reach and the markets' areas.
205///
206/// Original: the map queries of `FUN_140093200`.
207pub trait StorageSearchMap {
208    /// The cell a citizen walks to at an entity.
209    fn entrance(&mut self, entity: usize, rng: &mut Random) -> i32;
210    /// The path-rank distance between two cells.
211    fn rank(&mut self, a: i32, b: i32) -> i32;
212    /// A market's position, if it can be reached.
213    fn market_position(
214        &mut self,
215        entity: usize,
216        from: i32,
217        rng: &mut Random,
218    ) -> Option<(i32, [f32; 3])>;
219    /// A storage's drop point, if it can be reached from `from`.
220    fn storage_position(&mut self, entity: usize, from: i32) -> Option<(i32, [f32; 3])>;
221}
222
223/// Strict rank improvements, radius-list precedence, then storage. The covered-home flag is sticky
224/// after eligible material selection, even if its slot fails.
225///
226/// Original: `FUN_140093200`.
227#[allow(clippy::too_many_arguments)]
228pub fn find_storage(
229    sites: &[StorageSite],
230    storages: &[Storage],
231    materials: &[RawMaterial],
232    category: u32,
233    cell: i32,
234    home: i32,
235    map: &mut impl StorageSearchMap,
236    rng: &mut Random,
237) -> Option<StorageMatch> {
238    let (_, found) = scan_storages(
239        sites,
240        storages,
241        materials,
242        category,
243        cell,
244        Some(home),
245        i32::MAX,
246        map,
247        rng,
248    );
249    found
250}
251
252/// The markets (radius list), then the storages, each taking a strictly better rank than `best`.
253/// With `home`, a market whose radius covers the home ends the search after the markets; without
254/// it, every storage is weighed. Returns the best rank and its match.
255///
256/// Original: `FUN_140093200`; the scan without a home is `FUN_140093840`.
257#[allow(clippy::too_many_arguments)]
258pub fn scan_storages(
259    sites: &[StorageSite],
260    storages: &[Storage],
261    materials: &[RawMaterial],
262    category: u32,
263    cell: i32,
264    home: Option<i32>,
265    best: i32,
266    map: &mut impl StorageSearchMap,
267    rng: &mut Random,
268) -> (i32, Option<StorageMatch>) {
269    let mut best = best;
270    let mut found = None;
271    let mut covered = false;
272    for (site, s) in sites.iter().enumerate().filter(|(_, s)| s.radius.is_some()) {
273        let entrance = map.entrance(s.entity, rng);
274        let in_radius = home.is_some_and(|h| h >= 0 && map.rank(entrance, h) <= s.radius.unwrap());
275        if map.rank(entrance, cell) < best {
276            if let Some(material) = storages[s.storage].find_item(materials, category, rng) {
277                covered |= in_radius;
278                if let Some((rank, target)) = map.market_position(s.entity, cell, rng) {
279                    if rank >= 0 && rank < best {
280                        best = rank;
281                        found = Some(StorageMatch {
282                            site,
283                            storage: s.storage,
284                            material,
285                            target,
286                        });
287                    }
288                }
289            }
290        }
291    }
292    if covered && found.is_some() {
293        return (best, found);
294    }
295    for (site, s) in sites.iter().enumerate().filter(|(_, s)| s.radius.is_none()) {
296        let entrance = map.entrance(s.entity, rng);
297        if map.rank(entrance, cell) < best {
298            let inventory = &storages[s.storage];
299            if let Some(material) = inventory.find_item(materials, category, rng) {
300                let flags = if s.location_flags == 0 {
301                    inventory.accepts
302                } else {
303                    s.location_flags
304                };
305                if materials[material].flags & flags != 0 {
306                    if let Some((rank, target)) = map.storage_position(s.entity, cell) {
307                        if rank >= 0 && rank < best {
308                            best = rank;
309                            found = Some(StorageMatch {
310                                site,
311                                storage: s.storage,
312                                material,
313                                target,
314                            });
315                        }
316                    }
317                }
318            }
319        }
320    }
321    (best, found)
322}
323
324/// The storage nearest `cell` (entrance path rank) that holds `count` of `material` (`put` false)
325/// or can take them (`put` true). Ordinary storages, then markets; `exclude` is skipped. A
326/// StorageLocation must accept the material's flags. On a strict entrance-rank improvement the
327/// target point (slot scan, or a market use point) must exist with its own rank below the best, and
328/// the best becomes the entrance rank.
329///
330/// Original: `FUN_140092a60`.
331#[allow(clippy::too_many_arguments)]
332pub fn find_material_storage(
333    sites: &[StorageSite],
334    storages: &[Storage],
335    materials: &[RawMaterial],
336    material: usize,
337    count: i32,
338    put: bool,
339    cell: i32,
340    exclude: Option<usize>,
341    map: &mut impl StorageSearchMap,
342    rng: &mut Random,
343) -> Option<StorageMatch> {
344    let mut best = i32::MAX;
345    let mut found = None;
346    for market in [false, true] {
347        for (site, s) in sites.iter().enumerate() {
348            if s.radius.is_some() != market || Some(s.entity) == exclude {
349                continue;
350            }
351            let inventory = &storages[s.storage];
352            if !market {
353                let flags = if s.location_flags == 0 {
354                    inventory.accepts
355                } else {
356                    s.location_flags
357                };
358                if materials[material].flags & flags == 0 {
359                    continue;
360                }
361            }
362            let ok = if put {
363                inventory.fits(materials, material, count)
364            } else {
365                inventory.available(material).wrapping_sub(count) >= 0
366            };
367            if !ok {
368                continue;
369            }
370            let entrance = map.entrance(s.entity, rng);
371            let rank = map.rank(entrance, cell);
372            if rank < best {
373                let point = if market {
374                    map.market_position(s.entity, cell, rng)
375                } else {
376                    map.storage_position(s.entity, cell)
377                };
378                if let Some((r, target)) = point {
379                    if r >= 0 && r < best {
380                        best = rank;
381                        found = Some(StorageMatch {
382                            site,
383                            storage: s.storage,
384                            material,
385                            target,
386                        });
387                    }
388                }
389            }
390        }
391    }
392    found
393}
394
395// ConsumeRawMaterial: vtable 0x140166050.
396/// The `ConsumeRawMaterial` action: take goods out of a storage. Reserved requests skip the check
397/// that the goods are there; the changes happen in list order.
398///
399/// Original: `FUN_1400cb420`.
400pub fn consume_stored(storage: &mut Storage, items: &[(usize, i32)], reserved: bool) -> bool {
401    // The stock manager's `+0xc` counts are kept by the caller (`character::World::used`).
402    if items.is_empty() {
403        return false;
404    }
405    for &(material, count) in items {
406        if reserved {
407            storage.unreserve(material, count);
408        } else if storage.available(material) < count {
409            return false;
410        }
411        storage.remove(material, count);
412    }
413    true
414}
415
416// PickupStoredRawMaterial: vtable 0x1401663a0; FUN_1400cf5a0 serialises the citizen and source
417// references, these material and count pairs, and partial.
418/// The `PickupStoredRawMaterial` action: a citizen takes goods from a storage. A request of zero
419/// or less takes as much as the citizen can still carry (by weight).
420///
421/// Original: `FUN_1400cf210`.
422pub fn pickup_stored(
423    source: &mut Storage,
424    carried: &mut Storage,
425    materials: &[RawMaterial],
426    items: &[(usize, i32)],
427    partial: bool,
428) -> bool {
429    for &(material, requested) in items {
430        let mut count = if requested > 0 {
431            requested
432        } else {
433            (carried.capacity - carried.used(materials)) / materials[material].size
434        };
435        let available = source.entry(material).map_or(0, |e| e.count);
436        if partial {
437            count = count.min(available);
438            if count == 0 {
439                return false;
440            }
441        } else if available < count {
442            return false;
443        }
444        source.unreserve(material, count);
445        source.remove(material, count);
446        carried.add(material, count);
447    }
448    true
449}
450
451/// Hand out up to `cap` single units of the candidates matching `mask`, round-robin over the
452/// candidates and one unit per storage per pass, until the cap is reached or a pass adds nothing.
453///
454/// Original: `FUN_140096b30`.
455pub fn distribute(
456    storages: &mut [Storage],
457    materials: &[RawMaterial],
458    candidates: &[usize],
459    mask: u32,
460    cap: i32,
461) {
462    let list: Vec<usize> = candidates
463        .iter()
464        .copied()
465        .filter(|&c| materials[c].flags & mask != 0)
466        .collect();
467    if list.is_empty() || cap <= 0 {
468        return;
469    }
470    let mut given = 0;
471    loop {
472        let mut progressed = false;
473        for s in storages.iter_mut() {
474            if given < cap {
475                let m = list[given as usize % list.len()];
476                if s.fits(materials, m, 1) {
477                    s.add(m, 1);
478                    given += 1;
479                    progressed = true;
480                }
481            }
482        }
483        if !progressed || cap <= given {
484            break;
485        }
486    }
487}
488
489// Game +0xf0 → +0x170
490/// The Status Bar stock slots (`Game →`, 0x20 bytes each): wood, stone, iron, firewood, tools,
491/// food, herbs, clothes, alcohol, coal, textile, custom 0..9.
492pub const STOCK_SLOTS: usize = 21;
493
494/// Which slot a raw material counts towards, in the original's test order (food, then firewood,
495/// then single flag bits).
496///
497/// Original: `FUN_14008f3a0`.
498pub fn stock_slot(flags: u32) -> Option<usize> {
499    if flags & 0x20 != 0 {
500        return Some(5);
501    }
502    if flags & 0x80 != 0 {
503        return Some(3);
504    }
505    const BITS: [(u32, usize); 18] = [
506        (8, 9),
507        (9, 4),
508        (10, 0),
509        (11, 1),
510        (12, 2),
511        (13, 6),
512        (14, 7),
513        (17, 8),
514        (16, 10),
515        (18, 11),
516        (19, 12),
517        (20, 13),
518        (21, 14),
519        (22, 15),
520        (23, 16),
521        (24, 17),
522        (25, 18),
523        (26, 19),
524    ];
525    for (bit, slot) in BITS {
526        if flags >> bit & 1 != 0 {
527            return Some(slot);
528        }
529    }
530    if flags >> 27 & 1 != 0 {
531        return Some(20);
532    }
533    None
534}
535
536/// Recompute the stock table from every storage.
537pub fn stock_table(storages: &[Storage], materials: &[RawMaterial]) -> [i32; STOCK_SLOTS] {
538    let mut table = [0; STOCK_SLOTS];
539    for s in storages {
540        for e in &s.items {
541            let (m, n) = (e.material, e.count);
542            if let Some(slot) = stock_slot(materials[m].flags) {
543                table[slot] += n;
544            }
545        }
546    }
547    table
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn material(size: i32, flags: u32) -> RawMaterial {
555        RawMaterial {
556            reference: String::new(),
557            size,
558            flags,
559            ..Default::default()
560        }
561    }
562
563    #[test]
564    fn slots() {
565        assert_eq!(stock_slot(0x400), Some(0)); // wood
566        assert_eq!(stock_slot(0x800), Some(1)); // stone
567        assert_eq!(stock_slot(0x1000), Some(2)); // iron
568        assert_eq!(stock_slot(0xc0), Some(3)); // firewood
569        assert_eq!(stock_slot(0x200), Some(4)); // tools
570        assert_eq!(stock_slot(0x22), Some(5)); // potato
571        assert_eq!(stock_slot(0x4000), Some(7)); // leather coat
572    }
573
574    #[test]
575    fn round_robin_respects_capacity_and_flags() {
576        let materials = vec![material(11, 0x400), material(1, 0x22)];
577        let mut storages = vec![
578            Storage {
579                capacity: 6000,
580                accepts: 0x16220,
581                items: vec![],
582            },
583            Storage {
584                capacity: 50,
585                accepts: 0x1c40,
586                items: vec![],
587            },
588        ];
589        distribute(&mut storages, &materials, &[0, 1], 0x400, 120);
590        distribute(&mut storages, &materials, &[0, 1], 0x20, 1800);
591        let table = stock_table(&storages, &materials);
592        assert_eq!(table[0], 4); // 4 logs of 11 fit in 50
593        assert_eq!(table[5], 1800);
594    }
595}