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:
Generated
+49
@@ -306,6 +306,7 @@ dependencies = [
|
||||
"async-task",
|
||||
"atomic-waker",
|
||||
"bevy_platform",
|
||||
"concurrent-queue",
|
||||
"crossbeam-queue",
|
||||
"derive_more",
|
||||
"futures-lite",
|
||||
@@ -485,6 +486,25 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -581,6 +601,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -605,6 +631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -1094,6 +1121,26 @@ dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -1260,6 +1307,7 @@ version = "0.1.34"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bevy_tasks",
|
||||
"bincode",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
@@ -1267,6 +1315,7 @@ dependencies = [
|
||||
"pathfinding",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rayon",
|
||||
"rmp-serde",
|
||||
"ron",
|
||||
"rusqlite",
|
||||
|
||||
@@ -6,6 +6,8 @@ edition = "2021"
|
||||
[dependencies]
|
||||
bevy_ecs = "0.18"
|
||||
bevy_app = "0.18"
|
||||
bevy_tasks = { version = "0.18", features = ["multi_threaded"] }
|
||||
rayon = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
ron = "0.8"
|
||||
|
||||
+16
-2
@@ -199,7 +199,7 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else {
|
||||
setup_proof_room(&mut app, archetype);
|
||||
setup_proof_room(&mut app, archetype, seed);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
@@ -395,9 +395,11 @@ fn dump_schedule_graph() {
|
||||
fn setup_proof_room(
|
||||
app: &mut App,
|
||||
archetype: settled_reach_server::bridge::types::CharacterArchetype,
|
||||
world_seed: u64,
|
||||
) {
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::simulation::rng::EntityRng;
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, RelationshipKind, RoutineEntry, ToleranceThreshold, Want,
|
||||
@@ -459,7 +461,10 @@ fn setup_proof_room(
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
let player_sid = registry.register(player);
|
||||
app.world_mut()
|
||||
.entity_mut(player)
|
||||
.insert(EntityRng::from_seed_and_id(world_seed, player_sid.0));
|
||||
|
||||
// NPC 1: Dock worker at (16,13) — behind wall, full routine
|
||||
let npc1 = app
|
||||
@@ -507,6 +512,9 @@ fn setup_proof_room(
|
||||
))
|
||||
.id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
app.world_mut()
|
||||
.entity_mut(npc1)
|
||||
.insert(EntityRng::from_seed_and_id(world_seed, npc1_sid.0));
|
||||
|
||||
// NPC 2: Field tech at (14,18) — visible to player, has routine
|
||||
let npc2 = app
|
||||
@@ -544,6 +552,9 @@ fn setup_proof_room(
|
||||
))
|
||||
.id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
app.world_mut()
|
||||
.entity_mut(npc2)
|
||||
.insert(EntityRng::from_seed_and_id(world_seed, npc2_sid.0));
|
||||
|
||||
// NPC 3: Guard at (18,14) — stationary, no routine
|
||||
let npc3 = app
|
||||
@@ -565,6 +576,9 @@ fn setup_proof_room(
|
||||
))
|
||||
.id();
|
||||
let npc3_sid = registry.register(npc3);
|
||||
app.world_mut()
|
||||
.entity_mut(npc3)
|
||||
.insert(EntityRng::from_seed_and_id(world_seed, npc3_sid.0));
|
||||
|
||||
// Populate RelationshipGraph with a few edges
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user