fix(game): #59 — per-command rejection on PUT /api/v1/order #71
@@ -148,14 +148,80 @@ jobs:
|
|||||||
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
||||||
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
||||||
|
|
||||||
|
- name: Recycle engine containers on image drift
|
||||||
|
run: |
|
||||||
|
# Compare the freshly-built `galaxy-engine:dev` SHA against
|
||||||
|
# every running `galaxy-game-*` container. The backend
|
||||||
|
# reconciler adopts pre-existing labelled engine containers
|
||||||
|
# without checking image drift, so a running sandbox would
|
||||||
|
# otherwise keep serving the previous engine code until the
|
||||||
|
# container is recycled by hand. This step makes the recycle
|
||||||
|
# automatic but only when it is actually needed:
|
||||||
|
#
|
||||||
|
# * BuildKit cache hit on the `Build galaxy-engine image`
|
||||||
|
# step → `galaxy-engine:dev` keeps its previous SHA →
|
||||||
|
# no drift → no-op (no engine source change to deploy).
|
||||||
|
# * engine source change → fresh SHA → for each drifted
|
||||||
|
# container we stop the backend, remove the container,
|
||||||
|
# wipe its bind-mounted state directory (Engine.Init()
|
||||||
|
# writes turn-0 over any pre-existing `turn-N` files —
|
||||||
|
# silent state corruption otherwise), and cascade-delete
|
||||||
|
# the lobby `games` row (the FKs in `00001_init.sql`
|
||||||
|
# drop the matching `runtime_records`, `memberships`,
|
||||||
|
# `player_mappings`, etc. in the same write). The
|
||||||
|
# `dev-sandbox` bootstrap on the next backend boot finds
|
||||||
|
# no live sandbox and provisions a fresh one on the new
|
||||||
|
# engine image.
|
||||||
|
#
|
||||||
|
# Backend is stopped first to keep the reconciler from
|
||||||
|
# racing the recycle (mid-stream adoption / restart). The
|
||||||
|
# subsequent `Bring up the stack` step restarts it.
|
||||||
|
set -u
|
||||||
|
new_sha=$(docker image inspect galaxy-engine:dev --format '{{.Id}}')
|
||||||
|
echo "fresh galaxy-engine:dev = $new_sha"
|
||||||
|
|
||||||
|
drift=()
|
||||||
|
for c in $(docker ps --filter "name=galaxy-game-" --format '{{.Names}}'); do
|
||||||
|
cur=$(docker inspect "$c" --format '{{.Image}}')
|
||||||
|
if [ "$cur" != "$new_sha" ]; then
|
||||||
|
drift+=("${c#galaxy-game-}")
|
||||||
|
echo " drift: $c was on $cur"
|
||||||
|
else
|
||||||
|
echo " match: $c"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#drift[@]} -eq 0 ]; then
|
||||||
|
echo "no drift detected — recycle skipped"
|
||||||
|
else
|
||||||
|
docker stop -t 30 galaxy-dev-backend >/dev/null 2>&1 || true
|
||||||
|
state_root="$HOME/.galaxy-dev/game-state"
|
||||||
|
for gid in "${drift[@]}"; do
|
||||||
|
echo "recycling $gid"
|
||||||
|
docker rm -f "galaxy-game-$gid" >/dev/null 2>&1 || true
|
||||||
|
# Wipe the per-game state dir as root inside a throwaway
|
||||||
|
# container so we can remove files left behind by the
|
||||||
|
# engine container even when its uid differs from the
|
||||||
|
# runner's.
|
||||||
|
docker run --rm -v "$state_root:/state" alpine \
|
||||||
|
sh -c "rm -rf -- /state/$gid"
|
||||||
|
done
|
||||||
|
ids_csv=$(printf "'%s'," "${drift[@]}")
|
||||||
|
ids_csv=${ids_csv%,}
|
||||||
|
docker exec galaxy-dev-postgres psql -v ON_ERROR_STOP=1 \
|
||||||
|
-U galaxy -d galaxy_backend \
|
||||||
|
-c "DELETE FROM backend.games WHERE game_id IN (${ids_csv});"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Reap stray dev-deploy containers
|
- name: Reap stray dev-deploy containers
|
||||||
run: |
|
run: |
|
||||||
# Remove any non-running compose-managed containers from
|
# Remove any non-running compose-managed containers from
|
||||||
# earlier deploys before `compose up`. Filter by the stack
|
# earlier deploys before `compose up`. Filter by the stack
|
||||||
# label so we never touch unrelated workloads on the same
|
# label so we never touch unrelated workloads on the same
|
||||||
# daemon. Running containers (incl. engine instances backend
|
# daemon. Running engine containers spawned by backend with
|
||||||
# spawned itself with the same label) are left intact —
|
# the same label are left intact when their image SHA still
|
||||||
# those are reattached by the backend reconciler on boot.
|
# matches the freshly-built `galaxy-engine:dev` (handled by
|
||||||
|
# the preceding `Recycle engine containers on image drift`
|
||||||
|
# step); the reconciler reattaches them on backend boot.
|
||||||
ids=$(docker ps -aq \
|
ids=$(docker ps -aq \
|
||||||
--filter "label=galaxy.stack=dev-deploy" \
|
--filter "label=galaxy.stack=dev-deploy" \
|
||||||
--filter "status=exited" \
|
--filter "status=exited" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
Запущенная игра постоянно чередуется между окном приёма команд
|
Запущенная игра постоянно чередуется между окном приёма команд
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
// validation of the remaining commands in this order.
|
||||||
|
_ = c.applyCommand(actor, commands[i])
|
||||||
}
|
}
|
||||||
err = errors.Join(err, c.applyCommand(actor, commands[i]))
|
return nil
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
|
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
|
||||||
@@ -102,11 +109,11 @@ func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ge, ok := errors.AsType[*e.GenericError](err); ok {
|
if ge, ok := errors.AsType[*e.GenericError](err); ok {
|
||||||
m.Result(ge.Code)
|
m.Result(ge.Code, ge.Error())
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
panic(fmt.Errorf("error applying command has unknown origin: %w", err))
|
panic(fmt.Errorf("error applying command has unknown origin: %w", err))
|
||||||
} else {
|
} else {
|
||||||
m.Result(0)
|
m.Result(0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -75,14 +75,14 @@ func TestSaveOrder(t *testing.T) {
|
|||||||
for i := range o.Commands {
|
for i := range o.Commands {
|
||||||
if v, ok := order.AsCommand[*order.CommandRaceVote](o.Commands[i]); ok {
|
if v, ok := order.AsCommand[*order.CommandRaceVote](o.Commands[i]); ok {
|
||||||
m := &v.CommandMeta
|
m := &v.CommandMeta
|
||||||
m.Result(0)
|
m.Result(0, "")
|
||||||
} else if v, ok := order.AsCommand[*order.CommandRaceQuit](o.Commands[i]); ok {
|
} else if v, ok := order.AsCommand[*order.CommandRaceQuit](o.Commands[i]); ok {
|
||||||
v.Result(10)
|
v.Result(10, "race quit failed")
|
||||||
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](o.Commands[i]); ok {
|
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](o.Commands[i]); ok {
|
||||||
m := &v.CommandMeta
|
m := &v.CommandMeta
|
||||||
m.Result(33)
|
m.Result(33, "ship class create failed")
|
||||||
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](o.Commands[i]); ok {
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](o.Commands[i]); ok {
|
||||||
v.Result(0)
|
v.Result(0, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+55
-10
@@ -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,30 @@ 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.
|
||||||
|
cmdErrorMessage:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
Per-command rejection reason, formatted by the engine's
|
||||||
|
`GenericError.Error()` (e.g.
|
||||||
|
`Entity does not exists: ship type "Drone"`). Set alongside
|
||||||
|
`cmdApplied=false`/`cmdErrorCode!=0`, omitted when the
|
||||||
|
command was applied. Provided so clients can surface the
|
||||||
|
specific reason without keeping their own code → text
|
||||||
|
catalog in sync with the engine.
|
||||||
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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ type CommandMeta struct {
|
|||||||
CmdID string `json:"cmdId" binding:"required,uuid_rfc4122"`
|
CmdID string `json:"cmdId" binding:"required,uuid_rfc4122"`
|
||||||
CmdApplied *bool `json:"cmdApplied,omitempty"`
|
CmdApplied *bool `json:"cmdApplied,omitempty"`
|
||||||
CmdErrCode *int `json:"cmdErrorCode,omitempty"`
|
CmdErrCode *int `json:"cmdErrorCode,omitempty"`
|
||||||
|
CmdErrMsg *string `json:"cmdErrorMessage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm CommandMeta) CommandType() CommandType {
|
func (cm CommandMeta) CommandType() CommandType {
|
||||||
@@ -134,9 +135,18 @@ func (cm CommandMeta) CommandID() string {
|
|||||||
return cm.CmdID
|
return cm.CmdID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CommandMeta) Result(errCode int) {
|
// Result records the per-command outcome on the meta. errCode == 0 marks
|
||||||
|
// the command as applied and clears CmdErrMsg; non-zero records the
|
||||||
|
// rejection along with the human-readable engine message so clients can
|
||||||
|
// surface the reason without their own code-to-text catalog.
|
||||||
|
func (cm *CommandMeta) Result(errCode int, errMsg string) {
|
||||||
cm.CmdErrCode = &errCode
|
cm.CmdErrCode = &errCode
|
||||||
cm.CmdApplied = new(bool(errCode == 0))
|
cm.CmdApplied = new(bool(errCode == 0))
|
||||||
|
if errCode == 0 {
|
||||||
|
cm.CmdErrMsg = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cm.CmdErrMsg = &errMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandRaceQuit struct {
|
type CommandRaceQuit struct {
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ table CommandItem {
|
|||||||
cmd_applied: bool = null;
|
cmd_applied: bool = null;
|
||||||
cmd_error_code: int64 = null;
|
cmd_error_code: int64 = null;
|
||||||
payload: CommandPayload (required);
|
payload: CommandPayload (required);
|
||||||
|
// Human-readable failure reason returned by the engine when
|
||||||
|
// `cmd_applied = false`. Appended after `payload` to preserve the
|
||||||
|
// wire offsets of existing slots (FBS field IDs are allocated in
|
||||||
|
// declaration order, so inserting in the middle would shift every
|
||||||
|
// later slot). Omitted on requests and on applied commands.
|
||||||
|
cmd_error_message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserGamesCommand is the signed-gRPC request payload for
|
// UserGamesCommand is the signed-gRPC request payload for
|
||||||
|
|||||||
@@ -96,8 +96,16 @@ func (rcv *CommandItem) Payload(obj *flatbuffers.Table) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rcv *CommandItem) CmdErrorMessage() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func CommandItemStart(builder *flatbuffers.Builder) {
|
func CommandItemStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(5)
|
builder.StartObject(6)
|
||||||
}
|
}
|
||||||
func CommandItemAddCmdId(builder *flatbuffers.Builder, cmdId flatbuffers.UOffsetT) {
|
func CommandItemAddCmdId(builder *flatbuffers.Builder, cmdId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(cmdId), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(cmdId), 0)
|
||||||
@@ -116,6 +124,9 @@ func CommandItemAddPayloadType(builder *flatbuffers.Builder, payloadType Command
|
|||||||
func CommandItemAddPayload(builder *flatbuffers.Builder, payload flatbuffers.UOffsetT) {
|
func CommandItemAddPayload(builder *flatbuffers.Builder, payload flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(payload), 0)
|
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(payload), 0)
|
||||||
}
|
}
|
||||||
|
func CommandItemAddCmdErrorMessage(builder *flatbuffers.Builder, cmdErrorMessage flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(cmdErrorMessage), 0)
|
||||||
|
}
|
||||||
func CommandItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func CommandItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ type encodedCommand struct {
|
|||||||
cmdID string
|
cmdID string
|
||||||
cmdApplied *bool
|
cmdApplied *bool
|
||||||
cmdErrCode *int
|
cmdErrCode *int
|
||||||
|
cmdErrMsg *string
|
||||||
payloadType fbs.CommandPayload
|
payloadType fbs.CommandPayload
|
||||||
payloadOffset flatbuffers.UOffsetT
|
payloadOffset flatbuffers.UOffsetT
|
||||||
}
|
}
|
||||||
@@ -404,6 +405,7 @@ func encodedCommandFromMeta(meta model.CommandMeta, payloadType fbs.CommandPaylo
|
|||||||
cmdID: meta.CmdID,
|
cmdID: meta.CmdID,
|
||||||
cmdApplied: cloneBoolPointer(meta.CmdApplied),
|
cmdApplied: cloneBoolPointer(meta.CmdApplied),
|
||||||
cmdErrCode: cloneIntPointer(meta.CmdErrCode),
|
cmdErrCode: cloneIntPointer(meta.CmdErrCode),
|
||||||
|
cmdErrMsg: cloneStringPointer(meta.CmdErrMsg),
|
||||||
payloadType: payloadType,
|
payloadType: payloadType,
|
||||||
payloadOffset: payloadOffset,
|
payloadOffset: payloadOffset,
|
||||||
}
|
}
|
||||||
@@ -423,6 +425,11 @@ func decodeOrderCommand(flatCommand *fbs.CommandItem, index int) (model.Decodabl
|
|||||||
commandMeta.CmdErrCode = &decodedCmdErrCode
|
commandMeta.CmdErrCode = &decodedCmdErrCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cmdErrMsg := flatCommand.CmdErrorMessage(); cmdErrMsg != nil {
|
||||||
|
decodedCmdErrMsg := string(cmdErrMsg)
|
||||||
|
commandMeta.CmdErrMsg = &decodedCmdErrMsg
|
||||||
|
}
|
||||||
|
|
||||||
payloadType := flatCommand.PayloadType()
|
payloadType := flatCommand.PayloadType()
|
||||||
if payloadType == fbs.CommandPayloadNONE {
|
if payloadType == fbs.CommandPayloadNONE {
|
||||||
return nil, fmt.Errorf("decode order command %d: payload type is NONE", index)
|
return nil, fmt.Errorf("decode order command %d: payload type is NONE", index)
|
||||||
@@ -915,6 +922,15 @@ func cloneIntPointer(value *int) *int {
|
|||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneStringPointer(value *string) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := *value
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
// UserGamesCommandToPayload converts model.UserGamesCommand to
|
// UserGamesCommandToPayload converts model.UserGamesCommand to
|
||||||
// FlatBuffers bytes suitable for the authenticated gateway transport.
|
// FlatBuffers bytes suitable for the authenticated gateway transport.
|
||||||
// `GameID` is required.
|
// `GameID` is required.
|
||||||
@@ -1293,6 +1309,10 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
|
|||||||
return 0, fmt.Errorf("encode %s: %w", opLabel, err)
|
return 0, fmt.Errorf("encode %s: %w", opLabel, err)
|
||||||
}
|
}
|
||||||
cmdID := builder.CreateString(encoded.cmdID)
|
cmdID := builder.CreateString(encoded.cmdID)
|
||||||
|
var cmdErrMsg flatbuffers.UOffsetT
|
||||||
|
if encoded.cmdErrMsg != nil {
|
||||||
|
cmdErrMsg = builder.CreateString(*encoded.cmdErrMsg)
|
||||||
|
}
|
||||||
fbs.CommandItemStart(builder)
|
fbs.CommandItemStart(builder)
|
||||||
fbs.CommandItemAddCmdId(builder, cmdID)
|
fbs.CommandItemAddCmdId(builder, cmdID)
|
||||||
if encoded.cmdApplied != nil {
|
if encoded.cmdApplied != nil {
|
||||||
@@ -1303,6 +1323,9 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
|
|||||||
}
|
}
|
||||||
fbs.CommandItemAddPayloadType(builder, encoded.payloadType)
|
fbs.CommandItemAddPayloadType(builder, encoded.payloadType)
|
||||||
fbs.CommandItemAddPayload(builder, encoded.payloadOffset)
|
fbs.CommandItemAddPayload(builder, encoded.payloadOffset)
|
||||||
|
if encoded.cmdErrMsg != nil {
|
||||||
|
fbs.CommandItemAddCmdErrorMessage(builder, cmdErrMsg)
|
||||||
|
}
|
||||||
offsets[i] = fbs.CommandItemEnd(builder)
|
offsets[i] = fbs.CommandItemEnd(builder)
|
||||||
}
|
}
|
||||||
if len(offsets) == 0 {
|
if len(offsets) == 0 {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
|
|||||||
applied := true
|
applied := true
|
||||||
rejected := false
|
rejected := false
|
||||||
errCode := 7
|
errCode := 7
|
||||||
|
errMsg := "rename failed: planet does not exist"
|
||||||
source := &model.UserGamesOrder{
|
source := &model.UserGamesOrder{
|
||||||
GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
|
GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
|
||||||
UpdatedAt: 99,
|
UpdatedAt: 99,
|
||||||
@@ -104,7 +105,7 @@ func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
|
|||||||
Name: "alpha",
|
Name: "alpha",
|
||||||
},
|
},
|
||||||
&model.CommandPlanetRename{
|
&model.CommandPlanetRename{
|
||||||
CommandMeta: commandMeta("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode),
|
CommandMeta: commandMetaWithMsg("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode, &errMsg),
|
||||||
Number: 6,
|
Number: 6,
|
||||||
Name: "beta",
|
Name: "beta",
|
||||||
},
|
},
|
||||||
@@ -254,10 +255,15 @@ func TestInt64ToInt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func commandMeta(id string, cmdType model.CommandType, applied *bool, errCode *int) model.CommandMeta {
|
func commandMeta(id string, cmdType model.CommandType, applied *bool, errCode *int) model.CommandMeta {
|
||||||
|
return commandMetaWithMsg(id, cmdType, applied, errCode, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandMetaWithMsg(id string, cmdType model.CommandType, applied *bool, errCode *int, errMsg *string) model.CommandMeta {
|
||||||
return model.CommandMeta{
|
return model.CommandMeta{
|
||||||
CmdType: cmdType,
|
CmdType: cmdType,
|
||||||
CmdID: id,
|
CmdID: id,
|
||||||
CmdApplied: applied,
|
CmdApplied: applied,
|
||||||
CmdErrCode: errCode,
|
CmdErrCode: errCode,
|
||||||
|
CmdErrMsg: errMsg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,29 @@ The deploy is idempotent — when the PR later merges into
|
|||||||
healthcheck steps, overwriting whatever the manual dispatch left
|
healthcheck steps, overwriting whatever the manual dispatch left
|
||||||
behind. There is no separate state to clean up between the two paths.
|
behind. There is no separate state to clean up between the two paths.
|
||||||
|
|
||||||
|
### Engine image drift recycle
|
||||||
|
|
||||||
|
`backend` spawns one engine container per game (the long-lived "Dev
|
||||||
|
Sandbox" plus any user-created games) and the reconciler reattaches
|
||||||
|
to whatever it finds with the `galaxy.stack=dev-deploy` label. That
|
||||||
|
reattach does not check the running container's image SHA against the
|
||||||
|
freshly-built `galaxy-engine:dev` tag, so an unchanged container would
|
||||||
|
otherwise keep serving the previous engine code after a redeploy.
|
||||||
|
|
||||||
|
The `dev-deploy.yaml` workflow handles this in the
|
||||||
|
`Recycle engine containers on image drift` step. When `docker build`
|
||||||
|
produces a new `galaxy-engine:dev` SHA, the step compares it against
|
||||||
|
every running `galaxy-game-*` container and, for each drifted one,
|
||||||
|
stops the backend, removes the container, wipes its bind-mounted
|
||||||
|
state directory (Engine.Init() writes turn-0 over any pre-existing
|
||||||
|
`turn-N` files), and cascade-deletes the lobby `games` row. The
|
||||||
|
`dev-sandbox` bootstrap on the next backend boot finds no live
|
||||||
|
sandbox and provisions a fresh one on the new engine image.
|
||||||
|
|
||||||
|
When the engine sources are unchanged, the BuildKit cache hits and
|
||||||
|
the SHA stays the same — the recycle step is a no-op and the running
|
||||||
|
games keep their state across the deploy.
|
||||||
|
|
||||||
## Relationship to other infrastructure
|
## Relationship to other infrastructure
|
||||||
|
|
||||||
- `tools/local-dev/` — single-developer playground, host-port mapped,
|
- `tools/local-dev/` — single-developer playground, host-port mapped,
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
|||||||
function statusOf(cmd: OrderCommand): CommandStatus {
|
function statusOf(cmd: OrderCommand): CommandStatus {
|
||||||
return draft?.statuses[cmd.id] ?? "draft";
|
return draft?.statuses[cmd.id] ?? "draft";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessageOf(cmd: OrderCommand): string | null {
|
||||||
|
return draft?.errorMessages[cmd.id] ?? null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="tool" data-testid="sidebar-tool-order">
|
<section class="tool" data-testid="sidebar-tool-order">
|
||||||
@@ -191,6 +195,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
|||||||
<ol class="commands" data-testid="order-list">
|
<ol class="commands" data-testid="order-list">
|
||||||
{#each draft.commands as cmd, index (cmd.id)}
|
{#each draft.commands as cmd, index (cmd.id)}
|
||||||
{@const status = statusOf(cmd)}
|
{@const status = statusOf(cmd)}
|
||||||
|
{@const errorReason = errorMessageOf(cmd)}
|
||||||
<li
|
<li
|
||||||
class="command status-{status}"
|
class="command status-{status}"
|
||||||
data-testid="order-command-{index}"
|
data-testid="order-command-{index}"
|
||||||
@@ -200,6 +205,14 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
|||||||
<span class="label" data-testid="order-command-label-{index}">
|
<span class="label" data-testid="order-command-label-{index}">
|
||||||
{describe(cmd)}
|
{describe(cmd)}
|
||||||
</span>
|
</span>
|
||||||
|
{#if status === "rejected" && errorReason !== null}
|
||||||
|
<span
|
||||||
|
class="error-reason"
|
||||||
|
data-testid="order-command-error-{index}"
|
||||||
|
>
|
||||||
|
{errorReason}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
data-testid="order-command-status-{index}"
|
data-testid="order-command-status-{index}"
|
||||||
@@ -314,6 +327,16 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.error-reason {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
.banner {
|
.banner {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|||||||
@@ -75,8 +75,15 @@ payload<T extends flatbuffers.Table>(obj:any):any|null {
|
|||||||
return offset ? this.bb!.__union(obj, this.bb_pos + offset) : null;
|
return offset ? this.bb!.__union(obj, this.bb_pos + offset) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmdErrorMessage():string|null
|
||||||
|
cmdErrorMessage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
cmdErrorMessage(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
static startCommandItem(builder:flatbuffers.Builder) {
|
static startCommandItem(builder:flatbuffers.Builder) {
|
||||||
builder.startObject(5);
|
builder.startObject(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addCmdId(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset) {
|
static addCmdId(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset) {
|
||||||
@@ -84,11 +91,11 @@ static addCmdId(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static addCmdApplied(builder:flatbuffers.Builder, cmdApplied:boolean) {
|
static addCmdApplied(builder:flatbuffers.Builder, cmdApplied:boolean) {
|
||||||
builder.addFieldInt8(1, +cmdApplied, null);
|
builder.addFieldInt8(1, +cmdApplied, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addCmdErrorCode(builder:flatbuffers.Builder, cmdErrorCode:bigint) {
|
static addCmdErrorCode(builder:flatbuffers.Builder, cmdErrorCode:bigint) {
|
||||||
builder.addFieldInt64(2, cmdErrorCode, null);
|
builder.addFieldInt64(2, cmdErrorCode, BigInt(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
static addPayloadType(builder:flatbuffers.Builder, payloadType:CommandPayload) {
|
static addPayloadType(builder:flatbuffers.Builder, payloadType:CommandPayload) {
|
||||||
@@ -99,13 +106,17 @@ static addPayload(builder:flatbuffers.Builder, payloadOffset:flatbuffers.Offset)
|
|||||||
builder.addFieldOffset(4, payloadOffset, 0);
|
builder.addFieldOffset(4, payloadOffset, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static addCmdErrorMessage(builder:flatbuffers.Builder, cmdErrorMessageOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(5, cmdErrorMessageOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
static endCommandItem(builder:flatbuffers.Builder):flatbuffers.Offset {
|
static endCommandItem(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
const offset = builder.endObject();
|
const offset = builder.endObject();
|
||||||
builder.requiredField(offset, 12) // payload
|
builder.requiredField(offset, 12) // payload
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset, cmdApplied:boolean|null, cmdErrorCode:bigint|null, payloadType:CommandPayload, payloadOffset:flatbuffers.Offset):flatbuffers.Offset {
|
static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset, cmdApplied:boolean|null, cmdErrorCode:bigint|null, payloadType:CommandPayload, payloadOffset:flatbuffers.Offset, cmdErrorMessageOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
CommandItem.startCommandItem(builder);
|
CommandItem.startCommandItem(builder);
|
||||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
if (cmdApplied !== null)
|
if (cmdApplied !== null)
|
||||||
@@ -114,6 +125,7 @@ static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Of
|
|||||||
CommandItem.addCmdErrorCode(builder, cmdErrorCode);
|
CommandItem.addCmdErrorCode(builder, cmdErrorCode);
|
||||||
CommandItem.addPayloadType(builder, payloadType);
|
CommandItem.addPayloadType(builder, payloadType);
|
||||||
CommandItem.addPayload(builder, payloadOffset);
|
CommandItem.addPayload(builder, payloadOffset);
|
||||||
|
CommandItem.addCmdErrorMessage(builder, cmdErrorMessageOffset);
|
||||||
return CommandItem.endCommandItem(builder);
|
return CommandItem.endCommandItem(builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +139,8 @@ unpack(): CommandItemT {
|
|||||||
const temp = unionToCommandPayload(this.payloadType(), this.payload.bind(this));
|
const temp = unionToCommandPayload(this.payloadType(), this.payload.bind(this));
|
||||||
if(temp === null) { return null; }
|
if(temp === null) { return null; }
|
||||||
return temp.unpack()
|
return temp.unpack()
|
||||||
})()
|
})(),
|
||||||
|
this.cmdErrorMessage()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +155,7 @@ unpackTo(_o: CommandItemT): void {
|
|||||||
if(temp === null) { return null; }
|
if(temp === null) { return null; }
|
||||||
return temp.unpack()
|
return temp.unpack()
|
||||||
})();
|
})();
|
||||||
|
_o.cmdErrorMessage = this.cmdErrorMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,20 +165,23 @@ constructor(
|
|||||||
public cmdApplied: boolean|null = null,
|
public cmdApplied: boolean|null = null,
|
||||||
public cmdErrorCode: bigint|null = null,
|
public cmdErrorCode: bigint|null = null,
|
||||||
public payloadType: CommandPayload = CommandPayload.NONE,
|
public payloadType: CommandPayload = CommandPayload.NONE,
|
||||||
public payload: CommandFleetMergeT|CommandFleetSendT|CommandPlanetProduceT|CommandPlanetRenameT|CommandPlanetRouteRemoveT|CommandPlanetRouteSetT|CommandRaceQuitT|CommandRaceRelationT|CommandRaceVoteT|CommandScienceCreateT|CommandScienceRemoveT|CommandShipClassCreateT|CommandShipClassMergeT|CommandShipClassRemoveT|CommandShipGroupBreakT|CommandShipGroupDismantleT|CommandShipGroupJoinFleetT|CommandShipGroupLoadT|CommandShipGroupMergeT|CommandShipGroupSendT|CommandShipGroupTransferT|CommandShipGroupUnloadT|CommandShipGroupUpgradeT|null = null
|
public payload: CommandFleetMergeT|CommandFleetSendT|CommandPlanetProduceT|CommandPlanetRenameT|CommandPlanetRouteRemoveT|CommandPlanetRouteSetT|CommandRaceQuitT|CommandRaceRelationT|CommandRaceVoteT|CommandScienceCreateT|CommandScienceRemoveT|CommandShipClassCreateT|CommandShipClassMergeT|CommandShipClassRemoveT|CommandShipGroupBreakT|CommandShipGroupDismantleT|CommandShipGroupJoinFleetT|CommandShipGroupLoadT|CommandShipGroupMergeT|CommandShipGroupSendT|CommandShipGroupTransferT|CommandShipGroupUnloadT|CommandShipGroupUpgradeT|null = null,
|
||||||
|
public cmdErrorMessage: string|Uint8Array|null = null
|
||||||
){}
|
){}
|
||||||
|
|
||||||
|
|
||||||
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
||||||
const cmdId = (this.cmdId !== null ? builder.createString(this.cmdId!) : 0);
|
const cmdId = (this.cmdId !== null ? builder.createString(this.cmdId!) : 0);
|
||||||
const payload = builder.createObjectOffset(this.payload);
|
const payload = builder.createObjectOffset(this.payload);
|
||||||
|
const cmdErrorMessage = (this.cmdErrorMessage !== null ? builder.createString(this.cmdErrorMessage!) : 0);
|
||||||
|
|
||||||
return CommandItem.createCommandItem(builder,
|
return CommandItem.createCommandItem(builder,
|
||||||
cmdId,
|
cmdId,
|
||||||
this.cmdApplied,
|
this.cmdApplied,
|
||||||
this.cmdErrorCode,
|
this.cmdErrorCode,
|
||||||
this.payloadType,
|
this.payloadType,
|
||||||
payload
|
payload,
|
||||||
|
cmdErrorMessage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ export interface PausedBanner {
|
|||||||
export class OrderDraftStore {
|
export class OrderDraftStore {
|
||||||
commands: OrderCommand[] = $state([]);
|
commands: OrderCommand[] = $state([]);
|
||||||
statuses: Record<string, CommandStatus> = $state({});
|
statuses: Record<string, CommandStatus> = $state({});
|
||||||
|
/**
|
||||||
|
* errorMessages carries the engine-formatted rejection reason for
|
||||||
|
* each rejected command, keyed by `cmdId`. Populated from
|
||||||
|
* `submitOrder` responses and from the server-stored order on
|
||||||
|
* `hydrateFromServer`. Cleared when a command is re-mutated or
|
||||||
|
* removed. The order tab renders the message inline on rejected
|
||||||
|
* rows so the player can act without inspecting the network log.
|
||||||
|
*/
|
||||||
|
errorMessages: Record<string, string> = $state({});
|
||||||
updatedAt = $state(0);
|
updatedAt = $state(0);
|
||||||
status: Status = $state("idle");
|
status: Status = $state("idle");
|
||||||
error: string | null = $state(null);
|
error: string | null = $state(null);
|
||||||
@@ -261,16 +270,29 @@ export class OrderDraftStore {
|
|||||||
this.commands = fetched.commands;
|
this.commands = fetched.commands;
|
||||||
this.updatedAt = fetched.updatedAt;
|
this.updatedAt = fetched.updatedAt;
|
||||||
this.recomputeStatuses();
|
this.recomputeStatuses();
|
||||||
// Server-fetched commands echo cmdApplied=true for entries
|
// The engine echoes per-command `cmdApplied`/`cmdErrorCode`
|
||||||
// that survived previous turns; keep them as `applied` so
|
// /`cmdErrorMessage` on every stored order entry. Hydrate
|
||||||
// the overlay continues to project them on the inspector.
|
// the in-memory status + error maps from the fetched
|
||||||
|
// snapshot so a returning player sees the same per-command
|
||||||
|
// verdict (and rejection reason) the previous submission
|
||||||
|
// produced, instead of a synthetic blanket `applied` derived
|
||||||
|
// from the local cache.
|
||||||
const next = { ...this.statuses };
|
const next = { ...this.statuses };
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
for (const cmd of this.commands) {
|
for (const cmd of this.commands) {
|
||||||
if (next[cmd.id] === "valid") {
|
const status = fetched.statuses.get(cmd.id);
|
||||||
|
if (status !== undefined && next[cmd.id] !== "invalid") {
|
||||||
|
next[cmd.id] = status;
|
||||||
|
} else if (next[cmd.id] === "valid") {
|
||||||
next[cmd.id] = "applied";
|
next[cmd.id] = "applied";
|
||||||
}
|
}
|
||||||
|
const msg = fetched.errorMessages.get(cmd.id);
|
||||||
|
if (msg !== null && msg !== undefined && msg !== "") {
|
||||||
|
nextErrors[cmd.id] = msg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.statuses = next;
|
this.statuses = next;
|
||||||
|
this.errorMessages = nextErrors;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
if ((this.syncStatus as SyncStatus) !== "paused") {
|
if ((this.syncStatus as SyncStatus) !== "paused") {
|
||||||
this.syncStatus = "synced";
|
this.syncStatus = "synced";
|
||||||
@@ -384,11 +406,15 @@ export class OrderDraftStore {
|
|||||||
}
|
}
|
||||||
this.commands = nextCommands;
|
this.commands = nextCommands;
|
||||||
const nextStatuses = { ...this.statuses };
|
const nextStatuses = { ...this.statuses };
|
||||||
|
const nextErrors = { ...this.errorMessages };
|
||||||
for (const id of removed) {
|
for (const id of removed) {
|
||||||
delete nextStatuses[id];
|
delete nextStatuses[id];
|
||||||
|
delete nextErrors[id];
|
||||||
}
|
}
|
||||||
nextStatuses[command.id] = validateCommand(command);
|
nextStatuses[command.id] = validateCommand(command);
|
||||||
|
delete nextErrors[command.id];
|
||||||
this.statuses = nextStatuses;
|
this.statuses = nextStatuses;
|
||||||
|
this.errorMessages = nextErrors;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
this.scheduleSync();
|
this.scheduleSync();
|
||||||
}
|
}
|
||||||
@@ -408,8 +434,11 @@ export class OrderDraftStore {
|
|||||||
this.clearConflictForMutation();
|
this.clearConflictForMutation();
|
||||||
this.commands = next;
|
this.commands = next;
|
||||||
const nextStatuses = { ...this.statuses };
|
const nextStatuses = { ...this.statuses };
|
||||||
|
const nextErrors = { ...this.errorMessages };
|
||||||
delete nextStatuses[id];
|
delete nextStatuses[id];
|
||||||
|
delete nextErrors[id];
|
||||||
this.statuses = nextStatuses;
|
this.statuses = nextStatuses;
|
||||||
|
this.errorMessages = nextErrors;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
this.scheduleSync();
|
this.scheduleSync();
|
||||||
}
|
}
|
||||||
@@ -484,6 +513,7 @@ export class OrderDraftStore {
|
|||||||
if (this.status !== "ready") return;
|
if (this.status !== "ready") return;
|
||||||
this.commands = [];
|
this.commands = [];
|
||||||
this.statuses = {};
|
this.statuses = {};
|
||||||
|
this.errorMessages = {};
|
||||||
this.updatedAt = 0;
|
this.updatedAt = 0;
|
||||||
this.conflictBanner = null;
|
this.conflictBanner = null;
|
||||||
this.pausedBanner = null;
|
this.pausedBanner = null;
|
||||||
@@ -571,20 +601,18 @@ export class OrderDraftStore {
|
|||||||
this.applyResultsInternal(
|
this.applyResultsInternal(
|
||||||
outcome.result.results,
|
outcome.result.results,
|
||||||
outcome.result.updatedAt,
|
outcome.result.updatedAt,
|
||||||
|
outcome.result.errorMessages,
|
||||||
);
|
);
|
||||||
// Even with `result.ok === true` an individual
|
// A `success` outcome means the gateway → backend →
|
||||||
// command may have been rejected by the engine
|
// engine round trip completed cleanly, even when
|
||||||
// (e.g. validation passed transcoders but failed
|
// individual commands were rejected by the in-game
|
||||||
// the in-game rule). Surface that as an error in
|
// rules. Per-command rejection is a player-fixable
|
||||||
// the sync bar so the player notices and can fix
|
// state surfaced via the row's `rejected` highlight
|
||||||
// or remove the offending command.
|
// and the inline reason; the sync bar stays green so
|
||||||
const anyRejected = Array.from(
|
// the banner + Retry button are reserved for genuine
|
||||||
outcome.result.results.values(),
|
// transport / engine failures the auto-retry can fix.
|
||||||
).some((s) => s === "rejected");
|
this.syncStatus = "synced";
|
||||||
this.syncStatus = anyRejected ? "error" : "synced";
|
this.syncError = null;
|
||||||
this.syncError = anyRejected
|
|
||||||
? "engine rejected one or more commands"
|
|
||||||
: null;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "rejected": {
|
case "rejected": {
|
||||||
@@ -669,9 +697,11 @@ export class OrderDraftStore {
|
|||||||
private applyResultsInternal(
|
private applyResultsInternal(
|
||||||
results: Map<string, CommandStatus>,
|
results: Map<string, CommandStatus>,
|
||||||
updatedAt: number,
|
updatedAt: number,
|
||||||
|
errorMessages?: Map<string, string | null>,
|
||||||
): void {
|
): void {
|
||||||
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
|
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
|
||||||
const next = { ...this.statuses };
|
const next = { ...this.statuses };
|
||||||
|
const nextErrors = { ...this.errorMessages };
|
||||||
for (const [id, status] of results.entries()) {
|
for (const [id, status] of results.entries()) {
|
||||||
// Drop verdicts for commands the user removed while the
|
// Drop verdicts for commands the user removed while the
|
||||||
// request was in flight — they are no longer in the
|
// request was in flight — they are no longer in the
|
||||||
@@ -679,8 +709,15 @@ export class OrderDraftStore {
|
|||||||
// confuse the order tab and the overlay.
|
// confuse the order tab and the overlay.
|
||||||
if (!liveIds.has(id)) continue;
|
if (!liveIds.has(id)) continue;
|
||||||
next[id] = status;
|
next[id] = status;
|
||||||
|
const msg = errorMessages?.get(id) ?? null;
|
||||||
|
if (msg !== null && msg !== "") {
|
||||||
|
nextErrors[id] = msg;
|
||||||
|
} else {
|
||||||
|
delete nextErrors[id];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.statuses = next;
|
this.statuses = next;
|
||||||
|
this.errorMessages = nextErrors;
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ export class OrderLoadError extends Error {
|
|||||||
|
|
||||||
export interface FetchedOrder {
|
export interface FetchedOrder {
|
||||||
commands: OrderCommand[];
|
commands: OrderCommand[];
|
||||||
|
// Per-command status keyed by cmdId. Populated from the engine's
|
||||||
|
// stored order so a returning player sees the same per-command
|
||||||
|
// verdict (applied / rejected) the previous submission produced —
|
||||||
|
// not a synthetic "applied" derived from the local cache.
|
||||||
|
statuses: Map<string, "applied" | "rejected">;
|
||||||
|
// Per-command engine-formatted error code/message, keyed by cmdId.
|
||||||
|
// Both maps carry an entry for every loaded command; the value is
|
||||||
|
// null when the command was applied (no error). The message lets
|
||||||
|
// the UI surface the rejection reason without a code → text catalog.
|
||||||
|
errorCodes: Map<string, number | null>;
|
||||||
|
errorMessages: Map<string, string | null>;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +130,13 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
|
|||||||
const buffer = new ByteBuffer(payload);
|
const buffer = new ByteBuffer(payload);
|
||||||
const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer);
|
const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer);
|
||||||
if (!response.found()) {
|
if (!response.found()) {
|
||||||
return { commands: [], updatedAt: 0 };
|
return {
|
||||||
|
commands: [],
|
||||||
|
statuses: new Map(),
|
||||||
|
errorCodes: new Map(),
|
||||||
|
errorMessages: new Map(),
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const order = response.order();
|
const order = response.order();
|
||||||
if (order === null) {
|
if (order === null) {
|
||||||
@@ -130,6 +147,9 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const commands: OrderCommand[] = [];
|
const commands: OrderCommand[] = [];
|
||||||
|
const statuses = new Map<string, "applied" | "rejected">();
|
||||||
|
const errorCodes = new Map<string, number | null>();
|
||||||
|
const errorMessages = new Map<string, string | null>();
|
||||||
const length = order.commandsLength();
|
const length = order.commandsLength();
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const item = order.commands(i);
|
const item = order.commands(i);
|
||||||
@@ -137,9 +157,20 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
|
|||||||
const cmd = decodeCommand(item);
|
const cmd = decodeCommand(item);
|
||||||
if (cmd === null) continue;
|
if (cmd === null) continue;
|
||||||
commands.push(cmd);
|
commands.push(cmd);
|
||||||
|
// The engine echoes `cmd_applied = false` only when the order
|
||||||
|
// was rejected per-command; missing / true both mean applied.
|
||||||
|
const applied = item.cmdApplied();
|
||||||
|
statuses.set(cmd.id, applied === false ? "rejected" : "applied");
|
||||||
|
const code = item.cmdErrorCode();
|
||||||
|
errorCodes.set(cmd.id, code === null ? null : Number(code));
|
||||||
|
const msg = item.cmdErrorMessage();
|
||||||
|
errorMessages.set(cmd.id, msg === null ? null : msg);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
commands,
|
commands,
|
||||||
|
statuses,
|
||||||
|
errorCodes,
|
||||||
|
errorMessages,
|
||||||
updatedAt: Number(order.updatedAt()),
|
updatedAt: Number(order.updatedAt()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export interface SubmitSuccess {
|
|||||||
ok: true;
|
ok: true;
|
||||||
results: Map<string, CommandOutcome>;
|
results: Map<string, CommandOutcome>;
|
||||||
errorCodes: Map<string, number | null>;
|
errorCodes: Map<string, number | null>;
|
||||||
|
errorMessages: Map<string, string | null>;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +512,7 @@ function decodeOrderResponse(
|
|||||||
): SubmitSuccess {
|
): SubmitSuccess {
|
||||||
const results = new Map<string, CommandOutcome>();
|
const results = new Map<string, CommandOutcome>();
|
||||||
const errorCodes = new Map<string, number | null>();
|
const errorCodes = new Map<string, number | null>();
|
||||||
|
const errorMessages = new Map<string, string | null>();
|
||||||
let updatedAt = 0;
|
let updatedAt = 0;
|
||||||
|
|
||||||
if (payload.length === 0) {
|
if (payload.length === 0) {
|
||||||
@@ -518,8 +520,9 @@ function decodeOrderResponse(
|
|||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
results.set(cmd.id, "applied");
|
results.set(cmd.id, "applied");
|
||||||
errorCodes.set(cmd.id, null);
|
errorCodes.set(cmd.id, null);
|
||||||
|
errorMessages.set(cmd.id, null);
|
||||||
}
|
}
|
||||||
return { ok: true, results, errorCodes, updatedAt };
|
return { ok: true, results, errorCodes, errorMessages, updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = new ByteBuffer(payload);
|
const buffer = new ByteBuffer(payload);
|
||||||
@@ -531,8 +534,9 @@ function decodeOrderResponse(
|
|||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
results.set(cmd.id, "applied");
|
results.set(cmd.id, "applied");
|
||||||
errorCodes.set(cmd.id, null);
|
errorCodes.set(cmd.id, null);
|
||||||
|
errorMessages.set(cmd.id, null);
|
||||||
}
|
}
|
||||||
return { ok: true, results, errorCodes, updatedAt };
|
return { ok: true, results, errorCodes, errorMessages, updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
@@ -542,8 +546,10 @@ function decodeOrderResponse(
|
|||||||
if (cmdId === null) continue;
|
if (cmdId === null) continue;
|
||||||
const applied = item.cmdApplied();
|
const applied = item.cmdApplied();
|
||||||
const errorCode = item.cmdErrorCode();
|
const errorCode = item.cmdErrorCode();
|
||||||
|
const errorMessage = item.cmdErrorMessage();
|
||||||
results.set(cmdId, applied === false ? "rejected" : "applied");
|
results.set(cmdId, applied === false ? "rejected" : "applied");
|
||||||
errorCodes.set(cmdId, errorCode === null ? null : Number(errorCode));
|
errorCodes.set(cmdId, errorCode === null ? null : Number(errorCode));
|
||||||
|
errorMessages.set(cmdId, errorMessage === null ? null : errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defensive: any submitted command not echoed back falls back to
|
// Defensive: any submitted command not echoed back falls back to
|
||||||
@@ -552,10 +558,11 @@ function decodeOrderResponse(
|
|||||||
if (!results.has(cmd.id)) {
|
if (!results.has(cmd.id)) {
|
||||||
results.set(cmd.id, "applied");
|
results.set(cmd.id, "applied");
|
||||||
errorCodes.set(cmd.id, null);
|
errorCodes.set(cmd.id, null);
|
||||||
|
errorMessages.set(cmd.id, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, results, errorCodes, updatedAt };
|
return { ok: true, results, errorCodes, errorMessages, updatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeError(
|
function decodeError(
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ interface CommandResultFixtureBase {
|
|||||||
cmdId: string;
|
cmdId: string;
|
||||||
applied: boolean | null;
|
applied: boolean | null;
|
||||||
errorCode: number | null;
|
errorCode: number | null;
|
||||||
|
// Optional engine-formatted rejection reason; mirrors the
|
||||||
|
// `cmd_error_message` field added in the same patch as
|
||||||
|
// `cmdErrorCode`'s shelf renumber. Omit on applied commands.
|
||||||
|
errorMessage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanetRenameResultFixture extends CommandResultFixtureBase {
|
export interface PlanetRenameResultFixture extends CommandResultFixtureBase {
|
||||||
@@ -132,6 +136,14 @@ export function buildOrderResponsePayload(
|
|||||||
updatedAt: number,
|
updatedAt: number,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const builder = new Builder(256);
|
const builder = new Builder(256);
|
||||||
|
// flatbuffers@25 elides any field equal to its generated default;
|
||||||
|
// for `cmd_applied: bool = null` the default is int8 0, so the
|
||||||
|
// false case (per-command rejection) would silently disappear from
|
||||||
|
// the encoded payload. The production Go transcoder uses explicit
|
||||||
|
// `Slot()` calls that force the write, and the unit-test helpers
|
||||||
|
// in `tests/helpers/fake-order-client.ts` flip `forceDefaults`;
|
||||||
|
// these e2e fixtures need the same toggle.
|
||||||
|
builder.forceDefaults(true);
|
||||||
const itemOffsets = commands.map((c) => encodeItem(builder, c));
|
const itemOffsets = commands.map((c) => encodeItem(builder, c));
|
||||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
||||||
builder,
|
builder,
|
||||||
@@ -155,6 +167,11 @@ export function buildOrderGetResponsePayload(
|
|||||||
found = true,
|
found = true,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const builder = new Builder(256);
|
const builder = new Builder(256);
|
||||||
|
// See `buildOrderResponsePayload` — the GET response path needs
|
||||||
|
// the same `forceDefaults` toggle so a stored per-command rejection
|
||||||
|
// (cmd_applied=false / cmd_error_code=0) survives the round trip
|
||||||
|
// to the UI's `hydrateFromServer`.
|
||||||
|
builder.forceDefaults(true);
|
||||||
|
|
||||||
let orderOffset = 0;
|
let orderOffset = 0;
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -290,6 +307,10 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const errMsgOffset =
|
||||||
|
c.errorMessage !== undefined && c.errorMessage !== null
|
||||||
|
? builder.createString(c.errorMessage)
|
||||||
|
: 0;
|
||||||
CommandItem.startCommandItem(builder);
|
CommandItem.startCommandItem(builder);
|
||||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
||||||
@@ -298,6 +319,9 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
|
|||||||
}
|
}
|
||||||
CommandItem.addPayloadType(builder, payloadType);
|
CommandItem.addPayloadType(builder, payloadType);
|
||||||
CommandItem.addPayload(builder, inner);
|
CommandItem.addPayload(builder, inner);
|
||||||
|
if (errMsgOffset !== 0) {
|
||||||
|
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
|
||||||
|
}
|
||||||
return CommandItem.endCommandItem(builder);
|
return CommandItem.endCommandItem(builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
|
|||||||
name: submittedName,
|
name: submittedName,
|
||||||
applied,
|
applied,
|
||||||
errorCode: applied ? null : 1,
|
errorCode: applied ? null : 1,
|
||||||
|
errorMessage: applied
|
||||||
|
? null
|
||||||
|
: "Entity does not exists: planet #99",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (opts.submitOutcome === "applied") {
|
if (opts.submitOutcome === "applied") {
|
||||||
@@ -320,14 +323,19 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
|
|||||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||||
|
|
||||||
// The auto-sync pipeline reaches the server immediately after
|
// The auto-sync pipeline reaches the server immediately after
|
||||||
// the inline confirm; the rejected verdict surfaces through the
|
// the inline confirm. Per-command rejection is a player-correctable
|
||||||
// per-row status badge and the sync bar.
|
// state: the round trip succeeded, the engine just refused this
|
||||||
|
// command, so the sync bar stays `synced` while the rejected row
|
||||||
|
// surfaces the engine-formatted reason for the player.
|
||||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||||
"rejected",
|
"rejected",
|
||||||
);
|
);
|
||||||
|
await expect(orderTool.getByTestId("order-command-error-0")).toHaveText(
|
||||||
|
"Entity does not exists: planet #99",
|
||||||
|
);
|
||||||
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
||||||
"data-sync-status",
|
"data-sync-status",
|
||||||
"error",
|
"synced",
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByTestId("sidebar-tab-inspector").click();
|
await page.getByTestId("sidebar-tab-inspector").click();
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
|
|||||||
cargo: lastCreate.cargo,
|
cargo: lastCreate.cargo,
|
||||||
applied,
|
applied,
|
||||||
errorCode: applied ? null : 1,
|
errorCode: applied ? null : 1,
|
||||||
|
errorMessage: applied
|
||||||
|
? null
|
||||||
|
: "Entity already exists: ship type \"Drone\"",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -392,9 +395,10 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
|
|||||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||||
"rejected",
|
"rejected",
|
||||||
);
|
);
|
||||||
|
await expect(orderTool.getByTestId("order-command-error-0")).toBeVisible();
|
||||||
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
||||||
"data-sync-status",
|
"data-sync-status",
|
||||||
"error",
|
"synced",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Switch sidebar back to inspector so the active-view (table)
|
// Switch sidebar back to inspector so the active-view (table)
|
||||||
|
|||||||
@@ -174,11 +174,25 @@ export function recordingClient(
|
|||||||
* decode a realistic payload without standing up a full mock
|
* decode a realistic payload without standing up a full mock
|
||||||
* gateway.
|
* gateway.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* FakeFetchStatus carries the per-command result fields the engine
|
||||||
|
* would echo on `user.games.order.get` (cmdApplied / cmdErrorCode /
|
||||||
|
* cmdErrorMessage). The helper omits each set only when the
|
||||||
|
* corresponding pointer is `undefined`, mimicking the engine's
|
||||||
|
* `omitempty` semantics.
|
||||||
|
*/
|
||||||
|
export interface FakeFetchStatus {
|
||||||
|
applied?: boolean;
|
||||||
|
errorCode?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function fakeFetchClient(
|
export function fakeFetchClient(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
commands: OrderCommand[],
|
commands: OrderCommand[],
|
||||||
updatedAt: number,
|
updatedAt: number,
|
||||||
found = true,
|
found = true,
|
||||||
|
statuses?: Record<string, FakeFetchStatus>,
|
||||||
): { client: GalaxyClient } {
|
): { client: GalaxyClient } {
|
||||||
const client: GalaxyClient = {
|
const client: GalaxyClient = {
|
||||||
async executeCommand(messageType: string) {
|
async executeCommand(messageType: string) {
|
||||||
@@ -187,7 +201,7 @@ export function fakeFetchClient(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
resultCode: "ok",
|
resultCode: "ok",
|
||||||
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
|
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found, statuses ?? {}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
} as unknown as GalaxyClient;
|
} as unknown as GalaxyClient;
|
||||||
@@ -200,6 +214,10 @@ function encodeApplied(
|
|||||||
applied: boolean,
|
applied: boolean,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const builder = new Builder(256);
|
const builder = new Builder(256);
|
||||||
|
// See `encodeOrderGet` — flatbuffers@25 elides `cmd_applied=false`
|
||||||
|
// against its int8 default; mirror the Go transcoder's force-slot
|
||||||
|
// behaviour so the boolean survives the round trip in tests.
|
||||||
|
builder.forceDefaults(true);
|
||||||
const itemOffsets = cmdIds.map((id) => {
|
const itemOffsets = cmdIds.map((id) => {
|
||||||
const cmdIdOffset = builder.createString(id);
|
const cmdIdOffset = builder.createString(id);
|
||||||
const nameOffset = builder.createString("ignored");
|
const nameOffset = builder.createString("ignored");
|
||||||
@@ -235,8 +253,15 @@ function encodeOrderGet(
|
|||||||
commands: OrderCommand[],
|
commands: OrderCommand[],
|
||||||
updatedAt: number,
|
updatedAt: number,
|
||||||
found: boolean,
|
found: boolean,
|
||||||
|
statuses: Record<string, FakeFetchStatus>,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const builder = new Builder(256);
|
const builder = new Builder(256);
|
||||||
|
// flatbuffers@25 elides fields equal to their generated default; the
|
||||||
|
// Go transcoder works around this via explicit `Slot()` calls.
|
||||||
|
// Mirror that behaviour here so `cmd_applied=false` and
|
||||||
|
// `cmd_error_code=0` round-trip through the helper instead of being
|
||||||
|
// silently dropped.
|
||||||
|
builder.forceDefaults(true);
|
||||||
|
|
||||||
let orderOffset = 0;
|
let orderOffset = 0;
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -246,6 +271,11 @@ function encodeOrderGet(
|
|||||||
}
|
}
|
||||||
const cmdIdOffset = builder.createString(cmd.id);
|
const cmdIdOffset = builder.createString(cmd.id);
|
||||||
const nameOffset = builder.createString(cmd.name);
|
const nameOffset = builder.createString(cmd.name);
|
||||||
|
const status = statuses[cmd.id] ?? {};
|
||||||
|
const errMsgOffset =
|
||||||
|
status.errorMessage !== undefined
|
||||||
|
? builder.createString(status.errorMessage)
|
||||||
|
: 0;
|
||||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||||
builder,
|
builder,
|
||||||
BigInt(cmd.planetNumber),
|
BigInt(cmd.planetNumber),
|
||||||
@@ -253,8 +283,17 @@ function encodeOrderGet(
|
|||||||
);
|
);
|
||||||
CommandItem.startCommandItem(builder);
|
CommandItem.startCommandItem(builder);
|
||||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
|
if (status.applied !== undefined) {
|
||||||
|
CommandItem.addCmdApplied(builder, status.applied);
|
||||||
|
}
|
||||||
|
if (status.errorCode !== undefined) {
|
||||||
|
CommandItem.addCmdErrorCode(builder, BigInt(status.errorCode));
|
||||||
|
}
|
||||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||||
CommandItem.addPayload(builder, inner);
|
CommandItem.addPayload(builder, inner);
|
||||||
|
if (errMsgOffset !== 0) {
|
||||||
|
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
|
||||||
|
}
|
||||||
return CommandItem.endCommandItem(builder);
|
return CommandItem.endCommandItem(builder);
|
||||||
});
|
});
|
||||||
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
|
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
|
||||||
|
|||||||
@@ -10,12 +10,13 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { waitFor } from "@testing-library/svelte";
|
import { waitFor } from "@testing-library/svelte";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import type { IDBPDatabase } from "idb";
|
import type { IDBPDatabase } from "idb";
|
||||||
|
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import type { Cache } from "../src/platform/store/index";
|
||||||
|
import type { GalaxyClient } from "../src/api/galaxy-client";
|
||||||
import { OrderDraftStore } from "../src/sync/order-draft.svelte";
|
import { OrderDraftStore } from "../src/sync/order-draft.svelte";
|
||||||
import type { OrderCommand } from "../src/sync/order-types";
|
import type { OrderCommand } from "../src/sync/order-types";
|
||||||
|
|
||||||
@@ -448,6 +449,51 @@ describe("OrderDraftStore", () => {
|
|||||||
store.dispose();
|
store.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hydrateFromServer preserves per-command rejected status and error message", async () => {
|
||||||
|
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||||
|
const { client } = fakeFetchClient(
|
||||||
|
GAME_ID,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "ok-1",
|
||||||
|
planetNumber: 7,
|
||||||
|
name: "OkTown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "bad-1",
|
||||||
|
planetNumber: 8,
|
||||||
|
name: "Doomed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
42,
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ok-1": { applied: true, errorCode: 0 },
|
||||||
|
"bad-1": {
|
||||||
|
applied: false,
|
||||||
|
errorCode: 3003,
|
||||||
|
errorMessage: 'Entity does not exists: planet #99',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
await store.hydrateFromServer({ client, turn: 5 });
|
||||||
|
|
||||||
|
expect(store.commands).toHaveLength(2);
|
||||||
|
expect(store.statuses["ok-1"]).toBe("applied");
|
||||||
|
expect(store.statuses["bad-1"]).toBe("rejected");
|
||||||
|
expect(store.errorMessages["ok-1"]).toBeUndefined();
|
||||||
|
expect(store.errorMessages["bad-1"]).toBe(
|
||||||
|
"Entity does not exists: planet #99",
|
||||||
|
);
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("hydrate empties the local cache when server returns found=false", async () => {
|
test("hydrate empties the local cache when server returns found=false", async () => {
|
||||||
// First seed a local draft.
|
// First seed a local draft.
|
||||||
const seeded = new OrderDraftStore();
|
const seeded = new OrderDraftStore();
|
||||||
@@ -555,6 +601,85 @@ describe("OrderDraftStore auto-sync", () => {
|
|||||||
store.dispose();
|
store.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("per-command rejection in a successful response keeps syncStatus 'synced'", async () => {
|
||||||
|
// Per-command rejection means the round trip succeeded — only
|
||||||
|
// individual commands were rejected by the in-game rules — so
|
||||||
|
// the sync bar must stay green. A blanket `syncStatus = "error"`
|
||||||
|
// + Retry button only fits genuine transport / engine failures.
|
||||||
|
const { Builder } = await import("flatbuffers");
|
||||||
|
const fbs = await import("../src/proto/galaxy/fbs/order");
|
||||||
|
const common = await import("../src/proto/galaxy/fbs/common");
|
||||||
|
const { uuidToHiLo } = await import("../src/api/game-state");
|
||||||
|
|
||||||
|
const cmd = {
|
||||||
|
kind: "planetRename" as const,
|
||||||
|
id: "rej-1",
|
||||||
|
planetNumber: 9,
|
||||||
|
name: "Doomed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const exec = vi.fn(async () => {
|
||||||
|
const builder = new Builder(256);
|
||||||
|
builder.forceDefaults(true);
|
||||||
|
const cmdIdOffset = builder.createString(cmd.id);
|
||||||
|
const nameOffset = builder.createString("ignored");
|
||||||
|
const errMsg = builder.createString(
|
||||||
|
"Entity does not exists: planet #99",
|
||||||
|
);
|
||||||
|
const inner = fbs.CommandPlanetRename.createCommandPlanetRename(
|
||||||
|
builder,
|
||||||
|
BigInt(0),
|
||||||
|
nameOffset,
|
||||||
|
);
|
||||||
|
fbs.CommandItem.startCommandItem(builder);
|
||||||
|
fbs.CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
|
fbs.CommandItem.addCmdApplied(builder, false);
|
||||||
|
fbs.CommandItem.addCmdErrorCode(builder, BigInt(3003));
|
||||||
|
fbs.CommandItem.addPayloadType(
|
||||||
|
builder,
|
||||||
|
fbs.CommandPayload.CommandPlanetRename,
|
||||||
|
);
|
||||||
|
fbs.CommandItem.addPayload(builder, inner);
|
||||||
|
fbs.CommandItem.addCmdErrorMessage(builder, errMsg);
|
||||||
|
const item = fbs.CommandItem.endCommandItem(builder);
|
||||||
|
const commandsVec = fbs.UserGamesOrderResponse.createCommandsVector(
|
||||||
|
builder,
|
||||||
|
[item],
|
||||||
|
);
|
||||||
|
const [hi, lo] = uuidToHiLo(GAME_ID);
|
||||||
|
const gameIdOffset = common.UUID.createUUID(builder, hi, lo);
|
||||||
|
fbs.UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||||
|
fbs.UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||||
|
fbs.UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
|
||||||
|
fbs.UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||||
|
const offset = fbs.UserGamesOrderResponse.endUserGamesOrderResponse(
|
||||||
|
builder,
|
||||||
|
);
|
||||||
|
builder.finish(offset);
|
||||||
|
return {
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadBytes: builder.asUint8Array(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const client = { executeCommand: exec } as unknown as GalaxyClient;
|
||||||
|
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
store.bindClient(client);
|
||||||
|
await store.add(cmd);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(exec).toHaveBeenCalled();
|
||||||
|
expect(store.statuses[cmd.id]).toBe("rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
expect(store.syncError).toBeNull();
|
||||||
|
expect(store.errorMessages[cmd.id]).toBe(
|
||||||
|
"Entity does not exists: planet #99",
|
||||||
|
);
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("non-ok response marks every in-flight command as rejected", async () => {
|
test("non-ok response marks every in-flight command as rejected", async () => {
|
||||||
const { recordingClient } = await import("./helpers/fake-order-client");
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
const handle = recordingClient(GAME_ID, "rejected");
|
const handle = recordingClient(GAME_ID, "rejected");
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function success(updatedAt = 1): SubmitSuccess {
|
|||||||
ok: true,
|
ok: true,
|
||||||
results: new Map([["id", "applied"]]),
|
results: new Map([["id", "applied"]]),
|
||||||
errorCodes: new Map([["id", null]]),
|
errorCodes: new Map([["id", null]]),
|
||||||
|
errorMessages: new Map([["id", null]]),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,28 @@ function mockClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildResponse(
|
function buildResponse(
|
||||||
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
|
commands: {
|
||||||
|
id: string;
|
||||||
|
applied: boolean | null;
|
||||||
|
errorCode: number | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}[],
|
||||||
updatedAt: number,
|
updatedAt: number,
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
const builder = new Builder(256);
|
const builder = new Builder(256);
|
||||||
|
// flatbuffers@25 skips fields equal to their generated default when
|
||||||
|
// writing — the Go transcoder works around this via explicit
|
||||||
|
// `Slot()` calls. This fixture stands in for the engine + gateway,
|
||||||
|
// so we flip `forceDefaults` to keep `cmd_applied=false` (=== int8
|
||||||
|
// default 0) and `cmd_error_code=0` from being silently elided.
|
||||||
|
builder.forceDefaults(true);
|
||||||
const itemOffsets = commands.map((c) => {
|
const itemOffsets = commands.map((c) => {
|
||||||
const cmdIdOffset = builder.createString(c.id);
|
const cmdIdOffset = builder.createString(c.id);
|
||||||
const nameOffset = builder.createString("ignored");
|
const nameOffset = builder.createString("ignored");
|
||||||
|
const errMsgOffset =
|
||||||
|
c.errorMessage !== undefined && c.errorMessage !== null
|
||||||
|
? builder.createString(c.errorMessage)
|
||||||
|
: 0;
|
||||||
const payloadOffset = CommandPlanetRename.createCommandPlanetRename(
|
const payloadOffset = CommandPlanetRename.createCommandPlanetRename(
|
||||||
builder,
|
builder,
|
||||||
BigInt(0),
|
BigInt(0),
|
||||||
@@ -60,6 +75,9 @@ function buildResponse(
|
|||||||
}
|
}
|
||||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||||
CommandItem.addPayload(builder, payloadOffset);
|
CommandItem.addPayload(builder, payloadOffset);
|
||||||
|
if (errMsgOffset !== 0) {
|
||||||
|
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
|
||||||
|
}
|
||||||
return CommandItem.endCommandItem(builder);
|
return CommandItem.endCommandItem(builder);
|
||||||
});
|
});
|
||||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, itemOffsets);
|
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, itemOffsets);
|
||||||
|
|||||||
Reference in New Issue
Block a user