fix(game): #59 — per-command rejection on PUT /api/v1/order
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 3m3s
Tests · Integration / integration (pull_request) Successful in 1m40s

Validation of a player's order now applies every command against a
transient game-state snapshot and records the per-command outcome
(cmdApplied, cmdErrorCode) in each command's meta. The order is
persisted even when some commands are rejected, and the response is
202 + UserGamesOrder so clients can surface the partial failure
without the chain collapsing into "downstream service is unavailable".

Pkg/error consts are reshelved onto three explicit ranges with a
package doc and helpers (IsInternalCode/IsInputCode/IsGameStateCode):
1xxx internal/server (500/501), 2xxx structural input (400), 3xxx
game-state per-command rejection (400 when escaping HTTP, otherwise
recorded as cmdErrorCode). Two pre-existing typos fixed mechanically
(ErrBeakGroupNumberNotEnough -> ErrBreakGroupNumberNotEnough,
ErrRaceExinct -> ErrRaceExtinct) along with all callsites.

Engine errorResponse maps *GenericError by shelf rather than mapping
everything to 500. The Quit-not-last structural check in
Controller.ValidateOrder is preserved and its type assertion fixed
(was a value assertion against a pointer-typed command, so the check
silently never fired).

Backend, gateway and UI are unchanged — they were already correct on
the 202 path; only the engine collapsing per-command rejection into
500 was needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-29 09:36:29 +02:00
parent ce1dc19a29
commit af30846091
22 changed files with 517 additions and 110 deletions
+110 -55
View File
@@ -1,71 +1,124 @@
// Package error defines the engine's error taxonomy used both as a wire
// contract over the engine REST API and as an internal Go error type.
//
// Codes are organised onto three semantic shelves. The high digit
// of each code identifies the shelf; the corresponding HTTP status
// mapping is enforced by router-level handlers (see
// game/internal/router/handler.errorResponse).
//
// Shelf 1xxx (internal / server)
// Engine infrastructure failures: storage, uninitialised game,
// invalid persisted state, missing report. HTTP 500, except
// ErrGameNotInitialized → 501.
//
// Shelf 2xxx (input validation, structural)
// Per-request structural rejections that can be decided without
// inspecting game state: enum mismatches, numeric ranges,
// cross-field shape rules ("ammunition without weapons"), and
// order-level structure ("quit must be the last command"). HTTP
// 400.
//
// Shelf 3xxx (game-state, per-command rejection)
// Game-state runtime rejections that depend on the current state
// snapshot: entity-not-found, not-owned, in-use, ships-busy,
// insufficient resources, send/upgrade/cargo dependencies. These
// surface as per-command `cmdErrorCode` on PUT /api/v1/order
// (and only escape as HTTP 400 from PUT /api/v1/command).
//
// Code 0 represents "applied without error" and is reserved as the
// successful per-command outcome on CommandMeta.Result. Code -1
// (ErrDummy) is reserved for test fixtures.
package error
import (
"fmt"
)
// Shelf 1xxx — internal / server errors.
const (
ErrStorageFailure int = 1000 + iota
ErrGameNotInitialized
ErrGameStateInvalid
ErrReportNotFound
ErrStorageFailure int = 1001
ErrGameNotInitialized int = 1002
ErrGameStateInvalid int = 1003
ErrReportNotFound int = 1004
)
// Shelf 2xxx — structural input validation.
const (
ErrDummy int = -1
ErrDeleteShipTypeExistingGroup = 5000
ErrDeleteShipTypePlanetProduction = 5001
ErrDeleteSciencePlanetProduction = 5002
ErrMergeShipTypeNotEqual = 5003
ErrBeakGroupNumberNotEnough = 5005
ErrEntityInUse = 5006
ErrShipsBusy = 5007
ErrShipsNotOnSamePlanet = 5008
ErrUpgradeGroupNumberNotEnough = 5010
ErrUpgradeInsufficientResources = 5011
ErrSendShipHasNoDrives = 5012
ErrSendUnreachableDestination = 5013
ErrSendShipOwnerHasNoPlanets = 5014
ErrRaceExinct = 5015
ErrInputUnknownRelation int = 2001
ErrInputSameRace int = 2002
ErrInputEntityTypeNameInvalid int = 2003
ErrInputEntityTypeNameEquality int = 2004
ErrInputPlanetNumber int = 2005
ErrInputDriveValue int = 2006
ErrInputWeaponsValue int = 2007
ErrInputShieldsValue int = 2008
ErrInputCargoValue int = 2009
ErrInputShipTypeArmamentValue int = 2010
ErrInputShipTypeWeaponsAndArmamentValue int = 2011
ErrInputShipTypeZeroValues int = 2012
ErrInputScienceSumValues int = 2013
ErrInputProductionInvalid int = 2014
ErrInputCargoTypeInvalid int = 2015
ErrInputBreakGroupIllegalNumber int = 2016
ErrInputTechUnknown int = 2017
ErrInputTechInvalidMixing int = 2018
ErrInputUpgradeParameterNotAllowed int = 2019
ErrInputQuitCommandFollowedByCommand int = 2020
ErrInputUnrecognizedCommand int = 2021
)
// Shelf 3xxx — game-state runtime errors (per-command rejection).
//
// Several constants here retain a historical "Input" prefix in their
// names although the underlying check needs the live game state; the
// shelf is the authoritative classification and supersedes the prefix.
const (
ErrInputUnknownRace int = 3000 + iota
ErrInputUnknownRelation
ErrInputSameRace
ErrInputEntityTypeNameInvalid
ErrInputNewEntityDuplicateIdentifier
ErrInputEntityTypeNameEquality
ErrInputEntityNotExists
ErrInputEntityNotOwned
ErrInputPlanetNumber
ErrInputDriveValue
ErrInputWeaponsValue
ErrInputShieldsValue
ErrInputCargoValue
ErrInputShipTypeArmamentValue
ErrInputShipTypeWeaponsAndArmamentValue
ErrInputShipTypeZeroValues
ErrInputScienceSumValues
ErrInputProductionInvalid
ErrInputCargoTypeInvalid
ErrInputCargoLoadNotEnough
ErrInputCargoLoadNotEqual
ErrInputNoCargoBay
ErrInputCargoLoadNoSpaceLeft
ErrInputCargoUnloadEmpty
ErrInputBreakGroupIllegalNumber
ErrInputTechUnknown
ErrInputTechInvalidMixing
ErrInputUpgradeShipTechNotUsed
ErrInputUpgradeParameterNotAllowed
ErrInputUpgradeShipsAlreadyUpToDate
ErrInputUpgradeTechLevelInsufficient
ErrInputQuitCommandFollowedByCommand
ErrInputUnrecognizedCommand
ErrInputUnknownRace int = 3001
ErrInputNewEntityDuplicateIdentifier int = 3002
ErrInputEntityNotExists int = 3003
ErrInputEntityNotOwned int = 3004
ErrEntityInUse int = 3005
ErrRaceExtinct int = 3006
ErrShipsBusy int = 3007
ErrShipsNotOnSamePlanet int = 3008
ErrDeleteShipTypeExistingGroup int = 3009
ErrDeleteShipTypePlanetProduction int = 3010
ErrDeleteSciencePlanetProduction int = 3011
ErrMergeShipTypeNotEqual int = 3012
ErrBreakGroupNumberNotEnough int = 3013
ErrInputCargoLoadNotEnough int = 3014
ErrInputCargoLoadNotEqual int = 3015
ErrInputNoCargoBay int = 3016
ErrInputCargoLoadNoSpaceLeft int = 3017
ErrInputCargoUnloadEmpty int = 3018
ErrInputUpgradeShipTechNotUsed int = 3019
ErrInputUpgradeShipsAlreadyUpToDate int = 3020
ErrUpgradeGroupNumberNotEnough int = 3021
ErrUpgradeInsufficientResources int = 3022
ErrInputUpgradeTechLevelInsufficient int = 3023
ErrSendShipHasNoDrives int = 3024
ErrSendUnreachableDestination int = 3025
ErrSendShipOwnerHasNoPlanets int = 3026
)
// ErrDummy is reserved for test fixtures; production code never uses it.
const ErrDummy int = -1
// IsInternalCode reports whether code belongs to the internal / server
// shelf (1xxx). Internal errors map to HTTP 500 (ErrGameNotInitialized
// is special-cased to 501).
func IsInternalCode(code int) bool { return code >= 1000 && code < 2000 }
// IsInputCode reports whether code belongs to the structural input
// validation shelf (2xxx). Input errors map to HTTP 400.
func IsInputCode(code int) bool { return code >= 2000 && code < 3000 }
// IsGameStateCode reports whether code belongs to the game-state /
// per-command rejection shelf (3xxx). On PUT /api/v1/order these are
// recorded into CommandMeta.CmdErrCode; on PUT /api/v1/command they
// map to HTTP 400.
func IsGameStateCode(code int) bool { return code >= 3000 && code < 4000 }
func GenericErrorText(code int) string {
switch code {
case ErrDummy:
@@ -76,6 +129,8 @@ func GenericErrorText(code int) string {
return "Game not yet initialized"
case ErrGameStateInvalid:
return "Invalid game state"
case ErrReportNotFound:
return "Report not found"
case ErrInputUnknownRace:
return "Race name is unknown to this game"
case ErrInputUnknownRelation:
@@ -136,7 +191,7 @@ func GenericErrorText(code int) string {
return "Illegal ships number to make new group"
case ErrMergeShipTypeNotEqual:
return "Source and target ship types are not the same"
case ErrBeakGroupNumberNotEnough:
case ErrBreakGroupNumberNotEnough:
return "Not enough ships in the group to make a separate group"
case ErrShipsBusy:
return "Ship(s) are'n free to use"
@@ -168,7 +223,7 @@ func GenericErrorText(code int) string {
return "Destination planet is too far for current Drive level"
case ErrSendShipOwnerHasNoPlanets:
return "Race is not owning any planet, all flights impossible"
case ErrRaceExinct:
case ErrRaceExtinct:
return "Race is extinct"
default:
return fmt.Sprintf("Undescribed error with code %d", code)