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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user