feat(spawners): virtual OnTick; SpawnerDto carries the group flag (#2640)
## Summary Two small additive changes a derived spawner needs. - **`BaseSpawner.OnTick()` is now `virtual`.** A subclass that gates spawning on external state (time windows, event triggers) must gate *timer* spawns without gating the manual `Spawn()` API, and `OnTick` is the only place the two paths differ: it is the timer callback and `Spawn()` is both what it calls and what commands and scripts call. Cost: one virtual dispatch on the existing timer callback; no change to stock behaviour. - **`group` in the JSON DTO.** `BaseSpawner.Group` (all dead, then respawn) is binary-persisted but was missing from `SpawnerDto`, so it did not survive export/import. Added to the abstract record after `spawnLocationIsHome`, assigned in `ApplyDto` after `InitSpawn` (which resets it), and exported by the three stock `ToDto` implementations. ## Test plan - [x] A derived spawner overriding `OnTick` with a closed gate: `OnTick()` spawns nothing; manual `Spawn()` still spawns and does not pass through `OnTick`. - [x] DTO round trip with `Group = true` carries `group` and restores it on `ToSpawner()`. - [x] `UOContent.Tests` full suite green. - [ ] CI
This commit is contained in:
parent
d16166591c
commit
c02909e2c8
7 changed files with 99 additions and 6 deletions
|
|
@ -0,0 +1,78 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Server.Engines.Spawners;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests.Engines.Spawners;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class SpawnerTickAndGroupDtoTests
|
||||
{
|
||||
private sealed class GatedSpawner : Spawner
|
||||
{
|
||||
public int Ticks;
|
||||
public bool Gate = false;
|
||||
|
||||
public GatedSpawner() : base(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit")
|
||||
{
|
||||
}
|
||||
|
||||
public GatedSpawner(Serial serial) : base(serial)
|
||||
{
|
||||
}
|
||||
|
||||
public override void OnTick()
|
||||
{
|
||||
Ticks++;
|
||||
if (Gate)
|
||||
{
|
||||
base.OnTick();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnTick_IsVirtual_AndManualSpawnBypassesIt()
|
||||
{
|
||||
var spawner = new GatedSpawner();
|
||||
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
|
||||
try
|
||||
{
|
||||
spawner.OnTick();
|
||||
Assert.Equal(1, spawner.Ticks);
|
||||
Assert.Empty(spawner.Spawned); // gate closed: base.OnTick not reached
|
||||
|
||||
spawner.Spawn(); // manual API does not go through OnTick
|
||||
Assert.Equal(1, spawner.Ticks);
|
||||
Assert.Single(spawner.Spawned);
|
||||
}
|
||||
finally
|
||||
{
|
||||
spawner.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_RoundTrip_CarriesGroup()
|
||||
{
|
||||
var spawner = new Spawner(1, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), 0, default, "Rabbit");
|
||||
spawner.MoveToWorld(new Point3D(1500, 1500, 0), Map.Felucca);
|
||||
Spawner loaded = null;
|
||||
try
|
||||
{
|
||||
spawner.Group = true;
|
||||
var json = SpawnerJsonSerializer.SerializeCompact(new List<SpawnerDto> { spawner.ToDto() });
|
||||
Assert.Contains("\"group\"", json);
|
||||
|
||||
var dtos = JsonSerializer.Deserialize<List<SpawnerDto>>(json, SpawnerJsonSerializer.Options);
|
||||
loaded = (Spawner)dtos[0].ToSpawner();
|
||||
Assert.True(loaded.Group);
|
||||
}
|
||||
finally
|
||||
{
|
||||
loaded?.Delete();
|
||||
spawner.Delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ public abstract partial class BaseSpawner
|
|||
|
||||
InitSpawn(dto.Count, dto.MinDelay, dto.MaxDelay, dto.Team, SpawnBounds);
|
||||
|
||||
_group = dto.Group;
|
||||
_walkingRange = dto.WalkingRange;
|
||||
_spawnLocationIsHome = dto.SpawnLocationIsHome;
|
||||
_spawnPositionMode = dto.SpawnPositionMode;
|
||||
|
|
|
|||
|
|
@ -822,7 +822,11 @@ public abstract partial class BaseSpawner : Item, ISpawner
|
|||
return remove && Spawned.Remove(spawned);
|
||||
}
|
||||
|
||||
public void OnTick()
|
||||
/// <summary>
|
||||
/// Timer callback. Override to gate or reorder tick work; manual <see cref="Spawn"/> does not
|
||||
/// pass through here.
|
||||
/// </summary>
|
||||
public virtual void OnTick()
|
||||
{
|
||||
if (_group)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -78,12 +78,19 @@ public abstract record SpawnerDto
|
|||
[JsonPropertyOrder(11)]
|
||||
public bool SpawnLocationIsHome { get; init; }
|
||||
|
||||
[JsonPropertyName("spawnPositionMode")]
|
||||
// Order values are unique on purpose: STJ's JsonPropertyOrder ties are documented as
|
||||
// undefined between equal values, so every field gets its own slot.
|
||||
/// <summary>All dead, then respawn together. Mirrors <see cref="BaseSpawner.Group"/>.</summary>
|
||||
[JsonPropertyName("group")]
|
||||
[JsonPropertyOrder(12)]
|
||||
public bool Group { get; init; }
|
||||
|
||||
[JsonPropertyName("spawnPositionMode")]
|
||||
[JsonPropertyOrder(13)]
|
||||
public SpawnPositionMode SpawnPositionMode { get; init; }
|
||||
|
||||
[JsonPropertyName("maxSpawnAttempts")]
|
||||
[JsonPropertyOrder(13)]
|
||||
[JsonPropertyOrder(14)]
|
||||
public int MaxSpawnAttempts { get; init; }
|
||||
|
||||
// Compact square-bounds form, -1 when absent. Written only when >= 0 (ShouldSerialize, since 0
|
||||
|
|
@ -195,15 +202,15 @@ public sealed record ProximitySpawnerDto : SpawnerDto
|
|||
public Rectangle3D SpawnBounds { get; init; }
|
||||
|
||||
[JsonPropertyName("triggerRange")]
|
||||
[JsonPropertyOrder(14)]
|
||||
[JsonPropertyOrder(15)]
|
||||
public int TriggerRange { get; init; }
|
||||
|
||||
[JsonPropertyName("spawnMessage")]
|
||||
[JsonPropertyOrder(15)]
|
||||
[JsonPropertyOrder(16)]
|
||||
public TextDefinition SpawnMessage { get; init; }
|
||||
|
||||
[JsonPropertyName("instant")]
|
||||
[JsonPropertyOrder(16)]
|
||||
[JsonPropertyOrder(17)]
|
||||
public bool Instant { get; init; }
|
||||
|
||||
[JsonPropertyName("entries")]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public partial class ProximitySpawner
|
|||
WalkingRange = DtoWalkingRange,
|
||||
Entries = EntryList ?? [],
|
||||
SpawnLocationIsHome = SpawnLocationIsHome,
|
||||
Group = Group,
|
||||
SpawnPositionMode = DtoSpawnPositionMode,
|
||||
MaxSpawnAttempts = DtoMaxSpawnAttempts,
|
||||
HomeRange = homeRange,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ public partial class RegionSpawner
|
|||
WalkingRange = DtoWalkingRange,
|
||||
Entries = EntryList ?? [],
|
||||
SpawnLocationIsHome = SpawnLocationIsHome,
|
||||
Group = Group,
|
||||
SpawnPositionMode = DtoSpawnPositionMode,
|
||||
MaxSpawnAttempts = DtoMaxSpawnAttempts,
|
||||
Region = SpawnRegion?.Name
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public partial class Spawner
|
|||
WalkingRange = DtoWalkingRange,
|
||||
Entries = EntryList ?? [],
|
||||
SpawnLocationIsHome = SpawnLocationIsHome,
|
||||
Group = Group,
|
||||
SpawnPositionMode = DtoSpawnPositionMode,
|
||||
MaxSpawnAttempts = DtoMaxSpawnAttempts,
|
||||
HomeRange = homeRange,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue