Skip to content

Commit a90da34

Browse files
milkyskiesclaude
andauthored
[#529] Aspirations: long-term goals seeded from personality (#758)
Adds an Aspirations component (SmallVec of up to 3) and an Aspiration kind catalog (BecomeGreatAt, FindPerson, Protect, Avenge, BuildHome, GainStatus, EscapeFate, Understand, FindBelonging, ProveSelf). Seeded at character creation by reading specific Big Five facets — high Achievement-Striving seeds BecomeGreatAt, high Altruism seeds Protect, high Ideas seeds Understand, etc. Wired into develop_phenotype_system using the existing genome-seeded RNG. Each aspiration tracks progress 0..1; advance() returns true exactly on the 1.0-crossing tick so future Satisfaction firing (T2.6) can hook in. Character sheet renders the aspirations with progress bars under the existing personality tab. Background-tag-driven seeding (warrior → ProveSelf, exile → FindBelonging) will be added when the Background system (#527) lands. Co-authored-by: milkyskies <milkyskies> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8267d67 commit a90da34

5 files changed

Lines changed: 374 additions & 1 deletion

File tree

src/agent/body/genetics/phenotype.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Phenotype component: traits derived from Genome + SpeciesProfile.
22
//!
33
//! Reads: Genome, SpeciesProfile (at spawn, via Added<Genome> trigger)
4-
//! Writes: Phenotype, Personality, Values, Vision (all inserted)
4+
//! Writes: Phenotype, Personality, Values, Aspirations, Vision (all inserted)
55
//! Upstream: genetics::genome (Genome), body::species (SpeciesProfile)
66
//! Downstream: nervous_system::execution (speed multiplier),
77
//! mind::perception (vision range), nervous_system (Personality)
@@ -19,6 +19,7 @@ use crate::agent::body::needs::{PsychologicalDrives, SocialDriveOverride};
1919
use crate::agent::body::species::SpeciesProfile;
2020
use crate::agent::events::{SimEvent, SimEventKind};
2121
use crate::agent::mind::perception::Vision;
22+
use crate::agent::psyche::aspirations::Aspirations;
2223
use crate::agent::psyche::personality::{Personality, PersonalityTraits};
2324
use crate::agent::psyche::values::Values;
2425
use crate::core::tick::TickCount;
@@ -313,6 +314,8 @@ pub fn develop_phenotype_system(
313314
),
314315
};
315316
let values = Values::from_personality(&personality.traits, &mut facet_rng);
317+
let aspirations =
318+
Aspirations::from_personality(&personality.traits, tick.current, &mut facet_rng);
316319

317320
let mut drives = PsychologicalDrives::from_personality(&personality.traits);
318321
if let Some(SocialDriveOverride(v)) = social_override {
@@ -338,6 +341,7 @@ pub fn develop_phenotype_system(
338341
},
339342
personality,
340343
values,
344+
aspirations,
341345
drives,
342346
));
343347
}

