969c0480ba
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.
Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).
UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
radial scene that consumes a BattleReport prop. Planet at the
centre, races on the outer ring at equal angular spacing, race
clusters by (race, className) with <class>:<numLeft> labels;
observer groups (inBattle: false) are not drawn; eliminated
races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
on the next frame. Playback controls: play/pause + step ± +
rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
via api/battle-fetch.ts; synthetic-gameId prefix routes to a
fixture loader, otherwise REST through the gateway. Always-
visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
corners of the planet's circumscribed square (stroke width
clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
marker is a stroke-only ring (yellow when damaged, red when
wiped). Wired into state-binding.ts; click handler dispatches
battle clicks to the viewer and bombing clicks to the matching
Reports row.
- i18n keys for the viewer in en + ru.
Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).
Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
398 lines
11 KiB
Go
398 lines
11 KiB
Go
package transcoder
|
|
|
|
import (
|
|
"bytes"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
model "galaxy/model/report"
|
|
fbs "galaxy/schema/fbs/report"
|
|
|
|
flatbuffers "github.com/google/flatbuffers/go"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestGameReportRequestPayloadRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source := &model.GameReportRequest{
|
|
GameID: uuid.MustParse("11111111-2222-3333-4444-555555555555"),
|
|
Turn: 42,
|
|
}
|
|
|
|
payload, err := GameReportRequestToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode game report request: %v", err)
|
|
}
|
|
|
|
decoded, err := PayloadToGameReportRequest(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode game report request: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(source, decoded) {
|
|
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded: %#v", source, decoded)
|
|
}
|
|
}
|
|
|
|
func TestGameReportRequestRejectsEmptyAndNil(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if _, err := GameReportRequestToPayload(nil); err == nil {
|
|
t.Fatalf("expected error encoding nil request")
|
|
}
|
|
if _, err := PayloadToGameReportRequest(nil); err == nil {
|
|
t.Fatalf("expected error decoding empty payload")
|
|
}
|
|
}
|
|
|
|
func TestReportToPayloadAndPayloadToReportRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source := sampleReport()
|
|
|
|
payload, err := ReportToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode report payload: %v", err)
|
|
}
|
|
|
|
decoded, err := PayloadToReport(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode report payload: %v", err)
|
|
}
|
|
|
|
expected := reportWireClone(t, source)
|
|
if !reflect.DeepEqual(expected, decoded) {
|
|
t.Fatalf("round-trip mismatch\nexpected: %#v\ndecoded: %#v", expected, decoded)
|
|
}
|
|
}
|
|
|
|
func TestReportToPayloadNilReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ReportToPayload(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for nil report")
|
|
}
|
|
}
|
|
|
|
func TestPayloadToReportEmptyData(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := PayloadToReport(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty payload")
|
|
}
|
|
}
|
|
|
|
func TestPayloadToReportGarbageDataDoesNotPanic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := PayloadToReport([]byte{0x01, 0x02, 0x03})
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed payload")
|
|
}
|
|
}
|
|
|
|
func TestPayloadToReportMissingRequiredLocalGroupID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := buildReportPayload(func(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
|
class := builder.CreateString("frigate")
|
|
state := builder.CreateString("orbit")
|
|
|
|
fbs.LocalGroupStart(builder)
|
|
fbs.LocalGroupAddClass(builder, class)
|
|
fbs.LocalGroupAddState(builder, state)
|
|
localGroup := fbs.LocalGroupEnd(builder)
|
|
|
|
fbs.ReportStartLocalGroupVector(builder, 1)
|
|
builder.PrependUOffsetT(localGroup)
|
|
localGroups := builder.EndVector(1)
|
|
|
|
fbs.ReportStart(builder)
|
|
fbs.ReportAddLocalGroup(builder, localGroups)
|
|
return fbs.ReportEnd(builder)
|
|
})
|
|
|
|
_, err := PayloadToReport(payload)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing local group id")
|
|
}
|
|
if !strings.Contains(err.Error(), "id is missing") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPayloadToReportOverflow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if strconv.IntSize == 64 {
|
|
t.Skip("uint overflow from uint64 is not possible on 64-bit runtime")
|
|
}
|
|
|
|
maxUint := uint64(^uint(0))
|
|
overflowValue := maxUint + 1
|
|
payload := buildReportPayload(func(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
|
fbs.ReportStart(builder)
|
|
fbs.ReportAddTurn(builder, overflowValue)
|
|
return fbs.ReportEnd(builder)
|
|
})
|
|
|
|
_, err := PayloadToReport(payload)
|
|
if err == nil {
|
|
t.Fatal("expected overflow error")
|
|
}
|
|
if !strings.Contains(err.Error(), "overflows uint") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReportToPayloadDeterministicMapEncoding(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
report := sampleReport()
|
|
|
|
firstPayload, err := ReportToPayload(report)
|
|
if err != nil {
|
|
t.Fatalf("encode report payload: %v", err)
|
|
}
|
|
|
|
for i := 0; i < 20; i++ {
|
|
nextPayload, nextErr := ReportToPayload(report)
|
|
if nextErr != nil {
|
|
t.Fatalf("encode report payload #%d: %v", i+2, nextErr)
|
|
}
|
|
if !bytes.Equal(firstPayload, nextPayload) {
|
|
t.Fatalf("payload differs between runs at iteration %d", i+2)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReportToPayloadFloat32Quantization(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source := &model.Report{
|
|
Race: "Terrans",
|
|
Votes: model.Float(0.123456789),
|
|
LocalScience: []model.Science{
|
|
{
|
|
Name: "science-alpha",
|
|
Drive: model.Float(0.123456789),
|
|
},
|
|
},
|
|
}
|
|
|
|
payload, err := ReportToPayload(source)
|
|
if err != nil {
|
|
t.Fatalf("encode report payload: %v", err)
|
|
}
|
|
|
|
decoded, err := PayloadToReport(payload)
|
|
if err != nil {
|
|
t.Fatalf("decode report payload: %v", err)
|
|
}
|
|
|
|
wantVotes := model.Float(float64(float32(source.Votes.F())))
|
|
if decoded.Votes != wantVotes {
|
|
t.Fatalf("unexpected votes value: got=%v want=%v", decoded.Votes, wantVotes)
|
|
}
|
|
|
|
wantDrive := model.Float(float64(float32(source.LocalScience[0].Drive.F())))
|
|
if decoded.LocalScience[0].Drive != wantDrive {
|
|
t.Fatalf("unexpected drive value: got=%v want=%v", decoded.LocalScience[0].Drive, wantDrive)
|
|
}
|
|
|
|
if decoded.Votes == source.Votes {
|
|
t.Fatal("expected quantization for votes value")
|
|
}
|
|
}
|
|
|
|
func sampleReport() *model.Report {
|
|
originA := uint(11)
|
|
originB := uint(17)
|
|
rangeA := model.Float(6.5)
|
|
rangeB := model.Float(3.5)
|
|
fleetName := "Fleet-1"
|
|
|
|
return &model.Report{
|
|
Version: 2,
|
|
Turn: 7,
|
|
Width: 64,
|
|
Height: 48,
|
|
PlanetCount: 21,
|
|
Race: "Terrans",
|
|
RaceID: uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
|
Votes: model.Float(7.5),
|
|
VoteFor: "Martians",
|
|
Player: []model.Player{
|
|
{
|
|
ID: uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
|
|
Name: "Terrans",
|
|
Drive: model.Float(1.5),
|
|
Weapons: model.Float(2.0),
|
|
Shields: model.Float(2.5),
|
|
Cargo: model.Float(3.0),
|
|
Population: model.Float(120.0),
|
|
Industry: model.Float(90.0),
|
|
Planets: 5,
|
|
Relation: "-",
|
|
Votes: model.Float(7.5),
|
|
Extinct: false,
|
|
},
|
|
},
|
|
LocalScience: []model.Science{
|
|
{Name: "fusion", Drive: model.Float(1.25), Weapons: model.Float(1.5), Shields: model.Float(1.75), Cargo: model.Float(2.0)},
|
|
},
|
|
OtherScience: []model.OtherScience{
|
|
{Race: "Martians", Science: model.Science{Name: "warp", Drive: model.Float(2.0), Weapons: model.Float(2.25), Shields: model.Float(2.5), Cargo: model.Float(2.75)}},
|
|
},
|
|
LocalShipClass: []model.ShipClass{
|
|
{Name: "frigate", Drive: model.Float(1.5), Armament: 4, Weapons: model.Float(2.0), Shields: model.Float(2.5), Cargo: model.Float(3.0), Mass: model.Float(9.5)},
|
|
},
|
|
OtherShipClass: []model.OthersShipClass{
|
|
{Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}},
|
|
},
|
|
Battle: []model.BattleSummary{
|
|
{
|
|
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
|
Planet: 4,
|
|
Shots: 17,
|
|
},
|
|
{
|
|
ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"),
|
|
Planet: 11,
|
|
Shots: 3,
|
|
},
|
|
},
|
|
Bombing: []*model.Bombing{
|
|
{
|
|
PlanetOwnedID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"),
|
|
Number: 9,
|
|
Planet: "Nova",
|
|
Owner: "Terrans",
|
|
Attacker: "Martians",
|
|
Production: "SHIP",
|
|
Industry: model.Float(10.5),
|
|
Population: model.Float(8.5),
|
|
Colonists: model.Float(7.5),
|
|
Capital: model.Float(6.5),
|
|
Material: model.Float(5.5),
|
|
AttackPower: model.Float(4.5),
|
|
Wiped: false,
|
|
},
|
|
},
|
|
IncomingGroup: []model.IncomingGroup{
|
|
{Origin: 1, Destination: 2, Distance: model.Float(10.0), Speed: model.Float(2.0), Mass: model.Float(20.0)},
|
|
},
|
|
LocalPlanet: []model.LocalPlanet{
|
|
{
|
|
UninhabitedPlanet: model.UninhabitedPlanet{
|
|
UnidentifiedPlanet: model.UnidentifiedPlanet{X: model.Float(1.0), Y: model.Float(2.0), Number: 3},
|
|
Size: model.Float(50.0),
|
|
Name: "Terra",
|
|
Resources: model.Float(7.0),
|
|
Capital: model.Float(8.0),
|
|
Material: model.Float(9.0),
|
|
},
|
|
Industry: model.Float(10.0),
|
|
Population: model.Float(11.0),
|
|
Colonists: model.Float(12.0),
|
|
Production: "SHIP",
|
|
FreeIndustry: model.Float(13.0),
|
|
},
|
|
},
|
|
ShipProduction: []model.ShipProduction{
|
|
{Planet: 3, Class: "frigate", Cost: model.Float(4.0), ProdUsed: model.Float(2.0), Percent: model.Float(50.0), Free: model.Float(6.0)},
|
|
},
|
|
Route: []model.Route{
|
|
{Planet: 3, Route: map[uint]string{9: "MAT", 2: "CAP", 5: "EMP"}},
|
|
},
|
|
OtherPlanet: []model.OtherPlanet{
|
|
{
|
|
Owner: "Martians",
|
|
LocalPlanet: model.LocalPlanet{
|
|
UninhabitedPlanet: model.UninhabitedPlanet{
|
|
UnidentifiedPlanet: model.UnidentifiedPlanet{X: model.Float(4.0), Y: model.Float(5.0), Number: 6},
|
|
Size: model.Float(40.0),
|
|
Name: "Ares",
|
|
Resources: model.Float(6.0),
|
|
Capital: model.Float(7.0),
|
|
Material: model.Float(8.0),
|
|
},
|
|
Industry: model.Float(9.0),
|
|
Population: model.Float(10.0),
|
|
Colonists: model.Float(11.0),
|
|
Production: "MAT",
|
|
FreeIndustry: model.Float(12.0),
|
|
},
|
|
},
|
|
},
|
|
UninhabitedPlanet: []model.UninhabitedPlanet{
|
|
{UnidentifiedPlanet: model.UnidentifiedPlanet{X: model.Float(6.0), Y: model.Float(7.0), Number: 8}, Size: model.Float(30.0), Name: "Nadir", Resources: model.Float(5.0), Capital: model.Float(4.0), Material: model.Float(3.0)},
|
|
},
|
|
UnidentifiedPlanet: []model.UnidentifiedPlanet{
|
|
{X: model.Float(8.0), Y: model.Float(9.0), Number: 10},
|
|
},
|
|
LocalFleet: []model.LocalFleet{
|
|
{Name: "Fleet-1", Groups: 2, Destination: 4, Origin: &originA, Range: &rangeA, Speed: model.Float(2.0), State: "moving"},
|
|
},
|
|
LocalGroup: []model.LocalGroup{
|
|
{
|
|
OtherGroup: model.OtherGroup{
|
|
Number: 1,
|
|
Class: "frigate",
|
|
Tech: map[string]model.Float{"WEAPONS": model.Float(2.0), "DRIVE": model.Float(1.5), "SHIELDS": model.Float(1.75)},
|
|
Cargo: "MAT",
|
|
Load: model.Float(4.0),
|
|
Destination: 4,
|
|
Origin: &originB,
|
|
Range: &rangeB,
|
|
Speed: model.Float(2.5),
|
|
Mass: model.Float(12.0),
|
|
},
|
|
ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"),
|
|
State: "in_orbit",
|
|
Fleet: &fleetName,
|
|
},
|
|
},
|
|
OtherGroup: []model.OtherGroup{
|
|
{Number: 2, Class: "scout", Tech: map[string]model.Float{"CARGO": model.Float(1.25), "DRIVE": model.Float(1.75)}, Cargo: "CAP", Load: model.Float(3.5), Destination: 5, Speed: model.Float(2.25), Mass: model.Float(8.5)},
|
|
},
|
|
UnidentifiedGroup: []model.UnidentifiedGroup{
|
|
{X: model.Float(10.0), Y: model.Float(11.0)},
|
|
},
|
|
OnPlanetGroupCache: map[uint][]int{
|
|
1: {2, 3},
|
|
},
|
|
InSpaceGroupRangeCache: map[int]map[uint]float64{
|
|
1: {4: 12.5},
|
|
},
|
|
}
|
|
}
|
|
|
|
func reportWireClone(t *testing.T, source *model.Report) *model.Report {
|
|
t.Helper()
|
|
|
|
data, err := source.MarshalBinary()
|
|
if err != nil {
|
|
t.Fatalf("marshal source report: %v", err)
|
|
}
|
|
|
|
result := new(model.Report)
|
|
if err := result.UnmarshalBinary(data); err != nil {
|
|
t.Fatalf("unmarshal source report clone: %v", err)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func buildReportPayload(build func(*flatbuffers.Builder) flatbuffers.UOffsetT) []byte {
|
|
builder := flatbuffers.NewBuilder(256)
|
|
offset := build(builder)
|
|
fbs.FinishReportBuffer(builder, offset)
|
|
return builder.FinishedBytes()
|
|
}
|