refactor the skill system to adhere to the lore handbook
This commit is contained in:
@@ -8,6 +8,8 @@ namespace NightclubArcadia.Skills
|
||||
/// <summary>
|
||||
/// Passive skill commentary outside the Yarn node tree.
|
||||
/// Fires at most one speaker per RequestCommentary call.
|
||||
/// Per-skill budget: 3600 / targetFiringsPerHour seconds between fires for that voice,
|
||||
/// on top of the global minSecondsBetween floor.
|
||||
/// </summary>
|
||||
public sealed class CommentarySystem : MonoBehaviour
|
||||
{
|
||||
@@ -19,6 +21,8 @@ namespace NightclubArcadia.Skills
|
||||
|
||||
readonly List<string> _recent = new List<string>();
|
||||
readonly HashSet<string> _firedContexts = new HashSet<string>(StringComparer.Ordinal);
|
||||
readonly Dictionary<string, float> _lastFiredBySkill =
|
||||
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
|
||||
float _lastFiredAt = float.NegativeInfinity;
|
||||
|
||||
public void RequestCommentary(string contextId)
|
||||
@@ -61,6 +65,7 @@ namespace NightclubArcadia.Skills
|
||||
return;
|
||||
}
|
||||
|
||||
var now = Time.time;
|
||||
var candidates = new List<SkillDefinition>();
|
||||
foreach (var skill in database.All)
|
||||
{
|
||||
@@ -74,6 +79,16 @@ namespace NightclubArcadia.Skills
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_lastFiredBySkill.TryGetValue(skill.Id, out var lastFired))
|
||||
{
|
||||
lastFired = float.NegativeInfinity;
|
||||
}
|
||||
|
||||
if (!IsOffCooldown(lastFired, now, skill.TargetFiringsPerHour))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeName = $"Commentary_{contextId}_{skill.Id}";
|
||||
if (!NodeExists(nodeName))
|
||||
{
|
||||
@@ -91,7 +106,8 @@ namespace NightclubArcadia.Skills
|
||||
|
||||
var startNode = $"Commentary_{contextId}_{picked.Id}";
|
||||
_ = dialogueRunner.StartDialogue(startNode);
|
||||
_lastFiredAt = Time.time;
|
||||
_lastFiredAt = now;
|
||||
_lastFiredBySkill[picked.Id] = now;
|
||||
_firedContexts.Add(contextId);
|
||||
_recent.Add(picked.Id);
|
||||
while (_recent.Count > Math.Max(1, recencyWindow))
|
||||
@@ -100,6 +116,21 @@ namespace NightclubArcadia.Skills
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when enough time has elapsed since lastFired given targetFiringsPerHour.
|
||||
/// targetFiringsPerHour <= 0 means the skill never fires.
|
||||
/// </summary>
|
||||
public static bool IsOffCooldown(float lastFired, float now, float targetPerHour)
|
||||
{
|
||||
if (targetPerHour <= 0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var minInterval = 3600f / targetPerHour;
|
||||
return now - lastFired >= minInterval;
|
||||
}
|
||||
|
||||
bool NodeExists(string nodeName)
|
||||
{
|
||||
if (dialogueRunner.Dialogue != null && dialogueRunner.Dialogue.NodeExists(nodeName))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace NightclubArcadia.Skills
|
||||
{
|
||||
public enum ClueYield
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98fef2c3069c3479884778df838f7dc2
|
||||
@@ -1,20 +1,29 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Skills
|
||||
{
|
||||
public enum DcBand
|
||||
{
|
||||
Trivial = 5,
|
||||
Easy = 8,
|
||||
Standard = 11,
|
||||
Trivial = 8,
|
||||
Routine = 11,
|
||||
Hard = 14,
|
||||
Formidable = 17,
|
||||
Legendary = 20,
|
||||
Specialist = 17,
|
||||
BuildDefining = 20,
|
||||
}
|
||||
|
||||
public static class DcBands
|
||||
{
|
||||
static readonly int[] BandValues =
|
||||
{
|
||||
(int)DcBand.Trivial,
|
||||
(int)DcBand.Routine,
|
||||
(int)DcBand.Hard,
|
||||
(int)DcBand.Specialist,
|
||||
(int)DcBand.BuildDefining,
|
||||
};
|
||||
|
||||
public static bool TryParse(string input, out int dc)
|
||||
{
|
||||
dc = 0;
|
||||
@@ -28,6 +37,13 @@ namespace NightclubArcadia.Skills
|
||||
if (int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
dc = numeric;
|
||||
if (!IsBandValue(numeric))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[DcBands] DC {numeric} is not a named band " +
|
||||
$"(8 / 11 / 14 / 17 / 20). Prefer band names.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -39,5 +55,18 @@ namespace NightclubArcadia.Skills
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsBandValue(int dc)
|
||||
{
|
||||
for (var i = 0; i < BandValues.Length; i++)
|
||||
{
|
||||
if (BandValues[i] == dc)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
namespace NightclubArcadia.Skills
|
||||
{
|
||||
public enum SkillDomain
|
||||
public enum SkillAxis
|
||||
{
|
||||
Reason,
|
||||
Instinct,
|
||||
Presence,
|
||||
Body,
|
||||
Social,
|
||||
Self,
|
||||
Specialist,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8cafa1b8c20645ea81912844473c92e
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Skills
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON mirror of the Skill Bible. Deserialized by SkillSystemSetup; also the
|
||||
/// EditorSet DTO for SkillDefinition. Enum fields are strings in JSON.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public sealed class SkillBibleRoot
|
||||
{
|
||||
public SkillBibleEntry[] skills;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SkillBibleEntry
|
||||
{
|
||||
public string id;
|
||||
public string displayName;
|
||||
public string axis;
|
||||
public string domain;
|
||||
public string notes;
|
||||
public float targetFiringsPerHour;
|
||||
public string opposedTo;
|
||||
public string[] alliedWith;
|
||||
public string primaryChannel;
|
||||
public string secondaryChannel;
|
||||
public string clueYield;
|
||||
public int startingRank;
|
||||
public Color accentColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47ea723e59b8c45f688a38c9b4ede3a5
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Skills
|
||||
@@ -7,6 +8,8 @@ namespace NightclubArcadia.Skills
|
||||
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Database", fileName = "SkillDatabase")]
|
||||
public sealed class SkillDatabase : ScriptableObject
|
||||
{
|
||||
static readonly Regex IdPattern = new Regex(@"^[a-z][a-z0-9_]*$", RegexOptions.Compiled);
|
||||
|
||||
[SerializeField] List<SkillDefinition> skills = new List<SkillDefinition>();
|
||||
|
||||
Dictionary<string, SkillDefinition> _lookup;
|
||||
@@ -58,6 +61,11 @@ namespace NightclubArcadia.Skills
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var byId = new Dictionary<string, SkillDefinition>(StringComparer.OrdinalIgnoreCase);
|
||||
var axisCounts = new Dictionary<SkillAxis, int>();
|
||||
var channelCounts = new Dictionary<TraceChannel, int>();
|
||||
var firingSum = 0f;
|
||||
|
||||
foreach (var skill in skills)
|
||||
{
|
||||
if (skill == null)
|
||||
@@ -72,10 +80,127 @@ namespace NightclubArcadia.Skills
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IdPattern.IsMatch(skill.Id))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[{nameof(SkillDatabase)}] Skill Id '{skill.Id}' must match ^[a-z][a-z0-9_]*$.",
|
||||
this);
|
||||
}
|
||||
|
||||
if (!seen.Add(skill.Id))
|
||||
{
|
||||
Debug.LogError($"[{nameof(SkillDatabase)}] Duplicate skill Id '{skill.Id}'.", this);
|
||||
}
|
||||
else
|
||||
{
|
||||
byId[skill.Id] = skill;
|
||||
}
|
||||
|
||||
if (skill.TargetFiringsPerHour <= 0f)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[{nameof(SkillDatabase)}] Skill '{skill.Id}' has targetFiringsPerHour <= 0.",
|
||||
this);
|
||||
}
|
||||
else
|
||||
{
|
||||
firingSum += skill.TargetFiringsPerHour;
|
||||
}
|
||||
|
||||
if (!axisCounts.ContainsKey(skill.Axis))
|
||||
{
|
||||
axisCounts[skill.Axis] = 0;
|
||||
}
|
||||
|
||||
axisCounts[skill.Axis]++;
|
||||
|
||||
if (!channelCounts.ContainsKey(skill.PrimaryChannel))
|
||||
{
|
||||
channelCounts[skill.PrimaryChannel] = 0;
|
||||
}
|
||||
|
||||
channelCounts[skill.PrimaryChannel]++;
|
||||
}
|
||||
|
||||
foreach (var skill in byId.Values)
|
||||
{
|
||||
ValidateReference(skill.OpposedTo, skill.Id, "opposedTo", byId);
|
||||
if (skill.AlliedWith != null)
|
||||
{
|
||||
foreach (var ally in skill.AlliedWith)
|
||||
{
|
||||
ValidateReference(ally, skill.Id, "alliedWith", byId);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(skill.OpposedTo) || !byId.TryGetValue(skill.OpposedTo, out var opposed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Symmetry required only when both skills are non-Specialist.
|
||||
if (skill.Axis == SkillAxis.Specialist || opposed.Axis == SkillAxis.Specialist)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(opposed.OpposedTo, skill.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[{nameof(SkillDatabase)}] opposedTo asymmetry: '{skill.Id}' → '{skill.OpposedTo}' " +
|
||||
$"but '{opposed.Id}' → '{opposed.OpposedTo}'.",
|
||||
this);
|
||||
}
|
||||
}
|
||||
|
||||
AssertAxisCount(axisCounts, SkillAxis.Reason, 2);
|
||||
AssertAxisCount(axisCounts, SkillAxis.Body, 2);
|
||||
AssertAxisCount(axisCounts, SkillAxis.Social, 2);
|
||||
AssertAxisCount(axisCounts, SkillAxis.Self, 2);
|
||||
AssertAxisCount(axisCounts, SkillAxis.Specialist, 3);
|
||||
|
||||
Debug.Log(
|
||||
$"[{nameof(SkillDatabase)}] targetFiringsPerHour sum = {firingSum} (Bible expects 71).",
|
||||
this);
|
||||
|
||||
channelCounts.TryGetValue(TraceChannel.Physical, out var physical);
|
||||
channelCounts.TryGetValue(TraceChannel.Testimonial, out var testimonial);
|
||||
channelCounts.TryGetValue(TraceChannel.Behavioural, out var behavioural);
|
||||
channelCounts.TryGetValue(TraceChannel.Documentary, out var documentary);
|
||||
Debug.Log(
|
||||
$"[{nameof(SkillDatabase)}] primary-channel tally: physical {physical} / " +
|
||||
$"testimonial {testimonial} / behavioural {behavioural} / documentary {documentary} " +
|
||||
$"(Bible expects 3 / 3 / 3 / 2).",
|
||||
this);
|
||||
}
|
||||
|
||||
void ValidateReference(
|
||||
string refId,
|
||||
string ownerId,
|
||||
string fieldName,
|
||||
Dictionary<string, SkillDefinition> byId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(refId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!byId.ContainsKey(refId))
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[{nameof(SkillDatabase)}] Skill '{ownerId}' {fieldName} '{refId}' is not in this database.",
|
||||
this);
|
||||
}
|
||||
}
|
||||
|
||||
void AssertAxisCount(Dictionary<SkillAxis, int> counts, SkillAxis axis, int expected)
|
||||
{
|
||||
counts.TryGetValue(axis, out var actual);
|
||||
if (actual != expected)
|
||||
{
|
||||
Debug.LogError(
|
||||
$"[{nameof(SkillDatabase)}] Axis {axis} has {actual} skills; expected {expected}.",
|
||||
this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,77 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NightclubArcadia.Skills
|
||||
{
|
||||
// Generated from Assets/Skills/skill_bible.json by SkillSystemSetup.
|
||||
// Hand-edits to .asset files are overwritten on script reload — edit the JSON.
|
||||
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Definition", fileName = "NewSkill")]
|
||||
public sealed class SkillDefinition : ScriptableObject
|
||||
{
|
||||
[SerializeField] string id;
|
||||
[SerializeField] string displayName;
|
||||
[SerializeField] SkillDomain domain;
|
||||
[TextArea] [SerializeField] string description;
|
||||
[SerializeField] SkillAxis axis;
|
||||
[SerializeField] string domain;
|
||||
[TextArea(8, 24)] [SerializeField] string notes;
|
||||
[SerializeField] float targetFiringsPerHour = 5f;
|
||||
[SerializeField] string opposedTo;
|
||||
[SerializeField] string[] alliedWith;
|
||||
[SerializeField] TraceChannel primaryChannel;
|
||||
[SerializeField] TraceChannel secondaryChannel;
|
||||
[SerializeField] ClueYield clueYield;
|
||||
[SerializeField] int startingRank;
|
||||
[SerializeField] Color accentColor = Color.white;
|
||||
|
||||
public string Id => id;
|
||||
public string DisplayName => displayName;
|
||||
public SkillDomain Domain => domain;
|
||||
public string Description => description;
|
||||
public SkillAxis Axis => axis;
|
||||
public string Domain => domain;
|
||||
public string Notes => notes;
|
||||
public float TargetFiringsPerHour => targetFiringsPerHour;
|
||||
public string OpposedTo => opposedTo;
|
||||
public string[] AlliedWith => alliedWith;
|
||||
public TraceChannel PrimaryChannel => primaryChannel;
|
||||
public TraceChannel SecondaryChannel => secondaryChannel;
|
||||
public ClueYield ClueYield => clueYield;
|
||||
public int StartingRank => startingRank;
|
||||
public Color AccentColor => accentColor;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public void EditorSet(
|
||||
string newId,
|
||||
string newDisplayName,
|
||||
SkillDomain newDomain,
|
||||
string newDescription,
|
||||
int newStartingRank,
|
||||
Color newAccentColor)
|
||||
public void EditorSet(SkillBibleEntry entry)
|
||||
{
|
||||
id = newId;
|
||||
displayName = newDisplayName;
|
||||
domain = newDomain;
|
||||
description = newDescription;
|
||||
startingRank = newStartingRank;
|
||||
accentColor = newAccentColor;
|
||||
if (entry == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
id = entry.id;
|
||||
displayName = entry.displayName;
|
||||
axis = ParseEnumRequired<SkillAxis>(entry.axis, nameof(entry.axis), entry.id);
|
||||
domain = entry.domain ?? string.Empty;
|
||||
notes = entry.notes ?? string.Empty;
|
||||
targetFiringsPerHour = entry.targetFiringsPerHour;
|
||||
opposedTo = entry.opposedTo ?? string.Empty;
|
||||
alliedWith = entry.alliedWith ?? Array.Empty<string>();
|
||||
primaryChannel = ParseEnumRequired<TraceChannel>(entry.primaryChannel, nameof(entry.primaryChannel), entry.id);
|
||||
secondaryChannel = ParseEnumRequired<TraceChannel>(
|
||||
string.IsNullOrEmpty(entry.secondaryChannel) ? "None" : entry.secondaryChannel,
|
||||
nameof(entry.secondaryChannel),
|
||||
entry.id);
|
||||
clueYield = ParseEnumRequired<ClueYield>(entry.clueYield, nameof(entry.clueYield), entry.id);
|
||||
startingRank = entry.startingRank;
|
||||
accentColor = entry.accentColor;
|
||||
}
|
||||
|
||||
static TEnum ParseEnumRequired<TEnum>(string value, string fieldName, string skillId)
|
||||
where TEnum : struct
|
||||
{
|
||||
if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Skill '{skillId}': invalid {fieldName} '{value}' for {typeof(TEnum).Name}.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0bdf9a02be4b42a4a48a099fe924980e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace NightclubArcadia.Skills
|
||||
{
|
||||
public enum TraceChannel
|
||||
{
|
||||
None,
|
||||
Documentary,
|
||||
Behavioural,
|
||||
Testimonial,
|
||||
Physical,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 94dae2d20cc784f0198944f76cc362ef
|
||||
@@ -6,7 +6,18 @@ namespace NightclubArcadia.Skills
|
||||
{
|
||||
/// <summary>
|
||||
/// Static Yarn adapter. No MonoBehaviour, no check math — translation only.
|
||||
/// Call sites: <<check persuasion standard>> / <<check intimidation hard -1>>
|
||||
///
|
||||
/// Commands:
|
||||
/// <<check skillId band-or-int [situationalMod]>>
|
||||
/// <<skill_mod sourceKey skillId delta>> — set a state/situational mod
|
||||
/// <<clear_skill_mods sourceKey>> — clear one source key
|
||||
/// Function (kept as skill_rank; skeleton's "rank" would collide with future helpers):
|
||||
/// skill_rank("skillId")
|
||||
///
|
||||
/// Modifier convention: source key "scene" is the situational bucket and must be
|
||||
/// cleared on scene exit; any other key (drunk, injured, …) is a state bucket.
|
||||
/// Both feed SkillStateModifiers — three modifier sources total (rank / check arg /
|
||||
/// state), not a fourth dictionary.
|
||||
/// </summary>
|
||||
public static class YarnSkillCommands
|
||||
{
|
||||
@@ -28,8 +39,8 @@ namespace NightclubArcadia.Skills
|
||||
|
||||
if (!DcBands.TryParse(difficulty, out var dc))
|
||||
{
|
||||
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Standard (11).");
|
||||
dc = (int)DcBand.Standard;
|
||||
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Routine (11).");
|
||||
dc = (int)DcBand.Routine;
|
||||
}
|
||||
|
||||
if (runtime.Database == null || !runtime.Database.TryGet(skillId, out _))
|
||||
@@ -49,6 +60,44 @@ namespace NightclubArcadia.Skills
|
||||
yield return runtime.StartCoroutine(runtime.ResultView.Show(result));
|
||||
}
|
||||
|
||||
[YarnCommand("skill_mod")]
|
||||
public static void SkillMod(string sourceKey, string skillId, int delta)
|
||||
{
|
||||
var runtime = SkillRuntime.Instance;
|
||||
if (runtime == null || runtime.States == null)
|
||||
{
|
||||
Debug.LogError("[YarnSkillCommands] <<skill_mod>> called but SkillRuntime is not ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(sourceKey) || string.IsNullOrEmpty(skillId))
|
||||
{
|
||||
Debug.LogError("[YarnSkillCommands] <<skill_mod>> requires sourceKey and skillId.");
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.States.Set(sourceKey, skillId, delta);
|
||||
}
|
||||
|
||||
[YarnCommand("clear_skill_mods")]
|
||||
public static void ClearSkillMods(string sourceKey)
|
||||
{
|
||||
var runtime = SkillRuntime.Instance;
|
||||
if (runtime == null || runtime.States == null)
|
||||
{
|
||||
Debug.LogError("[YarnSkillCommands] <<clear_skill_mods>> called but SkillRuntime is not ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(sourceKey))
|
||||
{
|
||||
Debug.LogError("[YarnSkillCommands] <<clear_skill_mods>> requires a sourceKey.");
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.States.Clear(sourceKey);
|
||||
}
|
||||
|
||||
[YarnFunction("skill_rank")]
|
||||
public static float SkillRank(string skillId)
|
||||
{
|
||||
@@ -87,7 +136,7 @@ namespace NightclubArcadia.Skills
|
||||
DcBands.TryParse(difficulty, out var dc);
|
||||
if (dc == 0)
|
||||
{
|
||||
dc = (int)DcBand.Standard;
|
||||
dc = (int)DcBand.Routine;
|
||||
}
|
||||
|
||||
var storage = runner.VariableStorage;
|
||||
|
||||
Reference in New Issue
Block a user