Skip to main content

demystify_web/
game.rs

1//! Game-mode handlers, level catalogue, and the click-validation / hint logic.
2//!
3//! Game mode runs alongside the existing solver/explore UI. A player makes
4//! deductions themselves; the server accepts a click only if the signed
5//! literal is currently forced by the planner state (so guessing is impossible
6//! — wrong clicks bounce and bump `failures`). The hint system has three
7//! tiers: a 3-colour heatmap classifying currently-deducible literals by
8//! smallest-MUS size, a per-cell "Why?" that surfaces the smallest MUS, and a
9//! "give up" that reveals the solution.
10
11use 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
27// ─── Level catalogue ────────────────────────────────────────────────────────
28
29pub struct LevelInfo {
30    pub id: &'static str,
31    pub name: &'static str,
32    pub difficulty: &'static str,
33    pub model_filename: &'static str, // "puzzle.eprime" or "puzzle.essence"
34    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(&param_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, &param_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// ─── Game-side core logic (testable without HTTP) ───────────────────────────
101
102#[derive(Debug, PartialEq, Eq)]
103pub enum ClickOutcome {
104    /// Literal was deduced; puzzle now solved.
105    Won,
106    /// Literal was deduced; more remains.
107    Accepted,
108    /// Literal was not currently forced (or wrong sign). State unchanged.
109    Rejected,
110}
111
112/// Find the signed `Lit` (currently provable) matching `lit_def` and `positive`.
113fn 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
135/// Validate a click and, if valid, advance the planner. Pure logic; no HTTP.
136pub 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/// Classification of currently-deducible literals into three tiers based on
157/// the size of the smallest MUS that deduces them: tier 1 = smallest-MUS-size
158/// globally, tier 2 = second-smallest, tier 3 = everything else still deducible.
159#[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
199/// True if the puzzle is fully determined under current known literals (no
200/// more provable literals remain).
201pub fn is_won(planner: &mut PuzzlePlanner) -> bool {
202    planner.solver().get_provable_varlits().is_empty()
203}
204
205// ─── Handlers ───────────────────────────────────────────────────────────────
206
207pub 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    // Reuse the existing difficulty rendering. The 3-tier classification is
325    // available via `compute_heatmap_tiers` and tested separately; the
326    // rendered overlay shown here is the existing per-cell-difficulty
327    // heatmap (good enough for the demo). Replacing it with a true 3-colour
328    // overlay would require additions to PuzzleDraw — out of scope here.
329    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    // Run the planner to completion. (No win is recorded — give-up is a loss.)
370    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
391// ─── Tera context helpers ──────────────────────────────────────────────────
392
393fn 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
423// ─── Static asset for the level files (used by integration tests only) ────
424
425pub 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(&param_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        // Find a provable lit whose representative puzlit is positive.
485        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        // If no negative provable lit exists at this level's first step,
534        // that's still a valid puzzle shape; the positive test covers the
535        // accepted-click logic.
536    }
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        // Construct a synthetic lit_def that is not currently provable: take
557        // a provable lit, mark it as deduced (so it leaves provable_varlits),
558        // then click it again with the same sign — second attempt must reject.
559        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        // Second attempt: same lit is no longer in provable_varlits.
569        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        // Tier sets must be pairwise disjoint.
580        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        // If anything was classified, tier1 must be non-empty (the smallest tier).
591        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        // tier1_size < tier2_size when both exist.
600        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        // Drive the planner to completion via the same path quick_solve uses.
611        // After this, get_provable_varlits should be empty.
612        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        // A click in this state must reject (everything already known).
619        let outcome = apply_click(&mut planner, &mut game, &[1, 1, 1], true);
620        assert_eq!(outcome, ClickOutcome::Rejected);
621    }
622}