Add workbench, iron tools and gold statues

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 06:31 UTC
commit
4b47c06399a95160e2533c91fed37a6935165fdb
parent
74cb0e7c3d
tree
browse at this commit

9 files changed +400 -51

src/building.rs +5 -1

@@ -76,6 +76,8 @@ impl BuildKind {
76 76 BuildKind::Structure(StructureKind::Bed) => 600,
77 77 BuildKind::Structure(StructureKind::Wall) => 300,
78 78 BuildKind::Structure(StructureKind::Door) => 300,
79 + BuildKind::Structure(StructureKind::Workbench) => 600,
80 + BuildKind::Structure(StructureKind::Statue) => 900,
79 81 BuildKind::Roof => 120,
80 82 BuildKind::Pave => 120,
81 83 BuildKind::Refuel(_) => 60,
@@ -305,12 +307,14 @@ pub enum BuildMode {
305 307 Deconstruct,
306 308 }
307 309
308 -pub const BUILD_TOOL_CYCLE: [BuildMode; 6] = [
310 +pub const BUILD_TOOL_CYCLE: [BuildMode; 8] = [
309 311 BuildMode::Place(StructureKind::Wall),
310 312 BuildMode::Place(StructureKind::Door),
311 313 BuildMode::Place(StructureKind::Campfire),
312 314 BuildMode::Place(StructureKind::Torch),
313 315 BuildMode::Place(StructureKind::Bed),
316 + BuildMode::Place(StructureKind::Workbench),
317 + BuildMode::Place(StructureKind::Statue),
314 318 BuildMode::Deconstruct,
315 319 ];
316 320

src/economy.rs +12 -4

@@ -30,16 +30,18 @@ pub enum Stock {
30 30 Gold,
31 31 RawFood,
32 32 Meals,
33 + Tools,
33 34 }
34 35
35 36 impl Stock {
36 - pub const ALL: [Stock; 6] = [
37 + pub const ALL: [Stock; 7] = [
37 38 Stock::Wood,
38 39 Stock::Stone,
39 40 Stock::Iron,
40 41 Stock::Gold,
41 42 Stock::RawFood,
42 43 Stock::Meals,
44 + Stock::Tools,
43 45 ];
44 46
45 47 pub fn of(kind: ItemKind) -> Stock {
@@ -52,6 +54,7 @@ impl Stock {
52 54 Stock::RawFood
53 55 }
54 56 ItemKind::Meal => Stock::Meals,
57 + ItemKind::Tools => Stock::Tools,
55 58 }
56 59 }
57 60
@@ -73,6 +76,7 @@ impl Stock {
73 76 Stock::Gold => "Gold",
74 77 Stock::RawFood => "Raw food",
75 78 Stock::Meals => "Meals",
79 + Stock::Tools => "Tools",
76 80 }
77 81 }
78 82
@@ -88,9 +92,11 @@ impl Stock {
88 92 match self {
89 93 Stock::Wood => 15.0,
90 94 Stock::Stone => 5.0,
91 - Stock::Iron | Stock::Gold => 0.0,
95 + Stock::Iron => 6.0,
96 + Stock::Gold => 0.0,
92 97 Stock::RawFood => 10.0,
93 98 Stock::Meals => 3.0,
99 + Stock::Tools => 1.0,
94 100 }
95 101 }
96 102
@@ -98,9 +104,11 @@ impl Stock {
98 104 match self {
99 105 Stock::Wood => 20.0,
100 106 Stock::Stone => 10.0,
101 - Stock::Iron | Stock::Gold => 0.0,
107 + Stock::Iron => 3.0,
108 + Stock::Gold => 0.0,
102 109 Stock::RawFood => 30.0,
103 110 Stock::Meals => 3.0,
111 + Stock::Tools => 1.0,
104 112 }
105 113 }
106 114 }
@@ -288,7 +296,7 @@ mod tests {
288 296 let mut goals = ColonyGoals::default();
289 297 learn_days(&mut goals, ItemKind::Wood, 0, 10);
290 298 assert_eq!(goals.goal(Stock::Wood), 15);
291 - assert_eq!(goals.goal(Stock::Iron), 0);
299 + assert_eq!(goals.goal(Stock::Gold), 0);
292 300 }
293 301
294 302 // Raw foods stand in for each other (cooking accepts any), so the colony targets them as one

src/family.rs +55 -1

@@ -103,6 +103,10 @@ impl Home {
103 103 [corner, corner + UVec2::X]
104 104 }
105 105
106 + pub fn statue_tile(&self) -> UVec2 {
107 + UVec2::new(self.hi.x - 1, self.lo.y + 1)
108 + }
109 +
106 110 pub fn blueprints(&self, toward: UVec2) -> HashMap<UVec2, BuildKind> {
107 111 let corner = |t: &UVec2| {
108 112 (t.x == self.lo.x || t.x == self.hi.x) && (t.y == self.lo.y || t.y == self.hi.y)
@@ -352,13 +356,24 @@ fn plan_homes(
352 356 .filter(|(_, s)| s.kind == StructureKind::Door)
353 357 .map(|(pos, _)| pos.0)
354 358 .collect();
359 + let statues: HashSet<UVec2> = structures
360 + .iter()
361 + .filter(|(_, s)| s.kind == StructureKind::Statue)
362 + .map(|(pos, _)| pos.0)
363 + .collect();
355 364 for home in homes.by_family.values() {
356 365 if blueprints.0.keys().any(|t| home.contains(*t)) {
357 366 continue;
358 367 }
359 - for tile in roofs_needed(&map, &doors, home) {
368 + let roofs = roofs_needed(&map, &doors, home);
369 + let built = roofs.is_empty() && home.interior().all(|t| map.is_roofed(t.as_ivec2()));
370 + for tile in roofs {
360 371 blueprints.0.insert(tile, BuildKind::Roof);
361 372 }
373 + if built && !statues.iter().any(|&t| home.contains(t)) {
374 + let statue = BuildKind::Structure(StructureKind::Statue);
375 + blueprints.0.insert(home.statue_tile(), statue);
376 + }
362 377 }
363 378 let Some(family) = families
364 379 .iter()
@@ -625,4 +640,43 @@ mod tests {
625 640 let roofs = roofs_needed(&map, &HashSet::from([door]), &home);
626 641 assert_eq!(roofs.len(), (HOME_INTERIOR.x * HOME_INTERIOR.y) as usize);
627 642 }
643 +
644 + // Gold has one use: a statue in each finished home, which lifts the family's mood. Planning
645 + // it is what makes the colony want gold at all.
646 + #[test]
647 + fn finished_homes_get_a_statue() {
648 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
649 + let home = Home::at(UVec2::new(5, 5));
650 + let door = UVec2::new(7, 5);
651 + for tile in home.ring().filter(|t| *t != door) {
652 + map.set_wall(tile, true);
653 + }
654 + for tile in home.interior() {
655 + map.set_roof(tile, true);
656 + }
657 + let mut app = App::new();
658 + app.insert_resource(map)
659 + .init_resource::<SimTick>()
660 + .init_resource::<Zones>()
661 + .init_resource::<Blueprints>()
662 + .insert_resource(Homes {
663 + by_family: HashMap::from([(1, home)]),
664 + ..default()
665 + })
666 + .add_systems(Update, plan_homes);
667 + app.world_mut().spawn((
668 + Structure {
669 + kind: StructureKind::Door,
670 + },
671 + TilePos(door),
672 + ));
673 + app.world_mut()
674 + .spawn((Colonist { name: "Ada".into() }, Family(1)));
675 + app.update();
676 + let blueprints = app.world().resource::<Blueprints>();
677 + assert_eq!(
678 + blueprints.get(home.statue_tile()),
679 + Some(BuildKind::Structure(StructureKind::Statue))
680 + );
681 + }
628 682 }

src/items.rs +4 -1

@@ -30,10 +30,11 @@ pub enum ItemKind {
30 30 Vegetables,
31 31 Meal,
32 32 Carcass,
33 + Tools,
33 34 }
34 35
35 36 impl ItemKind {
36 - pub const ALL: [ItemKind; 9] = [
37 + pub const ALL: [ItemKind; 10] = [
37 38 ItemKind::Wood,
38 39 ItemKind::Stone,
39 40 ItemKind::Iron,
@@ -43,6 +44,7 @@ impl ItemKind {
43 44 ItemKind::Vegetables,
44 45 ItemKind::Meal,
45 46 ItemKind::Carcass,
47 + ItemKind::Tools,
46 48 ];
47 49
48 50 pub fn nutrition(self) -> f32 {
@@ -72,6 +74,7 @@ impl ItemKind {
72 74 ItemKind::Vegetables => Color::srgb_u8(120, 170, 60),
73 75 ItemKind::Meal => Color::srgb_u8(240, 210, 150),
74 76 ItemKind::Carcass => Color::srgb_u8(120, 60, 50),
77 + ItemKind::Tools => Color::srgb_u8(90, 100, 120),
75 78 }
76 79 }
77 80 }

src/jobs/mod.rs +188 -30

@@ -89,15 +89,17 @@ pub enum WorkType {
89 89 Gather,
90 90 Haul,
91 91 Tame,
92 + Craft,
92 93 }
93 94
94 95 impl WorkType {
95 - pub const ALL: [WorkType; 5] = [
96 + pub const ALL: [WorkType; 6] = [
96 97 WorkType::Food,
97 98 WorkType::Build,
98 99 WorkType::Gather,
99 100 WorkType::Haul,
100 101 WorkType::Tame,
102 + WorkType::Craft,
101 103 ];
102 104
103 105 pub fn label(self) -> &'static str {
@@ -107,6 +109,7 @@ impl WorkType {
107 109 WorkType::Gather => "Gather",
108 110 WorkType::Haul => "Haul",
109 111 WorkType::Tame => "Tame",
112 + WorkType::Craft => "Craft",
110 113 }
111 114 }
112 115 }
@@ -173,11 +176,15 @@ pub enum Task {
173 176 kind: AnimalKind,
174 177 progress: u32,
175 178 },
176 - Cook {
179 + Craft {
180 + recipe: Recipe,
177 181 pickup: Option<UVec2>,
178 182 station: UVec2,
179 183 progress: u32,
180 184 },
185 + Equip {
186 + pickup: UVec2,
187 + },
181 188 Butcher {
182 189 site: UVec2,
183 190 progress: u32,
@@ -189,6 +196,62 @@ pub enum Task {
189 196 },
190 197 }
191 198
199 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200 +pub enum Recipe {
201 + Meal,
202 + Tools,
203 +}
204 +
205 +impl Recipe {
206 + pub fn station(self) -> StructureKind {
207 + match self {
208 + Recipe::Meal => StructureKind::Campfire,
209 + Recipe::Tools => StructureKind::Workbench,
210 + }
211 + }
212 +
213 + pub fn accepts(self, kind: ItemKind) -> bool {
214 + match self {
215 + Recipe::Meal => kind.is_raw_food(),
216 + Recipe::Tools => kind == ItemKind::Iron,
217 + }
218 + }
219 +
220 + pub fn input(self) -> u32 {
221 + match self {
222 + Recipe::Meal => COOK_INPUT,
223 + Recipe::Tools => TOOL_IRON,
224 + }
225 + }
226 +
227 + pub fn work(self) -> u32 {
228 + match self {
229 + Recipe::Meal => COOK_WORK,
230 + Recipe::Tools => TOOL_WORK,
231 + }
232 + }
233 +
234 + pub fn output(self) -> ItemKind {
235 + match self {
236 + Recipe::Meal => ItemKind::Meal,
237 + Recipe::Tools => ItemKind::Tools,
238 + }
239 + }
240 +}
241 +
242 +pub const TOOL_IRON: u32 = 3;
243 +pub const TOOL_WORK: u32 = 600;
244 +pub const TOOL_LIFE_TICKS: u32 = 2 * 24 * crate::sim::TICKS_PER_HOUR as u32;
245 +
246 +#[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq)]
247 +pub struct Tool {
248 + pub wear: u32,
249 +}
250 +
251 +pub fn tool_rate(rate: u32, tool: bool) -> u32 {
252 + if tool { (rate * 3).div_ceil(2) } else { rate }
253 +}
254 +
192 255 pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 {
193 256 if light.is_lit(tile) {
194 257 LIT_WORK_RATE
@@ -416,7 +479,8 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
416 479 reservations.reserve(Target::Animal(animal), by)
417 480 }
418 481 Task::Butcher { site, .. } => reservations.reserve(Target::Pickup(site), by),
419 - Task::Cook {
482 + Task::Equip { pickup } => reservations.reserve(Target::Pickup(pickup), by),
483 + Task::Craft {
420 484 pickup, station, ..
421 485 } => {
422 486 reservations.reserve(Target::Station(station), by);
@@ -833,13 +897,13 @@ pub fn food_to_fetch(reservations: &Reservations, food_tiles: &HashSet<UVec2>) -
833 897 .any(|&p| reservations.is_free(Target::Pickup(p)))
834 898 }
835 899
836 -pub fn raw_food_source(ctx: &BuildContext, from: UVec2) -> Option<UVec2> {
900 +pub fn input_source(ctx: &BuildContext, from: UVec2, recipe: Recipe) -> Option<UVec2> {
837 901 let sources: HashSet<UVec2> = ctx
838 902 .items
839 903 .iter()
840 904 .filter(|(item, pos)| {
841 - item.kind.is_raw_food()
842 - && item.count >= COOK_INPUT
905 + recipe.accepts(item.kind)
906 + && item.count >= recipe.input()
843 907 && ctx.reservations.is_free(Target::Pickup(pos.0))
844 908 })
845 909 .map(|(_, pos)| pos.0)
@@ -866,12 +930,40 @@ pub fn find_butchering(
866 930 Some(Task::Butcher { site, progress: 0 })
867 931 }
868 932
869 -pub fn find_cooking(ctx: &BuildContext, from: UVec2) -> Option<Task> {
933 +pub fn find_workbench(ctx: &BuildContext, from: UVec2) -> Option<Task> {
934 + if ctx.count(StructureKind::Workbench) > 0 {
935 + return None;
936 + }
937 + let home = ctx.zones.home(from);
938 + let site = ctx.site_near(home, HOME_RADIUS * 1.5)?;
939 + ctx.build_task(from, site, BuildKind::Structure(StructureKind::Workbench))
940 +}
941 +
942 +pub fn tool_source(
943 + map: &WorldMap,
944 + reservations: &Reservations,
945 + items: &[(Item, TilePos)],
946 + from: UVec2,
947 +) -> Option<UVec2> {
948 + let tools: HashSet<UVec2> = items
949 + .iter()
950 + .filter(|(item, pos)| {
951 + item.kind == ItemKind::Tools && reservations.is_free(Target::Pickup(pos.0))
952 + })
953 + .map(|(_, pos)| pos.0)
954 + .collect();
955 + if tools.is_empty() {
956 + return None;
957 + }
958 + nearest_of(map, from, SEARCH_TILES, &tools)
959 +}
960 +
961 +pub fn find_crafting(ctx: &BuildContext, from: UVec2, recipe: Recipe) -> Option<Task> {
870 962 let station = ctx
871 963 .structures
872 964 .iter()
873 965 .filter(|&&(pos, kind, fuel)| {
874 - kind == StructureKind::Campfire
966 + kind == recipe.station()
875 967 && fuel >= COOK_MIN_FUEL
876 968 && ctx.reservations.is_free(Target::Station(pos))
877 969 })
@@ -881,8 +973,9 @@ pub fn find_cooking(ctx: &BuildContext, from: UVec2) -> Option<Task> {
881 973 .distance_squared(from.as_vec2())
882 974 .total_cmp(&b.as_vec2().distance_squared(from.as_vec2()))
883 975 })?;
884 - let pickup = raw_food_source(ctx, from)?;
885 - Some(Task::Cook {
976 + let pickup = input_source(ctx, from, recipe)?;
977 + Some(Task::Craft {
978 + recipe,
886 979 pickup: Some(pickup),
887 980 station,
888 981 progress: 0,
@@ -1024,6 +1117,7 @@ fn choose_jobs(
1024 1117 Has<Child>,
1025 1118 &WorkPriorities,
1026 1119 &Warmth,
1120 + Has<Tool>,
1027 1121 ),
1028 1122 With<Colonist>,
1029 1123 >,
@@ -1083,7 +1177,7 @@ fn choose_jobs(
1083 1177 for job in colonists.iter().filter_map(|c| c.3.0) {
1084 1178 match job.task {
1085 1179 Task::Hunt { kind, .. } => *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat(),
1086 - Task::Cook { .. } => *pending.entry(ItemKind::Meal).or_insert(0) += 1,
1180 + Task::Craft { recipe, .. } => *pending.entry(recipe.output()).or_insert(0) += 1,
1087 1181 _ => {}
1088 1182 }
1089 1183 }
@@ -1106,6 +1200,7 @@ fn choose_jobs(
1106 1200 child,
1107 1201 priorities,
1108 1202 warmth,
1203 + has_tool,
1109 1204 ) in &mut colonists
1110 1205 {
1111 1206 let exhausted = rest.0 < EXHAUSTED_REST;
@@ -1177,6 +1272,11 @@ fn choose_jobs(
1177 1272 carrying.0 = None;
1178 1273 }
1179 1274 storage.map(|to| Task::Haul { pickup: None, to })
1275 + } else if let Some(pickup) = (!has_tool)
1276 + .then(|| tool_source(&map, &reservations, &snapshot, tile.0))
1277 + .flatten()
1278 + {
1279 + Some(Task::Equip { pickup })
1180 1280 } else {
1181 1281 let occupied: HashSet<UVec2> = structures_list
1182 1282 .iter()
@@ -1195,8 +1295,19 @@ fn choose_jobs(
1195 1295 occupied: &occupied,
1196 1296 };
1197 1297 let cooking = || {
1198 - (wanted(ItemKind::Meal) && raw_food_source(&build, tile.0).is_some())
1199 - .then(|| find_cooking(&build, tile.0).or_else(|| find_campfire(&build, tile.0)))
1298 + (wanted(ItemKind::Meal) && input_source(&build, tile.0, Recipe::Meal).is_some())
1299 + .then(|| {
1300 + find_crafting(&build, tile.0, Recipe::Meal)
1301 + .or_else(|| find_campfire(&build, tile.0))
1302 + })
1303 + .flatten()
1304 + };
1305 + let toolmaking = || {
1306 + (wanted(ItemKind::Tools) && input_source(&build, tile.0, Recipe::Tools).is_some())
1307 + .then(|| {
1308 + find_crafting(&build, tile.0, Recipe::Tools)
1309 + .or_else(|| find_workbench(&build, tile.0))
1310 + })
1200 1311 .flatten()
1201 1312 };
1202 1313 let designated = || {
@@ -1208,7 +1319,7 @@ fn choose_jobs(
1208 1319 })
1209 1320 .flatten()
1210 1321 };
1211 - let candidates: [(WorkType, &dyn Fn() -> Option<Task>); 15] = [
1322 + let candidates: [(WorkType, &dyn Fn() -> Option<Task>); 16] = [
1212 1323 (WorkType::Haul, &|| {
1213 1324 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
1214 1325 }),
@@ -1239,6 +1350,7 @@ fn choose_jobs(
1239 1350 .flatten()
1240 1351 }),
1241 1352 (WorkType::Build, &|| find_bed_site(&build, adults, tile.0)),
1353 + (WorkType::Craft, &toolmaking),
1242 1354 (WorkType::Build, &|| find_blueprint_work(&build, tile.0)),
1243 1355 (WorkType::Gather, &|| {
1244 1356 ItemKind::ALL
@@ -1281,7 +1393,7 @@ fn choose_jobs(
1281 1393 Task::Hunt { kind, .. } => {
1282 1394 *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat()
1283 1395 }
1284 - Task::Cook { .. } => *pending.entry(ItemKind::Meal).or_insert(0) += 1,
1396 + Task::Craft { recipe, .. } => *pending.entry(recipe.output()).or_insert(0) += 1,
1285 1397 _ => {}
1286 1398 }
1287 1399 if let Task::Build {
@@ -1356,6 +1468,7 @@ fn run_jobs(
1356 1468 &Rest,
1357 1469 &mut Hunger,
1358 1470 Has<Sick>,
1471 + Option<&mut Tool>,
1359 1472 ),
1360 1473 With<Colonist>,
1361 1474 >,
@@ -1372,8 +1485,18 @@ fn run_jobs(
1372 1485 )
1373 1486 .flatten()
1374 1487 .collect();
1375 - for (entity, tile, mut path, mut current, mut idle, mut carrying, rest, mut hunger, sick) in
1376 - &mut colonists
1488 + for (
1489 + entity,
1490 + tile,
1491 + mut path,
1492 + mut current,
1493 + mut idle,
1494 + mut carrying,
1495 + rest,
1496 + mut hunger,
1497 + sick,
1498 + mut tool,
1499 + ) in &mut colonists
1377 1500 {
1378 1501 let Some(mut job) = current.0 else {
1379 1502 continue;
@@ -1382,20 +1505,52 @@ fn run_jobs(
1382 1505 rate if sick => rate.div_ceil(2),
1383 1506 rate => rate,
1384 1507 };
1508 + let rate = tool_rate(rate, tool.is_some());
1385 1509 if !path.0.is_empty() {
1386 1510 continue;
1387 1511 }
1512 + if let Some(tool) = tool.as_mut()
1513 + && !matches!(
1514 + job.task,
1515 + Task::MoveTo(_) | Task::Sleep { .. } | Task::Eat { .. } | Task::Equip { .. }
1516 + )
1517 + {
1518 + tool.wear += 1;
1519 + if tool.wear >= TOOL_LIFE_TICKS {
1520 + commands.entity(entity).remove::<Tool>();
1521 + }
1522 + }
1388 1523 let step = match &mut job.task {
1389 1524 Task::MoveTo(_) => Step::Done,
1390 - Task::Cook {
1525 + Task::Equip { pickup } => {
1526 + let from = *pickup;
1527 + if reservations.holder(Target::Pickup(from)) != Some(entity) {
1528 + Step::Done
1529 + } else if tile.0 != from {
1530 + walk_to(&map, tile.0, from, &mut path)
1531 + } else {
1532 + reservations.release(Target::Pickup(from));
1533 + let stack = items
1534 + .iter()
1535 + .any(|(_, item, pos)| pos.0 == from && item.kind == ItemKind::Tools);
1536 + if stack && take_items(&mut commands, &mut items, from, 1).is_some() {
1537 + goals.record_use(ItemKind::Tools, 1);
1538 + commands.entity(entity).insert(Tool::default());
1539 + }
1540 + Step::Done
1541 + }
1542 + }
1543 + Task::Craft {
1544 + recipe,
1391 1545 pickup,
1392 1546 station,
1393 1547 progress,
1394 1548 } => {
1395 - let station = *station;
1549 + let (recipe, station) = (*recipe, *station);
1550 + let input = recipe.input();
1396 1551 let fire = structures
1397 1552 .iter()
1398 - .any(|(_, pos, s, _)| pos.0 == station && s.kind == StructureKind::Campfire);
1553 + .any(|(_, pos, s, _)| pos.0 == station && s.kind == recipe.station());
1399 1554 let ours = reservations.holder(Target::Station(station)) == Some(entity);
1400 1555 if !fire || !ours {
1401 1556 Step::Done
@@ -1405,12 +1560,12 @@ fn run_jobs(
1405 1560 } else if tile.0 != from {
1406 1561 walk_to(&map, tile.0, from, &mut path)
1407 1562 } else {
1408 - let load = take_items(&mut commands, &mut items, from, COOK_INPUT)
1409 - .filter(|load| load.kind.is_raw_food() && load.count > 0);
1563 + let load = take_items(&mut commands, &mut items, from, input)
1564 + .filter(|load| recipe.accepts(load.kind) && load.count > 0);
1410 1565 carrying.0 = load;
1411 1566 reservations.release(Target::Pickup(from));
1412 1567 match load {
1413 - Some(load) if load.count >= COOK_INPUT => {
1568 + Some(load) if load.count >= input => {
1414 1569 *pickup = None;
1415 1570 Step::Continue
1416 1571 }
@@ -1419,7 +1574,7 @@ fn run_jobs(
1419 1574 }
1420 1575 } else if !carrying
1421 1576 .0
1422 - .is_some_and(|load| load.kind.is_raw_food() && load.count >= COOK_INPUT)
1577 + .is_some_and(|load| recipe.accepts(load.kind) && load.count >= input)
1423 1578 {
1424 1579 Step::Done
1425 1580 } else if !is_adjacent(tile.0.as_ivec2(), station.as_ivec2()) {
@@ -1432,15 +1587,15 @@ fn run_jobs(
1432 1587 }
1433 1588 } else {
1434 1589 *progress += rate;
1435 - if *progress < COOK_WORK {
1590 + if *progress < recipe.work() {
1436 1591 Step::Continue
1437 1592 } else {
1438 1593 if let Some(load) = carrying.0 {
1439 - goals.record_use(load.kind, COOK_INPUT);
1594 + goals.record_use(load.kind, input);
1440 1595 }
1441 1596 carrying.0 = carrying.0.and_then(|load| {
1442 - (load.count > COOK_INPUT).then_some(Item {
1443 - count: load.count - COOK_INPUT,
1597 + (load.count > input).then_some(Item {
1598 + count: load.count - input,
1444 1599 ..load
1445 1600 })
1446 1601 });
@@ -1449,7 +1604,7 @@ fn run_jobs(
1449 1604 &mut items,
1450 1605 &item_assets,
1451 1606 tile.0,
1452 - Item::new(ItemKind::Meal, 1),
1607 + Item::new(recipe.output(), 1),
1453 1608 );
1454 1609 Step::Done
1455 1610 }
@@ -1684,7 +1839,10 @@ fn run_jobs(
1684 1839 let ours = reservations.holder(Target::Site(site)) == Some(entity);
1685 1840 let planned = match kind {
1686 1841 BuildKind::Structure(
1687 - StructureKind::Campfire | StructureKind::Torch | StructureKind::Bed,
1842 + StructureKind::Campfire
1843 + | StructureKind::Torch
1844 + | StructureKind::Bed
1845 + | StructureKind::Workbench,
1688 1846 )
1689 1847 | BuildKind::Refuel(_) => true,
1690 1848 _ => blueprints.get(site) == Some(kind),

src/jobs/tests.rs +45 -0

@@ -1421,3 +1421,48 @@ fn cold_colonists_warm_up_by_a_fire() {
1421 1421 };
1422 1422 assert!(spot.as_vec2().distance(Vec2::splat(20.0)) <= 3.0);
1423 1423 }
1424 +
1425 +// Tools are made from iron at a workbench when the colony wants more, and colonists without
1426 +// tools pick one up; working with tools is half again as fast.
1427 +#[test]
1428 +fn colonists_make_tools_at_a_workbench_and_equip_them() {
1429 + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass));
1430 + set_goals(&mut app, &[(ItemKind::Tools, 1)]);
1431 + app.world_mut().spawn((
1432 + Structure {
1433 + kind: StructureKind::Workbench,
1434 + },
1435 + TilePos(UVec2::new(15, 15)),
1436 + ));
1437 + app.world_mut().spawn((
1438 + Item::new(ItemKind::Iron, TOOL_IRON),
1439 + TilePos(UVec2::new(5, 5)),
1440 + ));
1441 + let colonist = spawn_colonist(&mut app, UVec2::new(2, 2));
1442 + run_ticks(&mut app, 3000);
1443 + assert_eq!(items_of(&mut app, ItemKind::Iron), 0);
1444 + assert!(app.world().get::<Tool>(colonist).is_some());
1445 + assert_eq!(tool_rate(2, true), 3);
1446 + assert_eq!(tool_rate(1, true), 2);
1447 +}
1448 +
1449 +#[test]
1450 +fn a_workbench_is_built_when_tools_are_wanted() {
1451 + let mut app = test_app(WorldMap::filled(UVec2::splat(30), Terrain::Grass));
1452 + set_goals(&mut app, &[(ItemKind::Tools, 1)]);
1453 + put_wood(&mut app, UVec2::new(5, 5), 50);
1454 + put_beds(&mut app, 1);
1455 + app.world_mut().spawn((
1456 + Item::new(ItemKind::Iron, TOOL_IRON),
1457 + TilePos(UVec2::new(6, 5)),
1458 + ));
1459 + spawn_colonist(&mut app, UVec2::new(2, 2));
1460 + run_ticks(&mut app, 4000);
1461 + let world = app.world_mut();
1462 + let benches = world
1463 + .query::<&Structure>()
1464 + .iter(world)
1465 + .filter(|s| s.kind == StructureKind::Workbench)
1466 + .count();
1467 + assert_eq!(benches, 1);
1468 +}

src/mood.rs +16 -3

@@ -11,6 +11,7 @@ use crate::{
11 11 population::ColonyLog,
12 12 rng::SimRng,
13 13 sim::{SimSystems, SimTick, TICKS_PER_HOUR},
14 + structures::{Structure, StructureKind},
14 15 world::{TilePos, WorldMap},
15 16 worldgen::WorldGenerated,
16 17 };
@@ -107,6 +108,7 @@ pub struct MoodFactors {
107 108 pub home: bool,
108 109 pub grieving: bool,
109 110 pub cold: bool,
111 + pub beauty: bool,
110 112 }
111 113
112 114 pub fn mood_target(f: MoodFactors) -> f32 {
@@ -133,6 +135,9 @@ pub fn mood_target(f: MoodFactors) -> f32 {
133 135 if f.cold {
134 136 mood -= 0.15;
135 137 }
138 + if f.beauty {
139 + mood += 0.1;
140 + }
136 141 mood.clamp(0.0, 1.0)
137 142 }
138 143
@@ -184,6 +189,7 @@ fn update_mood(
184 189 homes: Res<Homes>,
185 190 map: Res<WorldMap>,
186 191 losses: Res<Losses>,
192 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
187 193 mut colonists: Query<(
188 194 Entity,
189 195 &mut Mood,
@@ -200,14 +206,19 @@ fn update_mood(
200 206 return;
201 207 }
202 208 let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS);
209 + let statues: Vec<UVec2> = structures
210 + .iter()
211 + .filter(|(_, s)| s.kind == StructureKind::Statue)
212 + .map(|(pos, _)| pos.0)
213 + .collect();
203 214 for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth) in &mut colonists {
204 215 let sick_now = sick.is_some_and(|s| tick.0 < s.until);
205 216 if sick.is_some() && !sick_now {
206 217 commands.entity(entity).remove::<Sick>();
207 218 }
208 - let home = homes
209 - .of(family.copied())
210 - .is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2())));
219 + let own_home = homes.of(family.copied());
220 + let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2())));
221 + let beauty = own_home.is_some_and(|h| statues.iter().any(|&t| h.contains(t)));
211 222 let target = mood_target(MoodFactors {
212 223 fed: hunger.0,
213 224 rest: rest.0,
@@ -219,6 +230,7 @@ fn update_mood(
219 230 home,
220 231 grieving,
221 232 cold: warmth.cold(),
233 + beauty,
222 234 });
223 235 mood.0 += (target - mood.0) / MOOD_HOURS;
224 236 }
@@ -327,6 +339,7 @@ mod tests {
327 339 let settled = mood_target(MoodFactors {
328 340 partner: true,
329 341 home: true,
342 + beauty: true,
330 343 last_meal_cooked: Some(true),
331 344 ..content()
332 345 });

src/selection.rs +25 -8

@@ -12,7 +12,7 @@ use crate::{
12 12 family::{Child, Partner},
13 13 food::Hunger,
14 14 items::{Carrying, Item, stock},
15 - jobs::{BUTCHER_WORK, COOK_WORK, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
15 + jobs::{BUTCHER_WORK, CurrentJob, EXPAND_WORK, Job, Order, Recipe, Task, Tool, WorkKind},
16 16 light::daylight,
17 17 mood::{Age, Mood, Sick},
18 18 needs::Rest,
@@ -280,6 +280,7 @@ fn update_tile_info(
280 280 Option<&Age>,
281 281 Has<Sick>,
282 282 Option<&Warmth>,
283 + Has<Tool>,
283 284 )>,
284 285 mut text: Single<&mut Text, With<TileInfoText>>,
285 286 ) {
@@ -300,7 +301,7 @@ fn update_tile_info(
300 301 fed: hunger.0,
301 302 }) + &kin
302 303 .get(entity)
303 - .map(|(partner, child, mood, age, sick, warmth)| {
304 + .map(|(partner, child, mood, age, sick, warmth, tool)| {
304 305 let partner = partner
305 306 .and_then(|p| colonists.get(p.0).ok())
306 307 .map(|p| format!(", partner {}", p.0.name));
@@ -313,10 +314,11 @@ fn update_tile_info(
313 314 .map(|a| format!(", age {}", a.years(*tick)))
314 315 .unwrap_or_default();
315 316 let sick = if sick { ", sick" } else { "" };
317 + let tool = if tool { ", has tools" } else { "" };
316 318 let warmth = warmth
317 319 .map(|w| {
318 320 let cold = if w.cold() { " (cold)" } else { "" };
319 - format!(", {:.0}°C{cold}", w.0)
321 + format!(", {:.0}C{cold}", w.0)
320 322 })
321 323 .unwrap_or_default();
322 324 partner.unwrap_or_default()
@@ -324,6 +326,7 @@ fn update_tile_info(
324 326 + &mood
325 327 + &age
326 328 + sick
329 + + tool
327 330 + &warmth
328 331 })
329 332 .unwrap_or_default()
@@ -331,7 +334,7 @@ fn update_tile_info(
331 334 .unwrap_or_else(|_| "-".into()),
332 335 };
333 336 let clock = format!(
334 - "{} ({}), {} day {}, {:.0}°C",
337 + "{} ({}), {} day {}, {:.0}C",
335 338 format_clock(*tick),
336 339 time_of_day(hour_of_day(*tick)),
337 340 season(*tick).name(),
@@ -502,7 +505,9 @@ pub fn progress_fraction(task: Task) -> Option<f32> {
502 505 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
503 506 Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
504 507 Task::Hunt { kind, progress, .. } => Some(progress as f32 / kind.hunt_work() as f32),
505 - Task::Cook { progress, .. } => Some(progress as f32 / COOK_WORK as f32),
508 + Task::Craft {
509 + progress, recipe, ..
510 + } => Some(progress as f32 / recipe.work() as f32),
506 511 Task::Butcher { progress, .. } => Some(progress as f32 / BUTCHER_WORK as f32),
507 512 Task::Tame { kind, progress, .. } => Some(progress as f32 / kind.tame_work() as f32),
508 513 _ => None,
@@ -597,13 +602,25 @@ pub fn describe_colonist(view: &ColonistView) -> String {
597 602 }
598 603 (Some(Task::Hunt { kind, .. }), _) => format!("stalking a {}", kind.name()),
599 604 (
600 - Some(Task::Cook {
605 + Some(Task::Craft {
601 606 pickup: Some(_), ..
602 607 }),
603 608 _,
604 609 ) => "fetching ingredients".to_string(),
605 - (Some(Task::Cook { .. }), n) if n > 0 => "carrying ingredients to the fire".to_string(),
606 - (Some(task @ Task::Cook { .. }), _) => format!("cooking a meal, {}%", percent(task)),
610 + (Some(Task::Craft { recipe, .. }), n) if n > 0 => {
611 + format!("carrying ingredients to the {}", recipe.station().name())
612 + }
613 + (
614 + Some(
615 + task @ Task::Craft {
616 + recipe: Recipe::Meal,
617 + ..
618 + },
619 + ),
620 + _,
621 + ) => format!("cooking a meal, {}%", percent(task)),
622 + (Some(task @ Task::Craft { .. }), _) => format!("making tools, {}%", percent(task)),
623 + (Some(Task::Equip { .. }), _) => "fetching tools".to_string(),
607 624 (Some(Task::Butcher { .. }), n) if n > 0 => "walking to a carcass".to_string(),
608 625 (Some(Task::Tame { kind, .. }), n) if n > 0 => format!("approaching a {}", kind.name()),
609 626 (Some(task @ Task::Tame { kind, .. }), _) => {

src/structures.rs +50 -3

@@ -27,6 +27,8 @@ pub enum StructureKind {
27 27 Bed,
28 28 Wall,
29 29 Door,
30 + Workbench,
31 + Statue,
30 32 }
31 33
32 34 impl StructureKind {
@@ -37,6 +39,8 @@ impl StructureKind {
37 39 StructureKind::Bed => (ItemKind::Wood, 15),
38 40 StructureKind::Wall => (ItemKind::Stone, 2),
39 41 StructureKind::Door => (ItemKind::Wood, 5),
42 + StructureKind::Workbench => (ItemKind::Wood, 20),
43 + StructureKind::Statue => (ItemKind::Gold, 5),
40 44 }
41 45 }
42 46
@@ -44,7 +48,7 @@ impl StructureKind {
44 48 let hours = match self {
45 49 StructureKind::Campfire => 12,
46 50 StructureKind::Torch => 8,
47 - StructureKind::Bed | StructureKind::Wall | StructureKind::Door => return None,
51 + _ => return None,
48 52 };
49 53 Some(hours * TICKS_PER_HOUR as u32)
50 54 }
@@ -53,7 +57,7 @@ impl StructureKind {
53 57 match self {
54 58 StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))),
55 59 StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))),
56 - StructureKind::Bed | StructureKind::Wall | StructureKind::Door => None,
60 + _ => None,
57 61 }
58 62 }
59 63
@@ -61,7 +65,7 @@ impl StructureKind {
61 65 match self {
62 66 StructureKind::Campfire => 20.0,
63 67 StructureKind::Torch => 4.0,
64 - StructureKind::Bed | StructureKind::Wall | StructureKind::Door => 0.0,
68 + _ => 0.0,
65 69 }
66 70 }
67 71
@@ -76,6 +80,8 @@ impl StructureKind {
76 80 StructureKind::Bed => "bed",
77 81 StructureKind::Wall => "wall",
78 82 StructureKind::Door => "door",
83 + StructureKind::Workbench => "workbench",
84 + StructureKind::Statue => "statue",
79 85 }
80 86 }
81 87 }
@@ -120,6 +126,11 @@ pub struct StructureAssets {
120 126 wall_top: Part,
121 127 door: Part,
122 128 door_knob: Part,
129 + bench: Part,
130 + bench_leg: Part,
131 + anvil: Part,
132 + plinth: Part,
133 + gold: Part,
123 134 }
124 135
125 136 const CAMPFIRE_STONES: usize = 7;
@@ -188,6 +199,31 @@ fn create_structure_assets(
188 199 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(),
189 200 Color::srgb_u8(60, 44, 30),
190 201 ),
202 + bench: part(
203 + Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.45).into(),
204 + Color::srgb_u8(150, 104, 60),
205 + ),
206 + bench_leg: part(
207 + Rectangle::new(TILE_SIZE * 0.1, TILE_SIZE * 0.35).into(),
208 + Color::srgb_u8(96, 66, 38),
209 + ),
210 + anvil: part(
211 + Rectangle::new(TILE_SIZE * 0.32, TILE_SIZE * 0.18).into(),
212 + Color::srgb_u8(70, 74, 82),
213 + ),
214 + plinth: part(
215 + Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.22).into(),
216 + Color::srgb_u8(150, 146, 140),
217 + ),
218 + gold: part(
219 + Triangle2d::new(
220 + Vec2::new(-TILE_SIZE * 0.2, -TILE_SIZE * 0.25),
221 + Vec2::new(TILE_SIZE * 0.2, -TILE_SIZE * 0.25),
222 + Vec2::new(0.0, TILE_SIZE * 0.35),
223 + )
224 + .into(),
225 + Color::srgb_u8(236, 196, 60),
226 + ),
191 227 });
192 228 }
193 229
@@ -242,6 +278,17 @@ pub fn spawn_structure(
242 278 c.spawn(part_bundle(&assets.door, at(0.0, 0.0, 0.0)));
243 279 c.spawn(part_bundle(&assets.door_knob, at(0.25, 0.0, 0.01)));
244 280 }),
281 + StructureKind::Workbench => entity.with_children(|c| {
282 + for x in [-0.35, 0.35] {
283 + c.spawn(part_bundle(&assets.bench_leg, at(x, -0.25, 0.0)));
284 + }
285 + c.spawn(part_bundle(&assets.bench, at(0.0, 0.05, 0.01)));
286 + c.spawn(part_bundle(&assets.anvil, at(0.15, 0.2, 0.02)));
287 + }),
288 + StructureKind::Statue => entity.with_children(|c| {
289 + c.spawn(part_bundle(&assets.plinth, at(0.0, -0.35, 0.0)));
290 + c.spawn(part_bundle(&assets.gold, at(0.0, 0.05, 0.01)));
291 + }),
245 292 StructureKind::Torch => entity.with_children(|c| {
246 293 c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0)));
247 294 let small = Vec3::splat(0.6);