Add friendships, rivalries and mourning

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

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

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