feat(simulation): multi-threaded executor + EntityRng (#843 Part B)

Enable Bevy multi-threaded executor via bevy_tasks multi_threaded
feature. Systems within the same TickPhase that don't share mutable
resources now run in parallel automatically.

Add EntityRng component — per-entity ChaCha20Rng seeded from
world_seed + StableId via splitmix64 mixing. More deterministic than
shared SimRng (order-independent). Migrate all monologue systems
(4 of 13 SimRng consumers) to EntityRng, removing contention that
serialized them against conversation/dialogue systems.

Add rayon dependency (infrastructure only, no par_iter calls yet).

SimRng retained for world-level randomness: conversation pairing,
knowledge transfer, dialogue, ticker, storyteller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 00:08:41 +02:00
co-authored by Claude Opus 4.6
parent 309c05d441
commit 2175e9b31c
5 changed files with 128 additions and 19 deletions
+19 -15
View File
@@ -18,7 +18,7 @@ use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry};
use crate::perception::interpretation::ObservationTrigger;
use crate::simulation::conversation::NpcName;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::rng::SimRng;
use crate::simulation::rng::EntityRng;
use crate::simulation::time::SimulationTime;
use crate::storyteller::EngagementRecord;
@@ -242,17 +242,17 @@ impl SprintAnomalyQueue {
/// System ordering: after trigger_monologue, before compute_observer_snapshot.
pub fn process_sprint_anomaly_monologue(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
mut query: Query<
(
&mut SprintAnomalyQueue,
&mut MonologueBuffer,
&mut MonologueState,
&mut EntityRng,
),
With<PlayerCharacter>,
>,
) {
let Ok((mut queue, mut buffer, mut state)) = query.single_mut() else {
let Ok((mut queue, mut buffer, mut state, mut entity_rng)) = query.single_mut() else {
return;
};
@@ -262,7 +262,7 @@ pub fn process_sprint_anomaly_monologue(
}
if let Some(_entity_id) = queue.take_ready(time.tick) {
let index = rng.rng.random_range(0..ANOMALY_LINES.len());
let index = entity_rng.rng.random_range(0..ANOMALY_LINES.len());
let (id, text) = ANOMALY_LINES[index];
buffer.event = Some(MonologueEvent {
@@ -295,18 +295,19 @@ pub fn process_sprint_anomaly_monologue(
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
pub fn trigger_recognition_monologue(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
mut query: Query<
(
&mut crate::perception::cognitive_delay::CognitiveDelay,
&mut MonologueBuffer,
&mut MonologueState,
&mut EntityRng,
),
With<PlayerCharacter>,
>,
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
) {
let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else {
let Ok((mut cognitive_delay, mut buffer, mut state, mut entity_rng)) = query.single_mut()
else {
return;
};
@@ -339,7 +340,7 @@ pub fn trigger_recognition_monologue(
return;
};
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
let i = entity_rng.rng.random_range(0..RECOGNITION_LINES.len());
let (id, text) = (
RECOGNITION_LINES[i].0.to_string(),
RECOGNITION_LINES[i].1.to_string(),
@@ -420,7 +421,6 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 {
#[allow(clippy::too_many_arguments)]
pub fn trigger_event_monologue(
time: Res<SimulationTime>,
mut rng: ResMut<SimRng>,
observation_queue: Option<Res<crate::perception::interpretation::ObservationEventQueue>>,
sound_queue: Option<Res<crate::simulation::sound::SoundEventQueue>>,
mut post_conv_queue: ResMut<PostConversationQueue>,
@@ -430,6 +430,7 @@ pub fn trigger_event_monologue(
&mut MonologueState,
&mut MonologueBuffer,
Option<&crate::simulation::conversation::ConversationEventBuffer>,
&mut EntityRng,
),
With<PlayerCharacter>,
>,
@@ -440,7 +441,9 @@ pub fn trigger_event_monologue(
// Saved for NPC attribution (engagement tracking #570) and trigger detection.
let post_conv_npcs: Vec<Entity> = post_conv_queue.drain();
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt)) = query.single_mut() else {
let Ok((player_pos, mut state, mut buffer, conv_buffer_opt, mut entity_rng)) =
query.single_mut()
else {
return;
};
@@ -489,7 +492,7 @@ pub fn trigger_event_monologue(
let Some(trigger) = trigger else { return };
let (id, text) = select_hardcoded_fallback(trigger, &mut rng.rng);
let (id, text) = select_hardcoded_fallback(trigger, &mut entity_rng.rng);
buffer.event = Some(MonologueEvent {
id: id.clone(),
@@ -584,7 +587,6 @@ fn has_hear_sound_event(
/// Remove this stub when those systems' ordering constraints are refactored.
pub fn trigger_monologue(
_time: Res<SimulationTime>,
_rng: ResMut<SimRng>,
_query: Query<
(&TilePosition, &mut MonologueState, &mut MonologueBuffer),
With<PlayerCharacter>,
@@ -635,10 +637,12 @@ pub(crate) fn resolve_name(
pub fn process_contradiction_monologue(
time: Res<SimulationTime>,
_registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut contradiction_queue: ResMut<ContradictionDetectedQueue>,
_npc_names: Query<&NpcName>,
mut player_query: Query<(&mut MonologueBuffer, &mut MonologueState), With<PlayerCharacter>>,
mut player_query: Query<
(&mut MonologueBuffer, &mut MonologueState, &mut EntityRng),
With<PlayerCharacter>,
>,
) {
// _registry and _npc_names are available for future triggers needing live name resolution
// via resolve_name(). ContradictionDetected uses pre-resolved names from the event payload.
@@ -647,7 +651,7 @@ pub fn process_contradiction_monologue(
return;
}
let Ok((mut buffer, mut state)) = player_query.single_mut() else {
let Ok((mut buffer, mut state, mut entity_rng)) = player_query.single_mut() else {
contradiction_queue.drain();
return;
};
@@ -664,7 +668,7 @@ pub fn process_contradiction_monologue(
return;
};
let template_idx = rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
let template_idx = entity_rng.rng.random_range(0..CONTRADICTION_TEMPLATE_LINES.len());
let text = CONTRADICTION_TEMPLATE_LINES[template_idx]
.replace("{source}", &event.source_display_name)
.replace("{subject}", &event.subject_display_name);
+42 -2
View File
@@ -6,8 +6,11 @@ use bevy_ecs::prelude::*;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
/// Simulation RNG resource
/// ChaCha20 RNG with stored seed for deterministic replay
/// Simulation RNG resource — shared world-level randomness.
///
/// Used for non-entity randomness: storyteller dice, world events, ticker rotation.
/// For entity-level randomness, use [`EntityRng`] instead — it enables parallelism
/// because each entity has its own independent, deterministic RNG stream.
#[derive(Resource)]
pub struct SimRng {
pub rng: ChaCha20Rng,
@@ -29,6 +32,43 @@ impl SimRng {
}
}
/// Per-entity RNG component — deterministic, parallel-safe (#843).
///
/// Each entity gets its own ChaCha20 stream seeded from `world_seed + StableId`.
/// Systems that need randomness for a specific entity query `&mut EntityRng`
/// instead of `ResMut<SimRng>`. This unlocks parallelism: Bevy can run systems
/// on disjoint entity sets concurrently.
///
/// Determinism: same `world_seed` + same `StableId` = same RNG sequence,
/// regardless of thread execution order.
#[derive(Component)]
pub struct EntityRng {
pub rng: ChaCha20Rng,
}
impl EntityRng {
/// Create a new EntityRng from a world seed and entity stable ID.
///
/// Uses splitmix64 mixing to combine the two seeds with good avalanche
/// properties — avoids correlated sequences for entities with similar IDs.
pub fn from_seed_and_id(world_seed: u64, stable_id: u64) -> Self {
let mixed = splitmix64(world_seed.wrapping_add(stable_id));
Self {
rng: ChaCha20Rng::seed_from_u64(mixed),
}
}
}
/// Splitmix64 mixing function — good avalanche properties for seed derivation.
/// Ensures that similar inputs (e.g., consecutive StableIds) produce
/// uncorrelated output seeds.
fn splitmix64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9e3779b97f4a7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
#[cfg(test)]
mod tests {
use super::*;