Skip to content

Commit da1fec2

Browse files
authored
Merge pull request #34 from CurtisTechSolutions/feat/sudoku-reference-hints
feat(sudoku): add explained reference-solver recommendations
2 parents 0df0ad1 + 4ce38a8 commit da1fec2

13 files changed

Lines changed: 384 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,9 @@ variation, node count, depth and whether the budget was exhausted. Match analysi
4141
requires `assists.allow_analysis: true`. A standalone copy of an active position
4242
cannot bypass that policy. Searches run in four bounded workers and receive
4343
only the authorized observation and legal actions.
44+
45+
Sudoku also advertises the `reference` opponent. Analysis returns its next
46+
placement and supporting candidate eliminations. Search-backed steps are marked
47+
`advice.is_guess: true`; an incorrect entered digit produces an explicit
48+
`solution_check` erasure recommendation. Ordinary state responses do not include
49+
these recommendations or the solution grid.

‎crates/apps/gfa-server/src/analysis_tests.rs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,75 @@ async fn real_analysis_finds_a_win_without_mutating_history_and_reports_uncalibr
113113
app.store.close().await;
114114
Ok(())
115115
}
116+
117+
#[tokio::test]
118+
async fn sudoku_reference_solver_plays_only_through_generic_analysis_and_moves(
119+
) -> Result<(), ServerError> {
120+
let directory = tempfile::tempdir()?;
121+
let app = fixture(&config(&directory)).await?;
122+
let (_, catalog) = call(
123+
&app.router,
124+
"GET",
125+
"/v1/games/sudoku/opponents",
126+
Value::Null,
127+
None,
128+
)
129+
.await?;
130+
assert!(catalog
131+
.as_array()
132+
.ok_or("catalog")?
133+
.iter()
134+
.any(|item| item["id"] == "reference"));
135+
let (_, mut state) = call(
136+
&app.router,
137+
"POST",
138+
"/v1/matches",
139+
json!({
140+
"game_id":"sudoku", "config":{"size":4}, "seed":42,
141+
"assists":{"allow_analysis":true}
142+
}),
143+
None,
144+
)
145+
.await?;
146+
let id = state["match_id"].as_str().ok_or("id")?.to_owned();
147+
for _ in 0..16 {
148+
if state["terminated"] == true {
149+
break;
150+
}
151+
let (status, result) = call(
152+
&app.router,
153+
"POST",
154+
"/v1/analysis",
155+
json!({
156+
"game_id":"sudoku","from":{"match_id":id,"seat":0},
157+
"opponent":{"id":"reference"},"seed":7
158+
}),
159+
None,
160+
)
161+
.await?;
162+
assert_eq!(status, StatusCode::OK, "{result}");
163+
assert!(result["advice"]["summary"]
164+
.as_str()
165+
.is_some_and(|s| !s.is_empty()));
166+
assert!(state["legal_actions"]
167+
.as_array()
168+
.ok_or("legal")?
169+
.contains(&result["best_moves"][0]));
170+
let (status, moved) = call(
171+
&app.router,
172+
"POST",
173+
&format!("/v1/matches/{id}/actions"),
174+
json!({
175+
"seat":0,"turn":state["turn"],"action":result["best_moves"][0]["json"]
176+
}),
177+
None,
178+
)
179+
.await?;
180+
assert_eq!(status, StatusCode::OK);
181+
state = moved["state"].clone();
182+
}
183+
assert_eq!(state["returns"], json!([1.0]));
184+
assert_eq!(state["observation"]["json"]["outcome"], "solved");
185+
app.store.close().await;
186+
Ok(())
187+
}

‎crates/core/gfa-core/src/dynamic.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ pub trait DynGame: Send + Sync {
4343
"This engine cannot reconstruct planning states",
4444
))
4545
}
46+
/// Whether the engine exposes a reference solver through the generic hook.
47+
fn supports_reference_advice(&self) -> bool {
48+
false
49+
}
50+
/// Obtain a legal recommendation from an authorized planning state.
51+
fn reference_advice(
52+
&self,
53+
_state: &Value,
54+
_player: PlayerId,
55+
) -> Result<Option<crate::Advice<LegalAction>>, GameError> {
56+
Ok(None)
57+
}
4658
/// Validate a JSON state before accepting it from a caller.
4759
fn validate_state(&self, state: &Value) -> Result<(), GameError>;
4860
/// Seats currently allowed to act.
@@ -181,6 +193,34 @@ impl<G: Game> DynGame for GameAdapter<G> {
181193
Ok(serde_json::to_value(state)?)
182194
}
183195

