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
+14
View File
@@ -648,6 +648,20 @@ validity and ordering of in-game decisions. Gateway needs to know
the typed FB shape only to transcode the wire format; the per-command
semantics live in the engine.
For `user.games.order` specifically, the engine validates every
command in the submitted order against a transient view of the
current game state and reports the outcome per command on each
command's meta (`cmdApplied`, `cmdErrorCode`) inside the same
`UserGamesOrder` body. The order is persisted with these per-command
verdicts even when some commands are rejected — for example, deleting
the "create ship class X" command from an order that still contains
"produce ship X" makes the second command fail with a per-command
`cmdErrorCode` for "entity does not exist", while the rest of the
order remains stored and the response is still a `202 Accepted`. A
`400` is returned only for order-level structural rejections
(`quit` not the last command, unrecognized command type, malformed
input); `500` only for genuine engine-internal failures.
### 6.3 Turn cutoff and auto-pause
A running game continuously alternates between a command-accepting
+14
View File
@@ -666,6 +666,20 @@ Backend не парсит содержимое payload команд или пр
FB-форму только чтобы транскодировать wire-формат; per-command-
семантика живёт в движке.
Специально для `user.games.order` движок валидирует каждую команду
приказа на транзиентном слепке текущего состояния игры и записывает
итог по каждой команде в её мету (`cmdApplied`, `cmdErrorCode`) в
том же ответе `UserGamesOrder`. Приказ сохраняется с этими
per-command-вердиктами даже если часть команд была отклонена —
например, удаление команды «создать класс корабля X» из приказа,
в котором остаётся «строить X», приводит к тому, что вторая команда
возвращается с `cmdErrorCode` «сущность не существует», а остальные
команды приказа остаются сохранёнными, и ответ остаётся
`202 Accepted`. `400` возвращается только для структурных отказов
на уровне приказа (`quit` не последняя команда, неизвестный
command type, малформированный вход); `500` — только для реальных
внутренних сбоев движка.
### 6.3 Окно хода и auto-pause
Запущенная игра постоянно чередуется между окном приёма команд
+1 -1
View File
@@ -57,7 +57,7 @@ func TestFleetSend(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.FleetSend(Race_Extinct.Name, fleetSending, 2),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, "UnknownFleet", 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
+3 -3
View File
@@ -37,7 +37,7 @@ func TestShipGroupJoinFleet(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetOne, groupIndex),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
// ensure race has no Fleets
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0)
@@ -124,14 +124,14 @@ func TestFleetMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.FleetMerge(Race_Extinct.Name, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(UnknownRace, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetSourceOne, c.ShipGroup(0).ID))
+15 -8
View File
@@ -13,17 +13,24 @@ import (
"github.com/google/uuid"
)
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) (err error) {
// ValidateOrder applies every command in the order against a transient
// view of the engine state, records the per-command outcome in each
// command's CommandMeta via applyCommand, and reports only order-level
// structural errors as the function return. Per-command rejections are
// surfaced through CommandMeta.Result so the caller can persist and
// forward them as `cmdApplied`/`cmdErrorCode` in the response body.
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) error {
for i := range commands {
if _, ok := commands[i].(order.CommandRaceQuit); ok && i != len(commands)-1 {
err = e.NewQuitCommandFollowedByCommandError()
if _, ok := commands[i].(*order.CommandRaceQuit); ok && i != len(commands)-1 {
return e.NewQuitCommandFollowedByCommandError()
}
if err != nil {
return err
// applyCommand never returns a non-GenericError outside of
// programmer-error panics; the per-command code, if any, is
// already recorded on the command's meta and must not abort
// validation of the remaining commands in this order.
_ = c.applyCommand(actor, commands[i])
}
err = errors.Join(err, c.applyCommand(actor, commands[i]))
}
return
return nil
}
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
+145
View File
@@ -0,0 +1,145 @@
package controller_test
import (
"errors"
"testing"
e "galaxy/error"
"galaxy/model/order"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateOrderRejectsCommandReferencingMissingShipClass mirrors
// the scenario reported in issue #59: an order whose only command
// builds a ship of a class that does not exist must not turn into a
// generic engine failure. The engine records the rejection in the
// command's meta and reports no order-level error so the caller can
// persist the partial result and forward it as a per-command status.
func TestValidateOrderRejectsCommandReferencingMissingShipClass(t *testing.T) {
_, ctl := newCache()
cmd := &order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetProduce,
},
Number: int(R0_Planet_0_num),
Production: "SHIP",
Subject: "Nonexistent",
}
err := ctl.ValidateOrder(Race_0.Name, cmd)
assert.NoError(t, err, "per-command rejection must not become an order-level error")
require.NotNil(t, cmd.CmdApplied, "cmdApplied must be set on every processed command")
assert.False(t, *cmd.CmdApplied)
require.NotNil(t, cmd.CmdErrCode, "cmdErrorCode must be set when the command is rejected")
assert.Equal(t, e.ErrInputEntityNotExists, *cmd.CmdErrCode)
}
// TestValidateOrderContinuesAfterRejection — when one command in an
// order is rejected, every remaining command is still validated and
// receives its own per-command status. Without this property, the
// engine would silently drop the tail of an order on the first
// failure, which is exactly what produced the issue #59 symptom.
func TestValidateOrderContinuesAfterRejection(t *testing.T) {
_, ctl := newCache()
rejected := &order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetProduce,
},
Number: int(R0_Planet_0_num),
Production: "SHIP",
Subject: "Nonexistent",
}
succeeding := &order.CommandPlanetRename{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetRename,
},
Number: int(R0_Planet_0_num),
Name: "Homeworld",
}
err := ctl.ValidateOrder(Race_0.Name, rejected, succeeding)
assert.NoError(t, err)
require.NotNil(t, rejected.CmdApplied)
assert.False(t, *rejected.CmdApplied)
require.NotNil(t, rejected.CmdErrCode)
assert.Equal(t, e.ErrInputEntityNotExists, *rejected.CmdErrCode)
require.NotNil(t, succeeding.CmdApplied)
assert.True(t, *succeeding.CmdApplied)
require.NotNil(t, succeeding.CmdErrCode)
assert.Equal(t, 0, *succeeding.CmdErrCode)
}
// TestValidateOrderSimulatesPriorCommands — a later command may
// depend on the in-memory state mutation performed by an earlier
// command in the same order. Creating a ship class and producing a
// ship of that class in the same batch should both succeed because
// validation runs the commands against the transient state in
// submission order.
func TestValidateOrderSimulatesPriorCommands(t *testing.T) {
_, ctl := newCache()
create := &order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypeShipClassCreate,
},
Name: "Drone",
Drive: 1,
}
produce := &order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypePlanetProduce,
},
Number: int(R0_Planet_0_num),
Production: "SHIP",
Subject: "Drone",
}
err := ctl.ValidateOrder(Race_0.Name, create, produce)
assert.NoError(t, err)
require.NotNil(t, create.CmdApplied)
assert.True(t, *create.CmdApplied)
require.NotNil(t, produce.CmdApplied)
assert.True(t, *produce.CmdApplied)
}
// TestValidateOrderRejectsQuitFollowedByCommand — quit must be the
// last command in the order; if it is followed by another command,
// validation aborts at the order level with a structural error so
// the caller can surface HTTP 400.
func TestValidateOrderRejectsQuitFollowedByCommand(t *testing.T) {
_, ctl := newCache()
quit := &order.CommandRaceQuit{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypeRaceQuit,
},
}
follow := &order.CommandRaceVote{
CommandMeta: order.CommandMeta{
CmdID: uuid.NewString(),
CmdType: order.CommandTypeRaceVote,
},
Acceptor: Race_1.Name,
}
err := ctl.ValidateOrder(Race_0.Name, quit, follow)
require.Error(t, err)
var ge *e.GenericError
require.True(t, errors.As(err, &ge), "expected GenericError")
assert.Equal(t, e.ErrInputQuitCommandFollowedByCommand, ge.Code)
}
+2 -2
View File
@@ -27,7 +27,7 @@ func TestPlanetRename(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRename(Race_Extinct.Name, int(R0_Planet_0_num), "Home_World"),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.PlanetRename(Race_0.Name, -1, "Home_World"),
e.GenericErrorText(e.ErrInputPlanetNumber))
@@ -107,7 +107,7 @@ func TestPlanetProduce(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetProduce(Race_Extinct.Name, pn, "DRIVE", ""),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "Hyperdrive", ""),
e.GenericErrorText(e.ErrInputProductionInvalid))
+1 -1
View File
@@ -98,7 +98,7 @@ func (c *Cache) validRace(name string) (int, error) {
return -1, err
}
if c.g.Race[i].Extinct {
return -1, e.NewRaceExinctError(name)
return -1, e.NewRaceExtinctError(name)
}
return i, nil
}
+6 -6
View File
@@ -28,10 +28,10 @@ func TestRaceVote(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceVote(Race_0.Name, Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.RaceVote(Race_Extinct.Name, Race_1.Name),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
}
func TestRaceRelation(t *testing.T) {
@@ -54,10 +54,10 @@ func TestRaceRelation(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceRelation(Race_0.Name, Race_Extinct.Name, "War"),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.RaceRelation(Race_Extinct.Name, Race_0.Name, "War"),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
}
func TestRaceQuit(t *testing.T) {
@@ -69,7 +69,7 @@ func TestRaceQuit(t *testing.T) {
assert.ErrorContains(t,
g.RaceQuit(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.NoError(t, g.RaceQuit(Race_0.Name))
assert.Equal(t, 3, int(c.Race(Race_0_idx).TTL))
@@ -84,7 +84,7 @@ func TestRaceID(t *testing.T) {
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
_, err = g.RaceID(Race_Extinct.Name)
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrRaceExtinct))
id, err := g.RaceID(Race_0.Name)
assert.NoError(t, err)
+2 -2
View File
@@ -49,7 +49,7 @@ func TestPlanetRouteSet(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_Extinct.Name, "COL", 0, 2),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "IND", 0, 2),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
@@ -87,7 +87,7 @@ func TestPlanetRouteRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_Extinct.Name, "COL", 0),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_0.Name, "IND", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
+2 -2
View File
@@ -33,7 +33,7 @@ func TestScienceCreate(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ScienceCreate(Race_Extinct.Name, second, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, BadEntityName, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
@@ -95,7 +95,7 @@ func TestScienceRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ScienceRemove(Race_Extinct.Name, second),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ScienceRemove(Race_0.Name, first),
e.GenericErrorText(e.ErrInputEntityNotExists))
+3 -3
View File
@@ -31,7 +31,7 @@ func TestShipClassCreate(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipClassCreate(Race_Extinct.Name, "Drone", 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipClassCreate(Race_0.Name, BadEntityName, 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
@@ -109,7 +109,7 @@ func TestShipClassMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipClassMerge(Race_Extinct.Name, "Spy", "Drone"),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Spy", "Spy"),
e.GenericErrorText(e.ErrInputEntityTypeNameEquality))
@@ -134,7 +134,7 @@ func TestShipClassRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipClassRemove(Race_Extinct.Name, Race_0_Freighter),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipClassRemove(Race_0.Name, "Elephant"),
e.GenericErrorText(e.ErrInputEntityNotExists))
+1 -1
View File
@@ -408,7 +408,7 @@ func (c *Cache) ShipGroupBreak(ri int, groupID, newID uuid.UUID, quantity uint)
}
if c.ShipGroup(sgi).Number < quantity {
return e.NewBeakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
return e.NewBreakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
}
if quantity > 0 && quantity < c.ShipGroup(sgi).Number {
@@ -33,7 +33,7 @@ func TestShipGroupSend(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupSend(Race_Extinct.Name, c.ShipGroup(0).ID, 2),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, uuid.New(), 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
+8 -8
View File
@@ -89,7 +89,7 @@ func TestShipGroupMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupMerge(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.NoError(t, g.ShipGroupMerge(Race_0.Name))
@@ -139,7 +139,7 @@ func TestShipGroupBreak(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_Extinct.Name, c.ShipGroup(0).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, uuid.New(), uuid.New(), 1),
e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -148,7 +148,7 @@ func TestShipGroupBreak(t *testing.T) {
e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(0).ID, uuid.New(), 17),
e.GenericErrorText(e.ErrBeakGroupNumberNotEnough))
e.GenericErrorText(e.ErrBreakGroupNumberNotEnough))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(1).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrShipsBusy))
@@ -220,10 +220,10 @@ func TestShipGroupTransfer(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_Extinct.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_Extinct.Name, Race_1.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_0.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrInputSameRace))
@@ -320,7 +320,7 @@ func TestShipGroupLoad(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_Extinct.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, "GOLD", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
@@ -434,7 +434,7 @@ func TestShipGroupUnload(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_Extinct.Name, c.ShipGroup(0).ID, 0),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, uuid.New(), 0),
e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -509,7 +509,7 @@ func TestShipGroupDismantle(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupDismantle(Race_Extinct.Name, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupDismantle(Race_0.Name, uuid.New()),
e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -125,7 +125,7 @@ func TestShipGroupUpgrade(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_Extinct.Name, c.ShipGroup(0).ID, "DRIVE", 0),
e.GenericErrorText(e.ErrRaceExinct))
e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, uuid.New(), "DRIVE", 0),
e.GenericErrorText(e.ErrInputEntityNotExists))
+16 -2
View File
@@ -142,6 +142,18 @@ func stateResponse(s game.State) rest.StateResponse {
return *result
}
// errorResponse renders err onto c and reports whether the caller
// should stop further processing. The HTTP status is selected by the
// GenericError shelf (see pkg/error for the taxonomy):
//
// - validator.ValidationErrors (request struct binding) → 400 with
// {"error": ...}.
// - GenericError, ErrGameNotInitialized → 501 with no body.
// - GenericError on the internal shelf (1xxx) → 500 with
// {"generic_error", "code"}.
// - GenericError on the input-validation shelf (2xxx) or the
// game-state shelf (3xxx) → 400 with {"generic_error", "code"}.
// - everything else (non-GenericError) → 500 with {"error": ...}.
func errorResponse(c *gin.Context, err error) bool {
if err == nil {
return false
@@ -153,9 +165,11 @@ func errorResponse(c *gin.Context, err error) bool {
}
if ge, ok := errors.AsType[*e.GenericError](err); ok {
switch ge.Code {
case e.ErrGameNotInitialized:
switch {
case ge.Code == e.ErrGameNotInitialized:
c.Status(http.StatusNotImplemented)
case e.IsInputCode(ge.Code), e.IsGameStateCode(ge.Code):
c.JSON(http.StatusBadRequest, gin.H{"generic_error": ge.Error(), "code": ge.Code})
default:
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
}
+123
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"testing"
e "galaxy/error"
"galaxy/model/order"
"galaxy/model/rest"
@@ -1016,6 +1017,128 @@ func TestPutOrderEngineError(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body)
}
// TestPutOrderPerCommandRejection — when the engine returns an order
// where some commands carry `cmdErrorCode != 0`, the handler must
// still answer 202 with the full UserGamesOrder body so the client
// can surface the per-command failure (rather than treating the
// response as a generic error).
func TestPutOrderPerCommandRejection(t *testing.T) {
applied := true
rejectedFlag := false
rejectedCode := e.ErrInputEntityNotExists
zero := 0
result := &order.UserGamesOrder{
GameID: uuid.New(),
UpdatedAt: 4242,
Commands: []order.DecodableCommand{
&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{
CmdID: id(),
CmdType: order.CommandTypeShipClassCreate,
CmdApplied: &applied,
CmdErrCode: &zero,
},
Name: "Drone",
Drive: 1,
},
&order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{
CmdID: id(),
CmdType: order.CommandTypePlanetProduce,
CmdApplied: &rejectedFlag,
CmdErrCode: &rejectedCode,
},
Number: 0,
Production: "SHIP",
Subject: "Nonexistent",
},
},
}
executor := &dummyExecutor{ValidateOrderResult: result}
r := setupRouterExecutor(executor)
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassCreate},
Name: "Drone",
Drive: 1,
}),
encodeCommand(&order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetProduce},
Number: 0,
Production: "SHIP",
Subject: "Nonexistent",
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
require.Equal(t, http.StatusAccepted, w.Code, w.Body)
var got struct {
GameID uuid.UUID `json:"game_id"`
UpdatedAt int64 `json:"updatedAt"`
Commands []json.RawMessage `json:"cmd"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
require.Len(t, got.Commands, 2)
var first struct {
CmdApplied *bool `json:"cmdApplied"`
CmdErrCode *int `json:"cmdErrorCode"`
}
require.NoError(t, json.Unmarshal(got.Commands[0], &first))
require.NotNil(t, first.CmdApplied)
assert.True(t, *first.CmdApplied)
var second struct {
CmdApplied *bool `json:"cmdApplied"`
CmdErrCode *int `json:"cmdErrorCode"`
}
require.NoError(t, json.Unmarshal(got.Commands[1], &second))
require.NotNil(t, second.CmdApplied)
assert.False(t, *second.CmdApplied)
require.NotNil(t, second.CmdErrCode)
assert.Equal(t, e.ErrInputEntityNotExists, *second.CmdErrCode)
}
// TestPutOrderStructuralRejection — order-level structural errors
// (e.g. `quit` not the last command) come back from the executor as a
// *GenericError on the input shelf, which must map to HTTP 400 with
// the `{"generic_error","code"}` envelope rather than 500.
func TestPutOrderStructuralRejection(t *testing.T) {
executor := &dummyExecutor{ValidateOrderErr: e.NewQuitCommandFollowedByCommandError()}
r := setupRouterExecutor(executor)
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceQuit},
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body)
var got struct {
Code int `json:"code"`
Msg string `json:"generic_error"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, e.ErrInputQuitCommandFollowedByCommand, got.Code)
assert.NotEmpty(t, got.Msg)
}
func TestGetOrderQueryValidation(t *testing.T) {
for _, tc := range []struct {
description string
+45 -10
View File
@@ -16,9 +16,19 @@ info:
`504 Gateway Timeout`
- `501 Not Implemented` is returned without a body when the game has not
been initialized
- validation errors return `400` with `{"error": "message"}`
- game-engine errors return `500` with `{"generic_error": "message", "code": integer}`
- other internal errors return `500` with `{"error": "message"}`
- request-binding validation errors return `400` with `{"error": "message"}`
- structural input errors and game-state rejections that escape to
HTTP return `400` with `{"generic_error": "message", "code": integer}`;
the `code` carries the engine's `GenericError` code (see
`pkg/error/generic.go` — shelf `2xxx` for structural input and
`3xxx` for game-state rejection)
- on `PUT /api/v1/order`, game-state rejections do not become HTTP
errors; the engine returns `202 Accepted` with the full
`UserGamesOrder` body and reports the failure on the offending
command via `cmdApplied=false` and `cmdErrorCode=<integer>`
- internal engine failures return `500` with
`{"generic_error": "message", "code": integer}` (code on shelf `1xxx`)
or `{"error": "message"}` for unclassified failures
servers:
- url: http://localhost:8080
description: Default local listener for Game Service.
@@ -161,10 +171,22 @@ paths:
operationId: validateOrder
summary: Validate and store a player order without executing it
description: |
Validates and stores the game commands structurally without executing them.
On success returns `202 Accepted` with the stored order, including the
engine-assigned `updatedAt` timestamp used by clients to detect stale
submissions.
Validates and stores the game commands without executing them. The
engine applies each command in submission order against a transient
view of the game state, records the per-command outcome on the
command's meta, and persists the resulting `UserGamesOrder` so
clients can reload the same per-command verdict via
`GET /api/v1/order`.
On success returns `202 Accepted` with the stored order; the
engine-assigned `updatedAt` timestamp is used by clients to detect
stale submissions. Game-state rejections (e.g. a "produce ship of
class X" command after class X was removed) are reported per
command via `cmdApplied=false` and `cmdErrorCode=<integer>` inside
the same `202` response — they do **not** become a `400` or `500`
on the whole order. Order-level structural rejections (e.g. a
`quit` command that is not the last command in the order) return
`400`.
requestBody:
required: true
content:
@@ -173,7 +195,10 @@ paths:
$ref: "#/components/schemas/CommandRequest"
responses:
"202":
description: Order is structurally valid and stored.
description: |
Order is stored. Each entry of `cmd` carries `cmdApplied` and
`cmdErrorCode` describing the per-command outcome; the order
is considered stored even when some commands were rejected.
content:
application/json:
schema:
@@ -481,10 +506,20 @@ components:
description: Unique command identifier (RFC 4122 UUID).
cmdApplied:
type: boolean
description: Set in command-result responses; true when the command was applied.
description: |
Per-command outcome. Set by the engine in every response that
carries a stored or freshly-validated order (`PUT /api/v1/order`
and `GET /api/v1/order`): `true` when the command was applied
against the engine state during validation or turn generation,
`false` when it was rejected (see `cmdErrorCode`). Omitted on
requests.
cmdErrorCode:
type: integer
description: Set in command-result responses; non-zero when the command was rejected.
description: |
Per-command error code. Set by the engine alongside `cmdApplied`:
`0` when the command was applied, a non-zero `GenericError`
code (shelves `2xxx`/`3xxx` in `pkg/error/generic.go`) when
the command was rejected. Omitted on requests.
CommandType:
type: string
description: Discriminator identifying the game command variant carried in a `cmd` element.
+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)
+2 -2
View File
@@ -108,8 +108,8 @@ func NewMergeShipTypeNotEqualError(arg ...any) error {
return newGenericError(ErrMergeShipTypeNotEqual, arg...)
}
func NewBeakGroupNumberNotEnoughError(arg ...any) error {
return newGenericError(ErrBeakGroupNumberNotEnough, arg...)
func NewBreakGroupNumberNotEnoughError(arg ...any) error {
return newGenericError(ErrBreakGroupNumberNotEnough, arg...)
}
func NewShipsBusyError(arg ...any) error {
+2 -2
View File
@@ -1,7 +1,7 @@
package error
func NewRaceExinctError(arg ...any) error {
return newGenericError(ErrRaceExinct, arg...)
func NewRaceExtinctError(arg ...any) error {
return newGenericError(ErrRaceExtinct, arg...)
}
func NewGameNotInitializedError(arg ...any) error {