use super::{EditorState, checked_level, level_here};
use crate::app::i18n::fill;
use crate::app::settings::GameSettings;
use crate::sim::{
ArenaProblem, Board, Effort, Goal, Level, LevelKind, SolveOutcome, TileKind, solve, solve_from,
};
use bevy::prelude::*;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Verdict {
pub(super) outcome: SolveOutcome,
pub(super) needs: Option<usize>,
pub(super) shared: Option<bool>,
pub(super) coop: bool,
}
pub(super) type SolverSlot = Arc<Mutex<Option<Verdict>>>;
const WIDER_BUDGET: u32 = crate::sim::DEFAULT_NODE_BUDGET / 4;
pub(super) struct Validation {
pub(super) slot: SolverSlot,
pub(super) level: String,
pub(super) save: Option<(std::path::PathBuf, String)>,
}
impl Validation {
#[cfg(test)]
pub(super) fn pending() -> Validation {
Validation {
slot: Arc::new(Mutex::new(None)),
level: String::new(),
save: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Balance {
Unknown,
Even { steps: u32 },
Uneven { near: (u8, u32), far: (u8, u32) },
CutOff(u8),
}
pub(super) fn balance(board: &Board) -> Balance {
let walks = crate::sim::seat_walks(board);
if walks.len() < 2 || walks.iter().any(|(_, holes)| holes.is_empty()) {
return Balance::Unknown;
}
let mut totals = Vec::new();
for (seat, holes) in walks {
match holes.into_iter().sum::<Option<u32>>() {
Some(steps) => totals.push((seat, steps)),
None => return Balance::CutOff(seat),
}
}
let near = totals.iter().copied().min_by_key(|&(_, steps)| steps);
let far = totals.iter().copied().max_by_key(|&(_, steps)| steps);
match (near, far) {
(Some(near), Some(far)) if near.1 != far.1 => Balance::Uneven { near, far },
(Some((_, steps)), _) => Balance::Even { steps },
(None, _) => Balance::Unknown,
}
}
pub(super) fn arena_report(board: &Board, tr: &crate::app::i18n::Tr) -> String {
if let Some(problem) = board.arena_problem() {
return arena_complaint(problem, tr);
}
if !board.feeds_crabs() {
return tr.ed_arena_no_crabs.to_string();
}
let seat = |seat: u8| crate::app::seat_label(tr, seat);
match balance(board) {
Balance::Uneven { near, far } => {
return fill(
tr.ed_fair_uneven,
&[
("p", &seat(near.0)),
("n", &near.1.to_string()),
("q", &seat(far.0)),
("m", &far.1.to_string()),
],
);
}
Balance::CutOff(cut) => return fill(tr.ed_fair_cut_off, &[("p", &seat(cut))]),
Balance::Unknown | Balance::Even { .. } => {}
}
let seats = board.castle_seats();
let holes = board
.tiles()
.filter(|(_, _, kind)| matches!(kind, TileKind::Spawner(_)))
.count();
fill(
tr.ed_arena_ok,
&[("n", &seats.to_string()), ("h", &holes.to_string())],
)
}
fn arena_complaint(problem: ArenaProblem, tr: &crate::app::i18n::Tr) -> String {
match problem {
ArenaProblem::SkippedSeat(seat) => fill(
tr.ed_arena_skips_seat,
&[("n", &(u32::from(seat) + 1).to_string())],
),
ArenaProblem::TooFew(seats) => fill(tr.ed_arena_needs_seats, &[("n", &seats.to_string())]),
}
}
pub(super) fn save_warning(
state: &EditorState,
level: &Level,
tr: &crate::app::i18n::Tr,
) -> Option<String> {
match state.kind {
LevelKind::Arena => arena_seat_warning(level, tr).or_else(|| arena_crab_warning(level, tr)),
LevelKind::Puzzle => {
puzzle_crab_warning(level, tr).or_else(|| puzzle_castle_warning(level, tr))
}
}
}
fn arena_seat_warning(level: &Level, tr: &crate::app::i18n::Tr) -> Option<String> {
level
.arena_problem()
.map(|problem| arena_complaint(problem, tr))
}
fn arena_crab_warning(level: &Level, tr: &crate::app::i18n::Tr) -> Option<String> {
(!level.board().feeds_crabs()).then(|| tr.ed_arena_no_crabs.to_string())
}
fn puzzle_crab_warning(level: &Level, tr: &crate::app::i18n::Tr) -> Option<String> {
(level.crab_count() == 0).then(|| tr.ed_puzzle_no_crabs.to_string())
}
fn puzzle_castle_warning(level: &Level, tr: &crate::app::i18n::Tr) -> Option<String> {
let banks = match level.goal {
Goal::AllCrabs | Goal::Bank(_) | Goal::Golden => true,
Goal::Survive => false,
};
let castles = level.board().castle_owners().next().is_some();
(banks && !castles).then(|| tr.ed_puzzle_no_castle.to_string())
}
pub(super) fn start_check(state: &mut EditorState, board: &Board) {
let level = checked_level(state, board);
let granted = state.posts;
let solo = state.second_hand.is_none();
let wider = (solo && granted < super::setting::MAX_POSTS).then(|| {
state.posts = super::setting::MAX_POSTS;
let wider = checked_level(state, board);
state.posts = granted;
wider
});
start_validation(state, level, wider);
}
pub(super) fn start_validation(state: &mut EditorState, level: Level, wider: Option<Level>) {
let slot: SolverSlot = Arc::new(Mutex::new(None));
let thread_slot = Arc::clone(&slot);
let text = level.to_text();
std::thread::spawn(move || {
let outcome = solve(&level);
let needs = match (&outcome, wider) {
(SolveOutcome::Unsolvable, Some(wider)) => {
let past = level.posts.saturating_add(1);
match solve_from(&wider, Effort::Budget(WIDER_BUDGET), past) {
SolveOutcome::Found(placements) => Some(placements.len()),
SolveOutcome::Unsolvable | SolveOutcome::GaveUp => None,
}
}
_ => None,
};
let shared = match (&outcome, level.is_coop()) {
(SolveOutcome::Found(_), true) => {
match crate::sim::needs_both(&level, Effort::Budget(WIDER_BUDGET)) {
SolveOutcome::Unsolvable => Some(true),
SolveOutcome::Found(_) => Some(false),
SolveOutcome::GaveUp => None,
}
}
_ => None,
};
*thread_slot.lock().unwrap() = Some(Verdict {
outcome,
needs,
shared,
coop: level.is_coop(),
});
});
state.solver = Some(Validation {
slot,
level: text,
save: None,
});
}
pub(super) fn arrow(dir: crate::sim::Direction) -> char {
use crate::sim::Direction;
match dir {
Direction::Up => '↑',
Direction::Down => '↓',
Direction::Left => '←',
Direction::Right => '→',
}
}
pub(super) fn verdict_line(verdict: &Verdict, posts: u8, tr: &crate::app::i18n::Tr) -> String {
match &verdict.outcome {
SolveOutcome::Found(solution) if solution.is_empty() => tr.ed_solvable_free.into(),
SolveOutcome::Found(solution) => {
let described: Vec<String> = solution
.iter()
.map(|&(x, y, _, seat)| match verdict.coop {
true => format!("{}({x},{y})", crate::app::seat_label(tr, seat)),
false => format!("({x},{y})"),
})
.collect();
let said = fill(
tr.ed_solvable,
&[
("n", &solution.len().to_string()),
("g", &posts.to_string()),
("placements", &described.join(" ")),
],
);
match verdict.shared {
Some(true) => format!("{} - {said}", tr.ed_needs_both),
Some(false) => format!("{} - {said}", tr.ed_one_alone),
None => said,
}
}
SolveOutcome::Unsolvable => match verdict.needs {
Some(needs) => fill(
tr.ed_not_solvable_needs,
&[("n", &posts.to_string()), ("m", &needs.to_string())],
),
None => fill(tr.ed_not_solvable, &[("n", &posts.to_string())]),
},
SolveOutcome::GaveUp => tr.ed_solver_gave_up.into(),
}
}
pub fn poll_solver(
settings: Res<GameSettings>,
sim: Res<crate::app::Sim>,
mut state: ResMut<EditorState>,
) {
let tr = settings.tr();
if state.is_testing() {
return;
}
let Some(check) = &state.solver else {
return;
};
let Some(verdict) = check.slot.lock().unwrap().take() else {
return; };
let Some(Validation { level, save, .. }) = state.solver.take() else {
return;
};
state.checked = Some(level);
if let SolveOutcome::Found(solution) = &verdict.outcome {
state.solution.clone_from(solution);
state.dirty = true;
let now = super::history::snapshot(&state, &sim.0);
state.history.amend(now);
}
let granted = state.posts.saturating_add(state.second_hand.unwrap_or(0));
let said = verdict_line(&verdict, granted, tr);
let filed =
save.and_then(|(path, name)| finish_save(&mut state, &sim.0, &path, &name, &verdict, tr));
state.feedback = match filed {
Some(filed) => format!("{said} - {filed}"),
None => said,
};
}
fn finish_save(
state: &mut EditorState,
board: &Board,
path: &std::path::Path,
name: &str,
verdict: &Verdict,
tr: &crate::app::i18n::Tr,
) -> Option<String> {
if !state.mine.contains(path) {
return None;
}
let file = super::shelf::file_name(path);
if matches!(verdict.outcome, SolveOutcome::Found(_)) {
let text = level_here(state, board, name).to_text();
if let Err(e) = crate::app::paths::write_atomic(path, &text) {
return Some(fill(tr.ed_save_failed, &[("e", &e.to_string())]));
}
if state.name == name {
state.filed = Some(super::filed_text(state, board));
}
}
Some(fill(tr.ed_saved_to, &[("file", &file)]))
}
#[cfg(test)]
mod tests {
#[test]
fn the_ui_face_draws_the_solution_arrows() {
let face = crate::app::i18n::metrics::characters_in_font(include_bytes!(
"../../../assets/fonts/DejaVuSansMono.ttf"
));
for dir in Direction::ALL {
let glyph = arrow(dir);
assert!(face.contains(&glyph), "{dir:?} draws as {glyph:?}");
}
}
use super::super::brush::SPAWNER_PERIOD;
use super::super::level_here;
use super::*;
use crate::sim::{CrabKind, Direction, Handedness, Spawner};
fn sand() -> Board {
Board::new(6, 5, 3)
}
fn verdict(outcome: Option<SolveOutcome>) -> (String, bool) {
said_of(outcome.map(|outcome| Verdict {
outcome,
needs: None,
shared: None,
coop: false,
}))
}
fn said_of(verdict: Option<Verdict>) -> (String, bool) {
let mut app = App::new();
app.insert_resource(GameSettings::default());
app.insert_resource(crate::app::Sim(sand()));
app.insert_resource(EditorState {
posts: 2,
solver: Some(Validation {
slot: Arc::new(Mutex::new(verdict)),
level: String::new(),
save: None,
}),
..EditorState::default()
});
app.add_systems(Update, poll_solver);
app.update();
let state = app.world().resource::<EditorState>();
(state.feedback.clone(), state.solver.is_some())
}
#[test]
fn a_solution_is_spelled_out_or_called_free() {
use crate::app::i18n::EN;
let (said, pending) = verdict(Some(SolveOutcome::Found(vec![(1, 2, Direction::Up, 0)])));
assert!(!pending, "the answer is taken");
assert_eq!(
said,
fill(
EN.ed_solvable,
&[("n", "1"), ("g", "2"), ("placements", "(1,2)")]
)
);
let (said, _) = verdict(Some(SolveOutcome::Found(Vec::new())));
assert_eq!(said, EN.ed_solvable_free);
}
#[test]
fn a_coop_solution_names_its_hands_whatever_the_second_search_said() {
use crate::app::i18n::EN;
let solution = vec![(3, 2, Direction::Up, 0), (5, 2, Direction::Down, 1)];
let said = verdict_line(
&Verdict {
outcome: SolveOutcome::Found(solution),
needs: None,
shared: None,
coop: true,
},
2,
&EN,
);
assert!(said.contains("P1(3,2) P2(5,2)"), "{said}");
}
#[test]
fn no_solution_and_no_answer_are_told_apart() {
use crate::app::i18n::EN;
let (said, _) = verdict(Some(SolveOutcome::Unsolvable));
assert_eq!(said, EN.ed_not_solvable.replace("{n}", "2"));
let (said, _) = said_of(Some(Verdict {
outcome: SolveOutcome::Unsolvable,
needs: Some(4),
shared: None,
coop: false,
}));
assert_eq!(
said,
fill(EN.ed_not_solvable_needs, &[("n", "2"), ("m", "4")]),
"and the fewest that would"
);
let (said, _) = verdict(Some(SolveOutcome::GaveUp));
assert_eq!(said, EN.ed_solver_gave_up);
let (said, pending) = verdict(None);
assert!(pending, "still searching");
assert!(said.is_empty(), "and nothing said yet: {said}");
}
#[test]
fn a_save_that_lands_nowhere_says_so() {
use crate::app::i18n::EN;
let mut board = sand();
let state = |kind| EditorState {
posts: 3,
kind,
..EditorState::default()
};
let warned = |state: &EditorState, board: &Board| {
save_warning(state, &level_here(state, board, "Probe"), &EN)
};
board.set_tile(0, 0, TileKind::Castle(0));
let arena = state(LevelKind::Arena);
let complaint = warned(&arena, &board).expect("nowhere to go");
assert!(complaint.contains('1'), "{complaint}");
let mut puzzle = state(LevelKind::Puzzle);
assert_eq!(
warned(&puzzle, &board).as_deref(),
Some(EN.ed_puzzle_no_crabs)
);
let mut castleless = sand();
castleless.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
assert_eq!(
warned(&puzzle, &castleless).as_deref(),
Some(EN.ed_puzzle_no_castle)
);
puzzle.goal = Goal::Survive;
assert_ne!(
warned(&puzzle, &castleless).as_deref(),
Some(EN.ed_puzzle_no_castle)
);
puzzle.goal = Goal::AllCrabs;
board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
assert_eq!(warned(&puzzle, &board), None);
board.set_tile(5, 4, TileKind::Castle(1));
assert_eq!(warned(&arena, &board), None);
let mut bare = sand();
bare.set_tile(0, 0, TileKind::Castle(0));
bare.set_tile(5, 4, TileKind::Castle(1));
assert_eq!(warned(&arena, &bare).as_deref(), Some(EN.ed_arena_no_crabs));
}
#[test]
fn a_beach_that_skips_a_player_says_which() {
use crate::app::i18n::EN;
let mut board = sand();
board.set_tile(0, 0, TileKind::Castle(0));
board.set_tile(5, 4, TileKind::Castle(2));
let said = fill(EN.ed_arena_skips_seat, &[("n", "2")]);
assert_eq!(arena_report(&board, &EN), said);
let arena = EditorState {
kind: LevelKind::Arena,
..EditorState::default()
};
let level = level_here(&arena, &board, "Gappy");
assert_eq!(save_warning(&arena, &level, &EN), Some(said));
board.set_tile(3, 3, TileKind::Castle(1));
assert!(!arena_report(&board, &EN).contains("castle -"));
}
#[test]
fn checking_a_beach_reads_its_castles() {
use crate::app::i18n::EN;
let mut board = sand();
assert!(arena_report(&board, &EN).contains('0'), "bare sand seats 0");
board.set_tile(0, 0, TileKind::Castle(0));
let one = arena_report(&board, &EN);
assert!(one.contains('1'), "one castle is not a match: {one}");
board.set_tile(5, 4, TileKind::Castle(1));
assert_eq!(
arena_report(&board, &EN),
EN.ed_arena_no_crabs,
"castles but nothing to fight over"
);
board.set_tile(
2,
2,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: SPAWNER_PERIOD,
}),
);
let ok = arena_report(&board, &EN);
assert!(ok.contains('2'), "two seats: {ok}");
assert!(!ok.contains("wants"), "no complaint left: {ok}");
}
#[test]
fn a_beach_is_weighed_for_its_seats() {
use crate::app::i18n::EN;
let mut board = Board::new(7, 3, 1);
board.set_tile(
3,
1,
TileKind::Spawner(Spawner {
dir: Direction::Up,
period: SPAWNER_PERIOD,
}),
);
board.set_tile(0, 1, TileKind::Castle(0));
board.set_tile(5, 1, TileKind::Castle(1));
assert_eq!(
balance(&board),
Balance::Uneven {
near: (1, 4),
far: (0, 5)
}
);
let said = fill(
EN.ed_fair_uneven,
&[("p", "P2"), ("n", "4"), ("q", "P1"), ("m", "5")],
);
assert_eq!(arena_report(&board, &EN), said);
board.set_tile(5, 1, TileKind::Empty);
board.set_tile(6, 1, TileKind::Castle(1));
assert_eq!(balance(&board), Balance::Even { steps: 5 });
assert!(!arena_report(&board, &EN).contains("P1"));
for y in 0..3 {
board.set_tile(5, y, TileKind::Rock);
}
assert_eq!(balance(&board), Balance::CutOff(1));
assert_eq!(
arena_report(&board, &EN),
fill(EN.ed_fair_cut_off, &[("p", "P2")])
);
}
#[test]
fn a_started_validation_leaves_a_verdict_in_the_slot() {
fn verdict(level: Level) -> SolveOutcome {
needed(level, None).outcome
}
fn needed(level: Level, wider: Option<Level>) -> Verdict {
let mut state = EditorState::default();
start_validation(&mut state, level, wider);
let slot = state.solver.expect("a validation is running").slot;
loop {
if let Some(verdict) = slot.lock().unwrap().take() {
return verdict;
}
std::thread::yield_now();
}
}
let mut easy = sand();
easy.set_tile(4, 2, TileKind::Castle(0));
easy.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
assert_eq!(
verdict(Level::from_board("Custom", 1, easy)),
SolveOutcome::Found(Vec::new())
);
let mut sealed = sand();
sealed.set_tile(4, 2, TileKind::Castle(0));
sealed.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
for dir in Direction::ALL {
sealed.set_wall(4, 2, dir, true);
}
assert_eq!(
verdict(Level::from_board("Custom", 1, sealed)),
SolveOutcome::Unsolvable
);
let mut corridor = sand();
corridor.set_tile(3, 1, TileKind::Castle(0));
for x in 0..6 {
corridor.set_wall(x, 2, Direction::Down, true);
if x != 3 {
corridor.set_wall(x, 2, Direction::Up, true);
}
}
corridor.spawn_crab(1, 2, Direction::Right, Handedness::Left, CrabKind::Common);
let granted = Level::from_board("Corridor", 0, corridor.clone());
let wider = Level::from_board("Corridor", 3, corridor);
assert_eq!(
needed(granted, Some(wider)),
Verdict {
outcome: SolveOutcome::Unsolvable,
needs: Some(1),
shared: None,
coop: false,
}
);
}
}