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,
}
#[derive(Debug)]
enum DurableTickTask {
Interval { key: String, interval_seconds: i64 },
DailySlots { key: String, slots: Vec<String> },
}
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<DurableTickTask> = {
let mut stmt = world
.connection()
.prepare(
"SELECT key, interval_seconds, COALESCE(schedule_kind, 'interval'), daily_slots_json\n\
FROM world_tick_tasks ORDER BY key",
)
.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?;
let rows = stmt
.query_map([], |row| {
let key: String = row.get(0)?;
let interval_seconds: i64 = row.get(1)?;
let schedule_kind: String = row.get(2)?;
let slots_json: Option<String> = row.get(3)?;
match schedule_kind.as_str() {
"interval" => Ok(DurableTickTask::Interval {
key,
interval_seconds,
}),
"daily_slots" => {
let slots = slots_json
.as_deref()
.and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
.unwrap_or_default();
Ok(DurableTickTask::DailySlots { key, slots })
}
_ => Ok(DurableTickTask::Interval {
key,
interval_seconds,
}),
}
})
.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?;
let mut out = Vec::<DurableTickTask>::new();
for row in rows {
out.push(row.map_err(|source| TickCommandError::ReadTasks {
details: source.to_string(),
})?);
}
out
};
for task in tasks {
let delay_ms = callback_delay_ms;
match task {
DurableTickTask::Interval {
key,
interval_seconds,
} => {
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 })?;
}
DurableTickTask::DailySlots { key, slots } => {
let slot_refs = slots.iter().map(String::as_str).collect::<Vec<_>>();
world
.register_daily_slot_tick(&key, &slot_refs, move |_tx, _context| {
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 })?;
let more_due = world
.has_due_ticks(&now)
.map_err(|source| TickCommandError::RunDueTicks { source })?;
Ok(TickRunSummary {
tasks_run,
tasks_skipped: due_count
.saturating_sub(tasks_run)
.max(usize::from(more_due)),
})
}