fix(game): #59 — per-command rejection on PUT /api/v1/order
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:
+110
-55
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user