@crowdedkingdoms/ckx-kit 0.4.0-dev.1 → 0.5.0-test.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/crates/ckx-sdk/Cargo.toml +1 -1
- package/crates/ckx-sdk/src/kit/econ/holdings.rs +241 -0
- package/crates/ckx-sdk/src/kit/econ/holds.rs +202 -0
- package/crates/ckx-sdk/src/kit/econ/items.rs +212 -0
- package/crates/ckx-sdk/src/kit/econ/loot.rs +116 -0
- package/crates/ckx-sdk/src/kit/econ/mod.rs +50 -0
- package/crates/ckx-sdk/src/kit/econ/orderbook.rs +155 -0
- package/crates/ckx-sdk/src/kit/econ/production.rs +155 -0
- package/crates/ckx-sdk/src/kit/econ/progression.rs +254 -0
- package/crates/ckx-sdk/src/kit/econ/recipes.rs +65 -0
- package/crates/ckx-sdk/src/kit/econ/vendor.rs +116 -0
- package/crates/ckx-sdk/src/kit/econ/wallet.rs +67 -0
- package/crates/ckx-sdk/src/kit/mod.rs +23 -2
- package/crates/ckx-sdk/src/lib.rs +2 -1
- package/crates/crowdy-client-sdk/Cargo.toml +1 -1
- package/dist/cli/src/new.js +2 -0
- package/kit.pin +4 -4
- package/package.json +1 -1
- package/starters/arena/Cargo.toml +1 -1
- package/starters/ckx.json +3 -1
- package/starters/deck/Cargo.toml +1 -1
- package/starters/director/Cargo.toml +1 -1
- package/starters/grid-mod/Cargo.toml +1 -1
- package/starters/instance/Cargo.toml +1 -1
- package/starters/leaderboard/Cargo.toml +1 -1
- package/starters/market/Cargo.toml +14 -0
- package/starters/market/src/lib.rs +1137 -0
- package/starters/matchmaker/Cargo.toml +1 -1
- package/starters/minigame/Cargo.toml +1 -1
- package/starters/npc-mobs/Cargo.toml +1 -1
- package/starters/player/Cargo.toml +14 -0
- package/starters/player/src/lib.rs +994 -0
- package/starters/racing/Cargo.toml +1 -1
- package/starters/session/Cargo.toml +1 -1
- package/starters/tactics/Cargo.toml +1 -1
- package/starters/territory/Cargo.toml +1 -1
- package/starters/world-tick/Cargo.toml +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//! Loot tables with pity: each roll picks by weight, and a pity group the player has not hit in
|
|
2
|
+
//! `pity_within` rolls is guaranteed on the next. A player's [`LootBook`] for a table keeps the
|
|
3
|
+
//! counters and the last rolls, for a dispute.
|
|
4
|
+
|
|
5
|
+
use std::collections::BTreeMap;
|
|
6
|
+
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
|
|
9
|
+
use crate::kit::Rng;
|
|
10
|
+
|
|
11
|
+
fn one() -> u64 {
|
|
12
|
+
1
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
16
|
+
pub struct LootEntry {
|
|
17
|
+
pub item: String,
|
|
18
|
+
#[serde(default = "one")]
|
|
19
|
+
pub count: u64,
|
|
20
|
+
pub weight: u64,
|
|
21
|
+
/// Entries in one pity group tick its counter together.
|
|
22
|
+
#[serde(default)]
|
|
23
|
+
pub pity_group: Option<String>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
27
|
+
#[serde(default)]
|
|
28
|
+
pub struct LootTable {
|
|
29
|
+
pub entries: Vec<LootEntry>,
|
|
30
|
+
/// A pity group, and the rolls within which it is guaranteed.
|
|
31
|
+
pub pity_within: BTreeMap<String, u32>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// What a player's rolls on one table have been.
|
|
35
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
36
|
+
#[serde(default)]
|
|
37
|
+
pub struct LootBook {
|
|
38
|
+
pub rolls: u64,
|
|
39
|
+
pub pity: BTreeMap<String, u32>,
|
|
40
|
+
/// The last rolls: `(roll number, item)`.
|
|
41
|
+
pub recent: Vec<(u64, String)>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// How many of a book's rolls it keeps.
|
|
45
|
+
pub const RECENT_KEEP: usize = 32;
|
|
46
|
+
|
|
47
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
48
|
+
pub struct LootRoll {
|
|
49
|
+
pub item: String,
|
|
50
|
+
pub count: u64,
|
|
51
|
+
/// Pity picked it.
|
|
52
|
+
pub pity: bool,
|
|
53
|
+
pub roll: u64,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Rolls once; `None` for a table with no weight. A due pity group picks its rarest entry.
|
|
57
|
+
pub fn roll(table: &LootTable, book: &mut LootBook, rng: &mut Rng) -> Option<LootRoll> {
|
|
58
|
+
let due = table.pity_within.iter().find(|(g, within)| book.pity.get(*g).copied().unwrap_or(0) + 1 >= **within).map(|(g, _)| g.clone());
|
|
59
|
+
let picked = match &due {
|
|
60
|
+
Some(g) => table.entries.iter().filter(|e| e.pity_group.as_deref() == Some(g)).min_by_key(|e| e.weight).cloned(),
|
|
61
|
+
None => {
|
|
62
|
+
let weights: Vec<u64> = table.entries.iter().map(|e| e.weight).collect();
|
|
63
|
+
rng.weighted(&weights).map(|i| table.entries[i].clone())
|
|
64
|
+
}
|
|
65
|
+
}?;
|
|
66
|
+
book.rolls += 1;
|
|
67
|
+
for g in table.pity_within.keys() {
|
|
68
|
+
let c = book.pity.entry(g.clone()).or_insert(0);
|
|
69
|
+
*c = if picked.pity_group.as_deref() == Some(g) { 0 } else { c.saturating_add(1) };
|
|
70
|
+
}
|
|
71
|
+
book.recent.push((book.rolls, picked.item.clone()));
|
|
72
|
+
if book.recent.len() > RECENT_KEEP {
|
|
73
|
+
let drop = book.recent.len() - RECENT_KEEP;
|
|
74
|
+
book.recent.drain(..drop);
|
|
75
|
+
}
|
|
76
|
+
Some(LootRoll { item: picked.item, count: picked.count, pity: due.is_some(), roll: book.rolls })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[cfg(test)]
|
|
80
|
+
mod tests {
|
|
81
|
+
use super::*;
|
|
82
|
+
|
|
83
|
+
fn gacha() -> LootTable {
|
|
84
|
+
LootTable {
|
|
85
|
+
entries: vec![
|
|
86
|
+
LootEntry { item: "common".into(), count: 1, weight: 990, pity_group: None },
|
|
87
|
+
LootEntry { item: "legendary".into(), count: 1, weight: 10, pity_group: Some("rare".into()) },
|
|
88
|
+
],
|
|
89
|
+
pity_within: BTreeMap::from([("rare".to_string(), 10)]),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn pity_guarantees_the_group_within_its_rolls() {
|
|
95
|
+
let (t, mut book, mut rng) = (gacha(), LootBook::default(), Rng::seeded(7));
|
|
96
|
+
let mut since = 0;
|
|
97
|
+
for _ in 0..200 {
|
|
98
|
+
let r = roll(&t, &mut book, &mut rng).unwrap();
|
|
99
|
+
since = if r.item == "legendary" { 0 } else { since + 1 };
|
|
100
|
+
assert!(since < 10, "pity fires within ten rolls");
|
|
101
|
+
}
|
|
102
|
+
assert_eq!((book.rolls, book.recent.len()), (200, RECENT_KEEP));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[test]
|
|
106
|
+
fn a_natural_hit_resets_the_counter_and_an_empty_table_rolls_nothing() {
|
|
107
|
+
let sure = LootTable {
|
|
108
|
+
entries: vec![LootEntry { item: "legendary".into(), count: 2, weight: 1, pity_group: Some("rare".into()) }],
|
|
109
|
+
pity_within: BTreeMap::from([("rare".to_string(), 10)]),
|
|
110
|
+
};
|
|
111
|
+
let mut book = LootBook { pity: BTreeMap::from([("rare".to_string(), 5)]), ..LootBook::default() };
|
|
112
|
+
let r = roll(&sure, &mut book, &mut Rng::seeded(1)).unwrap();
|
|
113
|
+
assert_eq!((r.count, r.pity, book.pity["rare"]), (2, false, 0));
|
|
114
|
+
assert!(roll(&LootTable::default(), &mut book, &mut Rng::seeded(1)).is_none());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//! The soft economy a game keeps in its hubs: items in stacks ([`items`]), soft currencies
|
|
2
|
+
//! ([`wallet`]), both together with a mailbox for what does not fit ([`holdings`]), escrow holds
|
|
3
|
+
//! and the references a hub answers once ([`holds`]), recipes ([`recipes`]), loot with pity
|
|
4
|
+
//! ([`loot`]), idle production that catches up ([`production`]), vendors that restock
|
|
5
|
+
//! ([`vendor`]), an order book ([`orderbook`]), and levels, achievements and quests
|
|
6
|
+
//! ([`progression`]).
|
|
7
|
+
//!
|
|
8
|
+
//! **It is not a ledger.** A hub's items and balances are its own state, persisted with its
|
|
9
|
+
//! snapshots like everything else it keeps. A transfer between hubs is calls each answered once
|
|
10
|
+
//! by its reference ([`holds::Applied`]), not a transaction: there is no double-entry book, no
|
|
11
|
+
//! audit trail beyond the hubs' logs, and no real money. Escrow is a hold in the owner's own hub
|
|
12
|
+
//! ([`holds::Holds`]), so goods in flight are never in two places, and whatever drives a
|
|
13
|
+
//! transfer (a market) retries each step until its hub answers.
|
|
14
|
+
|
|
15
|
+
pub mod holdings;
|
|
16
|
+
pub mod holds;
|
|
17
|
+
pub mod items;
|
|
18
|
+
pub mod loot;
|
|
19
|
+
pub mod orderbook;
|
|
20
|
+
pub mod production;
|
|
21
|
+
pub mod progression;
|
|
22
|
+
pub mod recipes;
|
|
23
|
+
pub mod vendor;
|
|
24
|
+
pub mod wallet;
|
|
25
|
+
|
|
26
|
+
pub use holdings::{Bundle, Holdings};
|
|
27
|
+
pub use holds::{Applied, Holds};
|
|
28
|
+
pub use items::{Catalog, Inventory, ItemDef, Stack};
|
|
29
|
+
pub use wallet::Wallet;
|
|
30
|
+
|
|
31
|
+
/// The longest item id or currency the economy takes.
|
|
32
|
+
pub const ID_MAX: usize = 64;
|
|
33
|
+
/// The longest reference [`Holds`] and [`Applied`] take: room for a driver's key, a counter and
|
|
34
|
+
/// the suffixes of the steps it derives from one.
|
|
35
|
+
pub const REFERENCE_MAX: usize = 128;
|
|
36
|
+
|
|
37
|
+
pub(crate) fn check_id(what: &str, id: &str) -> crate::Result<()> {
|
|
38
|
+
check_len(what, id, ID_MAX)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub(crate) fn check_reference(reference: &str) -> crate::Result<()> {
|
|
42
|
+
check_len("reference", reference, REFERENCE_MAX)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fn check_len(what: &str, s: &str, max: usize) -> crate::Result<()> {
|
|
46
|
+
if s.is_empty() || s.len() > max {
|
|
47
|
+
return Err(super::error(super::codes::BAD_REQUEST, format!("a {what} is 1 to {max} bytes")));
|
|
48
|
+
}
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
//! An order book for one item: limit orders matched at price-time priority, at the resting
|
|
2
|
+
//! order's price. Placing an order answers the [`Fill`]s whoever runs the book settles (the
|
|
3
|
+
//! buyer pays `price * count`, the seller gives `count`); escrow is the runner's ([`super::holds`]).
|
|
4
|
+
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
|
|
7
|
+
use crate::Result;
|
|
8
|
+
use crate::kit::{codes, error};
|
|
9
|
+
|
|
10
|
+
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
11
|
+
#[serde(rename_all = "snake_case")]
|
|
12
|
+
pub enum Side {
|
|
13
|
+
Buy,
|
|
14
|
+
Sell,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
18
|
+
pub struct Order {
|
|
19
|
+
pub id: u64,
|
|
20
|
+
pub player: u64,
|
|
21
|
+
pub side: Side,
|
|
22
|
+
/// The limit, per unit.
|
|
23
|
+
pub price: i64,
|
|
24
|
+
/// What is still open.
|
|
25
|
+
pub count: u64,
|
|
26
|
+
pub at: u64,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
|
30
|
+
pub struct Fill {
|
|
31
|
+
pub buyer: u64,
|
|
32
|
+
pub seller: u64,
|
|
33
|
+
pub buy_order: u64,
|
|
34
|
+
pub sell_order: u64,
|
|
35
|
+
pub price: i64,
|
|
36
|
+
pub count: u64,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Bids best (highest) first, asks best (lowest) first, earlier first at a price.
|
|
40
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
|
|
41
|
+
#[serde(default)]
|
|
42
|
+
pub struct OrderBook {
|
|
43
|
+
pub bids: Vec<Order>,
|
|
44
|
+
pub asks: Vec<Order>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
impl OrderBook {
|
|
48
|
+
/// Places order `id` and matches it at once; whatever is left rests on the book.
|
|
49
|
+
pub fn place(&mut self, id: u64, player: u64, side: Side, price: i64, count: u64, now: u64) -> Result<Vec<Fill>> {
|
|
50
|
+
if price <= 0 || count == 0 {
|
|
51
|
+
return Err(error(codes::BAD_REQUEST, "a price and a count are positive"));
|
|
52
|
+
}
|
|
53
|
+
let mut left = count;
|
|
54
|
+
let mut fills = Vec::new();
|
|
55
|
+
let (far, near) = match side {
|
|
56
|
+
Side::Buy => (&mut self.asks, &mut self.bids),
|
|
57
|
+
Side::Sell => (&mut self.bids, &mut self.asks),
|
|
58
|
+
};
|
|
59
|
+
while left > 0 {
|
|
60
|
+
let Some(best) = far.first_mut() else { break };
|
|
61
|
+
let crosses = match side {
|
|
62
|
+
Side::Buy => best.price <= price,
|
|
63
|
+
Side::Sell => best.price >= price,
|
|
64
|
+
};
|
|
65
|
+
if !crosses {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
let n = left.min(best.count);
|
|
69
|
+
let (buyer, seller, buy_order, sell_order) = match side {
|
|
70
|
+
Side::Buy => (player, best.player, id, best.id),
|
|
71
|
+
Side::Sell => (best.player, player, best.id, id),
|
|
72
|
+
};
|
|
73
|
+
fills.push(Fill { buyer, seller, buy_order, sell_order, price: best.price, count: n });
|
|
74
|
+
best.count -= n;
|
|
75
|
+
left -= n;
|
|
76
|
+
if best.count == 0 {
|
|
77
|
+
far.remove(0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if left > 0 {
|
|
81
|
+
let at = near
|
|
82
|
+
.iter()
|
|
83
|
+
.position(|o| match side {
|
|
84
|
+
Side::Buy => o.price < price,
|
|
85
|
+
Side::Sell => o.price > price,
|
|
86
|
+
})
|
|
87
|
+
.unwrap_or(near.len());
|
|
88
|
+
near.insert(at, Order { id, player, side, price, count: left, at: now });
|
|
89
|
+
}
|
|
90
|
+
Ok(fills)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Takes `player`'s order `id` off the book.
|
|
94
|
+
pub fn cancel(&mut self, id: u64, player: u64) -> Option<Order> {
|
|
95
|
+
for side in [&mut self.bids, &mut self.asks] {
|
|
96
|
+
if let Some(i) = side.iter().position(|o| o.id == id && o.player == player) {
|
|
97
|
+
return Some(side.remove(i));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
None
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pub fn order(&self, id: u64) -> Option<&Order> {
|
|
104
|
+
self.bids.iter().chain(self.asks.iter()).find(|o| o.id == id)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// `(price, count)` a level, best first.
|
|
108
|
+
pub fn depth(&self, side: Side, levels: usize) -> Vec<(i64, u64)> {
|
|
109
|
+
let orders = match side {
|
|
110
|
+
Side::Buy => &self.bids,
|
|
111
|
+
Side::Sell => &self.asks,
|
|
112
|
+
};
|
|
113
|
+
let mut out: Vec<(i64, u64)> = Vec::new();
|
|
114
|
+
for o in orders {
|
|
115
|
+
if let Some((p, n)) = out.last_mut()
|
|
116
|
+
&& *p == o.price
|
|
117
|
+
{
|
|
118
|
+
*n += o.count;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if out.len() == levels {
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
out.push((o.price, o.count));
|
|
125
|
+
}
|
|
126
|
+
out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub fn of(&self, player: u64) -> Vec<Order> {
|
|
130
|
+
self.bids.iter().chain(self.asks.iter()).filter(|o| o.player == player).cloned().collect()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[cfg(test)]
|
|
135
|
+
mod tests {
|
|
136
|
+
use super::*;
|
|
137
|
+
|
|
138
|
+
#[test]
|
|
139
|
+
fn orders_match_at_the_resting_price_in_price_time_order() {
|
|
140
|
+
let mut b = OrderBook::default();
|
|
141
|
+
b.place(1, 10, Side::Sell, 12, 5, 0).unwrap();
|
|
142
|
+
b.place(2, 11, Side::Sell, 10, 3, 1).unwrap();
|
|
143
|
+
b.place(3, 12, Side::Sell, 10, 4, 2).unwrap();
|
|
144
|
+
let fills = b.place(4, 20, Side::Buy, 11, 5, 3).unwrap();
|
|
145
|
+
assert_eq!(fills.iter().map(|f| (f.seller, f.price, f.count)).collect::<Vec<_>>(), vec![(11, 10, 3), (12, 10, 2)]);
|
|
146
|
+
assert_eq!(b.depth(Side::Sell, 5), vec![(10, 2), (12, 5)]);
|
|
147
|
+
assert!(b.bids.is_empty(), "the buy filled whole");
|
|
148
|
+
let rest = b.place(5, 21, Side::Buy, 9, 4, 4).unwrap();
|
|
149
|
+
assert!(rest.is_empty());
|
|
150
|
+
assert_eq!(b.depth(Side::Buy, 5), vec![(9, 4)]);
|
|
151
|
+
assert_eq!(b.cancel(5, 99), None, "only its owner cancels it");
|
|
152
|
+
assert_eq!(b.cancel(5, 21).unwrap().count, 4);
|
|
153
|
+
assert!(b.place(6, 1, Side::Buy, 0, 1, 5).is_err());
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
//! Idle production: producers that turn inputs into outputs at a rate, and catch up on any time
|
|
2
|
+
//! away in one step, bounded by their inputs and their outputs' storage.
|
|
3
|
+
|
|
4
|
+
use std::collections::BTreeMap;
|
|
5
|
+
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
|
|
8
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
9
|
+
#[serde(default)]
|
|
10
|
+
pub struct Producer {
|
|
11
|
+
pub id: String,
|
|
12
|
+
/// What a cycle takes from the chain's stock.
|
|
13
|
+
pub inputs: BTreeMap<String, u64>,
|
|
14
|
+
/// What a cycle adds to it.
|
|
15
|
+
pub outputs: BTreeMap<String, u64>,
|
|
16
|
+
pub per_minute: f64,
|
|
17
|
+
/// A cycle begun and not finished, from 0 to 1.
|
|
18
|
+
pub progress: f64,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Producers in order (an upstream producer before the ones it feeds), their stock, and how
|
|
22
|
+
/// much of each resource the stock holds at most.
|
|
23
|
+
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
24
|
+
#[serde(default)]
|
|
25
|
+
pub struct Chain {
|
|
26
|
+
pub producers: Vec<Producer>,
|
|
27
|
+
pub stock: BTreeMap<String, u64>,
|
|
28
|
+
pub capacity: BTreeMap<String, u64>,
|
|
29
|
+
/// When it last caught up; 0 before it starts.
|
|
30
|
+
pub at: u64,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl Chain {
|
|
34
|
+
/// Starts the clock.
|
|
35
|
+
pub fn start(&mut self, now: u64) {
|
|
36
|
+
if self.at == 0 {
|
|
37
|
+
self.at = now.max(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Catches up to `now`; answers what was made. A producer short of inputs or out of storage
|
|
42
|
+
/// runs as many whole cycles as it can and loses the rest of the time.
|
|
43
|
+
pub fn advance(&mut self, now: u64) -> BTreeMap<String, u64> {
|
|
44
|
+
let mut made = BTreeMap::new();
|
|
45
|
+
if self.at == 0 || now <= self.at {
|
|
46
|
+
self.start(now);
|
|
47
|
+
return made;
|
|
48
|
+
}
|
|
49
|
+
let minutes = (now - self.at) as f64 / 60_000.0;
|
|
50
|
+
self.at = now;
|
|
51
|
+
for i in 0..self.producers.len() {
|
|
52
|
+
let p = &self.producers[i];
|
|
53
|
+
let potential = p.per_minute.max(0.0) * minutes + p.progress;
|
|
54
|
+
let mut cycles = potential.floor();
|
|
55
|
+
for (r, &n) in &p.inputs {
|
|
56
|
+
if let Some(supported) = self.stock.get(r).copied().unwrap_or(0).checked_div(n) {
|
|
57
|
+
cycles = cycles.min(supported as f64);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (r, &n) in &p.outputs {
|
|
61
|
+
if let Some(&cap) = self.capacity.get(r)
|
|
62
|
+
&& let Some(fits) = cap.saturating_sub(self.stock.get(r).copied().unwrap_or(0)).checked_div(n)
|
|
63
|
+
{
|
|
64
|
+
cycles = cycles.min(fits as f64);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
let runs = cycles.max(0.0) as u64;
|
|
68
|
+
let bound = (runs as f64) < potential.floor();
|
|
69
|
+
let p = &mut self.producers[i];
|
|
70
|
+
p.progress = if bound { 0.0 } else { (potential - runs as f64).clamp(0.0, 0.999_999) };
|
|
71
|
+
if runs == 0 {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
let (inputs, outputs) = (p.inputs.clone(), p.outputs.clone());
|
|
75
|
+
for (r, n) in inputs {
|
|
76
|
+
let s = self.stock.entry(r).or_insert(0);
|
|
77
|
+
*s = s.saturating_sub(n.saturating_mul(runs));
|
|
78
|
+
}
|
|
79
|
+
for (r, n) in outputs {
|
|
80
|
+
let add = n.saturating_mul(runs);
|
|
81
|
+
*self.stock.entry(r.clone()).or_insert(0) += add;
|
|
82
|
+
*made.entry(r).or_insert(0) += add;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
self.stock.retain(|_, n| *n > 0);
|
|
86
|
+
made
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Takes up to `count` of `resource` out of the stock; answers how many.
|
|
90
|
+
pub fn take(&mut self, resource: &str, count: u64) -> u64 {
|
|
91
|
+
let Some(have) = self.stock.get_mut(resource) else { return 0 };
|
|
92
|
+
let taken = count.min(*have);
|
|
93
|
+
*have -= taken;
|
|
94
|
+
if *have == 0 {
|
|
95
|
+
self.stock.remove(resource);
|
|
96
|
+
}
|
|
97
|
+
taken
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#[cfg(test)]
|
|
102
|
+
mod tests {
|
|
103
|
+
use super::*;
|
|
104
|
+
|
|
105
|
+
fn chain() -> Chain {
|
|
106
|
+
Chain {
|
|
107
|
+
producers: vec![
|
|
108
|
+
Producer { id: "mine".into(), outputs: BTreeMap::from([("ore".into(), 1)]), per_minute: 6.0, ..Producer::default() },
|
|
109
|
+
Producer {
|
|
110
|
+
id: "smelter".into(),
|
|
111
|
+
inputs: BTreeMap::from([("ore".into(), 2)]),
|
|
112
|
+
outputs: BTreeMap::from([("ingot".into(), 1)]),
|
|
113
|
+
per_minute: 2.0,
|
|
114
|
+
..Producer::default()
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
..Chain::default()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn a_night_away_catches_up_in_one_step() {
|
|
123
|
+
let mut c = chain();
|
|
124
|
+
c.start(1_000);
|
|
125
|
+
let made = c.advance(1_000 + 10 * 60_000);
|
|
126
|
+
assert_eq!(made["ore"], 60, "the mine runs six a minute");
|
|
127
|
+
assert_eq!(made["ingot"], 20, "the smelter runs two a minute on the mine's ore");
|
|
128
|
+
assert_eq!(c.stock["ore"], 20);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn storage_and_inputs_bound_it_and_a_bound_producer_loses_the_rest_of_the_time() {
|
|
133
|
+
let mut c = chain();
|
|
134
|
+
c.capacity.insert("ore".into(), 10);
|
|
135
|
+
c.producers.truncate(1);
|
|
136
|
+
c.start(1);
|
|
137
|
+
c.advance(1 + 60 * 60_000);
|
|
138
|
+
assert_eq!(c.stock["ore"], 10, "the store is full");
|
|
139
|
+
assert_eq!(c.producers[0].progress, 0.0);
|
|
140
|
+
assert_eq!(c.take("ore", 4), 4);
|
|
141
|
+
let made = c.advance(1 + 60 * 60_000 + 30_000);
|
|
142
|
+
assert_eq!(made["ore"], 3, "half a minute at six a minute");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[test]
|
|
146
|
+
fn a_partial_cycle_carries_over() {
|
|
147
|
+
let mut c = chain();
|
|
148
|
+
c.producers.truncate(1);
|
|
149
|
+
c.start(1);
|
|
150
|
+
c.advance(1 + 5_000);
|
|
151
|
+
assert!(c.stock.is_empty());
|
|
152
|
+
let made = c.advance(1 + 10_000);
|
|
153
|
+
assert_eq!(made["ore"], 1, "two halves make a cycle");
|
|
154
|
+
}
|
|
155
|
+
}
|