Add friendships, rivalries and mourning
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 19:20 UTC
- commit
- 09e56887dbd06ca8a7de6447b95f6dabe086b557
- parent
- cd9d4d9652
- tree
- browse at this commit
11 files changed +508 -18
| CLAUDE.md | +1 -0 | |
| src/colonist.rs | +3 -1 | |
| src/family.rs | +27 -4 | |
| src/jobs/mod.rs | +33 -8 | |
| src/jobs/tests.rs | +1 -0 | |
| src/main.rs | +2 -0 | |
| src/mood.rs | +26 -2 | |
| src/save.rs | +32 -2 | |
| src/selection.rs | +33 -1 | |
| src/social.rs | added | +349 -0 |
| tasks.md | +1 -0 |
CLAUDE.md +1 -0
| @@ -44,6 +44,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 44 | 44 | - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (MAX_COLONISTS = usize::MAX, i.e. uncapped; stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, `free_name_index`; `colonist_name` numbers repeats past the 26 NAMES, e.g. "Ada 2") appears at an `edge_tile` with an unordered MoveTo home. |
| 45 | 45 | - `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), hearth at `Home::hearth_tile` (hi−1), statue + `BuildKind::Floor` on unfloored interior tiles (`floored`; doesn't overwrite other blueprints) for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). Households (`Household{partners, children}` per family; `adults()` = max(partners,1)) size homes via `desired_interior` (solo 3×2, couple 4×3, +1 child 5×3, 2+ children 5×4). Couples get a vacant home (preferring ones big enough) or `plan_sized`; otherwise one solo adult per pass (no Partner, not Child, family None / parents' family / no home) gets a new `Family` (Commands) and a vacant home ≤ 4×3 or a planned solo room. Owned built homes smaller than desired get `HomeContext::expansion`: grow one side (width first, then height) where the new ring may reuse walls and the new strip is free; opened old wall/door tiles get Deconstruct, missing new ring tiles Wall; Homes entry updated at once. Owned built homes get Bed blueprints up to `adults()` at `bed_tiles_for`. New homes via `HomeContext::plan_sized`: BFS from colony home (center ≥ HOME_MIN_DISTANCE), first HOME_CANDIDATES fits, pick the one sharing most walls (`shared_walls`: ring tiles may be existing walls or planned wall blueprints, else open and not in a room; interior open and not in a room; some door site must open onto walkable, non-room ground). `Home::blueprints(map, toward)` plans only missing ring walls, door via `door_site` (non-wall, walkable outward), beds. Adoption: any rectangular room (`home_of_room`, interior ≥ MIN_HOME_INTERIOR 3×2) with ≥2 beds not already a home → vacant. `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes (owned or vacant). Selection text shows partner / child days left. |
| 46 | 46 | - `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick. |
| 47 | +- `src/social.rs` `Personality{warmth}` (required on Colonist, random in `assign_age`), `Relations(opinions, bonds)` keyed (min,max) entity pair. `socialize` (hourly): opinions fade, awake colonists within SOCIAL_RANGE roll chat (+, `chat_chance(warmth)`) or argument (−, more when grumpy); `bond_after` hysteresis: Friends at ≥ FRIEND 0.5 until < FRIEND_LOST 0.3, Rivals at ≤ −0.5 until > −0.3 (stops log spam); bond changes logged. `mourn`: friends/partner of a removed colonist get `Mourning` 2 days. Mood: +per friend (≤3), −per rival (≤2), lonely (no friend/partner), mourning. `bond` (family.rs) skips pairs with negative opinion, chance scales with opinion. Idle wanderers visit a friend (VISIT_CHANCE). Opinions saved per colonist pair. | |
| 47 | 48 | - Storage rules (zones.rs): `Zones.rules` per stockpile tile `StorageRule{refused: Vec<Stock>, priority: StoragePriority::{Low, Normal, High}}` (absent = default), `accepts(tile, kind)`, `update_region_rule` applies to the 4-connected stockpile region; removing tiles drops rules. `find_storage` tries priorities High→Normal→Low, nearest accepting free tile with room; `find_haul` treats items as loose when their tile doesn't accept them or a higher-priority accepting tile with room exists (`storage_candidates`), so stock moves up the priorities and settles. `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals. |
| 48 | 49 | - `src/panel.rs` top-right colonist panel. Roster = scrollable column (max ROSTER_MAX_HEIGHT, `Overflow::scroll_y`, `scroll_roster` moves `ScrollPosition` while `Hovered`; camera `scroll_zoom` ignores the wheel over UI) of `HouseholdButton` headers from `household_rows` (grouped by Family, None → "Others"; label = adult names, count, " :(" / " hurt" flags) that toggle `ExpandedHouseholds`; expanded rows list `ColonistButton`s; selecting a colonist expands their household. (Also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities. |
| 49 | 50 | - `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). |
src/colonist.rs +3 -1
| @@ -12,6 +12,7 @@ use crate::{ | ||
| 12 | 12 | population::Starvation, |
| 13 | 13 | sim::{SimPos, SimSystems, sim_pos}, |
| 14 | 14 | skills::{Skills, WorkCarry}, |
| 15 | + social::Personality, | |
| 15 | 16 | threats::Health, |
| 16 | 17 | world::{TILE_SIZE, TilePos, WorldMap}, |
| 17 | 18 | worldgen::{WorldGenerated, spawn_tiles}, |
| @@ -112,7 +113,8 @@ pub fn spawn_colonist( | ||
| 112 | 113 | Warmth, |
| 113 | 114 | Skills, |
| 114 | 115 | WorkCarry, |
| 115 | - Health | |
| 116 | + Health, | |
| 117 | + Personality | |
| 116 | 118 | )] |
| 117 | 119 | pub struct Colonist { |
| 118 | 120 | pub name: String, |
src/family.rs +27 -4
| @@ -14,6 +14,7 @@ use crate::{ | ||
| 14 | 14 | rng::SimRng, |
| 15 | 15 | rooms::{Room, Rooms}, |
| 16 | 16 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 17 | + social::Relations, | |
| 17 | 18 | structures::{Structure, StructureKind}, |
| 18 | 19 | world::{Terrain, TilePos, WorldMap}, |
| 19 | 20 | worldgen::WorldGenerated, |
| @@ -262,6 +263,7 @@ pub fn bond_hour( | ||
| 262 | 263 | affinity: &mut Affinity, |
| 263 | 264 | singles: &[Single], |
| 264 | 265 | rng: &mut SimRng, |
| 266 | + liking: impl Fn(Entity, Entity) -> f32, | |
| 265 | 267 | ) -> Vec<(Entity, Entity)> { |
| 266 | 268 | let mut bonded: Vec<(Entity, Entity)> = Vec::new(); |
| 267 | 269 | let taken = |
| @@ -270,7 +272,8 @@ pub fn bond_hour( | ||
| 270 | 272 | for b in &singles[i + 1..] { |
| 271 | 273 | let near = (a.tile.as_ivec2() - b.tile.as_ivec2()).abs().max_element() <= BOND_RANGE; |
| 272 | 274 | let kin = a.family.is_some() && a.family == b.family; |
| 273 | - if !near || kin { | |
| 275 | + let opinion = liking(a.entity, b.entity); | |
| 276 | + if !near || kin || opinion < 0.0 { | |
| 274 | 277 | continue; |
| 275 | 278 | } |
| 276 | 279 | let key = (a.entity.min(b.entity), a.entity.max(b.entity)); |
| @@ -279,7 +282,7 @@ pub fn bond_hour( | ||
| 279 | 282 | if *hours >= BOND_HOURS |
| 280 | 283 | && !taken(&bonded, a.entity) |
| 281 | 284 | && !taken(&bonded, b.entity) |
| 282 | - && rng.range_f32(0.0..1.0) < BOND_CHANCE | |
| 285 | + && rng.range_f32(0.0..1.0) < BOND_CHANCE * (0.5 + opinion) | |
| 283 | 286 | { |
| 284 | 287 | bonded.push(key); |
| 285 | 288 | } |
| @@ -301,6 +304,7 @@ fn bond( | ||
| 301 | 304 | mut homes: ResMut<Homes>, |
| 302 | 305 | mut affinity: ResMut<Affinity>, |
| 303 | 306 | mut log: ResMut<ColonyLog>, |
| 307 | + relations: Res<Relations>, | |
| 304 | 308 | colonists: Query<( |
| 305 | 309 | Entity, |
| 306 | 310 | &Colonist, |
| @@ -329,7 +333,8 @@ fn bond( | ||
| 329 | 333 | family: family.copied(), |
| 330 | 334 | }) |
| 331 | 335 | .collect(); |
| 332 | - for (a, b) in bond_hour(&mut affinity, &singles, &mut rng) { | |
| 336 | + let liking = |a, b| relations.opinion(a, b); | |
| 337 | + for (a, b) in bond_hour(&mut affinity, &singles, &mut rng, liking) { | |
| 333 | 338 | let family = homes.new_family(); |
| 334 | 339 | commands.entity(a).insert((Partner(b), family)); |
| 335 | 340 | commands.entity(b).insert((Partner(a), family)); |
| @@ -948,7 +953,7 @@ mod tests { | ||
| 948 | 953 | .filter(|s| !bonded.iter().any(|&(a, b)| a == s.entity || b == s.entity)) |
| 949 | 954 | .copied() |
| 950 | 955 | .collect(); |
| 951 | - bonded.extend(bond_hour(&mut affinity, &free, &mut rng)); | |
| 956 | + bonded.extend(bond_hour(&mut affinity, &free, &mut rng, |_, _| 0.5)); | |
| 952 | 957 | } |
| 953 | 958 | bonded |
| 954 | 959 | } |
| @@ -1382,4 +1387,22 @@ mod tests { | ||
| 1382 | 1387 | let home = ctx.plan(UVec2::new(24, 20)).unwrap(); |
| 1383 | 1388 | assert!(home.lo.x > 28, "{home:?}"); |
| 1384 | 1389 | } |
| 1390 | + | |
| 1391 | + // Couples form between people who like each other; rivals never pair up however much time | |
| 1392 | + // they spend together. | |
| 1393 | + #[test] | |
| 1394 | + fn rivals_never_become_partners() { | |
| 1395 | + let e = |i: u32| Entity::from_raw_u32(i).unwrap(); | |
| 1396 | + let single = |i: u32, x: u32| Single { | |
| 1397 | + entity: e(i), | |
| 1398 | + tile: UVec2::new(x, 0), | |
| 1399 | + family: None, | |
| 1400 | + }; | |
| 1401 | + let pair = [single(1, 0), single(2, 1)]; | |
| 1402 | + let mut affinity = Affinity::default(); | |
| 1403 | + let mut rng = SimRng::new(1); | |
| 1404 | + for _ in 0..BOND_HOURS * 20 { | |
| 1405 | + assert!(bond_hour(&mut affinity, &pair, &mut rng, |_, _| -0.6).is_empty()); | |
| 1406 | + } | |
| 1407 | + } | |
| 1385 | 1408 | } |
src/jobs/mod.rs +33 -8
| @@ -36,6 +36,7 @@ use crate::{ | ||
| 36 | 36 | rng::SimRng, |
| 37 | 37 | sim::{SimSystems, SimTick, hour_of_day}, |
| 38 | 38 | skills::{POOR_LEVEL, Skill, Skills, WorkCarry, speed}, |
| 39 | + social::{FRIEND, Relations}, | |
| 39 | 40 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 40 | 41 | threats::{Health, INJURED, TEND_WORK, TENDED_TICKS, Tended, needs_tending, tend_quality}, |
| 41 | 42 | world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, |
| @@ -1114,6 +1115,9 @@ pub const SHOT_DAMAGE: f32 = 0.2; | ||
| 1114 | 1115 | const FIGHT_SEARCH: usize = 4000; |
| 1115 | 1116 | const FIGHT_STEPS: usize = 4; |
| 1116 | 1117 | pub const DANGER_RANGE: i32 = 6; |
| 1118 | +const VISIT_CHANCE: f32 = 0.4; | |
| 1119 | +const VISIT_RANGE: i32 = 30; | |
| 1120 | +const VISIT_SEARCH: usize = 3000; | |
| 1117 | 1121 | pub const DEFEND_RANGE: i32 = 15; |
| 1118 | 1122 | const FLEE_STEPS: f32 = 10.0; |
| 1119 | 1123 | |
| @@ -1351,10 +1355,15 @@ fn choose_jobs( | ||
| 1351 | 1355 | Res<ColonyReach>, |
| 1352 | 1356 | Query<(Entity, &TilePos, &Health, Option<&Tended>), With<Colonist>>, |
| 1353 | 1357 | Query<(Entity, &TilePos), With<Raider>>, |
| 1358 | + Res<Relations>, | |
| 1354 | 1359 | ), |
| 1355 | 1360 | mut gather_memo: Local<GatherMemo>, |
| 1356 | 1361 | ) { |
| 1357 | - let (homes, designations, reach, patient_query, raider_query) = plans; | |
| 1362 | + let (homes, designations, reach, patient_query, raider_query, relations) = plans; | |
| 1363 | + let whereabouts: HashMap<Entity, UVec2> = patient_query | |
| 1364 | + .iter() | |
| 1365 | + .map(|(e, pos, ..)| (e, pos.0)) | |
| 1366 | + .collect(); | |
| 1358 | 1367 | let raiders: HashSet<Entity> = raider_query.iter().map(|(e, _)| e).collect(); |
| 1359 | 1368 | let patients: Vec<(Entity, UVec2)> = patient_query |
| 1360 | 1369 | .iter() |
| @@ -1808,14 +1817,30 @@ fn choose_jobs( | ||
| 1808 | 1817 | }); |
| 1809 | 1818 | continue; |
| 1810 | 1819 | } |
| 1811 | - let goal = tile.0.as_ivec2() | |
| 1812 | - + IVec2::new( | |
| 1813 | - rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 1814 | - rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 1815 | - ); | |
| 1820 | + let friend = (rng.range_f32(0.0..1.0) < VISIT_CHANCE) | |
| 1821 | + .then(|| { | |
| 1822 | + relations | |
| 1823 | + .of(entity) | |
| 1824 | + .filter(|(_, opinion)| *opinion >= FRIEND) | |
| 1825 | + .filter_map(|(friend, _)| whereabouts.get(&friend).copied()) | |
| 1826 | + .filter(|at| chebyshev(*at, tile.0) <= VISIT_RANGE) | |
| 1827 | + .min_by_key(|at| (chebyshev(*at, tile.0), at.y, at.x)) | |
| 1828 | + }) | |
| 1829 | + .flatten(); | |
| 1830 | + let offset = IVec2::new(rng.range_i32(-1..2), rng.range_i32(-1..2)); | |
| 1831 | + let (goal, search) = match friend { | |
| 1832 | + Some(at) => (at.as_ivec2() + offset, VISIT_SEARCH), | |
| 1833 | + None => ( | |
| 1834 | + tile.0.as_ivec2() | |
| 1835 | + + IVec2::new( | |
| 1836 | + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 1837 | + rng.range_i32(-WANDER_RADIUS..WANDER_RADIUS + 1), | |
| 1838 | + ), | |
| 1839 | + WANDER_SEARCH, | |
| 1840 | + ), | |
| 1841 | + }; | |
| 1816 | 1842 | if map.in_bounds(goal) |
| 1817 | - && let Some(wander_path) = | |
| 1818 | - find_nearby_path(&map, tile.0, goal.as_uvec2(), WANDER_SEARCH) | |
| 1843 | + && let Some(wander_path) = find_nearby_path(&map, tile.0, goal.as_uvec2(), search) | |
| 1819 | 1844 | { |
| 1820 | 1845 | path.0 = wander_path.into(); |
| 1821 | 1846 | } |
src/jobs/tests.rs +1 -0
| @@ -23,6 +23,7 @@ fn test_app(map: WorldMap) -> App { | ||
| 23 | 23 | .init_resource::<crate::family::Homes>() |
| 24 | 24 | .init_resource::<crate::pathfinding::ColonyReach>() |
| 25 | 25 | .init_resource::<crate::rooms::Rooms>() |
| 26 | + .init_resource::<crate::social::Relations>() | |
| 26 | 27 | .add_message::<crate::mood::Ate>() |
| 27 | 28 | .init_resource::<crate::designations::Designations>() |
| 28 | 29 | .init_resource::<ColonyGoals>() |
src/main.rs +2 -0
| @@ -27,6 +27,7 @@ mod save; | ||
| 27 | 27 | mod selection; |
| 28 | 28 | mod sim; |
| 29 | 29 | mod skills; |
| 30 | +mod social; | |
| 30 | 31 | mod structures; |
| 31 | 32 | mod threats; |
| 32 | 33 | mod tilemap; |
| @@ -89,6 +90,7 @@ fn main() { | ||
| 89 | 90 | threats::ThreatsPlugin, |
| 90 | 91 | rooms::RoomsPlugin, |
| 91 | 92 | raids::RaidsPlugin, |
| 93 | + social::SocialPlugin, | |
| 92 | 94 | )) |
| 93 | 95 | .run(); |
| 94 | 96 | } |
src/mood.rs +26 -2
| @@ -14,6 +14,7 @@ use crate::{ | ||
| 14 | 14 | rooms::Rooms, |
| 15 | 15 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 16 | 16 | skills::Skills, |
| 17 | + social::{Mourning, Personality, Relations}, | |
| 17 | 18 | threats::{HURT, Health}, |
| 18 | 19 | world::{TilePos, WorldMap}, |
| 19 | 20 | worldgen::WorldGenerated, |
| @@ -40,6 +41,10 @@ pub const SICK_TICKS: u64 = 12 * TICKS_PER_HOUR; | ||
| 40 | 41 | pub const MOOD_HOURS: f32 = 12.0; |
| 41 | 42 | pub const LEAVE_MOOD: f32 = 0.2; |
| 42 | 43 | pub const ROOM_MOOD: f32 = 0.3; |
| 44 | +pub const FRIEND_MOOD: f32 = 0.04; | |
| 45 | +pub const RIVAL_MOOD: f32 = 0.06; | |
| 46 | +pub const LONELY_MOOD: f32 = 0.05; | |
| 47 | +pub const MOURNING_MOOD: f32 = 0.2; | |
| 43 | 48 | pub const LEAVE_AFTER_HOURS: u32 = 48; |
| 44 | 49 | pub const OLD_AGE: u64 = 60; |
| 45 | 50 | pub const OLD_AGE_DEATH_PER_YEAR: f32 = 0.03; |
| @@ -113,6 +118,9 @@ pub struct MoodFactors { | ||
| 113 | 118 | pub grieving: bool, |
| 114 | 119 | pub cold: bool, |
| 115 | 120 | pub room: f32, |
| 121 | + pub friends: usize, | |
| 122 | + pub rivals: usize, | |
| 123 | + pub mourning: bool, | |
| 116 | 124 | pub hurt: bool, |
| 117 | 125 | } |
| 118 | 126 | |
| @@ -141,6 +149,14 @@ pub fn mood_target(f: MoodFactors) -> f32 { | ||
| 141 | 149 | mood -= 0.15; |
| 142 | 150 | } |
| 143 | 151 | mood += ROOM_MOOD * f.room; |
| 152 | + mood += FRIEND_MOOD * f.friends.min(3) as f32; | |
| 153 | + mood -= RIVAL_MOOD * f.rivals.min(2) as f32; | |
| 154 | + if f.friends == 0 && !f.partner { | |
| 155 | + mood -= LONELY_MOOD; | |
| 156 | + } | |
| 157 | + if f.mourning { | |
| 158 | + mood -= MOURNING_MOOD; | |
| 159 | + } | |
| 144 | 160 | if f.hurt { |
| 145 | 161 | mood -= 0.1; |
| 146 | 162 | } |
| @@ -161,9 +177,12 @@ fn assign_age( | ||
| 161 | 177 | } else { |
| 162 | 178 | Skills::random(&mut rng) |
| 163 | 179 | }; |
| 180 | + let personality = Personality { | |
| 181 | + warmth: rng.range_f32(0.0..1.0), | |
| 182 | + }; | |
| 164 | 183 | commands |
| 165 | 184 | .entity(entity) |
| 166 | - .insert((Age { born }, Mood::default(), skills)); | |
| 185 | + .insert((Age { born }, Mood::default(), skills, personality)); | |
| 167 | 186 | } |
| 168 | 187 | } |
| 169 | 188 | |
| @@ -201,6 +220,7 @@ fn update_mood( | ||
| 201 | 220 | map: Res<WorldMap>, |
| 202 | 221 | losses: Res<Losses>, |
| 203 | 222 | rooms: Res<Rooms>, |
| 223 | + relations: Res<Relations>, | |
| 204 | 224 | mut colonists: Query<( |
| 205 | 225 | Entity, |
| 206 | 226 | &mut Mood, |
| @@ -212,13 +232,14 @@ fn update_mood( | ||
| 212 | 232 | Option<&Family>, |
| 213 | 233 | &Warmth, |
| 214 | 234 | &Health, |
| 235 | + Has<Mourning>, | |
| 215 | 236 | )>, |
| 216 | 237 | ) { |
| 217 | 238 | if !tick.0.is_multiple_of(TICKS_PER_HOUR) { |
| 218 | 239 | return; |
| 219 | 240 | } |
| 220 | 241 | let grieving = losses.0.iter().any(|&t| tick.0 < t + GRIEF_TICKS); |
| 221 | - for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health) in | |
| 242 | + for (entity, mut mood, hunger, rest, sick, meal, partner, family, warmth, health, mourning) in | |
| 222 | 243 | &mut colonists |
| 223 | 244 | { |
| 224 | 245 | let sick_now = sick.is_some_and(|s| tick.0 < s.until); |
| @@ -244,6 +265,9 @@ fn update_mood( | ||
| 244 | 265 | cold: warmth.cold(), |
| 245 | 266 | room, |
| 246 | 267 | hurt: health.0 < HURT, |
| 268 | + friends: relations.friends(entity), | |
| 269 | + rivals: relations.rivals(entity), | |
| 270 | + mourning, | |
| 247 | 271 | }); |
| 248 | 272 | mood.0 += (target - mood.0) / MOOD_HOURS; |
| 249 | 273 | } |
src/save.rs +32 -2
| @@ -18,6 +18,7 @@ use crate::{ | ||
| 18 | 18 | rng::SimRng, |
| 19 | 19 | sim::{SimPos, SimTick, sim_pos}, |
| 20 | 20 | skills::Skills, |
| 21 | + social::{Personality, Relations}, | |
| 21 | 22 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| 22 | 23 | threats::Health, |
| 23 | 24 | world::{MapLayers, TilePos, WorldMap}, |
| @@ -60,6 +61,10 @@ pub struct ColonistSave { | ||
| 60 | 61 | skills: Skills, |
| 61 | 62 | #[serde(default = "full_health")] |
| 62 | 63 | health: f32, |
| 64 | + #[serde(default)] | |
| 65 | + personality: Personality, | |
| 66 | + #[serde(default)] | |
| 67 | + opinions: Vec<(usize, f32)>, | |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | 70 | fn full_health() -> f32 { |
| @@ -118,11 +123,12 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 118 | 123 | Option<&Starvation>, |
| 119 | 124 | Option<&Skills>, |
| 120 | 125 | Option<&Health>, |
| 126 | + Option<&Personality>, | |
| 121 | 127 | ), |
| 122 | 128 | )>(); |
| 123 | 129 | let rows: Vec<_> = colonist_query.iter(world).collect(); |
| 124 | 130 | let order: Vec<Entity> = rows.iter().map(|r| r.0).collect(); |
| 125 | - let colonists = rows | |
| 131 | + let mut colonists: Vec<ColonistSave> = rows | |
| 126 | 132 | .iter() |
| 127 | 133 | .map( |
| 128 | 134 | |&(_, colonist, pos, tile, rest, hunger, carrying, priorities, extra)| { |
| @@ -139,6 +145,7 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 139 | 145 | starvation, |
| 140 | 146 | skills, |
| 141 | 147 | health, |
| 148 | + personality, | |
| 142 | 149 | ) = extra; |
| 143 | 150 | ColonistSave { |
| 144 | 151 | name: colonist.name.clone(), |
| @@ -160,10 +167,20 @@ pub fn snapshot(world: &mut World) -> SaveGame { | ||
| 160 | 167 | starvation: starvation.map_or(0, |s| s.0), |
| 161 | 168 | skills: skills.cloned().unwrap_or_default(), |
| 162 | 169 | health: health.map_or(1.0, |h| h.0), |
| 170 | + personality: personality.copied().unwrap_or_default(), | |
| 171 | + opinions: Vec::new(), | |
| 163 | 172 | } |
| 164 | 173 | }, |
| 165 | 174 | ) |
| 166 | 175 | .collect(); |
| 176 | + let relations = world.resource::<Relations>(); | |
| 177 | + for (i, save) in colonists.iter_mut().enumerate() { | |
| 178 | + save.opinions = relations | |
| 179 | + .of(order[i]) | |
| 180 | + .filter_map(|(other, v)| order.iter().position(|&o| o == other).map(|j| (j, v))) | |
| 181 | + .filter(|&(j, _)| j > i) | |
| 182 | + .collect(); | |
| 183 | + } | |
| 167 | 184 | let items = world |
| 168 | 185 | .query::<(&Item, &TilePos)>() |
| 169 | 186 | .iter(world) |
| @@ -312,6 +329,7 @@ pub fn apply(world: &mut World, save: SaveGame) { | ||
| 312 | 329 | Starvation(c.starvation), |
| 313 | 330 | c.skills.clone(), |
| 314 | 331 | Health(c.health), |
| 332 | + c.personality, | |
| 315 | 333 | )); |
| 316 | 334 | if let Some(mood) = c.mood { |
| 317 | 335 | e.insert(Mood(mood)); |
| @@ -341,11 +359,18 @@ pub fn apply(world: &mut World, save: SaveGame) { | ||
| 341 | 359 | entity |
| 342 | 360 | }) |
| 343 | 361 | .collect(); |
| 362 | + let mut relations = Relations::default(); | |
| 344 | 363 | for (c, &entity) in save.colonists.iter().zip(&entities) { |
| 345 | 364 | if let Some(&partner) = c.partner.and_then(|i| entities.get(i)) { |
| 346 | 365 | commands.entity(entity).insert(Partner(partner)); |
| 347 | 366 | } |
| 367 | + for &(other, opinion) in &c.opinions { | |
| 368 | + if let Some(&other) = entities.get(other) { | |
| 369 | + relations.set(entity, other, opinion); | |
| 370 | + } | |
| 371 | + } | |
| 348 | 372 | } |
| 373 | + commands.insert_resource(relations); | |
| 349 | 374 | state.apply(world); |
| 350 | 375 | } |
| 351 | 376 | |
| @@ -472,7 +497,8 @@ mod tests { | ||
| 472 | 497 | .init_resource::<ItemAssets>() |
| 473 | 498 | .init_resource::<StructureAssets>() |
| 474 | 499 | .init_resource::<AnimalAssets>() |
| 475 | - .init_resource::<Assets<ColorMaterial>>(); | |
| 500 | + .init_resource::<Assets<ColorMaterial>>() | |
| 501 | + .init_resource::<Relations>(); | |
| 476 | 502 | app |
| 477 | 503 | } |
| 478 | 504 | |
| @@ -515,6 +541,7 @@ mod tests { | ||
| 515 | 541 | )) |
| 516 | 542 | .id(); |
| 517 | 543 | world.entity_mut(ada).insert(Partner(bram)); |
| 544 | + world.resource_mut::<Relations>().set(ada, bram, 0.7); | |
| 518 | 545 | world.spawn((Item::new(ItemKind::Meal, 3), TilePos(UVec2::new(1, 1)))); |
| 519 | 546 | world.spawn(( |
| 520 | 547 | Structure { |
| @@ -550,11 +577,14 @@ mod tests { | ||
| 550 | 577 | let find = |name: &str| rows.iter().find(|r| r.1.name == name).unwrap(); |
| 551 | 578 | let (ada, bram) = (find("Ada"), find("Bram")); |
| 552 | 579 | assert_eq!(ada.2.map(|p| p.0), Some(bram.0)); |
| 580 | + let (ada_e, bram_e) = (ada.0, bram.0); | |
| 553 | 581 | assert_eq!(ada.3.0, 0.4); |
| 554 | 582 | assert_eq!(world.get::<Tool>(ada.0), Some(&Tool { wear: 99 })); |
| 555 | 583 | assert_eq!(world.get::<Carrying>(ada.0).unwrap().0.unwrap().count, 7); |
| 556 | 584 | let fuel: Vec<u32> = world.query::<&Fuel>().iter(world).map(|f| f.0).collect(); |
| 557 | 585 | assert_eq!(fuel, vec![500]); |
| 558 | 586 | assert_eq!(world.query::<&Item>().iter(world).count(), 1); |
| 587 | + let opinion = world.resource::<Relations>().opinion(ada_e, bram_e); | |
| 588 | + assert!((opinion - 0.7).abs() < 1e-6, "{opinion}"); | |
| 559 | 589 | } |
| 560 | 590 | } |
src/selection.rs +33 -1
| @@ -20,6 +20,7 @@ use crate::{ | ||
| 20 | 20 | rooms::Rooms, |
| 21 | 21 | sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day}, |
| 22 | 22 | skills::Skills, |
| 23 | + social::{FRIEND, RIVAL, Relations}, | |
| 23 | 24 | structures::{Fuel, Structure, StructureKind}, |
| 24 | 25 | threats::{Health, TEND_WORK}, |
| 25 | 26 | ui::{HideableUi, PointerOverUi}, |
| @@ -274,6 +275,7 @@ fn update_tile_info( | ||
| 274 | 275 | Res<BuildTool>, |
| 275 | 276 | Res<DesignateTool>, |
| 276 | 277 | Res<Rooms>, |
| 278 | + Res<Relations>, | |
| 277 | 279 | ), |
| 278 | 280 | clock: ( |
| 279 | 281 | Res<Time<Virtual>>, |
| @@ -356,6 +358,9 @@ fn update_tile_info( | ||
| 356 | 358 | + &health |
| 357 | 359 | + &warmth |
| 358 | 360 | + &skills |
| 361 | + + &social_line(&tools.4, entity, |e| { | |
| 362 | + colonists.get(e).ok().map(|c| c.0.name.clone()) | |
| 363 | + }) | |
| 359 | 364 | }, |
| 360 | 365 | ) |
| 361 | 366 | .unwrap_or_default() |
| @@ -397,7 +402,7 @@ fn update_tile_info( | ||
| 397 | 402 | .join(" ") |
| 398 | 403 | }; |
| 399 | 404 | let materials = format!("{}\n{}", stock_line(false), stock_line(true)); |
| 400 | - let (tool, build_tool, designate_tool, rooms) = tools; | |
| 405 | + let (tool, build_tool, designate_tool, rooms, _) = tools; | |
| 401 | 406 | let hovered_room = cursor |
| 402 | 407 | .tile |
| 403 | 408 | .and_then(|tile| rooms.at(tile)) |
| @@ -547,6 +552,33 @@ pub fn describe_structure(kind: StructureKind, fuel: Option<u32>) -> String { | ||
| 547 | 552 | } |
| 548 | 553 | } |
| 549 | 554 | |
| 555 | +pub fn social_line( | |
| 556 | + relations: &Relations, | |
| 557 | + who: Entity, | |
| 558 | + name: impl Fn(Entity) -> Option<String>, | |
| 559 | +) -> String { | |
| 560 | + let mut known: Vec<(Entity, f32)> = relations.of(who).collect(); | |
| 561 | + known.sort_by(|a, b| b.1.total_cmp(&a.1)); | |
| 562 | + let names = |pick: &dyn Fn(f32) -> bool| -> Vec<String> { | |
| 563 | + known | |
| 564 | + .iter() | |
| 565 | + .filter(|(_, v)| pick(*v)) | |
| 566 | + .filter_map(|(e, _)| name(*e)) | |
| 567 | + .take(4) | |
| 568 | + .collect() | |
| 569 | + }; | |
| 570 | + let friends = names(&|v| v >= FRIEND); | |
| 571 | + let rivals = names(&|v| v <= RIVAL); | |
| 572 | + let mut line = String::new(); | |
| 573 | + if !friends.is_empty() { | |
| 574 | + line += &format!("\nFriends: {}", friends.join(", ")); | |
| 575 | + } | |
| 576 | + if !rivals.is_empty() { | |
| 577 | + line += &format!("\nRivals: {}", rivals.join(", ")); | |
| 578 | + } | |
| 579 | + line | |
| 580 | +} | |
| 581 | + | |
| 550 | 582 | pub fn progress_fraction(task: Task) -> Option<f32> { |
| 551 | 583 | match task { |
| 552 | 584 | Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32), |
src/social.rs +349 -0
| @@ -0,0 +1,349 @@ | ||
| 1 | +use std::collections::HashMap; | |
| 2 | + | |
| 3 | +use bevy::prelude::*; | |
| 4 | +use serde::{Deserialize, Serialize}; | |
| 5 | + | |
| 6 | +use crate::{ | |
| 7 | + colonist::{Colonist, Path}, | |
| 8 | + family::Partner, | |
| 9 | + jobs::CurrentJob, | |
| 10 | + mood::Mood, | |
| 11 | + needs::is_sleeping, | |
| 12 | + population::ColonyLog, | |
| 13 | + rng::SimRng, | |
| 14 | + sim::{SimSystems, SimTick, TICKS_PER_HOUR}, | |
| 15 | + world::TilePos, | |
| 16 | + worldgen::WorldGenerated, | |
| 17 | +}; | |
| 18 | + | |
| 19 | +pub struct SocialPlugin; | |
| 20 | + | |
| 21 | +impl Plugin for SocialPlugin { | |
| 22 | + fn build(&self, app: &mut App) { | |
| 23 | + app.init_resource::<Relations>() | |
| 24 | + .add_systems(Update, clear_relations.run_if(on_message::<WorldGenerated>)) | |
| 25 | + .add_systems( | |
| 26 | + FixedUpdate, | |
| 27 | + (mourn, socialize).chain().in_set(SimSystems::Act), | |
| 28 | + ); | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +pub const SOCIAL_RANGE: i32 = 4; | |
| 33 | +pub const FRIEND: f32 = 0.5; | |
| 34 | +pub const RIVAL: f32 = -0.5; | |
| 35 | +pub const FRIEND_LOST: f32 = 0.3; | |
| 36 | +pub const RIVAL_LOST: f32 = -0.3; | |
| 37 | +const CHAT_GAIN: f32 = 0.1; | |
| 38 | +const ARGUMENT_LOSS: f32 = 0.12; | |
| 39 | +const CHAT_CHANCE: f32 = 0.35; | |
| 40 | +const ARGUMENT_CHANCE: f32 = 0.12; | |
| 41 | +const GRUMPY_MOOD: f32 = 0.3; | |
| 42 | +const FADE_PER_HOUR: f32 = 0.998; | |
| 43 | +pub const MOURNING_TICKS: u64 = 2 * 24 * TICKS_PER_HOUR; | |
| 44 | + | |
| 45 | +#[derive(Component, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] | |
| 46 | +pub struct Personality { | |
| 47 | + pub warmth: f32, | |
| 48 | +} | |
| 49 | + | |
| 50 | +impl Default for Personality { | |
| 51 | + fn default() -> Self { | |
| 52 | + Self { warmth: 0.5 } | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 57 | +pub struct Mourning { | |
| 58 | + pub until: u64, | |
| 59 | +} | |
| 60 | + | |
| 61 | +#[derive(Resource, Default, Debug, Clone)] | |
| 62 | +pub struct Relations( | |
| 63 | + pub HashMap<(Entity, Entity), f32>, | |
| 64 | + pub HashMap<(Entity, Entity), Bond>, | |
| 65 | +); | |
| 66 | + | |
| 67 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 68 | +pub enum Bond { | |
| 69 | + Friends, | |
| 70 | + Rivals, | |
| 71 | +} | |
| 72 | + | |
| 73 | +pub fn bond_after(bond: Option<Bond>, opinion: f32) -> Option<Bond> { | |
| 74 | + match bond { | |
| 75 | + Some(Bond::Friends) if opinion >= FRIEND_LOST => Some(Bond::Friends), | |
| 76 | + Some(Bond::Rivals) if opinion <= RIVAL_LOST => Some(Bond::Rivals), | |
| 77 | + _ if opinion >= FRIEND => Some(Bond::Friends), | |
| 78 | + _ if opinion <= RIVAL => Some(Bond::Rivals), | |
| 79 | + _ => None, | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +fn key(a: Entity, b: Entity) -> (Entity, Entity) { | |
| 84 | + (a.min(b), a.max(b)) | |
| 85 | +} | |
| 86 | + | |
| 87 | +impl Relations { | |
| 88 | + pub fn opinion(&self, a: Entity, b: Entity) -> f32 { | |
| 89 | + self.0.get(&key(a, b)).copied().unwrap_or(0.0) | |
| 90 | + } | |
| 91 | + | |
| 92 | + pub fn change(&mut self, a: Entity, b: Entity, delta: f32) -> (f32, f32) { | |
| 93 | + let value = self.0.entry(key(a, b)).or_insert(0.0); | |
| 94 | + let before = *value; | |
| 95 | + *value = (*value + delta).clamp(-1.0, 1.0); | |
| 96 | + (before, *value) | |
| 97 | + } | |
| 98 | + | |
| 99 | + pub fn set(&mut self, a: Entity, b: Entity, value: f32) { | |
| 100 | + self.0.insert(key(a, b), value.clamp(-1.0, 1.0)); | |
| 101 | + } | |
| 102 | + | |
| 103 | + pub fn of(&self, who: Entity) -> impl Iterator<Item = (Entity, f32)> + '_ { | |
| 104 | + self.0.iter().filter_map(move |(&(a, b), &v)| { | |
| 105 | + (a == who) | |
| 106 | + .then_some((b, v)) | |
| 107 | + .or((b == who).then_some((a, v))) | |
| 108 | + }) | |
| 109 | + } | |
| 110 | + | |
| 111 | + pub fn bond(&self, a: Entity, b: Entity) -> Option<Bond> { | |
| 112 | + self.1.get(&key(a, b)).copied() | |
| 113 | + } | |
| 114 | + | |
| 115 | + pub fn bonded(&self, who: Entity, bond: Bond) -> impl Iterator<Item = Entity> + '_ { | |
| 116 | + self.1.iter().filter_map(move |(&(a, b), &kind)| { | |
| 117 | + (kind == bond && (a == who || b == who)).then_some(if a == who { b } else { a }) | |
| 118 | + }) | |
| 119 | + } | |
| 120 | + | |
| 121 | + pub fn friends(&self, who: Entity) -> usize { | |
| 122 | + self.bonded(who, Bond::Friends).count() | |
| 123 | + } | |
| 124 | + | |
| 125 | + pub fn rivals(&self, who: Entity) -> usize { | |
| 126 | + self.bonded(who, Bond::Rivals).count() | |
| 127 | + } | |
| 128 | + | |
| 129 | + pub fn update_bond(&mut self, a: Entity, b: Entity) -> Option<Bond> { | |
| 130 | + let k = key(a, b); | |
| 131 | + let before = self.1.get(&k).copied(); | |
| 132 | + let after = bond_after(before, self.opinion(a, b)); | |
| 133 | + match after { | |
| 134 | + Some(bond) => self.1.insert(k, bond), | |
| 135 | + None => self.1.remove(&k), | |
| 136 | + }; | |
| 137 | + (after != before).then_some(after).flatten() | |
| 138 | + } | |
| 139 | + | |
| 140 | + pub fn forget(&mut self, who: Entity) { | |
| 141 | + self.0.retain(|&(a, b), _| a != who && b != who); | |
| 142 | + self.1.retain(|&(a, b), _| a != who && b != who); | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +fn clear_relations(mut relations: ResMut<Relations>) { | |
| 147 | + relations.0.clear(); | |
| 148 | +} | |
| 149 | + | |
| 150 | +pub fn chat_chance(warmth: f32) -> f32 { | |
| 151 | + CHAT_CHANCE * (0.5 + warmth) | |
| 152 | +} | |
| 153 | + | |
| 154 | +pub fn argument_chance(warmth: f32, grumpy: bool) -> f32 { | |
| 155 | + ARGUMENT_CHANCE * (1.5 - warmth) * if grumpy { 2.0 } else { 1.0 } | |
| 156 | +} | |
| 157 | + | |
| 158 | +#[allow(clippy::type_complexity)] | |
| 159 | +fn socialize( | |
| 160 | + tick: Res<SimTick>, | |
| 161 | + mut rng: ResMut<SimRng>, | |
| 162 | + mut relations: ResMut<Relations>, | |
| 163 | + mut log: ResMut<ColonyLog>, | |
| 164 | + colonists: Query<( | |
| 165 | + Entity, | |
| 166 | + &Colonist, | |
| 167 | + &TilePos, | |
| 168 | + &CurrentJob, | |
| 169 | + &Path, | |
| 170 | + Option<&Personality>, | |
| 171 | + Option<&Mood>, | |
| 172 | + )>, | |
| 173 | +) { | |
| 174 | + if !tick.0.is_multiple_of(TICKS_PER_HOUR) { | |
| 175 | + return; | |
| 176 | + } | |
| 177 | + for value in relations.0.values_mut() { | |
| 178 | + *value *= FADE_PER_HOUR; | |
| 179 | + } | |
| 180 | + let pairs: Vec<(Entity, Entity)> = relations.1.keys().copied().collect(); | |
| 181 | + for (a, b) in pairs { | |
| 182 | + relations.update_bond(a, b); | |
| 183 | + } | |
| 184 | + let awake: Vec<(Entity, &str, UVec2, f32, bool)> = colonists | |
| 185 | + .iter() | |
| 186 | + .filter(|(_, _, tile, job, path, ..)| !is_sleeping(job, tile, path)) | |
| 187 | + .map(|(e, c, tile, _, _, personality, mood)| { | |
| 188 | + let warmth = personality.copied().unwrap_or_default().warmth; | |
| 189 | + let grumpy = mood.is_some_and(|m| m.0 < GRUMPY_MOOD); | |
| 190 | + (e, c.name.as_str(), tile.0, warmth, grumpy) | |
| 191 | + }) | |
| 192 | + .collect(); | |
| 193 | + for (i, a) in awake.iter().enumerate() { | |
| 194 | + for b in &awake[i + 1..] { | |
| 195 | + let near = (a.2.as_ivec2() - b.2.as_ivec2()).abs().max_element() <= SOCIAL_RANGE; | |
| 196 | + if !near { | |
| 197 | + continue; | |
| 198 | + } | |
| 199 | + let warmth = (a.3 + b.3) / 2.0; | |
| 200 | + let roll = rng.range_f32(0.0..1.0); | |
| 201 | + let chat = chat_chance(warmth); | |
| 202 | + let delta = if roll < chat { | |
| 203 | + CHAT_GAIN | |
| 204 | + } else if roll < chat + argument_chance(warmth, a.4 || b.4) { | |
| 205 | + -ARGUMENT_LOSS | |
| 206 | + } else { | |
| 207 | + continue; | |
| 208 | + }; | |
| 209 | + relations.change(a.0, b.0, delta); | |
| 210 | + let event = match relations.update_bond(a.0, b.0) { | |
| 211 | + Some(Bond::Friends) => "became friends", | |
| 212 | + Some(Bond::Rivals) => "became rivals", | |
| 213 | + None => continue, | |
| 214 | + }; | |
| 215 | + log.push(*tick, format!("{} and {} {event}", a.1, b.1)); | |
| 216 | + } | |
| 217 | + } | |
| 218 | +} | |
| 219 | + | |
| 220 | +fn mourn( | |
| 221 | + mut commands: Commands, | |
| 222 | + tick: Res<SimTick>, | |
| 223 | + mut relations: ResMut<Relations>, | |
| 224 | + mut removed: RemovedComponents<Colonist>, | |
| 225 | + colonists: Query<(Entity, Option<&Partner>), With<Colonist>>, | |
| 226 | + mourning: Query<(Entity, &Mourning)>, | |
| 227 | +) { | |
| 228 | + for (entity, grief) in &mourning { | |
| 229 | + if tick.0 >= grief.until { | |
| 230 | + commands.entity(entity).remove::<Mourning>(); | |
| 231 | + } | |
| 232 | + } | |
| 233 | + for gone in removed.read() { | |
| 234 | + for (entity, partner) in &colonists { | |
| 235 | + let close = relations.bond(entity, gone) == Some(Bond::Friends) | |
| 236 | + || partner.is_some_and(|p| p.0 == gone); | |
| 237 | + if close { | |
| 238 | + commands.entity(entity).insert(Mourning { | |
| 239 | + until: tick.0 + MOURNING_TICKS, | |
| 240 | + }); | |
| 241 | + } | |
| 242 | + } | |
| 243 | + relations.forget(gone); | |
| 244 | + } | |
| 245 | +} | |
| 246 | + | |
| 247 | +#[cfg(test)] | |
| 248 | +mod tests { | |
| 249 | + use super::*; | |
| 250 | + | |
| 251 | + fn e(i: u32) -> Entity { | |
| 252 | + Entity::from_raw_u32(i).unwrap() | |
| 253 | + } | |
| 254 | + | |
| 255 | + #[test] | |
| 256 | + fn opinions_are_shared_and_cross_into_friendship_or_rivalry() { | |
| 257 | + let mut relations = Relations::default(); | |
| 258 | + relations.change(e(1), e(2), 0.3); | |
| 259 | + assert_eq!(relations.opinion(e(2), e(1)), 0.3); | |
| 260 | + relations.set(e(1), e(3), FRIEND); | |
| 261 | + relations.set(e(1), e(4), RIVAL); | |
| 262 | + assert_eq!(relations.update_bond(e(1), e(3)), Some(Bond::Friends)); | |
| 263 | + assert_eq!(relations.update_bond(e(1), e(4)), Some(Bond::Rivals)); | |
| 264 | + assert_eq!((relations.friends(e(1)), relations.rivals(e(1))), (1, 1)); | |
| 265 | + relations.forget(e(1)); | |
| 266 | + assert!(relations.0.is_empty()); | |
| 267 | + } | |
| 268 | + | |
| 269 | + // Warm people chat more and argue less; unhappy people pick fights. | |
| 270 | + #[test] | |
| 271 | + fn warmth_and_mood_steer_conversations() { | |
| 272 | + assert!(chat_chance(0.9) > chat_chance(0.1)); | |
| 273 | + assert!(argument_chance(0.1, false) > argument_chance(0.9, false)); | |
| 274 | + assert!(argument_chance(0.5, true) > argument_chance(0.5, false)); | |
| 275 | + } | |
| 276 | + | |
| 277 | + // Colonists who spend their days side by side grow close (or come to loathe each other); | |
| 278 | + // the colony log notes when a friendship forms. | |
| 279 | + #[test] | |
| 280 | + fn colonists_near_each_other_form_opinions() { | |
| 281 | + let mut app = App::new(); | |
| 282 | + app.init_resource::<SimTick>() | |
| 283 | + .insert_resource(SimRng::new(5)) | |
| 284 | + .init_resource::<Relations>() | |
| 285 | + .init_resource::<ColonyLog>() | |
| 286 | + .add_systems(Update, socialize); | |
| 287 | + let spawn = |app: &mut App, name: &str, x: u32| { | |
| 288 | + app.world_mut() | |
| 289 | + .spawn(( | |
| 290 | + Colonist { name: name.into() }, | |
| 291 | + TilePos(UVec2::new(x, 0)), | |
| 292 | + Personality { warmth: 1.0 }, | |
| 293 | + )) | |
| 294 | + .id() | |
| 295 | + }; | |
| 296 | + let ada = spawn(&mut app, "Ada", 0); | |
| 297 | + let bram = spawn(&mut app, "Bram", 2); | |
| 298 | + let far = spawn(&mut app, "Cleo", 30); | |
| 299 | + for hour in 0..100 { | |
| 300 | + app.world_mut().resource_mut::<SimTick>().0 = hour * TICKS_PER_HOUR; | |
| 301 | + app.update(); | |
| 302 | + } | |
| 303 | + let relations = app.world().resource::<Relations>(); | |
| 304 | + assert!(relations.opinion(ada, bram) >= FRIEND); | |
| 305 | + assert_eq!(relations.opinion(ada, far), 0.0); | |
| 306 | + let log = app.world().resource::<ColonyLog>(); | |
| 307 | + assert!( | |
| 308 | + log.0 | |
| 309 | + .iter() | |
| 310 | + .any(|l| l.ends_with("Ada and Bram became friends")) | |
| 311 | + ); | |
| 312 | + } | |
| 313 | + | |
| 314 | + // Losing a friend or partner hurts more than distant grief: they mourn for two days. | |
| 315 | + #[test] | |
| 316 | + fn friends_mourn_the_dead() { | |
| 317 | + let mut app = App::new(); | |
| 318 | + app.init_resource::<SimTick>() | |
| 319 | + .init_resource::<Relations>() | |
| 320 | + .add_systems(Update, mourn); | |
| 321 | + let spawn = | |
| 322 | + |app: &mut App, name: &str| app.world_mut().spawn(Colonist { name: name.into() }).id(); | |
| 323 | + let (ada, bram, cleo) = ( | |
| 324 | + spawn(&mut app, "Ada"), | |
| 325 | + spawn(&mut app, "Bram"), | |
| 326 | + spawn(&mut app, "Cleo"), | |
| 327 | + ); | |
| 328 | + let mut relations = app.world_mut().resource_mut::<Relations>(); | |
| 329 | + relations.set(ada, bram, 0.8); | |
| 330 | + relations.update_bond(ada, bram); | |
| 331 | + app.update(); | |
| 332 | + app.world_mut().despawn(bram); | |
| 333 | + app.update(); | |
| 334 | + assert!(app.world().get::<Mourning>(ada).is_some()); | |
| 335 | + assert!(app.world().get::<Mourning>(cleo).is_none()); | |
| 336 | + assert!(app.world().resource::<Relations>().1.is_empty()); | |
| 337 | + } | |
| 338 | + | |
| 339 | + // Friendships and rivalries need a clear swing to end, so an opinion hovering at the line | |
| 340 | + // doesn't flip them back and forth (and flood the log). | |
| 341 | + #[test] | |
| 342 | + fn bonds_have_hysteresis() { | |
| 343 | + assert_eq!(bond_after(None, 0.49), None); | |
| 344 | + assert_eq!(bond_after(None, 0.5), Some(Bond::Friends)); | |
| 345 | + assert_eq!(bond_after(Some(Bond::Friends), 0.35), Some(Bond::Friends)); | |
| 346 | + assert_eq!(bond_after(Some(Bond::Friends), 0.2), None); | |
| 347 | + assert_eq!(bond_after(Some(Bond::Rivals), -0.35), Some(Bond::Rivals)); | |
| 348 | + } | |
| 349 | +} | |
tasks.md +1 -0
| @@ -59,6 +59,7 @@ | ||
| 59 | 59 | - [x] Bridges: colonists bridge busy wading spots and water on much shorter (or otherwise impossible) routes, over deep water from the shore outwards; Build tool Bridge mode |
| 60 | 60 | - [x] Colonist panel for big colonies: households as collapsible, flagged rows in a scrollable list |
| 61 | 61 | - [x] Raiders: periodic bands that steal from stores and fight colonists; colonists defend (ranged, Hunting skill), raiders flee when hurt or loaded, killed raiders drop loot |
| 62 | +- [x] Social life: personalities (warmth), shared opinions from chats/arguments near each other, friendships/rivalries (with hysteresis) affect mood and loneliness, rivals never pair up, friends visit when idle and mourn the dead; saved | |
| 62 | 63 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 63 | 64 | |
| 64 | 65 | ## Planned |