feat(simulation): background worker pool infrastructure (#843 Part C)

Generic BackgroundWorkerPool<Req, Resp> with crossbeam channels, closure
handlers, and 3 delivery strategies (Fallback, GracefulDegrade, ModalLock).

Stub workers registered as Bevy resources:
  - ChunkGenWorker (2 threads) — terrain/props/navmesh generation
  - NpcPrepWorker (1 thread) — pre-compute NPC state for incoming areas
  - OffscreenTickWorker (1 thread) — advance NPCs outside active tier

Tick loop integration:
  - PreInput: poll_worker_results drains completed work
  - PostSnapshot: push_worker_requests queues new work (no-op until Phase 5)

Handlers are stubs — real computation plugs in when the phases that need
them arrive. The infrastructure (channels, threads, push/poll, shutdown)
is real and tested.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 00:15:05 +02:00
co-authored by Claude Opus 4.6
parent 2175e9b31c
commit 0098ee5a41
7 changed files with 429 additions and 1 deletions
+18
View File
@@ -70,6 +70,12 @@ impl Plugin for SimulationPlugin {
.init_resource::<crate::npc::relationships::RelationshipGraph>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>();
// Background worker pools (#843 Part C) — off-tick computation on separate cores.
// Stub handlers for now; real implementations plug in at Phase 5+.
app.insert_resource(crate::workers::stubs::ChunkGenWorker::new(2));
app.insert_resource(crate::workers::stubs::NpcPrepWorker::new(1));
app.insert_resource(crate::workers::stubs::OffscreenTickWorker::new(1));
// Phase sub-plugins (#843) — each owns its domain
app.add_plugins(time_plugin::TimePlugin);
app.add_plugins(input_plugin::InputPlugin);
@@ -77,6 +83,18 @@ impl Plugin for SimulationPlugin {
app.add_plugins(social_plugin::SocialPlugin);
app.add_plugins(economy_plugin::EconomyPlugin);
// Background worker tick integration (#843 Part C)
app.add_systems(
Update,
crate::workers::systems::poll_worker_results
.in_set(crate::tick_phases::TickPhase::PreInput),
);
app.add_systems(
Update,
crate::workers::systems::push_worker_requests
.in_set(crate::tick_phases::TickPhase::PostSnapshot),
);
// save_load is an exclusive system (takes &mut World).
// Exclusive systems in Bevy 0.18 cannot use .in_set() — use .before()/.after()
// to position it within the Snapshot phase window.
+22 -1
View File
@@ -696,10 +696,15 @@ pub fn process_contradiction_monologue(
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation::rng::SimRng;
use crate::simulation::rng::{EntityRng, SimRng};
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
/// Test helper: create an EntityRng for the player entity in tests.
fn test_entity_rng() -> EntityRng {
EntityRng::from_seed_and_id(42, 0)
}
// -----------------------------------------------------------------------
// SprintAnomalyQueue unit tests (#428, D-055)
// -----------------------------------------------------------------------
@@ -795,6 +800,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -823,6 +829,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -857,6 +864,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
buffer,
@@ -887,6 +895,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -912,6 +921,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -935,6 +945,7 @@ mod tests {
let mut world = setup_anomaly_world();
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1000,6 +1011,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1042,6 +1054,7 @@ mod tests {
let player = world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1106,6 +1119,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
buffer,
@@ -1139,6 +1153,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1186,6 +1201,7 @@ mod tests {
let player = world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1228,6 +1244,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1268,6 +1285,7 @@ mod tests {
world.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(5, 5, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1344,6 +1362,7 @@ mod tests {
world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(10, 10, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -1912,6 +1931,7 @@ mod tests {
world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),
@@ -2120,6 +2140,7 @@ mod tests {
let player = world
.spawn((
PlayerCharacter,
test_entity_rng(),
TilePosition::new(0, 0, 0),
MonologueState::default(),
MonologueBuffer::default(),