src/agent/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ impl Plugin for AgentPlugin {
109109
.register_type::<inventory::EntityType>()
110110
.register_type::<psyche::personality::Personality>()
111111
.register_type::<psyche::values::Values>()
112+
.register_type::<psyche::aspirations::Aspirations>()
112113
.register_type::<body::species::SpeciesProfile>()
113114
.register_type::<body::genetics::genome::Genome>()
114115
.register_type::<body::genetics::phenotype::Phenotype>()

src/agent/psyche/aspirations.rs

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
//! Aspirations: long-term goals that persist across many sim ticks.
2+
//!
3+
//! Seeded at character creation from personality (and later, from background
4+
//! tags). Aspirations give agents narrative arcs — "become a great hunter",
5+
//! "protect the village", "find belonging" — that outlast any single reactive
6+
//! `Goal`. Future tiers feed aspirations into ideal-self, desirability
7+
//! appraisal, and the goal-arbitration biaser.
8+
//!
9+
//! Reads: nothing (pure data)
10+
//! Writes: Aspirations (set once at spawn; progress mutated by other systems)
11+
//! Upstream: psyche::personality
12+
//! Downstream: ui::character_sheet, future appraisal/ideal-self/goal bias
13+
14+
use bevy::prelude::*;
15+
use rand::Rng;
16+
use rand::seq::IndexedRandom;
17+
use smallvec::SmallVec;
18+
19+
use crate::agent::psyche::personality::PersonalityTraits;
20+
use crate::agent::skills::SkillKind;
21+
22+
/// Maximum concurrent aspirations per agent. Keeps narrative arcs legible
23+
/// and per-agent storage bounded.
24+
pub const MAX_ASPIRATIONS: usize = 3;
25+
26+
const SEEDED_INTENSITY: f32 = 0.6;
27+
28+
/// Personality facet score above which a "high" trait pushes its candidate
29+
/// into the seed pool. ≈ top-third of a uniform [0, 1] facet distribution,
30+
/// so an average personality yields one or two seeded aspirations rather
31+
/// than zero or maxing out the cap.
32+
const SEED_THRESHOLD: f32 = 0.65;
33+
34+
#[derive(Component, Debug, Clone, Default, Reflect)]
35+
#[reflect(Component)]
36+
pub struct Aspirations {
37+
#[reflect(ignore)]
38+
pub goals: SmallVec<[Aspiration; MAX_ASPIRATIONS]>,
39+
}
40+
41+
#[derive(Debug, Clone, Reflect)]
42+
pub struct Aspiration {
43+
pub kind: AspirationKind,
44+
pub target: Option<AspirationTarget>,
45+
/// Progress toward fulfillment in `[0, 1]`. At 1.0 downstream systems
46+
/// fire Satisfaction (T2.6) on the crossing tick.
47+
pub progress: f32,
48+
pub formed_at: u64,
49+
/// Strength of the held goal in `[0, 1]`. Affects future appraisal
50+
/// weighting and goal-bias magnitude.
51+
pub intensity: f32,
52+
}
53+
54+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)]
55+
pub enum AspirationKind {
56+
BecomeGreatAt,
57+
FindPerson,
58+
Protect,
59+
Avenge,
60+
BuildHome,
61+
GainStatus,
62+
EscapeFate,
63+
Understand,
64+
FindBelonging,
65+
ProveSelf,
66+
}
67+
68+
/// Concrete object of an aspiration. Only typed targets are represented —
69+
/// the variants here are exactly the ones some seeder constructs today.
70+
/// Future seeders (Background tags, runtime formation) will add typed
71+
/// variants as they need them.
72+
#[derive(Debug, Clone, Reflect)]
73+
pub enum AspirationTarget {
74+
Skill(SkillKind),
75+
Entity(Entity),
76+
}
77+
78+
/// Display metadata per kind, indexed by enum discriminant. Single source
79+
/// of truth so adding a kind is a one-row change. Alignment with the enum
80+
/// is asserted in tests.
81+
const ASPIRATION_META: [(AspirationKind, &str); 10] = [
82+
(AspirationKind::BecomeGreatAt, "Master a craft"),
83+
(AspirationKind::FindPerson, "Find someone"),
84+
(AspirationKind::Protect, "Protect what matters"),
85+
(AspirationKind::Avenge, "Avenge a wrong"),
86+
(AspirationKind::BuildHome, "Build a home"),
87+
(AspirationKind::GainStatus, "Gain status"),
88+
(AspirationKind::EscapeFate, "Escape fate"),
89+
(AspirationKind::Understand, "Understand the world"),
90+
(AspirationKind::FindBelonging, "Find belonging"),
91+
(AspirationKind::ProveSelf, "Prove themselves"),
92+
];
93+
94+
impl AspirationKind {
95+
pub const ALL: [AspirationKind; 10] = [
96+
Self::BecomeGreatAt,
97+
Self::FindPerson,
98+
Self::Protect,
99+
Self::Avenge,
100+
Self::BuildHome,
101+
Self::GainStatus,
102+
Self::EscapeFate,
103+
Self::Understand,
104+
Self::FindBelonging,
105+
Self::ProveSelf,
106+
];
107+
108+
pub fn display_name(&self) -> &'static str {
109+
ASPIRATION_META[*self as usize].1
110+
}
111+
}
112+
113+
impl Aspiration {
114+
pub fn new(kind: AspirationKind, target: Option<AspirationTarget>, formed_at: u64) -> Self {
115+
Self {
116+
kind,
117+
target,
118+
progress: 0.0,
119+
formed_at,
120+
intensity: SEEDED_INTENSITY,
121+
}
122+
}
123+
124+
pub fn is_fulfilled(&self) -> bool {
125+
self.progress >= 1.0
126+
}
127+
128+
/// Add to progress, clamping to `[0, 1]`. Returns `true` exactly on the
129+
/// transition that crosses 1.0 — callers fire Satisfaction once on that
130+
/// edge. Subsequent calls after fulfillment return `false`.
131+
pub fn advance(&mut self, delta: f32) -> bool {
132+
let was_fulfilled = self.is_fulfilled();
133+
self.progress = (self.progress + delta).clamp(0.0, 1.0);
134+
!was_fulfilled && self.is_fulfilled()
135+
}
136+
137+
pub fn label(&self) -> String {
138+
let suffix = match &self.target {
139+
Some(AspirationTarget::Skill(s)) => format!(": {}", s.display_name()),
140+
Some(AspirationTarget::Entity(e)) => format!(" (#{})", e.index()),
141+
None => String::new(),
142+
};
143+
format!("{}{suffix}", self.kind.display_name())
144+
}
145+
}
146+
147+
impl Aspirations {
148+
/// Seed aspirations from personality at character creation. Picks up to
149+
/// `MAX_ASPIRATIONS` candidates whose triggering facet score exceeds
150+
/// `SEED_THRESHOLD`; ties broken by facet score, then deterministic RNG.
151+
pub fn from_personality(
152+
traits: &PersonalityTraits,
153+
formed_at: u64,
154+
rng: &mut impl Rng,
155+
) -> Self {
156+
let candidates: [(f32, AspirationKind, Option<AspirationTarget>); 7] = [
157+
// Conscientiousness facets → achievement-flavoured arcs.
158+
(
159+
traits.conscientiousness.achievement_striving,
160+
AspirationKind::BecomeGreatAt,
161+
Some(pick_starter_skill(rng)),
162+
),
163+
(
164+
traits.conscientiousness.competence,
165+
AspirationKind::ProveSelf,
166+
None,
167+
),
168+
(
169+
traits.conscientiousness.order,
170+
AspirationKind::BuildHome,
171+
None,
172+
),
173+
// Agreeableness facets → prosocial arcs.
174+
(traits.agreeableness.altruism, AspirationKind::Protect, None),
175+
(
176+
traits.agreeableness.tender_mindedness,
177+
AspirationKind::FindBelonging,
178+
None,
179+
),
180+
// Openness facets → exploratory arcs.
181+
(traits.openness.ideas, AspirationKind::Understand, None),
182+
// Extraversion facets → status arcs.
183+
(
184+
traits.extraversion.assertiveness,
185+
AspirationKind::GainStatus,
186+
None,
187+
),
188+
];
189+
190+
let mut filtered: Vec<_> = candidates
191+
.into_iter()
192+
.filter(|(score, _, _)| *score >= SEED_THRESHOLD)
193+
.collect();
194+
filtered.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
195+
filtered.truncate(MAX_ASPIRATIONS);
196+
197+
let goals = filtered
198+
.into_iter()
199+
.map(|(_, kind, target)| Aspiration::new(kind, target, formed_at))
200+
.collect();
201+
202+
Self { goals }
203+
}
204+
}
205+
206+
fn pick_starter_skill(rng: &mut impl Rng) -> AspirationTarget {
207+
let skill = *SkillKind::ALL
208+
.choose(rng)
209+
.expect("SkillKind::ALL is non-empty");
210+
AspirationTarget::Skill(skill)
211+
}
212+
213+
#[cfg(test)]
214+
mod tests {
215+
use super::*;
216+
use crate::agent::psyche::personality::{
217+
AgreeablenessFacets, ConscientiousnessFacets, OpennessFacets, PersonalityTraits,
218+
};
219+
use rand_chacha::ChaCha8Rng;
220+
use rand_chacha::rand_core::SeedableRng;
221+
222+
fn rng() -> ChaCha8Rng {
223+
ChaCha8Rng::seed_from_u64(0xA5)
224+
}
225+
226+
#[test]
227+
fn caps_at_three_aspirations() {
228+
let traits = PersonalityTraits::uniform(1.0, 1.0, 1.0, 1.0, 0.5);
229+
let asp = Aspirations::from_personality(&traits, 0, &mut rng());
230+
assert_eq!(asp.goals.len(), MAX_ASPIRATIONS);
231+
}
232+
233+
#[test]
234+
fn neutral_personality_seeds_no_aspirations() {
235+
let traits = PersonalityTraits::default();
236+
let asp = Aspirations::from_personality(&traits, 0, &mut rng());
237+
assert!(
238+
asp.goals.is_empty(),
239+
"neutral facets should seed zero aspirations, got {}",
240+
asp.goals.len()
241+
);
242+
}
243+
244+
#[test]
245+
fn high_achievement_striving_seeds_become_great_at() {
246+
let traits = PersonalityTraits {
247+
conscientiousness: ConscientiousnessFacets {
248+
achievement_striving: 0.95,
249+
..Default::default()
250+
},
251+
..Default::default()
252+
};
253+
let asp = Aspirations::from_personality(&traits, 0, &mut rng());
254+
assert!(
255+
asp.goals
256+
.iter()
257+
.any(|g| g.kind == AspirationKind::BecomeGreatAt),
258+
"high achievement-striving should seed BecomeGreatAt; got {:?}",
259+
asp.goals.iter().map(|g| g.kind).collect::<Vec<_>>()
260+
);
261+
}
262+
263+
#[test]
264+
fn high_altruism_seeds_protect() {
265+
let traits = PersonalityTraits {
266+
agreeableness: AgreeablenessFacets {
267+
altruism: 0.95,
268+
..Default::default()
269+
},
270+
..Default::default()
271+
};
272+
let asp = Aspirations::from_personality(&traits, 0, &mut rng());
273+
assert!(
274+
asp.goals.iter().any(|g| g.kind == AspirationKind::Protect),
275+
"high altruism should seed Protect"
276+
);
277+
}
278+
279+
#[test]
280+
fn high_openness_ideas_seeds_understand() {
281+
let traits = PersonalityTraits {
282+
openness: OpennessFacets {
283+
ideas: 0.95,
284+
..Default::default()
285+
},
286+
..Default::default()
287+
};
288+
let asp = Aspirations::from_personality(&traits, 0, &mut rng());
289+
assert!(
290+
asp.goals
291+
.iter()
292+
.any(|g| g.kind == AspirationKind::Understand)
293+
);
294+
}
295+
296+
#[test]
297+
fn same_personality_and_seed_produces_same_aspirations() {
298+
let traits = PersonalityTraits::uniform(0.7, 0.8, 0.6, 0.85, 0.4);
299+
let a = Aspirations::from_personality(&traits, 100, &mut ChaCha8Rng::seed_from_u64(7));
300+
let b = Aspirations::from_personality(&traits, 100, &mut ChaCha8Rng::seed_from_u64(7));
301+
assert_eq!(a.goals.len(), b.goals.len());
302+
for (ga, gb) in a.goals.iter().zip(b.goals.iter()) {
303+
assert_eq!(ga.kind, gb.kind);
304+
assert_eq!(ga.formed_at, gb.formed_at);
305+
}
306+
}
307+
308+
#[test]
309+
fn advance_clamps_and_signals_fulfilment_once() {
310+
let mut a = Aspiration::new(AspirationKind::BuildHome, None, 0);
311+
assert!(!a.advance(0.5));
312+
assert!(!a.is_fulfilled());
313+
314+
// Crossing 1.0 fires once.
315+
assert!(a.advance(0.6));
316+
assert!(a.is_fulfilled());
317+
assert_eq!(a.progress, 1.0);
318+
319+
// Subsequent advances do not re-fire.
320+
assert!(!a.advance(0.5));
321+
assert_eq!(a.progress, 1.0);
322+
}
323+
324+
#[test]
325+
fn label_includes_target_when_present() {
326+
let a = Aspiration::new(
327+
AspirationKind::BecomeGreatAt,
328+
Some(AspirationTarget::Skill(SkillKind::Combat)),
329+
0,
330+
);
331+
let label = a.label();
332+
assert!(label.contains("Master a craft"));
333+
assert!(label.contains("Combat"));
334+
}
335+
336+
#[test]
337+
fn aspiration_meta_table_aligns_with_enum() {
338+
for kind in AspirationKind::ALL {
339+
assert_eq!(
340+
ASPIRATION_META[kind as usize].0, kind,
341+
"ASPIRATION_META misaligned at {kind:?}; reorder the table to match the enum"
342+
);
343+
}
344+
}
345+
}

src/agent/psyche/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod aspirations;
12
pub mod emotions;
23
pub mod flocking;
34
pub mod greetings;

0 commit comments

Comments
 (0)