Files
galaxy-game/pkg/error/generic.go
T
Ilia Denisov af30846091
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 3m3s
Tests · Integration / integration (pull_request) Successful in 1m40s
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>
2026-05-29 09:36:29 +02:00

281 lines
10 KiB
Go

// 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 = 1001
ErrGameNotInitialized int = 1002
ErrGameStateInvalid int = 1003
ErrReportNotFound int = 1004
)
// Shelf 2xxx — structural input validation.
const (
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 = 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:
return "Dummy"
case ErrStorageFailure:
return "Storage failure"
case ErrGameNotInitialized:
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:
return "Unknown relation"
case ErrInputSameRace:
return "Race name must be different from your own"
case ErrInputEntityTypeNameInvalid:
return "Name has invalid length or symbols"
case ErrInputNewEntityDuplicateIdentifier:
return "Entity already exists"
case ErrInputEntityTypeNameEquality:
return "Names should differ"
case ErrInputEntityNotExists:
return "Entity does not exists"
case ErrInputEntityNotOwned:
return "Entity is not owned"
case ErrEntityInUse:
return "Entity currently in use"
case ErrInputPlanetNumber:
return "Invalid Planet number"
case ErrInputDriveValue:
return "Invalid Drive value"
case ErrInputWeaponsValue:
return "Invalid Weapons value"
case ErrInputShieldsValue:
return "Invalid Shields value"
case ErrInputCargoValue:
return "Invalid Cargo value"
case ErrInputShipTypeArmamentValue:
return "Invalid Armament value"
case ErrInputShipTypeWeaponsAndArmamentValue:
return "Invalid Armament or Weapons value"
case ErrInputShipTypeZeroValues:
return "Ship type values cannot be all zeros"
case ErrDeleteShipTypeExistingGroup:
return "Ship type exists in a Group"
case ErrDeleteShipTypePlanetProduction:
return "Ship type in production on the Planet"
case ErrDeleteSciencePlanetProduction:
return "Science in production on the Planet"
case ErrInputScienceSumValues:
return "Science proportions sum should be equal 1"
case ErrInputProductionInvalid:
return "Invalid Production type"
case ErrInputCargoTypeInvalid:
return "Invalid cargo type"
case ErrInputCargoLoadNotEnough:
return "Not enough cargo to load"
case ErrInputCargoLoadNotEqual:
return "Ship(s) already loaded with another cargo"
case ErrInputNoCargoBay:
return "Ship type is not designed to carry cargo"
case ErrInputCargoLoadNoSpaceLeft:
return "No space left on the ships to load cargo"
case ErrInputCargoUnloadEmpty:
return "Ships are not carrying any cargo"
case ErrInputBreakGroupIllegalNumber:
return "Illegal ships number to make new group"
case ErrMergeShipTypeNotEqual:
return "Source and target ship types are not the same"
case ErrBreakGroupNumberNotEnough:
return "Not enough ships in the group to make a separate group"
case ErrShipsBusy:
return "Ship(s) are'n free to use"
case ErrShipsNotOnSamePlanet:
return "Ships not on the same planet"
case ErrInputTechUnknown:
return "Technology name unknown"
case ErrInputTechInvalidMixing:
return "Technologies list must containt only specific values"
case ErrInputUpgradeShipTechNotUsed:
return "Technology is not used with ship class"
case ErrInputUpgradeParameterNotAllowed:
return "Parameter not allowed for upgrade"
case ErrInputUpgradeShipsAlreadyUpToDate:
return "Ships already up to date, nothing to upgrade"
case ErrUpgradeGroupNumberNotEnough:
return "Not enough ships in the group to make an upgrade"
case ErrUpgradeInsufficientResources:
return "Insufficient planet production capacity"
case ErrInputUpgradeTechLevelInsufficient:
return "Insifficient Tech level for requested upgrade"
case ErrInputQuitCommandFollowedByCommand:
return "'Quit' must be the last order's command"
case ErrInputUnrecognizedCommand:
return "Unrecognized command"
case ErrSendShipHasNoDrives:
return "One or more ships are not equipped with hyperdrive and cannot be moved"
case ErrSendUnreachableDestination:
return "Destination planet is too far for current Drive level"
case ErrSendShipOwnerHasNoPlanets:
return "Race is not owning any planet, all flights impossible"
case ErrRaceExtinct:
return "Race is extinct"
default:
return fmt.Sprintf("Undescribed error with code %d", code)
}
}
type GenericError struct {
Code int
subject string
err error
}
func (ge GenericError) Error() string {
msg := GenericErrorText(ge.Code)
if ge.subject != "" {
msg += ": " + ge.subject
}
if ge.err != nil {
msg = fmt.Errorf("%s: %w", msg, ge.err).Error()
}
return msg
}
// Unwrap returns the underlying error wrapped by GenericError, if any.
func (ge GenericError) Unwrap() error {
return ge.err
}
func newGenericError(code int, arg ...any) error {
e := &GenericError{Code: code}
if len(arg) > 0 {
i := 0
switch arg[i].(type) {
case error:
e.err = arg[i].(error)
i += 1
}
if len(arg) >= i+2 {
e.subject = fmt.Sprintf(asString(arg[i]), arg[i+1:]...)
} else if len(arg) == i+1 {
e.subject = asString(arg[i])
}
}
return e
}
func asString(v any) string {
switch s := v.(type) {
case string:
return s
default:
return fmt.Sprint(v)
}
}