use std::path::Path;
use std::thread;
use std::time::Duration;
use foglet_game::{
ConfigError, GameConfig, WorldDb, WorldDbError, WorldTickError, WORLD_TICK_TASKS_MIGRATION,
};
use thiserror::Error;
use crate::emit_manifest::GAME_TOML_RELATIVE;
#[derive(Debug, Error)]
pub enum TickCommandError {
#[error("failed to load game config from `{path}`: {source}")]
Config {
path: String,
#[source]
source: ConfigError,
},
#[error(
"`[world_ticks]` is disabled; enable it in assets/game.toml before running `fgk tick`"
)]
WorldTicksDisabled,
#[error("failed to open world db at `{path}`: {source}")]
OpenWorldDb {
path: String,
#[source]
source: WorldDbError,
},
#[error("world db is missing at `{path}`")]
MissingWorldDb {
path: String,
},
#[error("failed to ensure `world_tick_tasks` table: {source}")]
EnsureTickTable {
#[source]
source: WorldDbError,
},
#[error("failed to read tasks while preparing tick callbacks: {details}")]
ReadTasks {
details: String,
},
#[error("failed to register in-memory tick callback for `{key}`: {source}")]
RegisterCallback {
key: String,
#[source]
source: WorldTickError,
},
#[error("run_due_ticks failed: {source}")]
RunDueTicks {
#[source]
source: WorldTickError,
},
}
#[derive(Debug, Clone, Copy)]
pub struct TickRunSummary {
pub tasks_run: usize,
pub tasks_skipped: usize,
}
pub fn run_tick(project_dir: &Path) -> Result<TickRunSummary, TickCommandError> {
let config_path = project_dir.join(GAME_TOML_RELATIVE);
let config = GameConfig::load(&config_path).map_err(|source| TickCommandError::Config {
path: config_path.display().to_string(),
source,
})?;
if !config.world_ticks.enabled {
return Err(TickCommandError::WorldTicksDisabled);
}
let world_path = project_dir.join(&config.world.path);
if !world_path.exists() {
return Err(TickCommandError::MissingWorldDb {
path: world_path.display().to_string(),
});
}
let mut world =
WorldDb::open(world_path.clone()).map_err(|source| TickCommandError::OpenWorldDb {
path: world_path.display().to_string(),
source,
})?;
let callback_delay_ms = std::env::var("FGK_TICK_TEST_CALLBACK_DELAY_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|delay_ms| *delay_ms > 0);
world
.apply_migration(&WORLD_TICK_TASKS_MIGRATION)
.map_err(|source| TickCommandError::EnsureTickTable { source })?;
let tasks: Vec<(String, i64)> = {
let mut stmt = world
.connection()
.prepare("SELECT key, interval_seconds FROM world_tick_tasks ORDER BY key")
.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?;
let rows = stmt
.query_map([], |row| Ok::<(String, i64), _>((row.get(0)?, row.get(1)?)))
.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?;
let mut out = Vec::<(String, i64)>::new();
for row in rows {
out.push(row.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?);
}
out
};
for (key, interval_seconds) in tasks {
let delay_ms = callback_delay_ms;
world
.register_tick(&key, interval_seconds, move |_tx| {
if let Some(delay_ms) = delay_ms {
thread::sleep(Duration::from_millis(delay_ms));
}
Ok(())
})
.map_err(|source| TickCommandError::RegisterCallback { key, source })?;
}
let now: String = world
.connection()
.query_row("SELECT datetime('now')", [], |row| row.get(0))
.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?;
let due_count: usize = world
.connection()
.query_row(
&format!(
"SELECT COUNT(*) FROM world_tick_tasks \
WHERE last_run_at IS NULL \
OR datetime(last_run_at, '+' || interval_seconds || ' seconds') <= '{}'",
now
),
[],
|row| row.get::<_, i64>(0),
)
.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})? as usize;
let tasks_run = world
.run_due_ticks(&now, config.world_ticks.max_catchup_per_call)
.map_err(|source| TickCommandError::RunDueTicks { source })?;
Ok(TickRunSummary {
tasks_run,
tasks_skipped: due_count.saturating_sub(tasks_run),
})
}