Add colonist skills

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

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

@@ -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