Add colonist skills
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 11:51 UTC
- commit
- 472bc951421b50a7de253696001ed245eaaddd74
- parent
- 5ff2fb9164
- tree
- browse at this commit
10 files changed +333 -25
| CLAUDE.md | +1 -0 | |
| src/colonist.rs | +4 -1 | |
| src/jobs/mod.rs | +58 -20 | |
| src/jobs/tests.rs | +59 -0 | |
| src/main.rs | +1 -0 | |
| src/mood.rs | +7 -1 | |
| src/save.rs | +19 -2 | |
| src/selection.rs | +15 -1 | |
| src/skills.rs | added | +168 -0 |
| tasks.md | +1 -0 |
CLAUDE.md +1 -0
| @@ -49,6 +49,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 49 | 49 | - `src/climate.rs` seasons/temperature. Year = YEAR_DAYS 12 (4 seasons × SEASON_DAYS 3, starts Spring; independent of ageing's day=year). `outdoor_temperature(tick)` = seasonal cosine (MEAN 12, swing 14, coldest at COLDEST_YEAR_FRACTION) + day/night swing (coldest 04:00); `daily_mean(day)`, `growing_day` (mean ≥ 8), `sowing_season` (next SOW_AHEAD_DAYS growing; gates find_sowing), `cold_days_ahead` (non-growing days in the next FORESIGHT_DAYS 6 → `ColonyGoals::set_cold_days`, food/wood buffer days = max(normal, cold days)). `growth_factor(temp)` scales plant growth; Crop with growth < 1 dies below FROST_TEMP. `spoil_factor` slows spoilage in cold. `tile_temperature(map, outdoor, tile, fires)`: roof pulls toward 16 by ROOF_INSULATION 0.7, fires add `StructureKind::heat()` with light-radius falloff, blocked by walls. `Warmth(f32)` on colonists (required), `update_warmth` every 60 ticks; `cold()` < COLD_TEMP 5 → hunger ×1.5, rest recovery ×0.5, mood −0.15. choose_jobs: cold colonists MoveTo `find_warm_spot` (tiles ≥ WARM_TEMP near fires) before work; `chilly_soon` triggers find_lighting's campfire (torches only when dark). `snow_cover(tick)` → `LightMap::with_snow` (8 steps, triggers recompute) → tilemap `Layer::Snow` (z 0.2, white alpha overlay on unroofed land). Info panel shows season/day/temp (font has no ° glyph). |
| 50 | 50 | - Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one; +0.05 (`floored`) when its interior is all WoodFloor. |
| 51 | 51 | - `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam). |
| 52 | +- `src/skills.rs` `Skills{xp: [f32; 8]}` (required on Colonist, default = level 5 everywhere so tests keep old speeds; `assign_age` gives adults `Skills::random` (levels 1–7 + one talent at 10), children level 0). `Skill::{Mining, Woodcutting, Construction, Farming, Cooking, Hunting, Crafting, Taming}`, `Skill::of(task)`; level = √(xp/600) ≤ 20; `speed(level)` = 0.5 + 0.1·level. run_jobs: rate (after light/sick/tool) × speed via `WorkCarry` fractional accumulator; +1 xp per tick the task's `Task::progress()` advances. choose_jobs candidates are `(WorkType, Option<Skill>, finder)`; candidates whose skill is < POOR_LEVEL 3 run after all others (cooking, crafting, blueprints, bed sites, hunting, taming, plain sowing; urgent food/fire work has no skill). Selection shows top 3 skills; saved in ColonistSave (serde default). | |
| 52 | 53 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 53 | 54 | |
| 54 | 55 | ## Performance notes |
src/colonist.rs +4 -1
| @@ -11,6 +11,7 @@ use crate::{ | ||
| 11 | 11 | pathfinding::find_path, |
| 12 | 12 | population::Starvation, |
| 13 | 13 | sim::{SimPos, SimSystems, sim_pos}, |
| 14 | + skills::{Skills, WorkCarry}, | |
| 14 | 15 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 15 | 16 | worldgen::{WorldGenerated, spawn_tiles}, |
| 16 | 17 | }; |
| @@ -93,7 +94,9 @@ pub fn spawn_colonist( | ||
| 93 | 94 | Hunger, |
| 94 | 95 | Starvation, |
| 95 | 96 | WorkPriorities, |
| 96 | - Warmth | |
| 97 | + Warmth, | |
| 98 | + Skills, | |
| 99 | + WorkCarry | |
| 97 | 100 | )] |
| 98 | 101 | pub struct Colonist { |
| 99 | 102 | pub name: String, |
src/jobs/mod.rs +58 -20
| @@ -31,6 +31,7 @@ use crate::{ | ||
| 31 | 31 | }, |
| 32 | 32 | rng::SimRng, |
| 33 | 33 | sim::{SimSystems, SimTick, hour_of_day}, |
| 34 | + skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed}, | |
| 34 | 35 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 35 | 36 | world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, |
| 36 | 37 | worldgen::WorldGenerated, |
| @@ -251,6 +252,22 @@ pub struct Tool { | ||
| 251 | 252 | pub wear: u32, |
| 252 | 253 | } |
| 253 | 254 | |
| 255 | +impl Task { | |
| 256 | + pub fn progress(&self) -> Option<u32> { | |
| 257 | + match *self { | |
| 258 | + Task::Work { progress, .. } | |
| 259 | + | Task::ExpandStockpile { progress, .. } | |
| 260 | + | Task::Build { progress, .. } | |
| 261 | + | Task::Hunt { progress, .. } | |
| 262 | + | Task::Craft { progress, .. } | |
| 263 | + | Task::Butcher { progress, .. } | |
| 264 | + | Task::Tame { progress, .. } | |
| 265 | + | Task::Eat { progress, .. } => Some(progress), | |
| 266 | + _ => None, | |
| 267 | + } | |
| 268 | + } | |
| 269 | +} | |
| 270 | + | |
| 254 | 271 | pub fn tool_rate(rate: u32, tool: bool) -> u32 { |
| 255 | 272 | if tool { (rate * 3).div_ceil(2) } else { rate } |
| 256 | 273 | } |
| @@ -1165,6 +1182,7 @@ fn choose_jobs( | ||
| 1165 | 1182 | &WorkPriorities, |
| 1166 | 1183 | &Warmth, |
| 1167 | 1184 | Has<Tool>, |
| 1185 | + &Skills, | |
| 1168 | 1186 | ), |
| 1169 | 1187 | With<Colonist>, |
| 1170 | 1188 | >, |
| @@ -1254,6 +1272,7 @@ fn choose_jobs( | ||
| 1254 | 1272 | priorities, |
| 1255 | 1273 | warmth, |
| 1256 | 1274 | has_tool, |
| 1275 | + skills, | |
| 1257 | 1276 | ) in &mut colonists |
| 1258 | 1277 | { |
| 1259 | 1278 | let exhausted = rest.0 < EXHAUSTED_REST; |
| @@ -1375,21 +1394,21 @@ fn choose_jobs( | ||
| 1375 | 1394 | .fold(0u32, |mask, k| mask | 1 << k.index()); |
| 1376 | 1395 | let skip_gather = gather_memo.mask == gather_mask && tick.0 < gather_memo.until; |
| 1377 | 1396 | let gather_failed = std::cell::Cell::new(false); |
| 1378 | - let candidates: [(WorkType, &dyn Fn() -> Option<Task>); 16] = [ | |
| 1379 | - (WorkType::Haul, &|| { | |
| 1397 | + let candidates: [(WorkType, Option<Skill>, &dyn Fn() -> Option<Task>); 16] = [ | |
| 1398 | + (WorkType::Haul, None, &|| { | |
| 1380 | 1399 | find_expansion(&map, &zones, &reservations, &snapshot, &blueprints) |
| 1381 | 1400 | }), |
| 1382 | - (WorkType::Build, &|| find_refuel(&build, tile.0)), | |
| 1383 | - (WorkType::Build, &|| { | |
| 1401 | + (WorkType::Build, None, &|| find_refuel(&build, tile.0)), | |
| 1402 | + (WorkType::Build, None, &|| { | |
| 1384 | 1403 | (dark || chilly) |
| 1385 | 1404 | .then(|| find_lighting(&build, tile.0, dark)) |
| 1386 | 1405 | .flatten() |
| 1387 | 1406 | }), |
| 1388 | - (WorkType::Food, &cooking), | |
| 1389 | - (WorkType::Food, &|| { | |
| 1407 | + (WorkType::Food, Some(Skill::Cooking), &cooking), | |
| 1408 | + (WorkType::Food, None, &|| { | |
| 1390 | 1409 | find_butchering(&map, &reservations, &snapshot, tile.0) |
| 1391 | 1410 | }), |
| 1392 | - (WorkType::Food, &|| { | |
| 1411 | + (WorkType::Food, None, &|| { | |
| 1393 | 1412 | ItemKind::ALL |
| 1394 | 1413 | .iter() |
| 1395 | 1414 | .any(|&k| k.is_food() && wanted(k)) |
| @@ -1399,16 +1418,20 @@ fn choose_jobs( | ||
| 1399 | 1418 | }) |
| 1400 | 1419 | .flatten() |
| 1401 | 1420 | }), |
| 1402 | - (WorkType::Gather, &designated), | |
| 1403 | - (WorkType::Food, &|| { | |
| 1421 | + (WorkType::Gather, None, &designated), | |
| 1422 | + (WorkType::Food, None, &|| { | |
| 1404 | 1423 | (sowing && wanted(ItemKind::Vegetables)) |
| 1405 | 1424 | .then(|| find_sowing(&map, &zones, &reservations, tile.0)) |
| 1406 | 1425 | .flatten() |
| 1407 | 1426 | }), |
| 1408 | - (WorkType::Build, &|| find_bed_site(&build, adults, tile.0)), | |
| 1409 | - (WorkType::Craft, &toolmaking), | |
| 1410 | - (WorkType::Build, &|| find_blueprint_work(&build, tile.0)), | |
| 1411 | - (WorkType::Gather, &|| { | |
| 1427 | + (WorkType::Build, Some(Skill::Construction), &|| { | |
| 1428 | + find_bed_site(&build, adults, tile.0) | |
| 1429 | + }), | |
| 1430 | + (WorkType::Craft, Some(Skill::Crafting), &toolmaking), | |
| 1431 | + (WorkType::Build, Some(Skill::Construction), &|| { | |
| 1432 | + find_blueprint_work(&build, tile.0) | |
| 1433 | + }), | |
| 1434 | + (WorkType::Gather, None, &|| { | |
| 1412 | 1435 | if gather_mask == 0 || skip_gather { |
| 1413 | 1436 | return None; |
| 1414 | 1437 | } |
| @@ -1416,29 +1439,34 @@ fn choose_jobs( | ||
| 1416 | 1439 | gather_failed.set(work.is_none()); |
| 1417 | 1440 | work |
| 1418 | 1441 | }), |
| 1419 | - (WorkType::Food, &|| { | |
| 1442 | + (WorkType::Food, Some(Skill::Hunting), &|| { | |
| 1420 | 1443 | wanted(ItemKind::Meat) |
| 1421 | 1444 | .then(|| find_hunt(&map, &reservations, &wildlife, tile.0)) |
| 1422 | 1445 | .flatten() |
| 1423 | 1446 | }), |
| 1424 | - (WorkType::Food, &|| { | |
| 1447 | + (WorkType::Food, Some(Skill::Farming), &|| { | |
| 1425 | 1448 | sowing |
| 1426 | 1449 | .then(|| find_sowing(&map, &zones, &reservations, tile.0)) |
| 1427 | 1450 | .flatten() |
| 1428 | 1451 | }), |
| 1429 | - (WorkType::Tame, &|| { | |
| 1452 | + (WorkType::Tame, Some(Skill::Taming), &|| { | |
| 1430 | 1453 | (!wanted(ItemKind::Meat)) |
| 1431 | 1454 | .then(|| find_taming(&map, &reservations, &wildlife, tile.0)) |
| 1432 | 1455 | .flatten() |
| 1433 | 1456 | }), |
| 1434 | - (WorkType::Haul, &|| { | |
| 1457 | + (WorkType::Haul, None, &|| { | |
| 1435 | 1458 | find_haul(&map, &zones, &reservations, &reach, &snapshot, tile.0) |
| 1436 | 1459 | }), |
| 1437 | 1460 | ]; |
| 1438 | - let task = candidates | |
| 1461 | + let poor = |skill: &Option<Skill>| skill.is_some_and(|s| skills.level(s) < POOR_LEVEL); | |
| 1462 | + let allowed = candidates | |
| 1439 | 1463 | .iter() |
| 1440 | - .filter(|(work, _)| priorities.allows(*work)) | |
| 1441 | - .find_map(|(_, find)| find()); | |
| 1464 | + .filter(|(work, ..)| priorities.allows(*work)); | |
| 1465 | + let task = allowed | |
| 1466 | + .clone() | |
| 1467 | + .filter(|(_, skill, _)| !poor(skill)) | |
| 1468 | + .chain(allowed.filter(|(_, skill, _)| poor(skill))) | |
| 1469 | + .find_map(|(.., find)| find()); | |
| 1442 | 1470 | if gather_failed.get() { |
| 1443 | 1471 | *gather_memo = GatherMemo { |
| 1444 | 1472 | mask: gather_mask, |
| @@ -1533,6 +1561,7 @@ fn run_jobs( | ||
| 1533 | 1561 | &mut Hunger, |
| 1534 | 1562 | Has<Sick>, |
| 1535 | 1563 | Option<&mut Tool>, |
| 1564 | + (&mut Skills, &mut WorkCarry), | |
| 1536 | 1565 | ), |
| 1537 | 1566 | With<Colonist>, |
| 1538 | 1567 | >, |
| @@ -1560,6 +1589,7 @@ fn run_jobs( | ||
| 1560 | 1589 | mut hunger, |
| 1561 | 1590 | sick, |
| 1562 | 1591 | mut tool, |
| 1592 | + (mut skills, mut carry), | |
| 1563 | 1593 | ) in &mut colonists |
| 1564 | 1594 | { |
| 1565 | 1595 | let Some(mut job) = current.0 else { |
| @@ -1573,6 +1603,9 @@ fn run_jobs( | ||
| 1573 | 1603 | if !path.0.is_empty() { |
| 1574 | 1604 | continue; |
| 1575 | 1605 | } |
| 1606 | + let skill = Skill::of(&job.task); | |
| 1607 | + let rate = carry.apply(rate, skill.map_or(1.0, |s| speed(skills.level(s)))); | |
| 1608 | + let progress_before = job.task.progress(); | |
| 1576 | 1609 | if let Some(tool) = tool.as_mut() |
| 1577 | 1610 | && !matches!( |
| 1578 | 1611 | job.task, |
| @@ -2105,6 +2138,11 @@ fn run_jobs( | ||
| 2105 | 2138 | } |
| 2106 | 2139 | } |
| 2107 | 2140 | }; |
| 2141 | + if let Some(skill) = skill | |
| 2142 | + && job.task.progress() > progress_before | |
| 2143 | + { | |
| 2144 | + skills.practice(skill, 1.0); | |
| 2145 | + } | |
| 2108 | 2146 | match step { |
| 2109 | 2147 | Step::Continue => current.0 = Some(job), |
| 2110 | 2148 | Step::Done => { |
src/jobs/tests.rs +59 -0
| @@ -1467,3 +1467,62 @@ fn a_workbench_is_built_when_tools_are_wanted() { | ||
| 1467 | 1467 | .count(); |
| 1468 | 1468 | assert_eq!(benches, 1); |
| 1469 | 1469 | } |
| 1470 | + | |
| 1471 | +// A colonist's skill sets how fast they work, and working practises it. | |
| 1472 | +#[test] | |
| 1473 | +fn skilled_colonists_work_faster_and_practice_improves_them() { | |
| 1474 | + let ticks_to_chop = |level: u32| { | |
| 1475 | + let tree = UVec2::new(8, 5); | |
| 1476 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 1477 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 1478 | + app.world_mut() | |
| 1479 | + .entity_mut(colonist) | |
| 1480 | + .insert(crate::skills::Skills::uniform(level)); | |
| 1481 | + let mut ticks = 0; | |
| 1482 | + while wood_on_map(&mut app) == 0 && ticks < 60 * 60 { | |
| 1483 | + run_ticks(&mut app, 10); | |
| 1484 | + ticks += 10; | |
| 1485 | + } | |
| 1486 | + let skills = app.world().get::<crate::skills::Skills>(colonist).unwrap(); | |
| 1487 | + assert_ne!(skills, &crate::skills::Skills::uniform(level)); | |
| 1488 | + ticks | |
| 1489 | + }; | |
| 1490 | + assert!(ticks_to_chop(15) * 3 / 2 < ticks_to_chop(1)); | |
| 1491 | +} | |
| 1492 | + | |
| 1493 | +// Colonists leave work they are poor at to others: it comes after everything else they could do. | |
| 1494 | +#[test] | |
| 1495 | +fn colonists_try_work_they_are_poor_at_last() { | |
| 1496 | + let first_job = |construction: u32| { | |
| 1497 | + let mut app = test_app(map_with_trees(&[UVec2::new(8, 5)])); | |
| 1498 | + set_goals(&mut app, &[(ItemKind::Wood, 100)]); | |
| 1499 | + put_wood(&mut app, UVec2::new(4, 4), 20); | |
| 1500 | + put_beds(&mut app, 1); | |
| 1501 | + app.world_mut() | |
| 1502 | + .resource_mut::<Blueprints>() | |
| 1503 | + .0 | |
| 1504 | + .insert(UVec2::new(3, 8), BuildKind::Floor); | |
| 1505 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 1506 | + let mut skills = crate::skills::Skills::uniform(5); | |
| 1507 | + let level = crate::skills::xp_for(construction) - crate::skills::xp_for(5); | |
| 1508 | + skills.practice(crate::skills::Skill::Construction, level); | |
| 1509 | + app.world_mut().entity_mut(colonist).insert(skills); | |
| 1510 | + for _ in 0..300 { | |
| 1511 | + run_ticks(&mut app, 1); | |
| 1512 | + if let Some(job) = job(&app, colonist) | |
| 1513 | + && !matches!(job.task, Task::ExpandStockpile { .. } | Task::Haul { .. }) | |
| 1514 | + { | |
| 1515 | + return job.task; | |
| 1516 | + } | |
| 1517 | + } | |
| 1518 | + panic!("no job"); | |
| 1519 | + }; | |
| 1520 | + assert!(matches!(first_job(8), Task::Build { .. })); | |
| 1521 | + assert!(matches!( | |
| 1522 | + first_job(1), | |
| 1523 | + Task::Work { | |
| 1524 | + kind: WorkKind::Chop, | |
| 1525 | + .. | |
| 1526 | + } | |
| 1527 | + )); | |
| 1528 | +} | |
src/main.rs +1 -0
| @@ -24,6 +24,7 @@ mod rng; | ||
| 24 | 24 | mod save; |
| 25 | 25 | mod selection; |
| 26 | 26 | mod sim; |
| 27 | +mod skills; | |
| 27 | 28 | mod structures; |
| 28 | 29 | mod tilemap; |
| 29 | 30 | mod ui; |
src/mood.rs +7 -1
| @@ -12,6 +12,7 @@ use crate::{ | ||
| 12 | 12 | population::ColonyLog, |
| 13 | 13 | rng::SimRng, |
| 14 | 14 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | + skills::Skills, | |
| 15 | 16 | structures::{Structure, StructureKind}, |
| 16 | 17 | world::{TilePos, WorldMap}, |
| 17 | 18 | worldgen::WorldGenerated, |
| @@ -155,9 +156,14 @@ fn assign_age( | ||
| 155 | 156 | for (entity, child) in &newcomers { |
| 156 | 157 | let years = if child { 0 } else { rng.range_i32(ADULT_AGES) }; |
| 157 | 158 | let born = tick.0 as i64 - years as i64 * DAY_TICKS as i64; |
| 159 | + let skills = if child { | |
| 160 | + Skills::uniform(0) | |
| 161 | + } else { | |
| 162 | + Skills::random(&mut rng) | |
| 163 | + }; | |
| 158 | 164 | commands |
| 159 | 165 | .entity(entity) |
| 160 | - .insert((Age { born }, Mood::default())); | |
| 166 | + .insert((Age { born }, Mood::default(), skills)); | |
| 161 | 167 | } |
| 162 | 168 | } |
| 163 | 169 | |
src/save.rs +19 -2
| @@ -17,6 +17,7 @@ use crate::{ | ||
| 17 | 17 | population::{ColonyLog, Starvation}, |
| 18 | 18 | rng::SimRng, |
| 19 | 19 | sim::{SimPos, SimTick, sim_pos}, |
| 20 | + skills::Skills, | |
| 20 | 21 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 21 | 22 | world::{MapLayers, TilePos, WorldMap}, |
| 22 | 23 | worldgen::{WorldGenerated, WorldSeed, generate}, |
| @@ -54,6 +55,8 @@ pub struct ColonistSave { | ||
| 54 | 55 | last_meal: Option<(bool, u64)>, |
| 55 | 56 | unhappy: u32, |
| 56 | 57 | starvation: u32, |
| 58 | + #[serde(default)] | |
| 59 | + skills: Skills, | |
| 57 | 60 | } |
| 58 | 61 | |
| 59 | 62 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] |
| @@ -106,6 +109,7 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 106 | 109 | Option<&LastMeal>, |
| 107 | 110 | Option<&Unhappy>, |
| 108 | 111 | Option<&Starvation>, |
| 112 | + Option<&Skills>, | |
| 109 | 113 | ), |
| 110 | 114 | )>(); |
| 111 | 115 | let rows: Vec<_> = colonist_query.iter(world).collect(); |
| @@ -114,8 +118,19 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 114 | 118 | .iter() |
| 115 | 119 | .map( |
| 116 | 120 | |&(_, colonist, pos, tile, rest, hunger, carrying, priorities, extra)| { |
| 117 | - let (mood, age, family, partner, child, tool, sick, meal, unhappy, starvation) = | |
| 118 | - extra; | |
| 121 | + let ( | |
| 122 | + mood, | |
| 123 | + age, | |
| 124 | + family, | |
| 125 | + partner, | |
| 126 | + child, | |
| 127 | + tool, | |
| 128 | + sick, | |
| 129 | + meal, | |
| 130 | + unhappy, | |
| 131 | + starvation, | |
| 132 | + skills, | |
| 133 | + ) = extra; | |
| 119 | 134 | ColonistSave { |
| 120 | 135 | name: colonist.name.clone(), |
| 121 | 136 | pos: pos.0, |
| @@ -134,6 +149,7 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 134 | 149 | last_meal: meal.map(|m| (m.cooked, m.tick)), |
| 135 | 150 | unhappy: unhappy.map_or(0, |u| u.0), |
| 136 | 151 | starvation: starvation.map_or(0, |s| s.0), |
| 152 | + skills: skills.cloned().unwrap_or_default(), | |
| 137 | 153 | } |
| 138 | 154 | }, |
| 139 | 155 | ) |
| @@ -283,6 +299,7 @@ pub fn apply(world: &mut World, save: SaveGame) { | ||
| 283 | 299 | c.priorities.clone(), |
| 284 | 300 | Unhappy(c.unhappy), |
| 285 | 301 | Starvation(c.starvation), |
| 302 | + c.skills.clone(), | |
| 286 | 303 | )); |
| 287 | 304 | if let Some(mood) = c.mood { |
| 288 | 305 | e.insert(Mood(mood)); |
src/selection.rs +15 -1
| @@ -18,6 +18,7 @@ use crate::{ | ||
| 18 | 18 | needs::Rest, |
| 19 | 19 | population::ColonyLog, |
| 20 | 20 | sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day}, |
| 21 | + skills::Skills, | |
| 21 | 22 | structures::{Fuel, Structure, StructureKind}, |
| 22 | 23 | ui::{HideableUi, PointerOverUi}, |
| 23 | 24 | world::{TILE_SIZE, TilePos, WorldMap}, |
| @@ -281,6 +282,7 @@ fn update_tile_info( | ||
| 281 | 282 | Has<Sick>, |
| 282 | 283 | Option<&Warmth>, |
| 283 | 284 | Has<Tool>, |
| 285 | + Option<&Skills>, | |
| 284 | 286 | )>, |
| 285 | 287 | mut text: Single<&mut Text, With<TileInfoText>>, |
| 286 | 288 | ) { |
| @@ -301,7 +303,7 @@ fn update_tile_info( | ||
| 301 | 303 | fed: hunger.0, |
| 302 | 304 | }) + &kin |
| 303 | 305 | .get(entity) |
| 304 | - .map(|(partner, child, mood, age, sick, warmth, tool)| { | |
| 306 | + .map(|(partner, child, mood, age, sick, warmth, tool, skills)| { | |
| 305 | 307 | let partner = partner |
| 306 | 308 | .and_then(|p| colonists.get(p.0).ok()) |
| 307 | 309 | .map(|p| format!(", partner {}", p.0.name)); |
| @@ -315,6 +317,17 @@ fn update_tile_info( | ||
| 315 | 317 | .unwrap_or_default(); |
| 316 | 318 | let sick = if sick { ", sick" } else { "" }; |
| 317 | 319 | let tool = if tool { ", has tools" } else { "" }; |
| 320 | + let skills = skills | |
| 321 | + .filter(|_| child.is_none()) | |
| 322 | + .map(|s| { | |
| 323 | + let best: Vec<String> = s | |
| 324 | + .best(3) | |
| 325 | + .iter() | |
| 326 | + .map(|(skill, level)| format!("{} {level}", skill.name())) | |
| 327 | + .collect(); | |
| 328 | + format!("\nSkills: {}", best.join(", ")) | |
| 329 | + }) | |
| 330 | + .unwrap_or_default(); | |
| 318 | 331 | let warmth = warmth |
| 319 | 332 | .map(|w| { |
| 320 | 333 | let cold = if w.cold() { " (cold)" } else { "" }; |
| @@ -328,6 +341,7 @@ fn update_tile_info( | ||
| 328 | 341 | + sick |
| 329 | 342 | + tool |
| 330 | 343 | + &warmth |
| 344 | + + &skills | |
| 331 | 345 | }) |
| 332 | 346 | .unwrap_or_default() |
| 333 | 347 | }) |
src/skills.rs +168 -0
| @@ -0,0 +1,168 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | +use serde::{Deserialize, Serialize}; | |
| 3 | + | |
| 4 | +use crate::{ | |
| 5 | + jobs::{Recipe, Task, WorkKind}, | |
| 6 | + rng::SimRng, | |
| 7 | +}; | |
| 8 | + | |
| 9 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 10 | +pub enum Skill { | |
| 11 | + Mining, | |
| 12 | + Woodcutting, | |
| 13 | + Construction, | |
| 14 | + Farming, | |
| 15 | + Cooking, | |
| 16 | + Hunting, | |
| 17 | + Crafting, | |
| 18 | + Taming, | |
| 19 | +} | |
| 20 | + | |
| 21 | +impl Skill { | |
| 22 | + pub const ALL: [Skill; 8] = [ | |
| 23 | + Skill::Mining, | |
| 24 | + Skill::Woodcutting, | |
| 25 | + Skill::Construction, | |
| 26 | + Skill::Farming, | |
| 27 | + Skill::Cooking, | |
| 28 | + Skill::Hunting, | |
| 29 | + Skill::Crafting, | |
| 30 | + Skill::Taming, | |
| 31 | + ]; | |
| 32 | + | |
| 33 | + pub fn name(self) -> &'static str { | |
| 34 | + match self { | |
| 35 | + Skill::Mining => "Mining", | |
| 36 | + Skill::Woodcutting => "Woodcutting", | |
| 37 | + Skill::Construction => "Construction", | |
| 38 | + Skill::Farming => "Farming", | |
| 39 | + Skill::Cooking => "Cooking", | |
| 40 | + Skill::Hunting => "Hunting", | |
| 41 | + Skill::Crafting => "Crafting", | |
| 42 | + Skill::Taming => "Taming", | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + pub fn of(task: &Task) -> Option<Skill> { | |
| 47 | + Some(match task { | |
| 48 | + Task::Work { kind, .. } => match kind { | |
| 49 | + WorkKind::Chop => Skill::Woodcutting, | |
| 50 | + WorkKind::Mine => Skill::Mining, | |
| 51 | + WorkKind::Harvest | WorkKind::Sow => Skill::Farming, | |
| 52 | + }, | |
| 53 | + Task::Build { .. } => Skill::Construction, | |
| 54 | + Task::Craft { | |
| 55 | + recipe: Recipe::Meal, | |
| 56 | + .. | |
| 57 | + } | |
| 58 | + | Task::Butcher { .. } => Skill::Cooking, | |
| 59 | + Task::Craft { .. } => Skill::Crafting, | |
| 60 | + Task::Hunt { .. } => Skill::Hunting, | |
| 61 | + Task::Tame { .. } => Skill::Taming, | |
| 62 | + _ => return None, | |
| 63 | + }) | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +pub const MAX_LEVEL: u32 = 20; | |
| 68 | +pub const AVERAGE_LEVEL: u32 = 5; | |
| 69 | +pub const POOR_LEVEL: u32 = 3; | |
| 70 | +const XP_PER_LEVEL_SQUARED: f32 = 600.0; | |
| 71 | +const TALENT_LEVEL: u32 = 10; | |
| 72 | +const STARTING_LEVELS: std::ops::Range<i32> = 1..8; | |
| 73 | + | |
| 74 | +pub fn xp_for(level: u32) -> f32 { | |
| 75 | + (level * level) as f32 * XP_PER_LEVEL_SQUARED | |
| 76 | +} | |
| 77 | + | |
| 78 | +pub fn speed(level: u32) -> f32 { | |
| 79 | + 0.5 + 0.1 * level as f32 | |
| 80 | +} | |
| 81 | + | |
| 82 | +#[derive(Component, Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 83 | +pub struct Skills { | |
| 84 | + xp: [f32; Skill::ALL.len()], | |
| 85 | +} | |
| 86 | + | |
| 87 | +impl Default for Skills { | |
| 88 | + fn default() -> Self { | |
| 89 | + Self::uniform(AVERAGE_LEVEL) | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +impl Skills { | |
| 94 | + pub fn uniform(level: u32) -> Self { | |
| 95 | + Self { | |
| 96 | + xp: [xp_for(level); Skill::ALL.len()], | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + pub fn random(rng: &mut SimRng) -> Self { | |
| 101 | + let mut skills = Self::uniform(0); | |
| 102 | + for xp in &mut skills.xp { | |
| 103 | + *xp = xp_for(rng.range_i32(STARTING_LEVELS) as u32); | |
| 104 | + } | |
| 105 | + let talent = rng.range_i32(0..Skill::ALL.len() as i32) as usize; | |
| 106 | + skills.xp[talent] = xp_for(TALENT_LEVEL); | |
| 107 | + skills | |
| 108 | + } | |
| 109 | + | |
| 110 | + pub fn level(&self, skill: Skill) -> u32 { | |
| 111 | + ((self.xp[skill as usize] / XP_PER_LEVEL_SQUARED).sqrt() as u32).min(MAX_LEVEL) | |
| 112 | + } | |
| 113 | + | |
| 114 | + pub fn practice(&mut self, skill: Skill, ticks: f32) { | |
| 115 | + self.xp[skill as usize] += ticks; | |
| 116 | + } | |
| 117 | + | |
| 118 | + pub fn best(&self, count: usize) -> Vec<(Skill, u32)> { | |
| 119 | + let mut all: Vec<(Skill, u32)> = Skill::ALL.map(|s| (s, self.level(s))).to_vec(); | |
| 120 | + all.sort_by_key(|&(s, level)| (std::cmp::Reverse(level), s as usize)); | |
| 121 | + all.truncate(count); | |
| 122 | + all | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +#[derive(Component, Debug, Default, Clone, Copy, PartialEq)] | |
| 127 | +pub struct WorkCarry(pub f32); | |
| 128 | + | |
| 129 | +impl WorkCarry { | |
| 130 | + pub fn apply(&mut self, rate: u32, factor: f32) -> u32 { | |
| 131 | + self.0 += rate as f32 * factor; | |
| 132 | + let whole = self.0.floor(); | |
| 133 | + self.0 -= whole; | |
| 134 | + whole as u32 | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +#[cfg(test)] | |
| 139 | +mod tests { | |
| 140 | + use super::*; | |
| 141 | + | |
| 142 | + // Level 5 is the old flat work speed; each level is 10% either way, so a master works three | |
| 143 | + // times as fast as a novice. Practice is slow and gets slower: levels need squared work time. | |
| 144 | + #[test] | |
| 145 | + fn practice_raises_levels_and_levels_set_speed() { | |
| 146 | + assert_eq!(speed(AVERAGE_LEVEL), 1.0); | |
| 147 | + assert!(speed(MAX_LEVEL) > 3.0 * speed(1)); | |
| 148 | + let mut skills = Skills::uniform(0); | |
| 149 | + skills.practice(Skill::Mining, xp_for(1)); | |
| 150 | + assert_eq!(skills.level(Skill::Mining), 1); | |
| 151 | + skills.practice(Skill::Mining, xp_for(4) - xp_for(1)); | |
| 152 | + assert_eq!(skills.level(Skill::Mining), 4); | |
| 153 | + assert_eq!(skills.level(Skill::Cooking), 0); | |
| 154 | + } | |
| 155 | + | |
| 156 | + #[test] | |
| 157 | + fn fractional_work_carries_over_between_ticks() { | |
| 158 | + let mut carry = WorkCarry::default(); | |
| 159 | + let total: u32 = (0..10).map(|_| carry.apply(1, 0.5)).sum(); | |
| 160 | + assert_eq!(total, 5); | |
| 161 | + } | |
| 162 | + | |
| 163 | + #[test] | |
| 164 | + fn newcomers_have_one_talent() { | |
| 165 | + let skills = Skills::random(&mut SimRng::new(4)); | |
| 166 | + assert_eq!(skills.best(1)[0].1, TALENT_LEVEL); | |
| 167 | + } | |
| 168 | +} | |
tasks.md +1 -0
| @@ -45,6 +45,7 @@ | ||
| 45 | 45 | - [x] Colonist cap raised to 20 (26 names); job search pass to keep Max speed up at 20 colonists |
| 46 | 46 | - [x] Wood floors: colonists floor finished homes (mood bonus), Build tool floor mode; light only recomputes on wall/roof changes; search reach fix for far stacks |
| 47 | 47 | - [x] Connected wall rendering (wall tops join neighbouring walls and doors) |
| 48 | +- [x] Skills: eight work skills set work speed, improve with practice, newcomers have a talent; poor-skill work is tried last | |
| 48 | 49 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 49 | 50 | |
| 50 | 51 | ## Planned |