1use std::{collections::BTreeSet, fs, io::Write, path::PathBuf, sync::Arc};
12
13use anyhow::{Context, anyhow};
14use axum::extract::State;
15use axum::http::header::HeaderMap;
16use axum::response::{Html, IntoResponse, Redirect, Response};
17use axum_session::{Session, SessionNullPool};
18use serde::Deserialize;
19
20use demystify::json::Problem;
21use demystify::problem::{self, PuzLit, planner::PuzzlePlanner, solver::PuzzleSolver};
22use rustsat::types::Lit;
23
24use crate::util::{self, AppState, GameState, SolverSession, get_solver_global, set_solver_global};
25use crate::wrap;
26
27pub struct LevelInfo {
30 pub id: &'static str,
31 pub name: &'static str,
32 pub difficulty: &'static str,
33 pub model_filename: &'static str, pub model: &'static str,
35 pub param: &'static str,
36}
37
38macro_rules! include_level_file {
39 ($path:expr) => {
40 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path))
41 };
42}
43
44pub static LEVELS: &[LevelInfo] = &[
45 LevelInfo {
46 id: "01_thermometer",
47 name: "Thermometer",
48 difficulty: "easy",
49 model_filename: "puzzle.eprime",
50 model: include_level_file!("levels/01_thermometer/puzzle.eprime"),
51 param: include_level_file!("levels/01_thermometer/puzzle.param"),
52 },
53 LevelInfo {
54 id: "02_binairo",
55 name: "Binairo",
56 difficulty: "medium",
57 model_filename: "puzzle.essence",
58 model: include_level_file!("levels/02_binairo/puzzle.essence"),
59 param: include_level_file!("levels/02_binairo/puzzle.param"),
60 },
61 LevelInfo {
62 id: "03_sudoku",
63 name: "Sudoku",
64 difficulty: "hard",
65 model_filename: "puzzle.eprime",
66 model: include_level_file!("levels/03_sudoku/puzzle.eprime"),
67 param: include_level_file!("levels/03_sudoku/puzzle.param"),
68 },
69];
70
71pub fn find_level(id: &str) -> Option<&'static LevelInfo> {
72 LEVELS.iter().find(|l| l.id == id)
73}
74
75fn load_level_planner(
76 level: &LevelInfo,
77 strategy_db: Arc<demystify::named_strategy::Database>,
78) -> anyhow::Result<PuzzlePlanner> {
79 let temp_dir = tempfile::Builder::new()
80 .prefix(".demystify-level-")
81 .tempdir_in(".")
82 .context("Failed to create temporary directory")?;
83
84 let model_path = temp_dir.path().join(level.model_filename);
85 let mut f = fs::File::create(&model_path).context("Failed to create model file")?;
86 f.write_all(level.model.as_bytes())
87 .context("Failed to write model file")?;
88
89 let param_path = temp_dir.path().join("puzzle.param");
90 let mut f = fs::File::create(¶m_path).context("Failed to create param file")?;
91 f.write_all(level.param.as_bytes())
92 .context("Failed to write param file")?;
93
94 let puzzle = problem::parse::parse_essence(&model_path, ¶m_path)?;
95 let puzzle = Arc::new(puzzle);
96 let solver = PuzzleSolver::new(puzzle)?;
97 Ok(PuzzlePlanner::new(solver).with_database(strategy_db))
98}
99
100#[derive(Debug, PartialEq, Eq)]
103pub enum ClickOutcome {
104 Won,
106 Accepted,
108 Rejected,
110}
111
112fn find_provable_signed_lit(
114 planner: &mut PuzzlePlanner,
115 lit_def: &[i64],
116 positive: bool,
117) -> Option<Lit> {
118 let provable: BTreeSet<Lit> = planner.solver().get_provable_varlits().clone();
119 for lit in provable {
120 let puzlit_set: BTreeSet<PuzLit> = planner.solver().lit_to_puzlit(&lit).clone();
121 for puzlit in puzlit_set {
122 if puzlit.sign() != positive {
123 continue;
124 }
125 let mut indices = puzlit.var().indices().clone();
126 indices.push(puzlit.val());
127 if indices == lit_def {
128 return Some(lit);
129 }
130 }
131 }
132 None
133}
134
135pub fn apply_click(
137 planner: &mut PuzzlePlanner,
138 game: &mut GameState,
139 lit_def: &[i64],
140 positive: bool,
141) -> ClickOutcome {
142 if let Some(lit) = find_provable_signed_lit(planner, lit_def, positive) {
143 planner.mark_lit_as_deduced(&lit);
144 if planner.solver().get_provable_varlits().is_empty() {
145 game.completed = true;
146 ClickOutcome::Won
147 } else {
148 ClickOutcome::Accepted
149 }
150 } else {
151 game.failures += 1;
152 ClickOutcome::Rejected
153 }
154}
155
156#[derive(Debug, Default)]
160pub struct HeatmapTiers {
161 pub tier1_size: Option<usize>,
162 pub tier2_size: Option<usize>,
163 pub tier1: BTreeSet<Lit>,
164 pub tier2: BTreeSet<Lit>,
165 pub tier3: BTreeSet<Lit>,
166}
167
168pub fn compute_heatmap_tiers(planner: &mut PuzzlePlanner) -> HeatmapTiers {
169 let dict = planner.all_smallish_muses();
170 let mut sizes: Vec<(Lit, usize)> = dict
171 .muses()
172 .keys()
173 .filter_map(|lit| dict.min_lit(*lit).map(|n| (*lit, n)))
174 .collect();
175 sizes.sort_by_key(|(_, n)| *n);
176
177 let mut distinct: Vec<usize> = sizes.iter().map(|(_, n)| *n).collect();
178 distinct.dedup();
179 let tier1_size = distinct.first().copied();
180 let tier2_size = distinct.get(1).copied();
181
182 let mut tiers = HeatmapTiers {
183 tier1_size,
184 tier2_size,
185 ..Default::default()
186 };
187 for (lit, n) in sizes {
188 if Some(n) == tier1_size {
189 tiers.tier1.insert(lit);
190 } else if Some(n) == tier2_size {
191 tiers.tier2.insert(lit);
192 } else {
193 tiers.tier3.insert(lit);
194 }
195 }
196 tiers
197}
198
199pub fn is_won(planner: &mut PuzzlePlanner) -> bool {
202 planner.solver().get_provable_varlits().is_empty()
203}
204
205pub async fn game_select(State(state): State<AppState>) -> Result<Html<String>, util::AppError> {
208 let mut ctx = tera::Context::new();
209 ctx.insert("view", "game");
210 let levels: Vec<tera::Value> = LEVELS
211 .iter()
212 .map(|l| {
213 let mut obj = serde_json::Map::new();
214 obj.insert("id".into(), tera::Value::String(l.id.into()));
215 obj.insert("name".into(), tera::Value::String(l.name.into()));
216 obj.insert(
217 "difficulty".into(),
218 tera::Value::String(l.difficulty.into()),
219 );
220 tera::Value::Object(obj)
221 })
222 .collect();
223 ctx.insert("levels", &levels);
224 let html = state.tera.render("game_select.html", &ctx)?;
225 Ok(Html(html))
226}
227
228#[derive(Deserialize)]
229pub struct StartParams {
230 pub level_id: String,
231}
232
233pub async fn game_start(
234 State(state): State<AppState>,
235 session: Session<SessionNullPool>,
236 form: axum::extract::Form<StartParams>,
237) -> Result<Response, util::AppError> {
238 let level =
239 find_level(&form.level_id).with_context(|| format!("Unknown level '{}'", form.level_id))?;
240 let planner = load_level_planner(level, state.strategy_db.clone())?;
241 set_solver_global(&session, planner);
242 let solver = get_solver_global(&session)?;
243 {
244 let mut s = solver.lock().unwrap();
245 s.game = Some(GameState::new(level.id.to_string()));
246 }
247 session.set("round", 0u32);
248 Ok(Redirect::to("/game/play").into_response())
249}
250
251pub async fn game_play(
252 State(state): State<AppState>,
253 session: Session<SessionNullPool>,
254) -> Result<Response, util::AppError> {
255 let solver = match get_solver_global(&session) {
256 Ok(s) => s,
257 Err(_) => return Ok(Redirect::to("/game").into_response()),
258 };
259 let mut solver = solver.lock().unwrap();
260 if solver.game.is_none() {
261 return Ok(Redirect::to("/game").into_response());
262 }
263 let round: u32 = session.get("round").unwrap_or(0);
264 let (problem, _) = solver.planner.refresh_problem();
265 let ctx = build_game_page_context(&problem, round, &solver);
266 let html = state.tera.render("game_play.html", &ctx)?;
267 Ok(Html(html).into_response())
268}
269
270#[derive(Deserialize)]
271pub struct ClickQuery {
272 pub sign: Option<String>,
273}
274
275pub async fn game_click(
276 State(state): State<AppState>,
277 session: Session<SessionNullPool>,
278 headers: HeaderMap,
279 axum::extract::Query(q): axum::extract::Query<ClickQuery>,
280) -> Result<Html<String>, util::AppError> {
281 let solver = get_solver_global(&session)?;
282 let mut solver = solver.lock().unwrap();
283
284 let lit_def = wrap::parse_cell_literal_pub(&headers)?;
285 let positive = match q.sign.as_deref().unwrap_or("pos") {
286 "pos" => true,
287 "neg" => false,
288 s => return Err(anyhow!("Invalid sign '{s}'").into()),
289 };
290
291 let game = solver
292 .game
293 .as_mut()
294 .ok_or_else(|| anyhow!("Not in game mode"))?;
295 let mut game_state = game.clone();
296 let outcome = apply_click(&mut solver.planner, &mut game_state, &lit_def, positive);
297 solver.game = Some(game_state);
298
299 let round: u32 = session.get("round").unwrap_or(0);
300 let (problem, _) = solver.planner.refresh_problem();
301 let mut ctx = build_game_stage_context(&problem, round, &solver);
302 ctx.insert(
303 "last_outcome",
304 match outcome {
305 ClickOutcome::Won => "won",
306 ClickOutcome::Accepted => "accepted",
307 ClickOutcome::Rejected => "rejected",
308 },
309 );
310 let html = state.tera.render("partials/game_stage.html", &ctx)?;
311 Ok(Html(html))
312}
313
314pub async fn game_hint_heatmap(
315 State(state): State<AppState>,
316 session: Session<SessionNullPool>,
317) -> Result<Html<String>, util::AppError> {
318 let solver = get_solver_global(&session)?;
319 let mut solver = solver.lock().unwrap();
320 if let Some(g) = solver.game.as_mut() {
321 g.hints_used += 1;
322 }
323
324 let problem = solver.planner.difficulty_problem(false);
330
331 let round: u32 = session.get("round").unwrap_or(0);
332 let ctx = build_game_stage_context(&problem, round, &solver);
333 let html = state.tera.render("partials/game_stage.html", &ctx)?;
334 Ok(Html(html))
335}
336
337pub async fn game_hint_why(
338 State(state): State<AppState>,
339 session: Session<SessionNullPool>,
340 headers: HeaderMap,
341) -> Result<Html<String>, util::AppError> {
342 let solver = get_solver_global(&session)?;
343 let mut solver = solver.lock().unwrap();
344 let lit_def = wrap::parse_cell_literal_pub(&headers)?;
345
346 if let Some(g) = solver.game.as_mut() {
347 g.hints_used += 1;
348 }
349
350 let all_muses = solver.planner.all_muses_for_literal(lit_def);
351 let problem: Problem = if all_muses.is_empty() {
352 solver.planner.refresh_problem().0
353 } else {
354 solver.planner.preview_mus(&all_muses[0])
355 };
356
357 let round: u32 = session.get("round").unwrap_or(0);
358 let ctx = build_game_stage_context(&problem, round, &solver);
359 let html = state.tera.render("partials/game_stage.html", &ctx)?;
360 Ok(Html(html))
361}
362
363pub async fn game_give_up(
364 State(state): State<AppState>,
365 session: Session<SessionNullPool>,
366) -> Result<Html<String>, util::AppError> {
367 let solver = get_solver_global(&session)?;
368 let mut solver = solver.lock().unwrap();
369 while !solver.planner.solver().get_provable_varlits().is_empty() {
371 let (_problem, lits) = solver.planner.solve_step();
372 if lits.is_empty() {
373 break;
374 }
375 }
376 let round: u32 = session.get("round").unwrap_or(0);
377 let (problem, _) = solver.planner.refresh_problem();
378 let mut ctx = build_game_stage_context(&problem, round, &solver);
379 ctx.insert("gave_up", &true);
380 let html = state.tera.render("partials/game_stage.html", &ctx)?;
381 Ok(Html(html))
382}
383
384pub async fn game_quit(session: Session<SessionNullPool>) -> Result<Response, util::AppError> {
385 if let Ok(solver) = get_solver_global(&session) {
386 solver.lock().unwrap().game = None;
387 }
388 Ok(Redirect::to("/game").into_response())
389}
390
391fn build_game_stage_context(
394 problem: &Problem,
395 round: u32,
396 session: &SolverSession,
397) -> tera::Context {
398 let mut ctx = wrap::build_solver_stage_context_pub(problem, round, session);
399
400 if let Some(g) = &session.game {
401 ctx.insert("game_failures", &g.failures);
402 ctx.insert("game_hints", &g.hints_used);
403 ctx.insert("game_level_id", &g.level_id);
404 ctx.insert("game_won", &g.completed);
405 let level_name = find_level(&g.level_id).map(|l| l.name).unwrap_or("");
406 let level_difficulty = find_level(&g.level_id).map(|l| l.difficulty).unwrap_or("");
407 ctx.insert("game_level_name", level_name);
408 ctx.insert("game_level_difficulty", level_difficulty);
409 }
410 ctx
411}
412
413fn build_game_page_context(
414 problem: &Problem,
415 round: u32,
416 session: &SolverSession,
417) -> tera::Context {
418 let mut ctx = build_game_stage_context(problem, round, session);
419 ctx.insert("view", "game");
420 ctx
421}
422
423pub fn level_temp_dir_for_test(
426 level: &LevelInfo,
427) -> anyhow::Result<(tempfile::TempDir, PathBuf, PathBuf)> {
428 let temp_dir = tempfile::Builder::new()
429 .prefix(".demystify-level-test-")
430 .tempdir_in(".")?;
431 let model_path = temp_dir.path().join(level.model_filename);
432 fs::write(&model_path, level.model)?;
433 let param_path = temp_dir.path().join("puzzle.param");
434 fs::write(¶m_path, level.param)?;
435 Ok((temp_dir, model_path, param_path))
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn fresh_planner(level: &LevelInfo) -> PuzzlePlanner {
443 let strategy_db = Arc::new(demystify::named_strategy::Database::empty());
444 load_level_planner(level, strategy_db).expect("level should load")
445 }
446
447 fn first_level() -> &'static LevelInfo {
448 &LEVELS[0]
449 }
450
451 fn pick_provable_lit(planner: &mut PuzzlePlanner) -> Lit {
452 *planner
453 .solver()
454 .get_provable_varlits()
455 .iter()
456 .next()
457 .expect("provable_varlits should be non-empty at level start")
458 }
459
460 fn lit_def_from(planner: &mut PuzzlePlanner, lit: &Lit) -> (Vec<i64>, bool) {
461 let puzlit = planner
462 .solver()
463 .lit_to_puzlit(lit)
464 .iter()
465 .next()
466 .expect("lit has at least one puzlit")
467 .clone();
468 let mut indices = puzlit.var().indices().clone();
469 indices.push(puzlit.val());
470 (indices, puzlit.sign())
471 }
472
473 #[test]
474 fn fresh_level_has_provable_literals() {
475 let mut planner = fresh_planner(first_level());
476 assert!(!planner.solver().get_provable_varlits().is_empty());
477 }
478
479 #[test]
480 fn correct_left_click_advances() {
481 let mut planner = fresh_planner(first_level());
482 let mut game = GameState::new(first_level().id.to_string());
483
484 let provable: BTreeSet<Lit> = planner.solver().get_provable_varlits().clone();
486 let (lit_def, positive) = provable
487 .iter()
488 .find_map(|lit| {
489 let p = planner.solver().lit_to_puzlit(lit).iter().next()?.clone();
490 if p.sign() {
491 let mut idx = p.var().indices().clone();
492 idx.push(p.val());
493 Some((idx, true))
494 } else {
495 None
496 }
497 })
498 .expect("expected a positive provable puzlit");
499
500 let outcome = apply_click(&mut planner, &mut game, &lit_def, positive);
501 assert!(matches!(
502 outcome,
503 ClickOutcome::Accepted | ClickOutcome::Won
504 ));
505 assert_eq!(game.failures, 0);
506 }
507
508 #[test]
509 fn correct_right_click_advances() {
510 let mut planner = fresh_planner(first_level());
511 let mut game = GameState::new(first_level().id.to_string());
512
513 let provable: BTreeSet<Lit> = planner.solver().get_provable_varlits().clone();
514 let negative_lit = provable.iter().find_map(|lit| {
515 let p = planner.solver().lit_to_puzlit(lit).iter().next()?.clone();
516 if !p.sign() {
517 let mut idx = p.var().indices().clone();
518 idx.push(p.val());
519 Some((idx, false))
520 } else {
521 None
522 }
523 });
524
525 if let Some((lit_def, positive)) = negative_lit {
526 let outcome = apply_click(&mut planner, &mut game, &lit_def, positive);
527 assert!(matches!(
528 outcome,
529 ClickOutcome::Accepted | ClickOutcome::Won
530 ));
531 assert_eq!(game.failures, 0);
532 }
533 }
537
538 #[test]
539 fn wrong_sign_is_rejected() {
540 let mut planner = fresh_planner(first_level());
541 let mut game = GameState::new(first_level().id.to_string());
542
543 let lit = pick_provable_lit(&mut planner);
544 let (lit_def, sign) = lit_def_from(&mut planner, &lit);
545 let wrong_sign = !sign;
546 let known_before = planner.get_all_known_lits().len();
547
548 let outcome = apply_click(&mut planner, &mut game, &lit_def, wrong_sign);
549 assert_eq!(outcome, ClickOutcome::Rejected);
550 assert_eq!(game.failures, 1);
551 assert_eq!(planner.get_all_known_lits().len(), known_before);
552 }
553
554 #[test]
555 fn undeducible_click_is_rejected() {
556 let mut planner = fresh_planner(first_level());
560 let mut game = GameState::new(first_level().id.to_string());
561
562 let lit = pick_provable_lit(&mut planner);
563 let (lit_def, sign) = lit_def_from(&mut planner, &lit);
564 let first = apply_click(&mut planner, &mut game, &lit_def, sign);
565 assert!(matches!(first, ClickOutcome::Accepted | ClickOutcome::Won));
566 assert_eq!(game.failures, 0);
567
568 let second = apply_click(&mut planner, &mut game, &lit_def, sign);
570 assert_eq!(second, ClickOutcome::Rejected);
571 assert_eq!(game.failures, 1);
572 }
573
574 #[test]
575 fn heatmap_tiers_partition_deducibles() {
576 let mut planner = fresh_planner(first_level());
577 let tiers = compute_heatmap_tiers(&mut planner);
578
579 for a in &tiers.tier1 {
581 assert!(!tiers.tier2.contains(a) && !tiers.tier3.contains(a));
582 }
583 for a in &tiers.tier2 {
584 assert!(!tiers.tier1.contains(a) && !tiers.tier3.contains(a));
585 }
586 for a in &tiers.tier3 {
587 assert!(!tiers.tier1.contains(a) && !tiers.tier2.contains(a));
588 }
589
590 let total_classified = tiers.tier1.len() + tiers.tier2.len() + tiers.tier3.len();
592 if total_classified > 0 {
593 assert!(
594 !tiers.tier1.is_empty(),
595 "tier1 must be non-empty when anything is classified"
596 );
597 assert!(tiers.tier1_size.is_some());
598 }
599 if let (Some(s1), Some(s2)) = (tiers.tier1_size, tiers.tier2_size) {
601 assert!(s1 < s2, "tier1_size ({s1}) must be < tier2_size ({s2})");
602 }
603 }
604
605 #[test]
606 fn win_condition() {
607 let mut planner = fresh_planner(first_level());
608 let mut game = GameState::new(first_level().id.to_string());
609
610 while !planner.solver().get_provable_varlits().is_empty() {
613 let lit = pick_provable_lit(&mut planner);
614 planner.mark_lit_as_deduced(&lit);
615 }
616 assert!(is_won(&mut planner));
617
618 let outcome = apply_click(&mut planner, &mut game, &[1, 1, 1], true);
620 assert_eq!(outcome, ClickOutcome::Rejected);
621 }
622}