Skip to content

Commit cd22df4

Browse files
committed
feat: validate spawn group refs
1 parent ed2152b commit cd22df4

4 files changed

Lines changed: 63 additions & 4 deletions

File tree

internal/minimal/factory_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3148,7 +3148,7 @@ func TestNewGameRuntimeExportsSpawnGroupsInContentBundle(t *testing.T) {
31483148
if err != nil {
31493149
t.Fatalf("unexpected game runtime error: %v", err)
31503150
}
3151-
if _, ok := runtime.registerStaticActorWithInteractionCombatProfileAndSpawnGroupRef("Practice Wolf", 3, 1200, 2200, 101, "", "", worldruntime.StaticActorCombatProfilePracticeMob, "spawn:wolf:1"); !ok {
3151+
if _, ok := runtime.registerStaticActorWithInteractionCombatProfileAndSpawnGroupRef("Practice Wolf", 3, 1200, 2200, 101, "", "", worldruntime.StaticActorCombatProfilePracticeMob, "spawn.wolf_1"); !ok {
31523152
t.Fatal("expected spawn-backed static actor registration to succeed")
31533153
}
31543154

@@ -3163,7 +3163,7 @@ func TestNewGameRuntimeExportsSpawnGroupsInContentBundle(t *testing.T) {
31633163
t.Fatalf("expected one exported spawn group, got %+v", bundle.SpawnGroups)
31643164
}
31653165
spawnGroup := bundle.SpawnGroups[0]
3166-
if spawnGroup.Ref != "spawn:wolf:1" || spawnGroup.Name != "Practice Wolf" || spawnGroup.MapIndex != 3 || spawnGroup.X != 1200 || spawnGroup.Y != 2200 || spawnGroup.RaceNum != 101 || spawnGroup.CombatProfile != worldruntime.StaticActorCombatProfilePracticeMob {
3166+
if spawnGroup.Ref != "spawn.wolf_1" || spawnGroup.Name != "Practice Wolf" || spawnGroup.MapIndex != 3 || spawnGroup.X != 1200 || spawnGroup.Y != 2200 || spawnGroup.RaceNum != 101 || spawnGroup.CombatProfile != worldruntime.StaticActorCombatProfilePracticeMob {
31673167
t.Fatalf("unexpected exported spawn group: %+v", spawnGroup)
31683168
}
31693169
}

internal/worldruntime/non_player_directory.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,40 @@ func ValidStaticActorCombatProfile(profile string) bool {
162162
}
163163

164164
func ValidStaticActorSpawnGroupRef(ref string) bool {
165-
return ref == strings.TrimSpace(ref)
165+
if ref == "" {
166+
return true
167+
}
168+
if ref != strings.TrimSpace(ref) {
169+
return false
170+
}
171+
segments := strings.Split(ref, ".")
172+
if len(segments) < 2 {
173+
return false
174+
}
175+
for _, segment := range segments {
176+
if !validSpawnGroupRefSegment(segment) {
177+
return false
178+
}
179+
}
180+
return true
181+
}
182+
183+
func validSpawnGroupRefSegment(segment string) bool {
184+
if segment == "" {
185+
return false
186+
}
187+
first := segment[0]
188+
if first < 'a' || first > 'z' {
189+
return false
190+
}
191+
for i := 1; i < len(segment); i++ {
192+
c := segment[i]
193+
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' {
194+
continue
195+
}
196+
return false
197+
}
198+
return true
166199
}
167200

168201
func sortStaticEntities(actors []StaticEntity) {

internal/worldruntime/non_player_directory_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,30 @@ func TestNonPlayerDirectoryPreservesSpawnGroupRefOnRegisterAndUpdate(t *testing.
414414
}
415415
}
416416

417+
func TestNonPlayerDirectoryRejectsNonCanonicalSpawnGroupRef(t *testing.T) {
418+
for name, ref := range map[string]string{
419+
"blank segment": "practice..mob",
420+
"uppercase segment": "practice.Mob",
421+
"hyphenated segment": "practice-mob",
422+
"leading digit": "1practice.mob",
423+
"missing namespace": "practice_mob",
424+
} {
425+
t.Run(name, func(t *testing.T) {
426+
directory := NewNonPlayerDirectory()
427+
actor := StaticEntity{
428+
Entity: Entity{ID: 14, Kind: EntityKindStaticActor, Name: "PracticeMobAlpha"},
429+
Position: NewPosition(42, 1800, 2900),
430+
RaceNum: 101,
431+
CombatProfile: StaticActorCombatProfilePracticeMob,
432+
SpawnGroupRef: ref,
433+
}
434+
if directory.Register(actor) {
435+
t.Fatalf("expected spawn-backed actor with ref %q to be rejected", ref)
436+
}
437+
})
438+
}
439+
}
440+
417441
func TestNonPlayerDirectoryRejectsStandaloneStaticActorDeathReward(t *testing.T) {
418442
directory := NewNonPlayerDirectory()
419443
actor := StaticEntity{

spec/protocol/content-spawn-groups-bootstrap.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ The first bootstrap spawn-group contract freezes these fields:
103103
- `ref`
104104
- stable authored identifier for the spawn group
105105
- unique within the bundle
106+
- canonical dotted lowercase identifier made of at least two `[a-z][a-z0-9_]*` segments, for example `practice.mob_alpha`
107+
- runtime import and static-actor snapshot validation reject non-canonical refs instead of preserving ambiguous authored ownership keys
106108
- this is the authored identity that future runtime respawn ownership binds to
107109
- `name`
108110
- required operator-friendly display label
@@ -190,7 +192,7 @@ What is **not** yet frozen here:
190192
## Validation rules
191193

192194
The first content contract should fail closed when:
193-
- `ref` is empty or duplicated
195+
- `ref` is empty, duplicated, or not in the canonical dotted lowercase form `[a-z][a-z0-9_]*(.[a-z][a-z0-9_]*)+`
194196
- `name` is empty after trimming whitespace
195197
- `map_index` is `0`
196198
- `race_num` is `0`

0 commit comments

Comments
 (0)