Add conversion statues shown in resolution phase
In Conversion mode, capture a snapshot (pose index + paint) of each hider at conversion time and materialize a frozen, painted statue at the found spot for the resolution phase, independent of the now-hunter avatar. - MatchStatue.cs (new): server-init pose/paint, network-driven pose then freeze, reveal-gate (hidden until paint applied), kills inherited sim. - PlayerPaintSync: _isStatue (sibling auto-detect) suppresses the three auto-behaviours so MatchStatue drives the paint flow deterministically. - MatchManager: statuePrefab; capture in ConvertToHunter; spawn on resolution enter; despawn + record clear on EndMatch/teardown/start. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a1e286a34
commit
0f108fce47
4 files changed
+232
-5
No files matched your search
@@ -72,6 +72,12 @@ public class MatchManager : MonoBehaviour
|
|||||||
"free-cam rig is a separate scene object. Must be a registered network prefab.")]
|
"free-cam rig is a separate scene object. Must be a registered network prefab.")]
|
||||||
[SerializeField] private GameObject spectatorPrefab;
|
[SerializeField] private GameObject spectatorPrefab;
|
||||||
|
|
||||||
|
[Tooltip("CONVERSION mode only: a frozen, painted 'statue' spawned at the start of Resolution at the exact " +
|
||||||
|
"spot / stance / painting each hider held the moment it was converted into a hunter, so everyone " +
|
||||||
|
"sees where each hider was caught. Purely presentational (no controller / collision / role). Must " +
|
||||||
|
"be a registered network prefab (a stripped Hider variant). Empty = statues disabled.")]
|
||||||
|
[SerializeField] private GameObject statuePrefab;
|
||||||
|
|
||||||
[Header("Roles")]
|
[Header("Roles")]
|
||||||
[Tooltip("How many randomly chosen players become Hunters at match start; everyone else is a Hider. " +
|
[Tooltip("How many randomly chosen players become Hunters at match start; everyone else is a Hider. " +
|
||||||
"0 = all Hiders; a value at or above the player count makes everyone a Hunter. Default 1.")]
|
"0 = all Hiders; a value at or above the player count makes everyone a Hunter. Default 1.")]
|
||||||
@@ -279,6 +285,13 @@ public class MatchManager : MonoBehaviour
|
|||||||
private int _loadGenerationClient;
|
private int _loadGenerationClient;
|
||||||
private Coroutine _clientLoadPoll;
|
private Coroutine _clientLoadPoll;
|
||||||
|
|
||||||
|
// Server-only. Conversion statues: a snapshot of each converted hider (spot / stance / painting) captured
|
||||||
|
// on the conversion, materialized as frozen statue objects only at the start of Resolution and despawned
|
||||||
|
// when the match ends. Only populated in Conversion mode (ConvertToHunter is the sole capture site).
|
||||||
|
private class StatueRecord { public Vector3 pos; public Quaternion rot; public int poseIndex; public byte[] paint; }
|
||||||
|
private readonly List<StatueRecord> _conversionRecords = new List<StatueRecord>();
|
||||||
|
private readonly List<NetworkObject> _statues = new List<NetworkObject>();
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
if (Instance && Instance != this) { Destroy(this); return; }
|
if (Instance && Instance != this) { Destroy(this); return; }
|
||||||
@@ -424,6 +437,9 @@ public class MatchManager : MonoBehaviour
|
|||||||
_matchAvatarIds.Clear();
|
_matchAvatarIds.Clear();
|
||||||
_loadTargetIds.Clear();
|
_loadTargetIds.Clear();
|
||||||
_clientLoadPoll = null;
|
_clientLoadPoll = null;
|
||||||
|
// Teardown unloads the gameplay scenes (destroys the statue objects); just drop our references.
|
||||||
|
_statues.Clear();
|
||||||
|
_conversionRecords.Clear();
|
||||||
if (_net) UnhookSessionCallbacks();
|
if (_net) UnhookSessionCallbacks();
|
||||||
|
|
||||||
ReturnToMainOnly();
|
ReturnToMainOnly();
|
||||||
@@ -613,6 +629,8 @@ public class MatchManager : MonoBehaviour
|
|||||||
|
|
||||||
private IEnumerator MatchSequence(HashSet<ulong> hunterIds)
|
private IEnumerator MatchSequence(HashSet<ulong> hunterIds)
|
||||||
{
|
{
|
||||||
|
_conversionRecords.Clear(); // fresh match inherits no stale statue captures (EndMatch also clears)
|
||||||
|
|
||||||
// Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the
|
// Freeze everyone in Loading BEFORE unloading the lobby: the lobby floor vanishes a moment before the
|
||||||
// map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap.
|
// map's is loaded, so the still-bound lobby avatars would otherwise drop into the void during the swap.
|
||||||
// Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the
|
// Loading gates movement, gravity AND the local own-body mesh on every client (see GameModeManager); the
|
||||||
@@ -653,6 +671,9 @@ public class MatchManager : MonoBehaviour
|
|||||||
yield return RunSeekPhase(seekDuration);
|
yield return RunSeekPhase(seekDuration);
|
||||||
|
|
||||||
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
|
SetPhaseServer(MatchSubPhase.Resolution, resolveDuration);
|
||||||
|
// Conversion mode: materialize a frozen statue of every converted hider at its capture spot, now that
|
||||||
|
// Resolution (which reveals everyone) has begun. No-op with no records / no statue prefab / in Classic.
|
||||||
|
SpawnConversionStatues();
|
||||||
yield return WaitPhase(resolveDuration, null);
|
yield return WaitPhase(resolveDuration, null);
|
||||||
|
|
||||||
_phaseRoutine = null;
|
_phaseRoutine = null;
|
||||||
@@ -1121,6 +1142,9 @@ public class MatchManager : MonoBehaviour
|
|||||||
}
|
}
|
||||||
Vector3 pos = current.transform.position;
|
Vector3 pos = current.transform.position;
|
||||||
Quaternion rot = current.transform.rotation;
|
Quaternion rot = current.transform.rotation;
|
||||||
|
// Snapshot the hider's spot / stance / painting NOW, before it despawns - a frozen statue is
|
||||||
|
// materialized from this at the start of Resolution (see SpawnConversionStatues).
|
||||||
|
CaptureConversionStatue(pos, rot, current);
|
||||||
current.Despawn(true);
|
current.Despawn(true);
|
||||||
|
|
||||||
var go = Instantiate(hunterPrefab, pos, rot);
|
var go = Instantiate(hunterPrefab, pos, rot);
|
||||||
@@ -1131,6 +1155,63 @@ public class MatchManager : MonoBehaviour
|
|||||||
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
|
go.GetComponent<NetworkObject>().SpawnAsPlayerObject(clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-only. Captures a converting hider's spot / stance / painting so a frozen statue can be
|
||||||
|
// materialized from it at Resolution. Skipped entirely when no statue prefab is assigned. The painting
|
||||||
|
// is captured from the SERVER's own replica (the host holds the full paint via the live stream); a
|
||||||
|
// capture miss stores a null snapshot -> the statue simply stays on the blank seed look.
|
||||||
|
private void CaptureConversionStatue(Vector3 pos, Quaternion rot, NetworkObject hider)
|
||||||
|
{
|
||||||
|
if (!statuePrefab) return;
|
||||||
|
|
||||||
|
int poseIndex = 0;
|
||||||
|
var pose = hider.GetComponentInChildren<PlayerPoseController>(true);
|
||||||
|
if (pose) poseIndex = pose.CurrentIndex;
|
||||||
|
|
||||||
|
byte[] paint = null;
|
||||||
|
var painter = hider.GetComponentInChildren<MeshPainter>(true);
|
||||||
|
if (painter) painter.TryCaptureSnapshot(out paint);
|
||||||
|
|
||||||
|
_conversionRecords.Add(new StatueRecord { pos = pos, rot = rot, poseIndex = poseIndex, paint = paint });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-only. Spawns one frozen statue per captured conversion at its recorded pos/rot, handing each the
|
||||||
|
// stance + painting snapshot before it spawns (server-owned, non-player). Called once at Resolution start;
|
||||||
|
// the records are consumed here. No-op with no prefab / no records (Classic never records any).
|
||||||
|
private void SpawnConversionStatues()
|
||||||
|
{
|
||||||
|
if (!statuePrefab || _conversionRecords.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var rec in _conversionRecords)
|
||||||
|
{
|
||||||
|
var go = Instantiate(statuePrefab, rec.pos, rec.rot);
|
||||||
|
var statue = go.GetComponent<MatchStatue>();
|
||||||
|
if (statue) statue.ServerInit(rec.poseIndex, rec.paint);
|
||||||
|
var netObj = go.GetComponent<NetworkObject>();
|
||||||
|
if (netObj)
|
||||||
|
{
|
||||||
|
netObj.Spawn(); // server-owned, non-player: replicates to every client as an observer
|
||||||
|
_statues.Add(netObj);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Debug.LogError("[MatchManager] Statue prefab has no NetworkObject - cannot spawn.", this);
|
||||||
|
Destroy(go);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_conversionRecords.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-only. Despawns every live conversion statue (match ending / back to lobby) and drops the list.
|
||||||
|
private void DespawnConversionStatues()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _statues.Count; i++)
|
||||||
|
{
|
||||||
|
var s = _statues[i];
|
||||||
|
if (s && s.IsSpawned) s.Despawn(true);
|
||||||
|
}
|
||||||
|
_statues.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
|
// True if the client's current avatar is a Hunter role variant (used to gate shooting server-side).
|
||||||
private bool IsHunter(ulong clientId)
|
private bool IsHunter(ulong clientId)
|
||||||
{
|
{
|
||||||
@@ -1150,6 +1231,8 @@ public class MatchManager : MonoBehaviour
|
|||||||
// the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps.
|
// the replicated sub-phase so every client drops its match HUD/visibility before the scene swaps.
|
||||||
if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; }
|
if (_phaseRoutine != null) { StopCoroutine(_phaseRoutine); _phaseRoutine = null; }
|
||||||
_pendingHiders.Clear();
|
_pendingHiders.Clear();
|
||||||
|
DespawnConversionStatues(); // drop any Resolution statues before the map unloads
|
||||||
|
_conversionRecords.Clear(); // and any captures from a match ended before Resolution
|
||||||
SetPhaseServer(MatchSubPhase.None, 0f);
|
SetPhaseServer(MatchSubPhase.None, 0f);
|
||||||
|
|
||||||
Phase = MatchPhase.Transitioning;
|
Phase = MatchPhase.Transitioning;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using Unity.Netcode;
|
||||||
|
using UnityEngine;
|
||||||
|
using Chameleon.Components;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A frozen, painted "statue" of a hider at the exact spot / stance / painting it held the moment it was
|
||||||
|
/// CONVERTED into a hunter (Conversion mode only). <see cref="MatchManager"/> captures those on the
|
||||||
|
/// conversion and spawns one statue per converted hider at the start of the Resolution phase, so every
|
||||||
|
/// player sees where each hider was caught as a faithful representation (same skinned body, camo painting
|
||||||
|
/// and fur) - independent of the now-hunter avatar, which has long since moved on.
|
||||||
|
///
|
||||||
|
/// Purely presentational: no controller, no collision, no gameplay role. It is a dedicated prefab (NOT a
|
||||||
|
/// <see cref="NetworkPlayerSetup"/> avatar), so it never pollutes the scoreboard, per-client visibility
|
||||||
|
/// matrix or resolution X-ray reveal - those all key off NetworkPlayerSetup, which the statue lacks.
|
||||||
|
///
|
||||||
|
/// Server-owned. Three replicated ingredients rebuild it identically on every client:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Transform</b> - the server Instantiates it at the captured pos/rot; the (server-authoritative)
|
||||||
|
/// NetworkTransform replicates that.</item>
|
||||||
|
/// <item><b>Painting</b> - rides the existing <see cref="PlayerPaintSync"/> snapshot transfer; this object
|
||||||
|
/// drives the push explicitly (server loads the captured snapshot, then broadcasts it), and PlayerPaintSync
|
||||||
|
/// suppresses its own auto pull/publish for a statue receiver so the two never race.</item>
|
||||||
|
/// <item><b>Stance</b> - a single replicated pose index the pose controller reproduces (network-driven),
|
||||||
|
/// then freezes.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[DisallowMultipleComponent]
|
||||||
|
public class MatchStatue : NetworkBehaviour
|
||||||
|
{
|
||||||
|
[Tooltip("Seconds the captured stance is allowed to blend in (as the network-driven idle of that pose) " +
|
||||||
|
"before the rig is frozen on that frame. Keeps the pose from snapping in mid-blend.")]
|
||||||
|
[Min(0f)]
|
||||||
|
[SerializeField] private float poseSettle = 0.35f;
|
||||||
|
|
||||||
|
// Server-only handoff from MatchManager.SpawnConversionStatues, consumed in OnNetworkSpawn / the first
|
||||||
|
// ready frame. The paint may be null (capture failed) -> the statue stays on the blank seed look.
|
||||||
|
private byte[] _serverPaint;
|
||||||
|
private int _serverPoseIndex;
|
||||||
|
|
||||||
|
// The captured stance, replicated so every client poses the rig identically (server-written on spawn).
|
||||||
|
private readonly NetworkVariable<int> _poseIndex =
|
||||||
|
new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
|
||||||
|
|
||||||
|
private MeshPainter _painter;
|
||||||
|
private PlayerPaintSync _paintSync;
|
||||||
|
private PlayerPoseController _pose;
|
||||||
|
private SkinnedMeshRenderer[] _bodyRenderers;
|
||||||
|
private FurShellRenderer[] _furRenderers;
|
||||||
|
|
||||||
|
private bool _hostPaintDone; // server: the captured snapshot has been loaded + pushed to peers (one-shot)
|
||||||
|
private bool _bodyShown; // reveal-gate change guard
|
||||||
|
private bool _frozen; // pose freeze applied
|
||||||
|
private float _freezeAt; // Time.time deadline that pins the settled pose
|
||||||
|
|
||||||
|
/// <summary>Server-only. Call on a freshly-instantiated statue BEFORE spawning it: the captured painting
|
||||||
|
/// snapshot (may be null) and the stance pose index to reproduce.</summary>
|
||||||
|
public void ServerInit(int poseIndex, byte[] paint)
|
||||||
|
{
|
||||||
|
_serverPoseIndex = poseIndex;
|
||||||
|
_serverPaint = paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnNetworkSpawn()
|
||||||
|
{
|
||||||
|
_painter = GetComponentInChildren<MeshPainter>(true);
|
||||||
|
_paintSync = GetComponentInChildren<PlayerPaintSync>(true);
|
||||||
|
_pose = GetComponentInChildren<PlayerPoseController>(true);
|
||||||
|
_bodyRenderers = GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||||
|
_furRenderers = GetComponentsInChildren<FurShellRenderer>(true);
|
||||||
|
|
||||||
|
// Pure presentation: kill any inherited simulation so nothing moves/falls or fights the held pose.
|
||||||
|
// Done before the disabled controllers' first Update (OnNetworkSpawn runs during the spawn), so no
|
||||||
|
// gravity frame slips through.
|
||||||
|
var controller = GetComponent<PlayerCharacterController>();
|
||||||
|
if (controller) controller.enabled = false;
|
||||||
|
var cc = GetComponent<CharacterController>();
|
||||||
|
if (cc) cc.enabled = false;
|
||||||
|
|
||||||
|
if (IsServer) _poseIndex.Value = _serverPoseIndex;
|
||||||
|
|
||||||
|
// Hide the body until the painting has actually landed locally, so no client flashes the blank seed
|
||||||
|
// material before its snapshot applies (mirrors the avatar's receiver-side reveal gate).
|
||||||
|
SetBodyVisible(false);
|
||||||
|
|
||||||
|
// Reproduce the captured stance: network-driven so no local input / capsule / camera work runs; the
|
||||||
|
// idle of that pose plays for a short settle, then Update freezes the rig on that frame.
|
||||||
|
if (_pose)
|
||||||
|
{
|
||||||
|
_pose.EnableNetworkDrive();
|
||||||
|
_pose.SetNetworkState(_poseIndex.Value, PlayerPoseController.Locomotion.Idle);
|
||||||
|
}
|
||||||
|
_freezeAt = Time.time + Mathf.Max(0f, poseSettle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Update()
|
||||||
|
{
|
||||||
|
// Server owner: once the painter is set up, load the captured snapshot and push it to every peer over
|
||||||
|
// the existing PlayerPaintSync transfer. Always publish a baseline (even a null capture -> the blank
|
||||||
|
// seed) so a client's receiver-side gate opens and the body can reveal. One-shot.
|
||||||
|
if (IsServer && !_hostPaintDone && _painter && _painter.IsPaintReady && _paintSync)
|
||||||
|
{
|
||||||
|
_hostPaintDone = true;
|
||||||
|
if (_serverPaint != null) _painter.LoadPaintFromSnapshot(_serverPaint);
|
||||||
|
_paintSync.BroadcastLoadedPaint();
|
||||||
|
_serverPaint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal once the painting is applied locally: the server right after its load/publish, a client once
|
||||||
|
// its received snapshot has caught up to the published baseline.
|
||||||
|
bool paintReady = IsServer ? _hostPaintDone : (_paintSync && _paintSync.PaintRevealReady);
|
||||||
|
if (paintReady && !_bodyShown) SetBodyVisible(true);
|
||||||
|
|
||||||
|
// Re-assert the stance until it has settled (covers a pose index arriving a frame late), then freeze.
|
||||||
|
if (_pose && !_frozen)
|
||||||
|
{
|
||||||
|
_pose.SetNetworkState(_poseIndex.Value, PlayerPoseController.Locomotion.Idle);
|
||||||
|
if (Time.time >= _freezeAt) { _pose.Frozen = true; _frozen = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggles the skinned body AND the custom-pass fur together (the fur is not a Renderer, so the skinned
|
||||||
|
// toggle misses it) - same atomic reveal the avatar uses, so a statue never shows stale pooled fur.
|
||||||
|
private void SetBodyVisible(bool visible)
|
||||||
|
{
|
||||||
|
_bodyShown = visible;
|
||||||
|
if (_bodyRenderers != null)
|
||||||
|
foreach (var r in _bodyRenderers) if (r) r.enabled = visible;
|
||||||
|
if (_furRenderers != null)
|
||||||
|
foreach (var f in _furRenderers) if (f) f.enabled = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: eeaab136fd6a85343b3ffe981dad693f
|
||||||
@@ -138,7 +138,18 @@ public class PlayerPaintSync : NetworkBehaviour
|
|||||||
// spawn), applied in LateUpdate once this fresh painter is ready. Null = nothing to carry.
|
// spawn), applied in LateUpdate once this fresh painter is ready. Null = nothing to carry.
|
||||||
private byte[] _pendingCarry;
|
private byte[] _pendingCarry;
|
||||||
|
|
||||||
private void Awake() => _painter = GetComponentInChildren<MeshPainter>(true);
|
// True when this receiver sits on a MatchStatue: a statue drives its painting explicitly (the server
|
||||||
|
// loads the captured snapshot then broadcasts it), so the owner-carry take, the client auto snapshot-
|
||||||
|
// request and the no-carry blank publish are all suppressed to keep them from racing that push.
|
||||||
|
private bool _isStatue;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
_painter = GetComponentInChildren<MeshPainter>(true);
|
||||||
|
// Detected from the sibling component so no inspector flag can be forgotten on the prefab.
|
||||||
|
var statue = GetComponentInChildren<MatchStatue>(true);
|
||||||
|
_isStatue = statue;
|
||||||
|
}
|
||||||
|
|
||||||
public override void OnNetworkSpawn()
|
public override void OnNetworkSpawn()
|
||||||
{
|
{
|
||||||
@@ -151,7 +162,7 @@ public class PlayerPaintSync : NetworkBehaviour
|
|||||||
_painter.OnHistoryCheckpoint += OnLocalCheckpoint;
|
_painter.OnHistoryCheckpoint += OnLocalCheckpoint;
|
||||||
_painter.OnHistoryRestore += OnLocalRestore;
|
_painter.OnHistoryRestore += OnLocalRestore;
|
||||||
|
|
||||||
if (MatchManager.Instance) _pendingCarry = MatchManager.Instance.TakeCarriedPaint();
|
if (MatchManager.Instance && !_isStatue) _pendingCarry = MatchManager.Instance.TakeCarriedPaint();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -178,8 +189,8 @@ public class PlayerPaintSync : NetworkBehaviour
|
|||||||
_heightApplyPending = true;
|
_heightApplyPending = true;
|
||||||
|
|
||||||
// The host's replica already holds the full paint via the live stream; only a genuine
|
// The host's replica already holds the full paint via the live stream; only a genuine
|
||||||
// late joiner needs the baseline pulled over.
|
// late joiner needs the baseline pulled over. A statue never pulls - the server pushes it.
|
||||||
if (!IsServer)
|
if (!IsServer && !_isStatue)
|
||||||
{
|
{
|
||||||
_awaitingSnapshot = true;
|
_awaitingSnapshot = true;
|
||||||
if (logSync) LogSync("late joiner: requesting baseline snapshot");
|
if (logSync) LogSync("late joiner: requesting baseline snapshot");
|
||||||
@@ -293,7 +304,7 @@ public class PlayerPaintSync : NetworkBehaviour
|
|||||||
// avatar must still hand every peer an explicit (blank) snapshot, otherwise its body stays hidden
|
// avatar must still hand every peer an explicit (blank) snapshot, otherwise its body stays hidden
|
||||||
// forever behind the receiver-side reveal gate. Carry/manual-load already publish (and set the
|
// forever behind the receiver-side reveal gate. Carry/manual-load already publish (and set the
|
||||||
// cutoff), so this only fires for the no-carry case.
|
// cutoff), so this only fires for the no-carry case.
|
||||||
if (_publishedCutoff.Value < 0 && _pendingCarry == null && _painter && _painter.IsPaintReady)
|
if (_publishedCutoff.Value < 0 && _pendingCarry == null && !_isStatue && _painter && _painter.IsPaintReady)
|
||||||
BroadcastLoadedPaint();
|
BroadcastLoadedPaint();
|
||||||
|
|
||||||
// Delta-only publish: the NetworkVariable dirties only on an actual change.
|
// Delta-only publish: the NetworkVariable dirties only on an actual change.
|
||||||
|
|||||||
Reference in new issue
Block a user