196+
fn supports_reference_advice(&self) -> bool {
197+
G::supports_reference_advice()
198+
}
199+
200+
fn reference_advice(
201+
&self,
202+
state: &Value,
203+
player: PlayerId,
204+
) -> Result<Option<crate::Advice<LegalAction>>, GameError> {
205+
let state = Self::state(state)?;
206+
let Some(advice) = G::reference_advice(&state, player)? else {
207+
return Ok(None);
208+
};
209+
if !G::legal_actions(&state, player).contains(&advice.action) {
210+
return Err(GameError::illegal(
211+
"Reference solver returned an illegal action",
212+
));
213+
}
214+
Ok(Some(crate::Advice {
215+
action: LegalAction {
216+
string: G::action_to_string(&state, &advice.action),
217+
index: G::action_to_index(&advice.action),
218+
json: serde_json::to_value(advice.action)?,
219+
},
220+
info: advice.info,
221+
}))
222+
}
223+
184224
fn validate_state(&self, state: &Value) -> Result<(), GameError> {
185225
Self::state(state).map(|_| ())
186226
}

‎crates/core/gfa-core/src/game.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ pub trait Game: Send + Sync + 'static {
5050
))
5151
}
5252

53+
/// Whether this engine provides a reference solver or hint policy.
54+
fn supports_reference_advice() -> bool {
55+
false
56+
}
57+
58+
/// Recommend a legal action from a caller-owned planning state.
59+
///
60+
/// Hosts must enforce assist permissions and reconstruct player observations
61+
/// before invoking this hook. The default preserves existing game behavior.
62+
fn reference_advice(
63+
_state: &Self::State,
64+
_player: PlayerId,
65+
) -> Result<Option<crate::Advice<Self::Action>>, GameError> {
66+
Ok(None)
67+
}
68+
5369
/// Validate state received through a persistence or API boundary.
5470
fn validate_state(state: &Self::State) -> Result<(), GameError>;
5571
/// Seats that can act; empty after a terminal or truncated transition.

‎crates/core/gfa-core/src/types.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,26 @@ impl PlayGuide {
203203
Ok(())
204204
}
205205
}
206+
207+
/// Explanation attached to a game-provided reference recommendation.
208+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
209+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
210+
pub struct AdviceInfo {
211+
/// Human-readable next step.
212+
pub summary: String,
213+
/// Stable technique or solver identifier.
214+
pub technique: String,
215+
/// True when the recommendation uses search beyond the supported logical rules.
216+
pub is_guess: bool,
217+
/// Game-specific evidence, such as candidate eliminations leading to a placement.
218+
pub details: Value,
219+
}
220+
221+
/// One game-provided reference action and its explanation.
222+
#[derive(Clone, Debug, PartialEq)]
223+
pub struct Advice<A> {
224+
/// Legal next action.
225+
pub action: A,
226+
/// Explanation derived from the supplied planning state.
227+
pub info: AdviceInfo,
228+
}

