Skip to content

Commit b878a2d

Browse files
committed
feat: add combat target runtime snapshot
1 parent 88eba7c commit b878a2d

3 files changed

Lines changed: 95 additions & 0 deletions

File tree

internal/minimal/shared_world.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ type StaticActorCombatTargetAttempt struct {
124124
Actor StaticActorSnapshot
125125
}
126126

127+
type CombatTargetSnapshot struct {
128+
SubjectEntityID uint64 `json:"subject_entity_id"`
129+
TargetVID uint32 `json:"target_vid"`
130+
SnapshotVersion uint64 `json:"snapshot_version"`
131+
HPPercent uint8 `json:"hp_percent"`
132+
Actor StaticActorSnapshot `json:"actor"`
133+
}
134+
127135
type StaticActorCombatAttackAttempt struct {
128136
Accepted bool
129137
Failure string
@@ -681,6 +689,45 @@ func (r *sharedWorldRegistry) SetSessionCombatTarget(entityID uint64, targetVID
681689
return true
682690
}
683691

692+
func (r *sharedWorldRegistry) CombatTargetSnapshot(entityID uint64) (CombatTargetSnapshot, bool) {
693+
if r == nil || entityID == 0 {
694+
return CombatTargetSnapshot{}, false
695+
}
696+
697+
r.mu.Lock()
698+
defer r.mu.Unlock()
699+
700+
targetVID, ok := r.sessionCombatTargets[entityID]
701+
if !ok || targetVID == 0 {
702+
return CombatTargetSnapshot{}, false
703+
}
704+
subject, ok := r.playerCharacter(entityID)
705+
if !ok {
706+
return CombatTargetSnapshot{}, false
707+
}
708+
actor, ok := r.scopesLocked().VisibleStaticActorByVID(subject, targetVID)
709+
if !ok || actor.Entity.ID == 0 {
710+
return CombatTargetSnapshot{}, false
711+
}
712+
currentHP, ok := r.ensureStaticActorCombatCurrentHPLocked(actor)
713+
if !ok {
714+
return CombatTargetSnapshot{}, false
715+
}
716+
hpPercent, ok := worldruntime.BootstrapStaticActorHPPercent(actor.CombatKind, currentHP)
717+
if !ok {
718+
return CombatTargetSnapshot{}, false
719+
}
720+
actorSnapshot := staticActorSnapshot(r.topology, actor)
721+
actorSnapshot.Dead = currentHP == 0
722+
return CombatTargetSnapshot{
723+
SubjectEntityID: entityID,
724+
TargetVID: targetVID,
725+
SnapshotVersion: r.staticActorCombatSnapshot[actor.Entity.ID],
726+
HPPercent: hpPercent,
727+
Actor: actorSnapshot,
728+
}, true
729+
}
730+
684731
func (r *sharedWorldRegistry) ClearSessionCombatTarget(entityID uint64) {
685732
if r == nil || entityID == 0 {
686733
return

internal/minimal/shared_world_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15597,6 +15597,38 @@ func TestSharedWorldRegistryAttemptStaticActorCombatTargetResolvesVisiblePractic
1559715597
}
1559815598
}
1559915599

15600+
func TestSharedWorldRegistryCombatTargetSnapshotReportsSelectedPracticeMob(t *testing.T) {
15601+
topology := worldruntime.NewBootstrapTopology(1).WithRadiusVisibilityPolicy(400, 200)
15602+
registry := newSharedWorldRegistryWithTopology(topology)
15603+
subject := peerVisibilityCharacter("Subject", 0x01030101, 0x02040101, 1100, 2100, 0, 101, 201)
15604+
subjectID, _ := registry.Join(subject, newPendingServerFrames(), nil)
15605+
if subjectID == 0 {
15606+
t.Fatal("expected subject join to return a live shared-world entity ID")
15607+
}
15608+
actor, ok := registry.RegisterStaticActorWithCombatKind(0, "PracticeMob", bootstrapMapIndex, 1200, 2200, 20350, worldruntime.StaticActorCombatProfilePracticeMob)
15609+
if !ok {
15610+
t.Fatal("expected visible practice-mob registration to succeed")
15611+
}
15612+
targetAttempt := registry.AttemptStaticActorCombatTarget(subjectID, uint32(actor.EntityID))
15613+
if !targetAttempt.Accepted {
15614+
t.Fatalf("expected practice-mob combat-target selection to succeed, got %+v", targetAttempt)
15615+
}
15616+
if !registry.SetSessionCombatTarget(subjectID, targetAttempt.TargetVID) {
15617+
t.Fatal("expected accepted target selection to be recorded")
15618+
}
15619+
15620+
snapshot, ok := registry.CombatTargetSnapshot(subjectID)
15621+
if !ok {
15622+
t.Fatal("expected selected combat target snapshot to be reported")
15623+
}
15624+
if snapshot.SubjectEntityID != subjectID || snapshot.TargetVID != targetAttempt.TargetVID || snapshot.SnapshotVersion != targetAttempt.SnapshotVersion || snapshot.HPPercent != targetAttempt.HPPercent {
15625+
t.Fatalf("unexpected selected combat target snapshot: %+v target attempt=%+v", snapshot, targetAttempt)
15626+
}
15627+
if snapshot.Actor.EntityID != actor.EntityID || snapshot.Actor.Name != "PracticeMob" || snapshot.Actor.CombatProfile != worldruntime.StaticActorCombatProfilePracticeMob {
15628+
t.Fatalf("unexpected selected combat target actor snapshot: %+v", snapshot.Actor)
15629+
}
15630+
}
15631+
1560015632
func TestSharedWorldRegistryRegisterSpawnGroupActorWithPracticeMobProfileIsCombatTargetable(t *testing.T) {
1560115633
topology := worldruntime.NewBootstrapTopology(1).WithRadiusVisibilityPolicy(400, 200)
1560215634
registry := newSharedWorldRegistryWithTopology(topology)

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ This contract currently applies only to:
3535
- one currently visible in-range non-player actor still marked as `training_dummy`
3636
- one immediate attack-intent request against that already selected target
3737
- one tiny target-refresh surface that can still describe `current target`, `updated hp percent`, or `no active target`
38+
- one read-only runtime snapshot of the session's current selected combat target for local/debug surfaces
3839

3940
This contract does **not** yet claim:
4041
- the full gameplay meaning of every non-zero `attack_type` value beyond the first narrow bootstrap ownership boundary
@@ -121,6 +122,21 @@ So the first owned target-state surface is now intentionally tiny but expressive
121122
2. `TARGET(target_vid > 0, hp_percent = updated)` — same selected dummy after accepted bootstrap attack-driven HP changes
122123
3. `TARGET(0, 0)` — selected target cleared or no longer valid
123124

125+
## Runtime combat-target snapshot
126+
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.
129+
130+
For a live shared-world session with an active selected static-actor combat target, the snapshot reports:
131+
- `subject_entity_id`
132+
- `target_vid`
133+
- the target `snapshot_version` captured from runtime combat ownership
134+
- current target `hp_percent`
135+
- the same compact static-actor snapshot shape used by local static-actor/visibility introspection
136+
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.
138+
This gives later loopback/operator surfaces a stable read-only seam without granting stale sockets or global actor lookups a new authoritative combat path.
139+
124140
## Relationship to later HP / death work
125141

126142
This document freezes the first deterministic bootstrap HP mutation and the preferred visible HP-refresh carrier.

0 commit comments

Comments
 (0)