feat(game): void unreplayable games as a draw instead of erroring
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
A committed move that becomes illegal under a tightened rule (the single-word connectivity fix) makes engine replay fail, which left such games unopenable — an empty screen and an 'illegal play' error. Now the first open closes the game gracefully as a draw (engine.EndAborted -> end_reason 'aborted', no winner), preserves the journal, and surfaces an impersonal organizer note at the end of the move history and in the GCG export. - engine: EndAborted + Abort() (draw, no rack adjustment; winner -1). - service: replay aborts on ErrIllegalPlay; liveGame persists the void once (lazy, on open); GameState re-reads for the settled view. - store: VoidGame finishes the game and stamps a draw without a journal row. - migration 00002: allow end_reason 'aborted'. - ui: organizer note under the history grid; i18n en/ru. - docs: ARCHITECTURE 6/9.1, FUNCTIONAL(+ru), PRERELEASE MW3.
This commit is contained in:
@@ -25,6 +25,10 @@ const (
|
||||
EndScoreless
|
||||
// EndResign fires when a player resigns.
|
||||
EndResign
|
||||
// EndAborted fires when a committed game can no longer be reconstructed from its
|
||||
// journal — a recorded move became illegal under tightened rules — and is closed as a
|
||||
// draw rather than left unopenable. See (*Game).Abort.
|
||||
EndAborted
|
||||
)
|
||||
|
||||
// String renders the end reason for logs and diagnostics.
|
||||
@@ -38,6 +42,8 @@ func (r EndReason) String() string {
|
||||
return "scoreless"
|
||||
case EndResign:
|
||||
return "resign"
|
||||
case EndAborted:
|
||||
return "aborted"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -370,6 +376,17 @@ func (g *Game) finish(reason EndReason) {
|
||||
g.applyEndAdjustment(reason)
|
||||
}
|
||||
|
||||
// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
|
||||
// service calls it when a committed game can no longer be reconstructed from its journal —
|
||||
// a recorded move became illegal under tightened rules — so the game ends gracefully
|
||||
// instead of being left unopenable. It is a no-op on an already-finished game.
|
||||
func (g *Game) Abort() {
|
||||
if g.over {
|
||||
return
|
||||
}
|
||||
g.finish(EndAborted)
|
||||
}
|
||||
|
||||
// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
|
||||
// empty, rack empty) they gain the sum of every opponent's rack value and each
|
||||
// opponent loses their own. A scoreless stalemate forfeits each player's own
|
||||
@@ -453,6 +470,9 @@ func (g *Game) winner() int {
|
||||
if !g.over {
|
||||
return -1
|
||||
}
|
||||
if g.reason == EndAborted {
|
||||
return -1 // an aborted game is a draw regardless of the running scores
|
||||
}
|
||||
best, tie := -1, false
|
||||
for i := range g.scores {
|
||||
if g.resigned[i] {
|
||||
|
||||
Reference in New Issue
Block a user