fix(game): #59 — per-command rejection on PUT /api/v1/order #71

Merged
developer merged 4 commits from feature/issue-59-invalid-order-per-command into development 2026-05-29 10:18:15 +00:00
22 changed files with 517 additions and 110 deletions
Showing only changes of commit af30846091 - Show all commits
+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 the typed FB shape only to transcode the wire format; the per-command
semantics live in the engine. 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 ### 6.3 Turn cutoff and auto-pause
A running game continuously alternates between a command-accepting A running game continuously alternates between a command-accepting
+14
View File
@@ -666,6 +666,20 @@ Backend не парсит содержимое payload команд или пр
FB-форму только чтобы транскодировать wire-формат; per-command- 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 ### 6.3 Окно хода и auto-pause
Запущенная игра постоянно чередуется между окном приёма команд Запущенная игра постоянно чередуется между окном приёма команд
+1 -1
View File
@@ -57,7 +57,7 @@ func TestFleetSend(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.FleetSend(Race_Extinct.Name, fleetSending, 2), g.FleetSend(Race_Extinct.Name, fleetSending, 2),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.FleetSend(Race_0.Name, "UnknownFleet", 2), g.FleetSend(Race_0.Name, "UnknownFleet", 2),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
+3 -3
View File
@@ -37,7 +37,7 @@ func TestShipGroupJoinFleet(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetOne, groupIndex), g.ShipGroupJoinFleet(Race_Extinct.Name, fleetOne, groupIndex),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
// ensure race has no Fleets // ensure race has no Fleets
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0) assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0)
@@ -124,14 +124,14 @@ func TestFleetMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.FleetMerge(Race_Extinct.Name, fleetSourceOne, fleetTargetTwo), g.FleetMerge(Race_Extinct.Name, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupJoinFleet(UnknownRace, fleetSourceOne, c.ShipGroup(0).ID), g.ShipGroupJoinFleet(UnknownRace, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetSourceOne, c.ShipGroup(0).ID), 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)) 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" "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 { for i := range commands {
if _, ok := commands[i].(order.CommandRaceQuit); ok && i != len(commands)-1 { if _, ok := commands[i].(*order.CommandRaceQuit); ok && i != len(commands)-1 {
err = e.NewQuitCommandFollowedByCommandError() return e.NewQuitCommandFollowedByCommandError()
} }
if err != nil { // applyCommand never returns a non-GenericError outside of
return err // programmer-error panics; the per-command code, if any, is
} // already recorded on the command's meta and must not abort
err = errors.Join(err, c.applyCommand(actor, commands[i])) // validation of the remaining commands in this order.
_ = c.applyCommand(actor, commands[i])
} }
return return nil
} }
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) { 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)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetRename(Race_Extinct.Name, int(R0_Planet_0_num), "Home_World"), g.PlanetRename(Race_Extinct.Name, int(R0_Planet_0_num), "Home_World"),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetRename(Race_0.Name, -1, "Home_World"), g.PlanetRename(Race_0.Name, -1, "Home_World"),
e.GenericErrorText(e.ErrInputPlanetNumber)) e.GenericErrorText(e.ErrInputPlanetNumber))
@@ -107,7 +107,7 @@ func TestPlanetProduce(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetProduce(Race_Extinct.Name, pn, "DRIVE", ""), g.PlanetProduce(Race_Extinct.Name, pn, "DRIVE", ""),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "Hyperdrive", ""), g.PlanetProduce(Race_0.Name, pn, "Hyperdrive", ""),
e.GenericErrorText(e.ErrInputProductionInvalid)) e.GenericErrorText(e.ErrInputProductionInvalid))
+1 -1
View File
@@ -98,7 +98,7 @@ func (c *Cache) validRace(name string) (int, error) {
return -1, err return -1, err
} }
if c.g.Race[i].Extinct { if c.g.Race[i].Extinct {
return -1, e.NewRaceExinctError(name) return -1, e.NewRaceExtinctError(name)
} }
return i, nil return i, nil
} }
+6 -6
View File
@@ -28,10 +28,10 @@ func TestRaceVote(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.RaceVote(Race_0.Name, Race_Extinct.Name), g.RaceVote(Race_0.Name, Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.RaceVote(Race_Extinct.Name, Race_1.Name), g.RaceVote(Race_Extinct.Name, Race_1.Name),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
} }
func TestRaceRelation(t *testing.T) { func TestRaceRelation(t *testing.T) {
@@ -54,10 +54,10 @@ func TestRaceRelation(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.RaceRelation(Race_0.Name, Race_Extinct.Name, "War"), g.RaceRelation(Race_0.Name, Race_Extinct.Name, "War"),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.RaceRelation(Race_Extinct.Name, Race_0.Name, "War"), g.RaceRelation(Race_Extinct.Name, Race_0.Name, "War"),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
} }
func TestRaceQuit(t *testing.T) { func TestRaceQuit(t *testing.T) {
@@ -69,7 +69,7 @@ func TestRaceQuit(t *testing.T) {
assert.ErrorContains(t, assert.ErrorContains(t,
g.RaceQuit(Race_Extinct.Name), g.RaceQuit(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.NoError(t, g.RaceQuit(Race_0.Name)) assert.NoError(t, g.RaceQuit(Race_0.Name))
assert.Equal(t, 3, int(c.Race(Race_0_idx).TTL)) 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)) assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
_, err = g.RaceID(Race_Extinct.Name) _, 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) id, err := g.RaceID(Race_0.Name)
assert.NoError(t, err) assert.NoError(t, err)
+2 -2
View File
@@ -49,7 +49,7 @@ func TestPlanetRouteSet(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetRouteSet(Race_Extinct.Name, "COL", 0, 2), g.PlanetRouteSet(Race_Extinct.Name, "COL", 0, 2),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "IND", 0, 2), g.PlanetRouteSet(Race_0.Name, "IND", 0, 2),
e.GenericErrorText(e.ErrInputCargoTypeInvalid)) e.GenericErrorText(e.ErrInputCargoTypeInvalid))
@@ -87,7 +87,7 @@ func TestPlanetRouteRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetRouteRemove(Race_Extinct.Name, "COL", 0), g.PlanetRouteRemove(Race_Extinct.Name, "COL", 0),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.PlanetRouteRemove(Race_0.Name, "IND", 0), g.PlanetRouteRemove(Race_0.Name, "IND", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid)) e.GenericErrorText(e.ErrInputCargoTypeInvalid))
+2 -2
View File
@@ -33,7 +33,7 @@ func TestScienceCreate(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ScienceCreate(Race_Extinct.Name, second, 0.4, 0, 0.6, 0), g.ScienceCreate(Race_Extinct.Name, second, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, BadEntityName, 0.4, 0, 0.6, 0), g.ScienceCreate(Race_0.Name, BadEntityName, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid)) e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
@@ -95,7 +95,7 @@ func TestScienceRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ScienceRemove(Race_Extinct.Name, second), g.ScienceRemove(Race_Extinct.Name, second),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ScienceRemove(Race_0.Name, first), g.ScienceRemove(Race_0.Name, first),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
+3 -3
View File
@@ -31,7 +31,7 @@ func TestShipClassCreate(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipClassCreate(Race_Extinct.Name, "Drone", 1, 0, 0, 0, 0), g.ShipClassCreate(Race_Extinct.Name, "Drone", 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipClassCreate(Race_0.Name, BadEntityName, 1, 0, 0, 0, 0), g.ShipClassCreate(Race_0.Name, BadEntityName, 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid)) e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
@@ -109,7 +109,7 @@ func TestShipClassMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipClassMerge(Race_Extinct.Name, "Spy", "Drone"), g.ShipClassMerge(Race_Extinct.Name, "Spy", "Drone"),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Spy", "Spy"), g.ShipClassMerge(Race_0.Name, "Spy", "Spy"),
e.GenericErrorText(e.ErrInputEntityTypeNameEquality)) e.GenericErrorText(e.ErrInputEntityTypeNameEquality))
@@ -134,7 +134,7 @@ func TestShipClassRemove(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipClassRemove(Race_Extinct.Name, Race_0_Freighter), g.ShipClassRemove(Race_Extinct.Name, Race_0_Freighter),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipClassRemove(Race_0.Name, "Elephant"), g.ShipClassRemove(Race_0.Name, "Elephant"),
e.GenericErrorText(e.ErrInputEntityNotExists)) 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 { 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 { if quantity > 0 && quantity < c.ShipGroup(sgi).Number {
@@ -33,7 +33,7 @@ func TestShipGroupSend(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupSend(Race_Extinct.Name, c.ShipGroup(0).ID, 2), g.ShipGroupSend(Race_Extinct.Name, c.ShipGroup(0).ID, 2),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, uuid.New(), 2), g.ShipGroupSend(Race_0.Name, uuid.New(), 2),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
+8 -8
View File
@@ -89,7 +89,7 @@ func TestShipGroupMerge(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupMerge(Race_Extinct.Name), g.ShipGroupMerge(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.NoError(t, g.ShipGroupMerge(Race_0.Name)) assert.NoError(t, g.ShipGroupMerge(Race_0.Name))
@@ -139,7 +139,7 @@ func TestShipGroupBreak(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupBreak(Race_Extinct.Name, c.ShipGroup(0).ID, uuid.New(), 1), g.ShipGroupBreak(Race_Extinct.Name, c.ShipGroup(0).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, uuid.New(), uuid.New(), 1), g.ShipGroupBreak(Race_0.Name, uuid.New(), uuid.New(), 1),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -148,7 +148,7 @@ func TestShipGroupBreak(t *testing.T) {
e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier)) e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(0).ID, uuid.New(), 17), g.ShipGroupBreak(Race_0.Name, c.ShipGroup(0).ID, uuid.New(), 17),
e.GenericErrorText(e.ErrBeakGroupNumberNotEnough)) e.GenericErrorText(e.ErrBreakGroupNumberNotEnough))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(1).ID, uuid.New(), 1), g.ShipGroupBreak(Race_0.Name, c.ShipGroup(1).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrShipsBusy)) e.GenericErrorText(e.ErrShipsBusy))
@@ -220,10 +220,10 @@ func TestShipGroupTransfer(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_Extinct.Name, c.ShipGroup(1).ID), g.ShipGroupTransfer(Race_0.Name, Race_Extinct.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupTransfer(Race_Extinct.Name, Race_1.Name, c.ShipGroup(1).ID), g.ShipGroupTransfer(Race_Extinct.Name, Race_1.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_0.Name, c.ShipGroup(1).ID), g.ShipGroupTransfer(Race_0.Name, Race_0.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrInputSameRace)) e.GenericErrorText(e.ErrInputSameRace))
@@ -320,7 +320,7 @@ func TestShipGroupLoad(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupLoad(Race_Extinct.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0), g.ShipGroupLoad(Race_Extinct.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, "GOLD", 0), g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, "GOLD", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid)) e.GenericErrorText(e.ErrInputCargoTypeInvalid))
@@ -434,7 +434,7 @@ func TestShipGroupUnload(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupUnload(Race_Extinct.Name, c.ShipGroup(0).ID, 0), g.ShipGroupUnload(Race_Extinct.Name, c.ShipGroup(0).ID, 0),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, uuid.New(), 0), g.ShipGroupUnload(Race_0.Name, uuid.New(), 0),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -509,7 +509,7 @@ func TestShipGroupDismantle(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupDismantle(Race_Extinct.Name, c.ShipGroup(0).ID), g.ShipGroupDismantle(Race_Extinct.Name, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupDismantle(Race_0.Name, uuid.New()), g.ShipGroupDismantle(Race_0.Name, uuid.New()),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
@@ -125,7 +125,7 @@ func TestShipGroupUpgrade(t *testing.T) {
e.GenericErrorText(e.ErrInputUnknownRace)) e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_Extinct.Name, c.ShipGroup(0).ID, "DRIVE", 0), g.ShipGroupUpgrade(Race_Extinct.Name, c.ShipGroup(0).ID, "DRIVE", 0),
e.GenericErrorText(e.ErrRaceExinct)) e.GenericErrorText(e.ErrRaceExtinct))
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, uuid.New(), "DRIVE", 0), g.ShipGroupUpgrade(Race_0.Name, uuid.New(), "DRIVE", 0),
e.GenericErrorText(e.ErrInputEntityNotExists)) e.GenericErrorText(e.ErrInputEntityNotExists))
+16 -2
View File
@@ -142,6 +142,18 @@ func stateResponse(s game.State) rest.StateResponse {
return *result 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 { func errorResponse(c *gin.Context, err error) bool {
if err == nil { if err == nil {
return false return false
@@ -153,9 +165,11 @@ func errorResponse(c *gin.Context, err error) bool {
} }
if ge, ok := errors.AsType[*e.GenericError](err); ok { if ge, ok := errors.AsType[*e.GenericError](err); ok {
switch ge.Code { switch {
case e.ErrGameNotInitialized: case ge.Code == e.ErrGameNotInitialized:
c.Status(http.StatusNotImplemented) 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: default:
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code}) c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
} }
+123
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
e "galaxy/error"
"galaxy/model/order" "galaxy/model/order"
"galaxy/model/rest" "galaxy/model/rest"
@@ -1016,6 +1017,128 @@ func TestPutOrderEngineError(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body) 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) { func TestGetOrderQueryValidation(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
description string description string
+45 -10
View File
@@ -16,9 +16,19 @@ info:
`504 Gateway Timeout` `504 Gateway Timeout`
- `501 Not Implemented` is returned without a body when the game has not - `501 Not Implemented` is returned without a body when the game has not
been initialized been initialized
- validation errors return `400` with `{"error": "message"}` - request-binding validation errors return `400` with `{"error": "message"}`
- game-engine errors return `500` with `{"generic_error": "message", "code": integer}` - structural input errors and game-state rejections that escape to
- other internal errors return `500` with `{"error": "message"}` 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: servers:
- url: http://localhost:8080 - url: http://localhost:8080
description: Default local listener for Game Service. description: Default local listener for Game Service.
@@ -161,10 +171,22 @@ paths:
operationId: validateOrder operationId: validateOrder
summary: Validate and store a player order without executing it summary: Validate and store a player order without executing it
description: | description: |
Validates and stores the game commands structurally without executing them. Validates and stores the game commands without executing them. The
On success returns `202 Accepted` with the stored order, including the engine applies each command in submission order against a transient
engine-assigned `updatedAt` timestamp used by clients to detect stale view of the game state, records the per-command outcome on the
submissions. 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: requestBody:
required: true required: true
content: content:
@@ -173,7 +195,10 @@ paths:
$ref: "#/components/schemas/CommandRequest" $ref: "#/components/schemas/CommandRequest"
responses: responses:
"202": "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: content:
application/json: application/json:
schema: schema:
@@ -481,10 +506,20 @@ components:
description: Unique command identifier (RFC 4122 UUID). description: Unique command identifier (RFC 4122 UUID).
cmdApplied: cmdApplied:
type: boolean 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: cmdErrorCode:
type: integer 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: CommandType:
type: string type: string
description: Discriminator identifying the game command variant carried in a `cmd` element. 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 package error
import ( import (
"fmt" "fmt"
) )
// Shelf 1xxx — internal / server errors.
const ( const (
ErrStorageFailure int = 1000 + iota ErrStorageFailure int = 1001
ErrGameNotInitialized ErrGameNotInitialized int = 1002
ErrGameStateInvalid ErrGameStateInvalid int = 1003
ErrReportNotFound ErrReportNotFound int = 1004
) )
// Shelf 2xxx — structural input validation.
const ( const (
ErrDummy int = -1 ErrInputUnknownRelation int = 2001
ErrInputSameRace int = 2002
ErrDeleteShipTypeExistingGroup = 5000 ErrInputEntityTypeNameInvalid int = 2003
ErrDeleteShipTypePlanetProduction = 5001 ErrInputEntityTypeNameEquality int = 2004
ErrDeleteSciencePlanetProduction = 5002 ErrInputPlanetNumber int = 2005
ErrMergeShipTypeNotEqual = 5003 ErrInputDriveValue int = 2006
ErrBeakGroupNumberNotEnough = 5005 ErrInputWeaponsValue int = 2007
ErrEntityInUse = 5006 ErrInputShieldsValue int = 2008
ErrShipsBusy = 5007 ErrInputCargoValue int = 2009
ErrShipsNotOnSamePlanet = 5008 ErrInputShipTypeArmamentValue int = 2010
ErrUpgradeGroupNumberNotEnough = 5010 ErrInputShipTypeWeaponsAndArmamentValue int = 2011
ErrUpgradeInsufficientResources = 5011 ErrInputShipTypeZeroValues int = 2012
ErrSendShipHasNoDrives = 5012 ErrInputScienceSumValues int = 2013
ErrSendUnreachableDestination = 5013 ErrInputProductionInvalid int = 2014
ErrSendShipOwnerHasNoPlanets = 5014 ErrInputCargoTypeInvalid int = 2015
ErrRaceExinct = 5015 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 ( const (
ErrInputUnknownRace int = 3000 + iota ErrInputUnknownRace int = 3001
ErrInputUnknownRelation ErrInputNewEntityDuplicateIdentifier int = 3002
ErrInputSameRace ErrInputEntityNotExists int = 3003
ErrInputEntityTypeNameInvalid ErrInputEntityNotOwned int = 3004
ErrInputNewEntityDuplicateIdentifier ErrEntityInUse int = 3005
ErrInputEntityTypeNameEquality ErrRaceExtinct int = 3006
ErrInputEntityNotExists ErrShipsBusy int = 3007
ErrInputEntityNotOwned ErrShipsNotOnSamePlanet int = 3008
ErrInputPlanetNumber ErrDeleteShipTypeExistingGroup int = 3009
ErrInputDriveValue ErrDeleteShipTypePlanetProduction int = 3010
ErrInputWeaponsValue ErrDeleteSciencePlanetProduction int = 3011
ErrInputShieldsValue ErrMergeShipTypeNotEqual int = 3012
ErrInputCargoValue ErrBreakGroupNumberNotEnough int = 3013
ErrInputShipTypeArmamentValue ErrInputCargoLoadNotEnough int = 3014
ErrInputShipTypeWeaponsAndArmamentValue ErrInputCargoLoadNotEqual int = 3015
ErrInputShipTypeZeroValues ErrInputNoCargoBay int = 3016
ErrInputScienceSumValues ErrInputCargoLoadNoSpaceLeft int = 3017
ErrInputProductionInvalid ErrInputCargoUnloadEmpty int = 3018
ErrInputCargoTypeInvalid ErrInputUpgradeShipTechNotUsed int = 3019
ErrInputCargoLoadNotEnough ErrInputUpgradeShipsAlreadyUpToDate int = 3020
ErrInputCargoLoadNotEqual ErrUpgradeGroupNumberNotEnough int = 3021
ErrInputNoCargoBay ErrUpgradeInsufficientResources int = 3022
ErrInputCargoLoadNoSpaceLeft ErrInputUpgradeTechLevelInsufficient int = 3023
ErrInputCargoUnloadEmpty ErrSendShipHasNoDrives int = 3024
ErrInputBreakGroupIllegalNumber ErrSendUnreachableDestination int = 3025
ErrInputTechUnknown ErrSendShipOwnerHasNoPlanets int = 3026
ErrInputTechInvalidMixing
ErrInputUpgradeShipTechNotUsed
ErrInputUpgradeParameterNotAllowed
ErrInputUpgradeShipsAlreadyUpToDate
ErrInputUpgradeTechLevelInsufficient
ErrInputQuitCommandFollowedByCommand
ErrInputUnrecognizedCommand
) )
// 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 { func GenericErrorText(code int) string {
switch code { switch code {
case ErrDummy: case ErrDummy:
@@ -76,6 +129,8 @@ func GenericErrorText(code int) string {
return "Game not yet initialized" return "Game not yet initialized"
case ErrGameStateInvalid: case ErrGameStateInvalid:
return "Invalid game state" return "Invalid game state"
case ErrReportNotFound:
return "Report not found"
case ErrInputUnknownRace: case ErrInputUnknownRace:
return "Race name is unknown to this game" return "Race name is unknown to this game"
case ErrInputUnknownRelation: case ErrInputUnknownRelation:
@@ -136,7 +191,7 @@ func GenericErrorText(code int) string {
return "Illegal ships number to make new group" return "Illegal ships number to make new group"
case ErrMergeShipTypeNotEqual: case ErrMergeShipTypeNotEqual:
return "Source and target ship types are not the same" 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" return "Not enough ships in the group to make a separate group"
case ErrShipsBusy: case ErrShipsBusy:
return "Ship(s) are'n free to use" 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" return "Destination planet is too far for current Drive level"
case ErrSendShipOwnerHasNoPlanets: case ErrSendShipOwnerHasNoPlanets:
return "Race is not owning any planet, all flights impossible" return "Race is not owning any planet, all flights impossible"
case ErrRaceExinct: case ErrRaceExtinct:
return "Race is extinct" return "Race is extinct"
default: default:
return fmt.Sprintf("Undescribed error with code %d", code) 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...) return newGenericError(ErrMergeShipTypeNotEqual, arg...)
} }
func NewBeakGroupNumberNotEnoughError(arg ...any) error { func NewBreakGroupNumberNotEnoughError(arg ...any) error {
return newGenericError(ErrBeakGroupNumberNotEnough, arg...) return newGenericError(ErrBreakGroupNumberNotEnough, arg...)
} }
func NewShipsBusyError(arg ...any) error { func NewShipsBusyError(arg ...any) error {
+2 -2
View File
@@ -1,7 +1,7 @@
package error package error
func NewRaceExinctError(arg ...any) error { func NewRaceExtinctError(arg ...any) error {
return newGenericError(ErrRaceExinct, arg...) return newGenericError(ErrRaceExtinct, arg...)
} }
func NewGameNotInitializedError(arg ...any) error { func NewGameNotInitializedError(arg ...any) error {