From af30846091d0d9ab7cd146c05d9ef6c76855c0f1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 29 May 2026 09:36:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(game):=20#59=20=E2=80=94=20per-command=20re?= =?UTF-8?q?jection=20on=20PUT=20/api/v1/order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/FUNCTIONAL.md | 14 ++ docs/FUNCTIONAL_ru.md | 14 ++ game/internal/controller/fleet_send_test.go | 2 +- game/internal/controller/fleet_test.go | 6 +- game/internal/controller/order.go | 23 ++- game/internal/controller/order_test.go | 145 +++++++++++++++ game/internal/controller/planet_test.go | 4 +- game/internal/controller/race.go | 2 +- game/internal/controller/race_test.go | 12 +- game/internal/controller/route_test.go | 4 +- game/internal/controller/science_test.go | 4 +- game/internal/controller/ship_class_test.go | 6 +- game/internal/controller/ship_group.go | 2 +- .../controller/ship_group_send_test.go | 2 +- game/internal/controller/ship_group_test.go | 16 +- .../controller/ship_group_upgrade_test.go | 2 +- game/internal/router/handler/handler.go | 18 +- game/internal/router/order_test.go | 123 +++++++++++++ game/openapi.yaml | 55 ++++-- pkg/error/generic.go | 165 ++++++++++++------ pkg/error/input.go | 4 +- pkg/error/state.go | 4 +- 22 files changed, 517 insertions(+), 110 deletions(-) create mode 100644 game/internal/controller/order_test.go diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 06867dd..b9c46c4 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ae233f3..e93ff7b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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 Запущенная игра постоянно чередуется между окном приёма команд diff --git a/game/internal/controller/fleet_send_test.go b/game/internal/controller/fleet_send_test.go index a02cb64..66ec268 100644 --- a/game/internal/controller/fleet_send_test.go +++ b/game/internal/controller/fleet_send_test.go @@ -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)) diff --git a/game/internal/controller/fleet_test.go b/game/internal/controller/fleet_test.go index a5df3aa..615ffdd 100644 --- a/game/internal/controller/fleet_test.go +++ b/game/internal/controller/fleet_test.go @@ -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)) diff --git a/game/internal/controller/order.go b/game/internal/controller/order.go index fe39c22..e695708 100644 --- a/game/internal/controller/order.go +++ b/game/internal/controller/order.go @@ -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 - } - err = errors.Join(err, c.applyCommand(actor, commands[i])) + // 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]) } - return + return nil } func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) { diff --git a/game/internal/controller/order_test.go b/game/internal/controller/order_test.go new file mode 100644 index 0000000..92e6c9e --- /dev/null +++ b/game/internal/controller/order_test.go @@ -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) +} diff --git a/game/internal/controller/planet_test.go b/game/internal/controller/planet_test.go index 141e152..ae2cdf0 100644 --- a/game/internal/controller/planet_test.go +++ b/game/internal/controller/planet_test.go @@ -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)) diff --git a/game/internal/controller/race.go b/game/internal/controller/race.go index 20eb0b4..c4d35c1 100644 --- a/game/internal/controller/race.go +++ b/game/internal/controller/race.go @@ -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 } diff --git a/game/internal/controller/race_test.go b/game/internal/controller/race_test.go index 388588b..043ac1b 100644 --- a/game/internal/controller/race_test.go +++ b/game/internal/controller/race_test.go @@ -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) diff --git a/game/internal/controller/route_test.go b/game/internal/controller/route_test.go index 6adf9d8..58707d6 100644 --- a/game/internal/controller/route_test.go +++ b/game/internal/controller/route_test.go @@ -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)) diff --git a/game/internal/controller/science_test.go b/game/internal/controller/science_test.go index bf88619..27db5d9 100644 --- a/game/internal/controller/science_test.go +++ b/game/internal/controller/science_test.go @@ -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)) diff --git a/game/internal/controller/ship_class_test.go b/game/internal/controller/ship_class_test.go index e04f165..5c7d94b 100644 --- a/game/internal/controller/ship_class_test.go +++ b/game/internal/controller/ship_class_test.go @@ -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)) diff --git a/game/internal/controller/ship_group.go b/game/internal/controller/ship_group.go index 66e1722..126bf13 100644 --- a/game/internal/controller/ship_group.go +++ b/game/internal/controller/ship_group.go @@ -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 { diff --git a/game/internal/controller/ship_group_send_test.go b/game/internal/controller/ship_group_send_test.go index 70275ba..1a71332 100644 --- a/game/internal/controller/ship_group_send_test.go +++ b/game/internal/controller/ship_group_send_test.go @@ -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)) diff --git a/game/internal/controller/ship_group_test.go b/game/internal/controller/ship_group_test.go index 44ecef7..0ef88fe 100644 --- a/game/internal/controller/ship_group_test.go +++ b/game/internal/controller/ship_group_test.go @@ -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)) diff --git a/game/internal/controller/ship_group_upgrade_test.go b/game/internal/controller/ship_group_upgrade_test.go index df885fc..c30c6be 100644 --- a/game/internal/controller/ship_group_upgrade_test.go +++ b/game/internal/controller/ship_group_upgrade_test.go @@ -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)) diff --git a/game/internal/router/handler/handler.go b/game/internal/router/handler/handler.go index 4be6c8b..62ccfdc 100644 --- a/game/internal/router/handler/handler.go +++ b/game/internal/router/handler/handler.go @@ -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}) } diff --git a/game/internal/router/order_test.go b/game/internal/router/order_test.go index 5f56d35..1aad10c 100644 --- a/game/internal/router/order_test.go +++ b/game/internal/router/order_test.go @@ -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 diff --git a/game/openapi.yaml b/game/openapi.yaml index 1ba3cb1..b875b5b 100644 --- a/game/openapi.yaml +++ b/game/openapi.yaml @@ -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=` + - 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=` 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. diff --git a/pkg/error/generic.go b/pkg/error/generic.go index f619fd2..b38337d 100644 --- a/pkg/error/generic.go +++ b/pkg/error/generic.go @@ -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) diff --git a/pkg/error/input.go b/pkg/error/input.go index 960aa67..31bdd40 100644 --- a/pkg/error/input.go +++ b/pkg/error/input.go @@ -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 { diff --git a/pkg/error/state.go b/pkg/error/state.go index f5d39c1..d0c9bdc 100644 --- a/pkg/error/state.go +++ b/pkg/error/state.go @@ -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 {