feat(hint): unify the vs_ai idle hint online + offline (server-enforced, monotonic)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s
Online vs_ai hints were broken: #207 made the vs_ai hint button always-enabled and wallet-free (assuming all vs_ai = the offline idle-gate), but the backend still served online vs_ai from the allowance/wallet, so over-clicking hit ErrNoHintsLeft -> a generic error toast. Now online vs_ai uses the SAME idle-gate model as offline. Backend: Hint() for a vs_ai game skips the allowance/wallet and increments no hints_used (owner: vs_ai counts toward no hint statistic), and is idle-gated from the SERVER clock -- it returns ErrHintLocked (code hint_locked) until the robot's last move + 30 min, else serves the top move. GameState/StateView expose hint_unlock_left_seconds (server-computed seconds remaining; 0 for a human game / first move / not-your-turn). Pure helper hintUnlockLeftSeconds unit-tested. Wire: StateView gains hint_unlock_left_seconds (FlatBuffers, additive); pkg/wire + gateway transcode carry it (round-trip test). Client: the gate counts down from a MONOTONIC clock (performance.now()) anchored to the source's seconds-left when it lands (on load from the view; to the full window when the robot moves), so a client clock change cannot skew it and a relaunch re-reads a fresh value. The vs_ai hint button (online + offline) shows the lock + toast; doHint handles the hint_locked backstop by re-syncing. Replaces #207's absolute hintUnlockAtMs on the wire/model/delta (the offline record keeps the absolute for persistence; the view exposes seconds-left). Docs FUNCTIONAL(+_ru)/ARCHITECTURE updated. Verified: go build/vet + game/server/transcode tests (+ new hintUnlockLeftSeconds); ui check 0 / unit 490 / e2e 198 (one pre-existing webkit offline flake) / app entry 114.2/115.
This commit is contained in:
@@ -1058,10 +1058,31 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
|
||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||
// turn, spending one hint from their per-game allowance and then their profile
|
||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||
// appropriate.
|
||||
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
|
||||
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
||||
const hintIdleWindow = 30 * time.Minute
|
||||
|
||||
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
|
||||
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
|
||||
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
|
||||
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
|
||||
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
|
||||
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
|
||||
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
|
||||
return 0
|
||||
}
|
||||
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
|
||||
if left <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
|
||||
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
|
||||
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
|
||||
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
|
||||
// hint statistic.
|
||||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -1080,6 +1101,26 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
if !pre.HintsAllowed {
|
||||
return HintResult{}, ErrHintsDisabled
|
||||
}
|
||||
if pre.VsAI {
|
||||
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
|
||||
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
|
||||
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
|
||||
// touching the allowance, the wallet or hints_used.
|
||||
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
|
||||
return HintResult{}, ErrHintLocked
|
||||
}
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
g, err := svc.liveGame(ctx, pre)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
move, ok := g.HintView()
|
||||
if !ok {
|
||||
return HintResult{}, ErrNoHintAvailable
|
||||
}
|
||||
return HintResult{Move: move}, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
@@ -1208,6 +1249,8 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user