Skip to content

Commit 1073679

Browse files
committed
feat: add combat target runtime snapshots
1 parent ebc7133 commit 1073679

4 files changed

Lines changed: 114 additions & 4 deletions

File tree

internal/minimal/factory.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,13 @@ func (r *gameRuntime) CharacterVisibility() []CharacterVisibilitySnapshot {
307307
return r.sharedWorld.CharacterVisibility()
308308
}
309309

310+
func (r *gameRuntime) CombatTargetSnapshots() []CombatTargetSnapshot {
311+
if r == nil || r.sharedWorld == nil {
312+
return nil
313+
}
314+
return r.sharedWorld.CombatTargetSnapshots()
315+
}
316+
310317
func (r *gameRuntime) InteractionVisibility() []CharacterInteractionVisibilitySnapshot {
311318
if r == nil || r.sharedWorld == nil {
312319
return nil

internal/minimal/shared_world.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,38 @@ func (r *sharedWorldRegistry) CombatTargetSnapshot(entityID uint64) (CombatTarge
697697
r.mu.Lock()
698698
defer r.mu.Unlock()
699699

700+
return r.combatTargetSnapshotLocked(entityID)
701+
}
702+
703+
func (r *sharedWorldRegistry) CombatTargetSnapshots() []CombatTargetSnapshot {
704+
if r == nil {
705+
return nil
706+
}
707+
708+
r.mu.Lock()
709+
defer r.mu.Unlock()
710+
711+
if len(r.sessionCombatTargets) == 0 {
712+
return nil
713+
}
714+
entityIDs := make([]uint64, 0, len(r.sessionCombatTargets))
715+
for entityID := range r.sessionCombatTargets {
716+
entityIDs = append(entityIDs, entityID)
717+
}
718+
sort.Slice(entityIDs, func(i, j int) bool { return entityIDs[i] < entityIDs[j] })
719+
720+
snapshots := make([]CombatTargetSnapshot, 0, len(entityIDs))
721+
for _, entityID := range entityIDs {
722+
snapshot, ok := r.combatTargetSnapshotLocked(entityID)
723+
if !ok {
724+
continue
725+
}
726+
snapshots = append(snapshots, snapshot)
727+
}
728+
return snapshots
729+
}
730+
731+
func (r *sharedWorldRegistry) combatTargetSnapshotLocked(entityID uint64) (CombatTargetSnapshot, bool) {
700732
targetVID, ok := r.sessionCombatTargets[entityID]
701733
if !ok || targetVID == 0 {
702734
return CombatTargetSnapshot{}, false

internal/minimal/shared_world_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15629,6 +15629,76 @@ func TestSharedWorldRegistryCombatTargetSnapshotReportsSelectedPracticeMob(t *te
1562915629
}
1563015630
}
1563115631

15632+
func TestSharedWorldRegistryCombatTargetSnapshotsReportsDeterministicActiveSelections(t *testing.T) {
15633+
topology := worldruntime.NewBootstrapTopology(1).WithRadiusVisibilityPolicy(400, 200)
15634+
registry := newSharedWorldRegistryWithTopology(topology)
15635+
firstSubject := peerVisibilityCharacter("FirstSubject", 0x01030101, 0x02040101, 1100, 2100, 0, 101, 201)
15636+
secondSubject := peerVisibilityCharacter("SecondSubject", 0x01030102, 0x02040102, 1110, 2110, 1, 101, 201)
15637+
firstSubjectID, _ := registry.Join(firstSubject, newPendingServerFrames(), nil)
15638+
secondSubjectID, _ := registry.Join(secondSubject, newPendingServerFrames(), nil)
15639+
if firstSubjectID == 0 || secondSubjectID == 0 {
15640+
t.Fatalf("expected both subjects to join, got first=%d second=%d", firstSubjectID, secondSubjectID)
15641+
}
15642+
firstActor, ok := registry.RegisterStaticActorWithCombatKind(0, "FirstPracticeMob", bootstrapMapIndex, 1200, 2200, 20350, worldruntime.StaticActorCombatProfilePracticeMob)
15643+
if !ok {
15644+
t.Fatal("expected first practice-mob registration to succeed")
15645+
}
15646+
secondActor, ok := registry.RegisterStaticActorWithCombatKind(0, "SecondPracticeMob", bootstrapMapIndex, 1210, 2210, 20351, worldruntime.StaticActorCombatProfilePracticeMob)
15647+
if !ok {
15648+
t.Fatal("expected second practice-mob registration to succeed")
15649+
}
15650+
secondAttempt := registry.AttemptStaticActorCombatTarget(secondSubjectID, uint32(secondActor.EntityID))
15651+
if !secondAttempt.Accepted || !registry.SetSessionCombatTarget(secondSubjectID, secondAttempt.TargetVID) {
15652+
t.Fatalf("expected second subject target selection to be recorded, got %+v", secondAttempt)
15653+
}
15654+
firstAttempt := registry.AttemptStaticActorCombatTarget(firstSubjectID, uint32(firstActor.EntityID))
15655+
if !firstAttempt.Accepted || !registry.SetSessionCombatTarget(firstSubjectID, firstAttempt.TargetVID) {
15656+
t.Fatalf("expected first subject target selection to be recorded, got %+v", firstAttempt)
15657+
}
15658+
15659+
snapshots := registry.CombatTargetSnapshots()
15660+
if len(snapshots) != 2 {
15661+
t.Fatalf("expected two active combat target snapshots, got %d: %+v", len(snapshots), snapshots)
15662+
}
15663+
if snapshots[0].SubjectEntityID != firstSubjectID || snapshots[0].TargetVID != uint32(firstActor.EntityID) {
15664+
t.Fatalf("expected first deterministic snapshot to describe first subject/actor, got %+v", snapshots[0])
15665+
}
15666+
if snapshots[1].SubjectEntityID != secondSubjectID || snapshots[1].TargetVID != uint32(secondActor.EntityID) {
15667+
t.Fatalf("expected second deterministic snapshot to describe second subject/actor, got %+v", snapshots[1])
15668+
}
15669+
if snapshots[0].Actor.Name != "FirstPracticeMob" || snapshots[1].Actor.Name != "SecondPracticeMob" {
15670+
t.Fatalf("unexpected active combat target actors: %+v", snapshots)
15671+
}
15672+
}
15673+
15674+
func TestGameRuntimeCombatTargetSnapshotsReportsActiveSelections(t *testing.T) {
15675+
store := loginticket.NewFileStore(t.TempDir())
15676+
attacker := peerVisibilityCharacter("SnapshotAttacker", 0x01030103, 0x02040103, 1100, 2100, 0, 101, 201)
15677+
issuePeerTicket(t, store, "snapshot-attacker", 0x33333333, attacker)
15678+
runtime, err := newGameRuntimeWithAccountStore(config.Service{LegacyAddr: ":13000", PublicAddr: "127.0.0.1"}, store, nil)
15679+
if err != nil {
15680+
t.Fatalf("unexpected game runtime error: %v", err)
15681+
}
15682+
actor, ok := runtime.sharedWorld.RegisterStaticActorWithCombatKind(0, "SnapshotPracticeMob", bootstrapMapIndex, 1200, 2200, 20350, worldruntime.StaticActorCombatProfilePracticeMob)
15683+
if !ok {
15684+
t.Fatal("expected practice-mob registration to succeed")
15685+
}
15686+
flow, _ := enterGameWithLoginTicket(t, runtime.SessionFactory(), "snapshot-attacker", 0x33333333)
15687+
defer closeSessionFlow(t, flow)
15688+
targetVID := uint32(actor.EntityID)
15689+
if selectOut, err := flow.HandleClientFrame(decodeSingleFrame(t, combatproto.EncodeClientTarget(combatproto.ClientTargetPacket{TargetVID: targetVID}))); err != nil || len(selectOut) != 1 {
15690+
t.Fatalf("expected target selection before runtime snapshot to succeed with one frame, got frames=%d err=%v", len(selectOut), err)
15691+
}
15692+
15693+
snapshots := runtime.CombatTargetSnapshots()
15694+
if len(snapshots) != 1 {
15695+
t.Fatalf("expected one runtime combat target snapshot, got %d: %+v", len(snapshots), snapshots)
15696+
}
15697+
if snapshots[0].TargetVID != targetVID || snapshots[0].HPPercent != 100 || snapshots[0].Actor.Name != "SnapshotPracticeMob" {
15698+
t.Fatalf("unexpected runtime combat target snapshot: %+v", snapshots[0])
15699+
}
15700+
}
15701+
1563215702
func TestSharedWorldRegistryRegisterSpawnGroupActorWithPracticeMobProfileIsCombatTargetable(t *testing.T) {
1563315703
topology := worldruntime.NewBootstrapTopology(1).WithRadiusVisibilityPolicy(400, 200)
1563415704
registry := newSharedWorldRegistryWithTopology(topology)

spec/protocol/combat-normal-attack-bootstrap.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,18 @@ So the first owned target-state surface is now intentionally tiny but expressive
124124

125125
## Runtime combat-target snapshot
126126

127-
The runtime now also owns one read-only selected-combat-target snapshot for local/debug callers.
128-
It is not a new client packet and does not replace the existing self-only `GC TARGET` wire surface.
127+
The runtime now also owns read-only selected-combat-target snapshots for local/debug callers.
128+
They are not new client packets and do not replace the existing self-only `GC TARGET` wire surface.
129129

130-
For a live shared-world session with an active selected static-actor combat target, the snapshot reports:
130+
For a live shared-world session with an active selected static-actor combat target, each snapshot reports:
131131
- `subject_entity_id`
132132
- `target_vid`
133133
- the target `snapshot_version` captured from runtime combat ownership
134134
- current target `hp_percent`
135135
- the same compact static-actor snapshot shape used by local static-actor/visibility introspection
136136

137-
The snapshot fails closed when the subject is missing, no target is selected, the selected target is no longer visible, or the selected actor no longer has owned bootstrap combat HP semantics.
137+
The per-subject snapshot fails closed when the subject is missing, no target is selected, the selected target is no longer visible, or the selected actor no longer has owned bootstrap combat HP semantics.
138+
The aggregate runtime snapshot skips those invalid/stale entries and returns active selections in deterministic `subject_entity_id` order.
138139
This gives later loopback/operator surfaces a stable read-only seam without granting stale sockets or global actor lookups a new authoritative combat path.
139140

140141
## Relationship to later HP / death work

0 commit comments

Comments
 (0)