af30846091
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>
146 lines
4.6 KiB
Go
146 lines
4.6 KiB
Go
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)
|
|
}
|