‎crates/domain/gfa-api-types/src/opponents.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ pub struct AnalysisRequest {
8484
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
8585
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
8686
pub struct AnalysisResult {
87+
/// Optional reference-solver explanation and technique evidence.
88+
#[serde(default, skip_serializing_if = "Option::is_none")]
89+
pub advice: Option<gfa_core::AdviceInfo>,
8790
/// Registered game.
8891
pub game_id: String,
8992
/// Opponent identifier.

‎crates/domain/gfa-service/src/opponents.rs‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ use crate::{error, planning::AssistKind, GameService};
33
use gfa_api_types::{
44
AnalysisRequest, AnalysisResult, ApiError, OpponentConfig, OpponentLevel, OpponentSpec,
55
};
6-
use gfa_core::{DynGame, GameSpec, Information, LegalAction, Observation, TurnStructure, Viewer};
6+
use gfa_core::{DynGame, Information, LegalAction, Observation, TurnStructure, Viewer};
77
use gfa_opponents::{
8-
ActionChoice, Algorithm, Clock, Opponent, PlayerTurn, Random, SearchLimits, SearchOpponent,
8+
ActionChoice, Algorithm, Clock, Opponent, PlayerTurn, Random, ReferenceOpponent, SearchLimits,
9+
SearchOpponent,
910
};
1011
use serde_json::Value;
1112
use std::{future::Future, pin::Pin, sync::Arc};
@@ -17,7 +18,7 @@ pub type OpponentFuture<'a> =
1718
/// Select installed players without exposing live state or transport details.
1819
pub trait OpponentFactory: Send + Sync {
1920
/// Stable catalog for this game.
20-
fn catalog(&self, game: &GameSpec) -> Vec<OpponentSpec>;
21+
fn catalog(&self, game: &dyn DynGame) -> Vec<OpponentSpec>;
2122
/// Construct one player with validated options and limits.
2223
fn create(
2324
&self,
@@ -82,8 +83,12 @@ fn spec(id: &str, name: &str, levels: bool) -> OpponentSpec {
8283
}
8384

8485
impl OpponentFactory for BuiltinOpponentFactory {
85-
fn catalog(&self, game: &GameSpec) -> Vec<OpponentSpec> {
86+
fn catalog(&self, game: &dyn DynGame) -> Vec<OpponentSpec> {
8687
let mut catalog = vec![spec("random", "Uniform random", false)];
88+
if game.supports_reference_advice() {
89+
catalog.push(spec("reference", "Reference solver", false));
90+
}
91+
let game = game.spec();
8792
if game.information == Information::Perfect
8893
&& !game.stochastic
8994
&& game.turn_structure == TurnStructure::Sequential
@@ -105,7 +110,7 @@ impl OpponentFactory for BuiltinOpponentFactory {
105110
seed: u64,
106111
) -> Result<(Arc<dyn Opponent>, SearchLimits), ApiError> {
107112
if !self
108-
.catalog(&game.spec())
113+
.catalog(game.as_ref())
109114
.iter()
110115
.any(|spec| spec.id == opponent.id)
111116
{
@@ -115,11 +120,11 @@ impl OpponentFactory for BuiltinOpponentFactory {
115120
"Select an installed opponent from the game's catalog.",
116121
));
117122
}
118-
if opponent.id == "random" && opponent.level.is_some() {
123+
if matches!(opponent.id.as_str(), "random" | "reference") && opponent.level.is_some() {
119124
return Err(ApiError::new(
120125
"INVALID_CONFIG",
121-
"Random has no difficulty levels",
122-
"Omit level for the random opponent.",
126+
"This opponent has no difficulty levels",
127+
"Omit level for random and reference opponents.",
123128
));
124129
}
125130
let mut limits =
@@ -136,6 +141,7 @@ impl OpponentFactory for BuiltinOpponentFactory {
136141
limits.validate().map_err(error::engine)?;
137142
let player: Arc<dyn Opponent> = match opponent.id.as_str() {
138143
"random" => Arc::new(Random),
144+
"reference" => Arc::new(ReferenceOpponent::new(game, config).map_err(error::engine)?),
139145
"minimax" => Arc::new(
140146
SearchOpponent::new(game, config, Algorithm::Minimax).map_err(error::engine)?,
141147
),
@@ -171,7 +177,7 @@ impl GameService {
171177
Ok(self
172178
.opponents
173179
.as_ref()
174-
.map_or_else(Vec::new, |(factory, _)| factory.catalog(&game.spec())))
180+
.map_or_else(Vec::new, |(factory, _)| factory.catalog(game.as_ref())))
175181
}
176182

177183
/// Recommend legal moves without modifying events, clocks, or idempotency receipts.
@@ -244,6 +250,7 @@ impl GameService {
244250
));
245251
}
246252
Ok(AnalysisResult {
253+
advice: choice.info.advice,
247254
game_id: request.game_id,
248255
opponent: request.opponent.id,
249256
seed,

‎crates/domain/gfa-service/src/tests.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1126,7 +1126,7 @@ impl OpponentExecutor for ImmediateOpponentExecutor {
11261126

11271127
struct ObservingFactory;
11281128
impl OpponentFactory for ObservingFactory {
1129-
fn catalog(&self, game: &GameSpec) -> Vec<gfa_api_types::OpponentSpec> {
1129+
fn catalog(&self, game: &dyn gfa_core::DynGame) -> Vec<gfa_api_types::OpponentSpec> {
11301130
BuiltinOpponentFactory.catalog(game)
11311131
}
11321132
fn create(

‎crates/games/gfa-game-sudoku/src/engine.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod actions;
2+
mod advice;
23
mod view;
34

45
use crate::{
@@ -106,6 +107,18 @@ impl Game for Sudoku {
106107
}
107108
}
108109

110+
fn supports_reference_advice() -> bool {
111+
true
112+
}
113+
114+
fn reference_advice(
115+
state: &State,
116+
player: PlayerId,
117+
) -> Result<Option<gfa_core::Advice<Action>>, GameError> {
118+
Self::validate_state(state)?;
119+
advice::recommend(state, player)
120+
}
121+
109122
fn play_guide() -> Option<gfa_core::PlayGuide> {
110123
Some(gfa_core::PlayGuide {
111124
objective: "Complete every row, column and box with digits 1..size. Givens are fixed. Only seat 0 acts; no opponent.".into(),
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use super::{outcome, puzzle_solution};
2+
use crate::{hint, Action, Grid, State, Technique};
3+
use gfa_core::{serde_json, Advice, AdviceInfo, ErrorCode, GameError, PlayerId};
4+
5+
pub(super) fn recommend(
6+
state: &State,
7+
player: PlayerId,
8+
) -> Result<Option<Advice<Action>>, GameError> {
9+
if player != 0 {
10+
return Err(GameError::new(
11+
ErrorCode::NotYourTurn,
12+
"Only the solver seat may request a hint",
13+
"Use seat 0.",
14+
));
15+
}
16+
if outcome(state).is_some() {
17+
return Ok(None);
18+
}
19+
let size = usize::from(state.config.size);
20+
let solution = puzzle_solution(state)?;
21+
if let Some(cell) = state
22+
.grid
23+
.iter()
24+
.zip(solution)
25+
.position(|(&digit, &correct)| digit != 0 && digit != correct)
26+
{
27+
let row = (cell / size + 1) as u8;
28+
let col = (cell % size + 1) as u8;
29+
return Ok(Some(Advice {
30+
action: Action::Erase { row, col },
31+
info: AdviceInfo {
32+
summary: format!("Erase r{row}c{col}: its value differs from the puzzle's verified unique solution."),
33+
technique: "solution_check".into(),
34+
is_guess: false,
35+
details: serde_json::json!({"cell":cell,"row":row,"col":col}),
36+
},
37+
}));
38+
}
39+
let grid = Grid::from_cells(state.config.size, state.grid.clone())?;
40+
let Some(steps) = hint(&grid)? else {
41+
return Ok(None);
42+
};
43+
let step = steps
44+
.last()
45+
.ok_or_else(|| GameError::position("Hint has no steps"))?;
46+
let placement = step
47+
.placements
48+
.first()
49+
.ok_or_else(|| GameError::position("Hint has no placement"))?;
50+
let row = (placement.cell / size + 1) as u8;
51+
let col = (placement.cell % size + 1) as u8;
52+
let digit = placement.digit;
53+
let technique = serde_json::to_value(step.technique)?
54+
.as_str()
55+
.ok_or_else(|| GameError::position("Hint technique is invalid"))?
56+
.to_owned();
57+
let is_guess = step.technique == Technique::Guess;
58+
let summary = if is_guess {
59+
format!("Search-backed guess: place {digit} at r{row}c{col}. No supported logical placement remains.")
60+
} else {
61+
format!(
62+
"Use {} to place {digit} at r{row}c{col}.",
63+
technique.replace('_', " ")
64+
)
65+
};
66+
Ok(Some(Advice {
67+
action: Action::Place { row, col, digit },
68+
info: AdviceInfo {
69+
summary,
70+
technique,
71+
is_guess,
72+
details: serde_json::json!({"steps":steps}),
73+
},
74+
}))
75+
}

0 commit comments

Comments
 (0)