Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)
- internal/robot: durable kind='robot' account pool (migration 00004); every per-game and per-turn choice derived deterministically from the game seed (restart-stable FNV mix); a background move driver; margin targeting (band 1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m); opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive 12h nudge; friend/chat blocked via profile toggles. - engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns; social.LastNudgeAt. - matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam. - config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT, BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning. - metrics: robot account_stats (authoritative balance) + robot_games_finished_total OTel counter + per-finish log. - docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment. - tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest robot full-game / substitution / proactive-nudge.
This commit is contained in:
@@ -107,6 +107,21 @@ func (g *Game) HintView() (MoveRecord, bool) {
|
||||
return g.decodeMove(move), true
|
||||
}
|
||||
|
||||
// Candidates returns every legal play for the current player as decoded
|
||||
// MoveRecords, ranked by descending score (so the first entry equals HintView's
|
||||
// move). It is empty when the player has no legal play. The robot opponent picks
|
||||
// from these by margin without importing the solver; each record carries the
|
||||
// move's score, so a caller can choose by resulting score difference rather than
|
||||
// always taking the maximum.
|
||||
func (g *Game) Candidates() []MoveRecord {
|
||||
moves := g.GenerateMoves()
|
||||
out := make([]MoveRecord, len(moves))
|
||||
for i, m := range moves {
|
||||
out[i] = g.decodeMove(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Hand returns the player's current rack decoded to concrete letters, with "?"
|
||||
// for each undesignated blank. The order mirrors the internal hand. It supplies
|
||||
// the GCG rack field and the per-player game-state view.
|
||||
|
||||
@@ -43,6 +43,37 @@ func TestSubmitPlayMatchesHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCandidatesRankedAndMatchesHint checks that Candidates decodes every
|
||||
// generated move, ranks them by descending score, and leads with the same move
|
||||
// HintView reveals.
|
||||
func TestCandidatesRankedAndMatchesHint(t *testing.T) {
|
||||
g := openingGame(t)
|
||||
cands := g.Candidates()
|
||||
if len(cands) == 0 {
|
||||
t.Fatal("opening game has no candidates")
|
||||
}
|
||||
if got, want := len(cands), len(g.GenerateMoves()); got != want {
|
||||
t.Errorf("candidate count = %d, want %d (one per generated move)", got, want)
|
||||
}
|
||||
for i := 1; i < len(cands); i++ {
|
||||
if cands[i-1].Score < cands[i].Score {
|
||||
t.Errorf("candidates not ranked: [%d].Score=%d < [%d].Score=%d", i-1, cands[i-1].Score, i, cands[i].Score)
|
||||
}
|
||||
}
|
||||
hint, ok := g.HintView()
|
||||
if !ok {
|
||||
t.Fatal("opening game has no hint")
|
||||
}
|
||||
if cands[0].Score != hint.Score {
|
||||
t.Errorf("top candidate score = %d, want hint score %d", cands[0].Score, hint.Score)
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c.Action != ActionPlay {
|
||||
t.Errorf("candidate action = %v, want play", c.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatePlayDoesNotCommit checks the preview scores like a real play but
|
||||
// leaves the board, scores, turn and bag untouched.
|
||||
func TestEvaluatePlayDoesNotCommit(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user