ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
9 changed files with 344 additions and 133 deletions
Showing only changes of commit bd11cd80da - Show all commits
+87
View File
@@ -8,6 +8,7 @@ import (
"galaxy/calc" "galaxy/calc"
"galaxy/game/internal/controller" "galaxy/game/internal/controller"
"galaxy/game/internal/model/game" "galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
assert.Zero(t, c.ShipGroup(3).Number) assert.Zero(t, c.ShipGroup(3).Number)
} }
} }
// TestTransformBattleAggregatesSameShipClass guards against the
// engine-side variant of the duplicate-class bug. Several ShipGroups
// of the same ShipClass.ID can take part in the same battle (arrivals
// from different planets, tech splits, etc.); they must collapse into
// a single BattleReportGroup with summed Number and NumberLeft. The
// pre-fix engine cached the first group's index and silently dropped
// every subsequent group's initial / survivor counts, which manifested
// downstream as more Destroyed shots in the protocol than the
// recorded initial roster could account for.
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
// (owned by Race_0; the Race_1 group lands there via the Unsafe
// helper that bypasses the ownership check). Group indices land
// at 0, 1, 2 in creation order.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
// Simulate post-battle survivor counts: Group 0 ended the battle
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
// group's 6 — that's the regression).
c.ShipGroup(0).Number = 8
c.ShipGroup(1).Number = 6
b := &controller.Battle{
Planet: R0_Planet_0_num,
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
// Protocol must reference every in-battle group at least once
// (otherwise TransformBattle won't register it through the
// `ship()` path). Two shots from Race_1 against each Race_0
// group hits both groupIds.
Protocol: []controller.BattleAction{
{Attacker: 2, Defender: 0, Destroyed: true},
{Attacker: 2, Defender: 1, Destroyed: true},
},
}
r := controller.TransformBattle(c, b)
// Two BattleReportGroup entries total: one merged Race_0_Gunship
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
if got, want := len(r.Ships), 2; got != want {
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
}
var gunship0, gunship1 *report.BattleReportGroup
for i := range r.Ships {
grp := r.Ships[i]
switch grp.Race {
case Race_0.Name:
gunship0 = &grp
case Race_1.Name:
gunship1 = &grp
}
}
if gunship0 == nil || gunship1 == nil {
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
}
if gunship0.ClassName != Race_0_Gunship {
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
}
if gunship0.Number != 20 {
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
}
if gunship0.NumberLeft != 14 {
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
}
if !gunship0.InBattle {
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
}
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
gunship1.Number, gunship1.NumberLeft)
}
}
+27 -5
View File
@@ -18,10 +18,35 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
cacheShipClass := make(map[uuid.UUID]int) cacheShipClass := make(map[uuid.UUID]int)
cacheRaceName := make(map[uuid.UUID]int) cacheRaceName := make(map[uuid.UUID]int)
processedGroup := make(map[int]bool)
addShipGroup := func(groupId int, inBattle bool) int { addShipGroup := func(groupId int, inBattle bool) int {
shipClass := c.ShipGroupShipClass(groupId) shipClass := c.ShipGroupShipClass(groupId)
sg := c.ShipGroup(groupId) sg := c.ShipGroup(groupId)
// Several ship-groups of the same race/class can take part
// in the same battle (different tech upgrades, arrivals from
// different planets, …). They share a single
// BattleReportGroup entry keyed by ShipClass.ID — when a
// later group lands on a cached class we add its Number and
// NumberLeft into the existing entry instead of dropping
// them, so the protocol's per-class destroy counts reconcile
// with the recorded totals. `processedGroup` guards against
// double-counting a single groupId across multiple shots in
// the protocol — `ship()` runs on every attacker and defender
// reference, the merge must happen once per groupId.
if existing, ok := cacheShipClass[shipClass.ID]; ok {
if !processedGroup[groupId] {
bg := r.Ships[existing]
bg.Number += b.InitialNumbers[groupId]
bg.NumberLeft += sg.Number
if inBattle {
bg.InBattle = true
}
r.Ships[existing] = bg
processedGroup[groupId] = true
}
return existing
}
itemNumber := len(r.Ships) itemNumber := len(r.Ships)
bg := &report.BattleReportGroup{ bg := &report.BattleReportGroup{
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name, Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
@@ -31,23 +56,20 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
ClassName: shipClass.Name, ClassName: shipClass.Name,
LoadType: sg.CargoString(), LoadType: sg.CargoString(),
LoadQuantity: report.F(sg.Load.F()), LoadQuantity: report.F(sg.Load.F()),
Tech: make(map[string]report.Float, len(sg.Tech)),
} }
for t, v := range sg.Tech { for t, v := range sg.Tech {
bg.Tech[t.String()] = report.F(v.F()) bg.Tech[t.String()] = report.F(v.F())
} }
r.Ships[itemNumber] = *bg r.Ships[itemNumber] = *bg
cacheShipClass[shipClass.ID] = itemNumber cacheShipClass[shipClass.ID] = itemNumber
processedGroup[groupId] = true
return itemNumber return itemNumber
} }
ship := func(groupId int) int { ship := func(groupId int) int {
shipClass := c.ShipGroupShipClass(groupId)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true) return addShipGroup(groupId, true)
} }
}
race := func(groupId int) int { race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId) race := c.ShipGroupOwnerRace(groupId)
+24
View File
@@ -814,6 +814,30 @@ func (p *parser) parseBattleRosterRow(fields []string) {
key := shipKey{race: p.pendingBattleRace, class: className} key := shipKey{race: p.pendingBattleRace, class: className}
idx := p.assignShipIndex(key) idx := p.assignShipIndex(key)
// Legacy battle rosters may list the same `(race, className)`
// across multiple rows — different tech variants, ships pulled
// from several stacks / planets, etc. We collapse those rows
// into one BattleReportGroup keyed by `(race, className)` (the
// viewer aggregates per class anyway) by SUMMING Number and
// NumberLeft instead of overwriting; otherwise only the last
// row's counts survive and the battle protocol's destroy count
// would dwarf the recorded initial count (the original
// motivation for the now-removed "phantom destroy" workaround).
if existing, found := p.pendingBattle.ships[idx]; found {
existing.Number += uint(number)
existing.NumberLeft += uint(numLeft)
// LoadQuantity is per-ship cargo — average is a fair fallback
// when several stacks of the same class merge into one bucket.
existing.LoadQuantity = report.F(
(existing.LoadQuantity.F() + loadQuantity) / 2,
)
// Tech / LoadType / InBattle keep their first-seen values:
// the viewer treats them as bucket-wide attributes and the
// first row is normally the most representative tech variant.
p.pendingBattle.ships[idx] = existing
return
}
p.pendingBattle.ships[idx] = report.BattleReportGroup{ p.pendingBattle.ships[idx] = report.BattleReportGroup{
Race: p.pendingBattleRace, Race: p.pendingBattleRace,
ClassName: className, ClassName: className,
@@ -533,6 +533,106 @@ func TestParseBattles(t *testing.T) {
} }
} }
// TestParseBattleAggregatesDuplicateClasses guards against the
// regression that produced the original "phantom destroys" symptom:
// the same `(race, className)` pair appearing on multiple roster
// rows must collapse into a single BattleReportGroup whose `Number`
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
// column, survivors) are the sums across rows. Without the
// aggregation only the last row's counts survived and the protocol's
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
func TestParseBattleAggregatesDuplicateClasses(t *testing.T) {
in := strings.Join([]string{
"Race Report for Galaxy PLUS Turn 1",
"",
"Battle at (#7) B-007",
"",
"Foo Groups",
"",
" # T D W S C T Q L",
" 3 Drone 1.0 1.0 0 0 - 0 1 In_Battle",
" 4 Drone 1.2 1.0 0 0 - 0 2 In_Battle",
"10 Cruiser 3.0 2.0 0 0 - 0 9 In_Battle",
"",
"Bar Groups",
"",
"# T D W S C T Q L",
"5 Pistolet 1.0 1.0 0 0 - 0 3 In_Battle",
"",
"Battle Protocol",
"",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"Bar Pistolet fires on Foo Drone : Destroyed",
"",
}, "\n")
rep, battles, err := Parse(strings.NewReader(in))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, want := len(battles), 1; got != want {
t.Fatalf("len(battles) = %d, want %d", got, want)
}
b := battles[0]
// The three Foo roster rows collapse into two BattleReportGroup
// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
// plus one Bar:Pistolet. Total 3 groups, NOT 4.
if got, want := len(b.Ships), 3; got != want {
t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
}
var drone, cruiser, pistolet *report.BattleReportGroup
for i := range b.Ships {
g := b.Ships[i]
switch g.ClassName {
case "Drone":
drone = &g
case "Cruiser":
cruiser = &g
case "Pistolet":
pistolet = &g
}
}
if drone == nil || cruiser == nil || pistolet == nil {
t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
}
// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
// Protocol corroborates: four Destroyed shots against Drone, so
// 7 - 3 = 4 — the protocol's destroy count reconciles with the
// recorded delta only when both rows are summed.
if drone.Number != 7 {
t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
}
if drone.NumberLeft != 3 {
t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
}
// Cruiser and Pistolet are single-row classes — counts must match
// the file verbatim with no spurious merging across classes.
if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
cruiser.Number, cruiser.NumberLeft)
}
if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
pistolet.Number, pistolet.NumberLeft)
}
// rep-level summary must reflect the merged shape: 4 shots, one
// battle, no crash or spurious extra battles.
if got, want := len(rep.Battle), 1; got != want {
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
}
if rep.Battle[0].Shots != 4 {
t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
}
}
// TestParseYourGroups exercises the local-group section. Two rows // TestParseYourGroups exercises the local-group section. Two rows
// cover the on-planet ("In_Orbit", origin "-") and in-space ("In_Space", // cover the on-planet ("In_Orbit", origin "-") and in-space ("In_Space",
// origin name + range) variants, plus a cargo-loaded row to assert the // origin name + range) variants, plus a cargo-loaded row to assert the
+61 -61
View File
@@ -14743,10 +14743,10 @@
"race": "KnightErrants", "race": "KnightErrants",
"className": "PeaceShip", "className": "PeaceShip",
"tech": { "tech": {
"DRIVE": 9.1 "DRIVE": 9.09
}, },
"num": 50, "num": 52,
"numLeft": 50, "numLeft": 52,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -15498,11 +15498,11 @@
"race": "Frightners", "race": "Frightners",
"className": "moan", "className": "moan",
"tech": { "tech": {
"DRIVE": 7.79, "DRIVE": 7.5,
"SHIELDS": 5.15 "SHIELDS": 4.85
}, },
"num": 85, "num": 259,
"numLeft": 85, "numLeft": 259,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -15988,13 +15988,13 @@
"race": "Slimes", "race": "Slimes",
"className": "Fly_1", "className": "Fly_1",
"tech": { "tech": {
"DRIVE": 5.79 "DRIVE": 6.02
}, },
"num": 105, "num": 348,
"numLeft": 105, "numLeft": 348,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": false
}, },
"6": { "6": {
"race": "Slimes", "race": "Slimes",
@@ -16325,10 +16325,10 @@
"race": "KnightErrants", "race": "KnightErrants",
"className": "PeaceShip", "className": "PeaceShip",
"tech": { "tech": {
"DRIVE": 9.1 "DRIVE": 10.62
}, },
"num": 99, "num": 100,
"numLeft": 99, "numLeft": 100,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -16497,10 +16497,10 @@
"race": "Enoxes", "race": "Enoxes",
"className": "Gnat", "className": "Gnat",
"tech": { "tech": {
"DRIVE": 11.4 "DRIVE": 9.07
}, },
"num": 26, "num": 167,
"numLeft": 26, "numLeft": 167,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -18583,8 +18583,8 @@
"tech": { "tech": {
"DRIVE": 5.16 "DRIVE": 5.16
}, },
"num": 19, "num": 1026,
"numLeft": 19, "numLeft": 1026,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -19025,10 +19025,10 @@
"CARGO": 1.1, "CARGO": 1.1,
"DRIVE": 10.85 "DRIVE": 10.85
}, },
"num": 1, "num": 3,
"numLeft": 0, "numLeft": 0,
"loadType": "COL", "loadType": "COL",
"loadQuantity": 1.4, "loadQuantity": 1.855,
"inBattle": true "inBattle": true
}, },
"12": { "12": {
@@ -19140,9 +19140,9 @@
"race": "Koreans", "race": "Koreans",
"className": "d", "className": "d",
"tech": { "tech": {
"DRIVE": 9.87 "DRIVE": 2.4
}, },
"num": 112, "num": 113,
"numLeft": 0, "numLeft": 0,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
@@ -29824,8 +29824,8 @@
"SHIELDS": 3.19, "SHIELDS": 3.19,
"WEAPONS": 3.97 "WEAPONS": 3.97
}, },
"num": 1, "num": 2,
"numLeft": 1, "numLeft": 2,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -29893,10 +29893,10 @@
"race": "Nails", "race": "Nails",
"className": "pup", "className": "pup",
"tech": { "tech": {
"DRIVE": 4.98 "DRIVE": 4.97
}, },
"num": 88, "num": 1168,
"numLeft": 6, "numLeft": 86,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -46450,10 +46450,10 @@
"race": "Ricksha", "race": "Ricksha",
"className": "Dron", "className": "Dron",
"tech": { "tech": {
"DRIVE": 7.63 "DRIVE": 3.2
}, },
"num": 647, "num": 1211,
"numLeft": 647, "numLeft": 1211,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -46462,11 +46462,11 @@
"race": "Ricksha", "race": "Ricksha",
"className": "HDron", "className": "HDron",
"tech": { "tech": {
"DRIVE": 7.63, "DRIVE": 6.88,
"SHIELDS": 3.95 "SHIELDS": 3.95
}, },
"num": 88, "num": 112,
"numLeft": 88, "numLeft": 112,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -46690,10 +46690,10 @@
"race": "KnightErrants", "race": "KnightErrants",
"className": "PeaceShip", "className": "PeaceShip",
"tech": { "tech": {
"DRIVE": 9.09 "DRIVE": 9.1
}, },
"num": 2, "num": 164,
"numLeft": 2, "numLeft": 163,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -48389,10 +48389,10 @@
"className": "FS-6", "className": "FS-6",
"tech": { "tech": {
"DRIVE": 11.4, "DRIVE": 11.4,
"SHIELDS": 5.64 "SHIELDS": 5.1
}, },
"num": 48, "num": 72,
"numLeft": 48, "numLeft": 72,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -48449,10 +48449,10 @@
"race": "Enoxes", "race": "Enoxes",
"className": "Gnat", "className": "Gnat",
"tech": { "tech": {
"DRIVE": 11.4 "DRIVE": 8.4
}, },
"num": 100, "num": 101,
"numLeft": 100, "numLeft": 101,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -48466,10 +48466,10 @@
"SHIELDS": 2.13, "SHIELDS": 2.13,
"WEAPONS": 4.22 "WEAPONS": 4.22
}, },
"num": 1, "num": 2,
"numLeft": 1, "numLeft": 2,
"loadType": "COL", "loadType": "COL",
"loadQuantity": 5.73, "loadQuantity": 5.375,
"inBattle": true "inBattle": true
}, },
"8": { "8": {
@@ -48479,10 +48479,10 @@
"CARGO": 1, "CARGO": 1,
"DRIVE": 11.4, "DRIVE": 11.4,
"SHIELDS": 5.1, "SHIELDS": 5.1,
"WEAPONS": 5.77 "WEAPONS": 5.44
}, },
"num": 1, "num": 2,
"numLeft": 1, "numLeft": 2,
"loadType": "COL", "loadType": "COL",
"loadQuantity": 1.05, "loadQuantity": 1.05,
"inBattle": true "inBattle": true
@@ -48539,10 +48539,10 @@
"race": "Flagist", "race": "Flagist",
"className": "Drone", "className": "Drone",
"tech": { "tech": {
"DRIVE": 8.49 "DRIVE": 6.08
}, },
"num": 1, "num": 2,
"numLeft": 1, "numLeft": 2,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": false "inBattle": false
@@ -48587,10 +48587,10 @@
"race": "Enoxes", "race": "Enoxes",
"className": "Gnat", "className": "Gnat",
"tech": { "tech": {
"DRIVE": 11.4 "DRIVE": 9.07
}, },
"num": 49, "num": 50,
"numLeft": 49, "numLeft": 50,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -48662,10 +48662,10 @@
"race": "KnightErrants", "race": "KnightErrants",
"className": "PeaceShip", "className": "PeaceShip",
"tech": { "tech": {
"DRIVE": 9.09 "DRIVE": 8.71
}, },
"num": 1, "num": 63,
"numLeft": 1, "numLeft": 63,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
@@ -48918,10 +48918,10 @@
"race": "KnightErrants", "race": "KnightErrants",
"className": "PeaceShip", "className": "PeaceShip",
"tech": { "tech": {
"DRIVE": 8.71 "DRIVE": 5.6
}, },
"num": 1, "num": 158,
"numLeft": 1, "numLeft": 158,
"loadType": "", "loadType": "",
"loadQuantity": 0, "loadQuantity": 0,
"inBattle": true "inBattle": true
+27 -15
View File
@@ -132,23 +132,35 @@ on the same defender therefore look like two distinct pulses
rather than one continuous line. On pause the line and flash rather than one continuous line. On pause the line and flash
stay drawn so the user can study the current shot. stay drawn so the user can study the current shot.
## Phantom destroys ## Aggregated ship-class buckets
Legacy emitters (the `dg` engine format that feeds the synthetic- Real legacy reports list the same `(race, className)` pair across
report path) occasionally log more `Destroyed` (and `Shields`) several roster rows — different tech variants, ships pulled from
lines against a ship-group bucket than the bucket's initial multiple stacks or planets. The legacy-report parser
population — the emitter keeps recording hits past the moment a ([parser.go](../../tools/local-dev/legacy-report/parser.go))
group emptied. `buildFrames` marks every such frame as collapses those rows into a single `BattleReportGroup` keyed by
`phantom: true` and skips the race-total decrement so the race `(race, className)` by SUMMING `Number` and `NumberLeft`; the
stays on the scene until its actual ships are gone. engine's `TransformBattle`
([battle_transform.go](../../game/internal/controller/battle_transform.go))
applies the same merge keyed by `ShipClass.ID`, guarded by a
processed-group set so the same source `groupId` is not summed
twice across multiple protocol references.
During play the BattleViewer fast-forwards through streaks of Without this aggregation only the last roster row's counts
phantom frames via a 0 ms timer so the user never sees a silent survived, and the protocol's destroy count against the class
gap (KNNTS041 had ~30 phantom frames between shots 224 and 255 would dwarf the recorded initial count — KNNTS041 turn 41 planet
right after the last `Nails:pup` died). Step controls and the \#7 had 7 separate `Nails:pup` rows totalling 1168 ships; the
scrubber can still land on a phantom frame deliberately — useful buggy parser stored only the last row's 88, so the 1082 destroys
when inspecting the protocol entry that the engine emitted into in the protocol looked like phantom hits past the empty bucket.
the void. After the fix both sides reconcile: 1168 initial 86 survivors =
1082 destroys.
`buildFrames`
([timeline.ts](../src/lib/battle-player/timeline.ts)) keeps a
defence-in-depth clamp `if (left > 0)` on the destroy decrement so
a malformed protocol never pushes a race below zero; in normal
operation the clamp is a no-op because parser + engine already
folded duplicate rows together.
## Final-frame freeze ## Final-frame freeze
@@ -67,7 +67,6 @@ matching `pkg/model/report/battle.go` and it plays back.
return { return {
shotIndex: cur.shotIndex, shotIndex: cur.shotIndex,
lastAction: cur.lastAction, lastAction: cur.lastAction,
phantom: cur.phantom,
remaining: prev.remaining, remaining: prev.remaining,
activeRaceIds: prev.activeRaceIds, activeRaceIds: prev.activeRaceIds,
}; };
@@ -79,29 +78,11 @@ matching `pkg/model/report/battle.go` and it plays back.
// 10 % of the frame's interval, then advance. Effect re-arms // 10 % of the frame's interval, then advance. Effect re-arms
// whenever frameIndex / playing / speed changes; previous // whenever frameIndex / playing / speed changes; previous
// timers clean up through the return. // timers clean up through the return.
//
// A phantom frame (shot against an already-empty defender)
// would otherwise hold the scene silent for the full interval.
// During play we fast-forward to the next non-phantom frame
// through a 0 ms timer, so streaks of phantoms (KNNTS041
// frames 225..255, 401..414, …) collapse into a single tick
// from the user's POV.
$effect(() => { $effect(() => {
void frameIndex; void frameIndex;
void speed; void speed;
shotVisible = true; shotVisible = true;
if (!playing) return; if (!playing) return;
if (rawFrame.phantom && frameIndex < frames.length - 1) {
let next = frameIndex + 1;
while (next < frames.length - 1 && frames[next].phantom) {
next++;
}
const target = next;
const skip = setTimeout(() => {
frameIndex = target;
}, 0);
return () => clearTimeout(skip);
}
const intervalMs = 400 / speed; const intervalMs = 400 / speed;
const blinkOff = setTimeout(() => { const blinkOff = setTimeout(() => {
shotVisible = false; shotVisible = false;
+13 -19
View File
@@ -19,19 +19,12 @@ import type {
* `BattleReport.ships`; `activeRaceIds` are the race indices with at * `BattleReport.ships`; `activeRaceIds` are the race indices with at
* least one surviving in-battle group. `lastAction` is the action * least one surviving in-battle group. `lastAction` is the action
* applied to produce this frame, or `null` for the initial frame. * applied to produce this frame, or `null` for the initial frame.
* `phantom` is true when the action's defender ship-group was
* already at zero before the action ran — legacy emitters keep
* logging hits past the moment a group emptied. The viewer fast-
* forwards through phantom frames during play so the user never
* sees a silent gap; the frame is still in the sequence so step
* controls and the scrubber can land on it deliberately.
*/ */
export interface Frame { export interface Frame {
shotIndex: number; shotIndex: number;
remaining: Map<number, number>; remaining: Map<number, number>;
activeRaceIds: number[]; activeRaceIds: number[];
lastAction: BattleActionReport | null; lastAction: BattleActionReport | null;
phantom: boolean;
} }
export interface NormalisedGroup { export interface NormalisedGroup {
@@ -102,7 +95,6 @@ export function buildFrames(report: BattleReport): Frame[] {
remaining: new Map(initialRemaining), remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals), activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null, lastAction: null,
phantom: false,
}); });
const groupRaceByKey = new Map<number, number>(); const groupRaceByKey = new Map<number, number>();
@@ -112,28 +104,30 @@ export function buildFrames(report: BattleReport): Frame[] {
const runningRaceTotals = new Map(raceTotals); const runningRaceTotals = new Map(raceTotals);
for (let i = 0; i < report.protocol.length; i++) { for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i]; const action = report.protocol[i];
// A shot whose defender group was empty before the action if (action.x) {
// ran is a phantom: legacy emitters keep logging hits past // Defence in depth: a malformed protocol that fires more
// the moment a group emptied. We keep the frame in the // `Destroyed` rows than the group has ships would push
// sequence (step controls and the scrubber can still land // `runningRaceTotals` below zero and drop the race from
// on it deliberately) but mark it so the play loop fast- // `activeRaceIds` prematurely. Real legacy data folds
// forwards across the silent gap. // duplicate `(race, className)` roster rows into the
const leftBefore = current.get(action.sd) ?? 0; // same `BattleReportGroup` (parser + engine), so this
const phantom = leftBefore === 0; // branch is hit only on a real shrink — but the clamp
if (action.x && !phantom) { // keeps the math from going negative either way.
current.set(action.sd, leftBefore - 1); const left = current.get(action.sd) ?? 0;
if (left > 0) {
current.set(action.sd, left - 1);
const raceId = groupRaceByKey.get(action.sd); const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) { if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1; const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t)); runningRaceTotals.set(raceId, Math.max(0, t));
} }
} }
}
frames.push({ frames.push({
shotIndex: i + 1, shotIndex: i + 1,
remaining: new Map(current), remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals), activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action, lastAction: action,
phantom,
}); });
} }
-9
View File
@@ -208,15 +208,6 @@ describe("buildFrames phantom-destroy clamp", () => {
// the only active race for the remainder of the protocol. // the only active race for the remainder of the protocol.
expect(frames[5].remaining.get(10)).toBe(0); expect(frames[5].remaining.get(10)).toBe(0);
expect(frames[5].activeRaceIds).toEqual([1]); expect(frames[5].activeRaceIds).toEqual([1]);
// Phantom flags: first two destroys land on a non-empty
// group → real shots; the remaining three are phantoms.
expect(frames[1].phantom).toBe(false);
expect(frames[2].phantom).toBe(false);
expect(frames[3].phantom).toBe(true);
expect(frames[4].phantom).toBe(true);
expect(frames[5].phantom).toBe(true);
// The initial frame is never a phantom.
expect(frames[0].phantom).toBe(false);
}); });
it("keeps a race active while phantom destroys hit one of its empty groups", () => { it("keeps a race active while phantom destroys hit one of its empty groups", () => {