Add AI parent meetings after a class last lesson.
Class teachers rarely keep the homeroom; invited parents walk onto the map, opinions nudge, then parents leave OffCampus. No player schedule API.
This commit is contained in:
@@ -158,6 +158,11 @@ internal static class ActivitySystem
|
||||
DirectorSummonSystem.CompleteHearing(school, identity.Id);
|
||||
}
|
||||
|
||||
if (IsParentMeeting(action.DefName))
|
||||
{
|
||||
ParentMeetingSystem.CompleteMeeting(school, identity.Id);
|
||||
}
|
||||
|
||||
activity = PersonActivity.Idle;
|
||||
completed.Add(identity.Id);
|
||||
});
|
||||
@@ -190,6 +195,10 @@ internal static class ActivitySystem
|
||||
actionId is not null
|
||||
&& actionId.Equals(DirectorSummons.HearingAction, StringComparison.Ordinal);
|
||||
|
||||
internal static bool IsParentMeeting(string? actionId) =>
|
||||
actionId is not null
|
||||
&& actionId.Equals(ParentMeetings.MeetingAction, StringComparison.Ordinal);
|
||||
|
||||
private static int Occupied(School school, string nodeId, string thing)
|
||||
{
|
||||
var occupied = 0;
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Ai;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Rare AI parent meetings after a class's last lesson. Only the school worker mutates
|
||||
/// <see cref="School.ActiveParentMeetings"/>.
|
||||
/// </summary>
|
||||
internal static class ParentMeetingSystem
|
||||
{
|
||||
private static readonly QueryDescription PresenceQuery =
|
||||
new QueryDescription().WithAll<PersonIdentity, Presence, PersonActivity, Intent>();
|
||||
|
||||
public static bool IsInMeeting(School school, string personId) =>
|
||||
school.ActiveParentMeetings.Any(session =>
|
||||
session.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||
|| session.AttendeeIds.Contains(personId));
|
||||
|
||||
public static bool TryDutyRoom(School school, string personId, out string roomId)
|
||||
{
|
||||
roomId = null!;
|
||||
var session = school.ActiveParentMeetings.FirstOrDefault(row =>
|
||||
row.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||
|| row.AttendeeIds.Contains(personId));
|
||||
if (session is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
roomId = session.HomeroomId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Apply(School school)
|
||||
{
|
||||
if (school.Roster is null || school.Map is null || school.Catalog is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryStartMeetings(school);
|
||||
foreach (var session in school.ActiveParentMeetings.ToArray())
|
||||
{
|
||||
TryStartMeetingAction(school, session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start a meeting for one class when rarity and attendance pass. Used by tests with chance 1.
|
||||
/// </summary>
|
||||
public static bool TryStart(
|
||||
School school,
|
||||
string classId,
|
||||
float chance,
|
||||
float fraction)
|
||||
{
|
||||
if (school.Roster is null || school.Catalog is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var schoolClass = school.Roster.Classes.FirstOrDefault(row =>
|
||||
row.Id.Equals(classId, StringComparison.Ordinal));
|
||||
if (schoolClass?.ClassTeacherId is null
|
||||
|| string.IsNullOrWhiteSpace(schoolClass.RoomId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (school.ActiveParentMeetings.Any(row =>
|
||||
row.ClassId.Equals(classId, StringComparison.Ordinal)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ParentMeetings.IsAfterLastLesson(
|
||||
school.Catalog,
|
||||
school.Timetable,
|
||||
schoolClass,
|
||||
school.Clock.Time,
|
||||
school.SchoolWeekDays))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsOnCampus(school, schoolClass.ClassTeacherId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
|
||||
school.ParentMeetingRolledToday.Add(schoolClass.Id);
|
||||
var seed = Seed.Mix(school.PeopleSeed, schoolClass.Id, dayNumber, Seed.MeetingSalt);
|
||||
if (!ParentMeetings.RollMeeting(chance, seed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var attendees = ParentMeetings.SelectAttendees(
|
||||
school.Roster,
|
||||
schoolClass,
|
||||
fraction,
|
||||
school.PeopleSeed,
|
||||
dayNumber);
|
||||
if (attendees.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BeginSession(school, schoolClass, attendees);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void ClearAll(School school)
|
||||
{
|
||||
school.ParentMeetingRolledToday.Clear();
|
||||
if (school.ActiveParentMeetings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ids = school.ActiveParentMeetings
|
||||
.SelectMany(session => session.AttendeeIds.Append(session.TeacherId))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
school.ActiveParentMeetings.Clear();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
ForceOffCampusIfParentOnly(school, id);
|
||||
PresenceSystem.Enqueue(school, id);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CompleteMeeting(School school, string teacherId)
|
||||
{
|
||||
var session = school.ActiveParentMeetings.FirstOrDefault(row =>
|
||||
row.TeacherId.Equals(teacherId, StringComparison.Ordinal) && row.InProgress);
|
||||
if (session is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyOpinionShifts(school, session);
|
||||
school.ActiveParentMeetings.Remove(session);
|
||||
foreach (var parentId in session.AttendeeIds)
|
||||
{
|
||||
SendHome(school, parentId);
|
||||
PresenceSystem.Enqueue(school, parentId);
|
||||
}
|
||||
|
||||
PresenceSystem.Enqueue(school, session.TeacherId);
|
||||
}
|
||||
|
||||
private static void TryStartMeetings(School school)
|
||||
{
|
||||
var roster = school.Roster!;
|
||||
var catalog = school.Catalog!;
|
||||
var rules = catalog.BehaviorRules;
|
||||
var chance = rules?.ParentMeetingChance ?? 0.08f;
|
||||
var fraction = rules?.ParentMeetingAttendanceFraction ?? 0.4f;
|
||||
var dayNumber = DateOnly.FromDateTime(school.Clock.Time).DayNumber;
|
||||
|
||||
foreach (var schoolClass in roster.Classes.OrderBy(row => row.Id, StringComparer.Ordinal))
|
||||
{
|
||||
if (schoolClass.ClassTeacherId is null
|
||||
|| string.IsNullOrWhiteSpace(schoolClass.RoomId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (school.ParentMeetingRolledToday.Contains(schoolClass.Id)
|
||||
|| school.ActiveParentMeetings.Any(row =>
|
||||
row.ClassId.Equals(schoolClass.Id, StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ParentMeetings.IsAfterLastLesson(
|
||||
catalog,
|
||||
school.Timetable,
|
||||
schoolClass,
|
||||
school.Clock.Time,
|
||||
school.SchoolWeekDays))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsOnCampus(school, schoolClass.ClassTeacherId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
school.ParentMeetingRolledToday.Add(schoolClass.Id);
|
||||
var seed = Seed.Mix(school.PeopleSeed, schoolClass.Id, dayNumber, Seed.MeetingSalt);
|
||||
if (!ParentMeetings.RollMeeting(chance, seed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attendees = ParentMeetings.SelectAttendees(
|
||||
roster,
|
||||
schoolClass,
|
||||
fraction,
|
||||
school.PeopleSeed,
|
||||
dayNumber);
|
||||
if (attendees.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BeginSession(school, schoolClass, attendees);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BeginSession(
|
||||
School school,
|
||||
SchoolClass schoolClass,
|
||||
IReadOnlyList<string> attendees)
|
||||
{
|
||||
var session = new ParentMeetingSession(
|
||||
schoolClass.Id,
|
||||
schoolClass.ClassTeacherId!,
|
||||
schoolClass.RoomId,
|
||||
attendees);
|
||||
school.ActiveParentMeetings.Add(session);
|
||||
PresenceSystem.Enqueue(school, session.TeacherId);
|
||||
foreach (var parentId in attendees)
|
||||
{
|
||||
PresenceSystem.Enqueue(school, parentId);
|
||||
}
|
||||
|
||||
school.RaiseWorldEvent(new WorldEvent(EventTriggers.ParentMeeting, session.TeacherId));
|
||||
}
|
||||
|
||||
private static void TryStartMeetingAction(School school, ParentMeetingSession session)
|
||||
{
|
||||
if (session.InProgress || school.Catalog is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIdleInRoom(school, session.TeacherId, session.HomeroomId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// At least one invited parent present and idle — teacher-parents already on campus count.
|
||||
var anyParentHere = session.AttendeeIds.Any(id => IsIdleInRoom(school, id, session.HomeroomId));
|
||||
if (!anyParentHere)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!school.Catalog.Actions.TryGetValue(ParentMeetings.MeetingAction, out var action)
|
||||
|| action.Abstract)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var minutes = school.Catalog.BehaviorRules?.ParentMeetingMinutes ?? action.Minutes;
|
||||
if (minutes <= 0f)
|
||||
{
|
||||
minutes = action.Minutes;
|
||||
}
|
||||
|
||||
var started = false;
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
if (started || !identity.Id.Equals(session.TeacherId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity = new PersonActivity(ParentMeetings.MeetingAction, null, minutes);
|
||||
intent = Intent.None;
|
||||
session.InProgress = true;
|
||||
started = true;
|
||||
});
|
||||
}
|
||||
|
||||
private static void ApplyOpinionShifts(School school, ParentMeetingSession session)
|
||||
{
|
||||
var roster = school.Roster!;
|
||||
var rules = school.Catalog?.BehaviorRules;
|
||||
if (rules is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var shift = rules.ParentMeetingOpinionShift;
|
||||
if (shift == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var teacher = roster.People.FirstOrDefault(row =>
|
||||
row.Id.Equals(session.TeacherId, StringComparison.Ordinal));
|
||||
if (teacher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var parentId in session.AttendeeIds.OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
var parent = roster.People.FirstOrDefault(row =>
|
||||
row.Id.Equals(parentId, StringComparison.Ordinal));
|
||||
if (parent is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parentOfTeacher = OpinionStore.Get(parent, session.TeacherId) ?? 0;
|
||||
OpinionStore.Set(parent, session.TeacherId, parentOfTeacher + shift);
|
||||
var teacherOfParent = OpinionStore.Get(teacher, parentId) ?? 0;
|
||||
OpinionStore.Set(teacher, parentId, teacherOfParent + shift);
|
||||
}
|
||||
|
||||
school.RosterTalkDirty = true;
|
||||
}
|
||||
|
||||
private static void SendHome(School school, string personId)
|
||||
{
|
||||
if (school.Walks is null)
|
||||
{
|
||||
ForceOffCampusIfParentOnly(school, personId);
|
||||
return;
|
||||
}
|
||||
|
||||
var person = school.Roster?.People.FirstOrDefault(row =>
|
||||
row.Id.Equals(personId, StringComparison.Ordinal));
|
||||
// Staff who are also parents stay for the rest of their work day.
|
||||
if (person is { IsStaff: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
presence = Presence.OffCampus;
|
||||
return;
|
||||
}
|
||||
|
||||
presence = PresenceStepper.StartWalk(
|
||||
presence,
|
||||
school.Walks,
|
||||
school.Walks.TerritoryId,
|
||||
headingHome: true);
|
||||
});
|
||||
}
|
||||
|
||||
private static void ForceOffCampusIfParentOnly(School school, string personId)
|
||||
{
|
||||
var person = school.Roster?.People.FirstOrDefault(row =>
|
||||
row.Id.Equals(personId, StringComparison.Ordinal));
|
||||
if (person is { IsStaff: true } || person is { IsStudent: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent intent) =>
|
||||
{
|
||||
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
presence = Presence.OffCampus;
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsOnCampus(School school, string personId)
|
||||
{
|
||||
var onCampus = false;
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity _, ref Intent _) =>
|
||||
{
|
||||
if (identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
onCampus = presence.IsOnCampus;
|
||||
}
|
||||
});
|
||||
return onCampus;
|
||||
}
|
||||
|
||||
private static bool IsIdleInRoom(School school, string personId, string roomId)
|
||||
{
|
||||
var found = false;
|
||||
school.World.Query(
|
||||
in PresenceQuery,
|
||||
(ref PersonIdentity identity, ref Presence presence, ref PersonActivity activity, ref Intent _) =>
|
||||
{
|
||||
if (!identity.Id.Equals(personId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var walking = presence.Path.Length > 0 || presence.RemainingMinutes > 0
|
||||
|| (presence.DestinationId is not null
|
||||
&& !presence.DestinationId.Equals(presence.NodeId, StringComparison.Ordinal));
|
||||
found = !activity.IsActive
|
||||
&& !walking
|
||||
&& presence.NodeId is not null
|
||||
&& presence.NodeId.Equals(roomId, StringComparison.Ordinal);
|
||||
});
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One active parent meeting for a class.</summary>
|
||||
internal sealed class ParentMeetingSession(
|
||||
string classId,
|
||||
string teacherId,
|
||||
string homeroomId,
|
||||
IReadOnlyList<string> attendeeIds)
|
||||
{
|
||||
public string ClassId { get; } = classId;
|
||||
|
||||
public string TeacherId { get; } = teacherId;
|
||||
|
||||
public string HomeroomId { get; } = homeroomId;
|
||||
|
||||
public IReadOnlyList<string> AttendeeIds { get; } = attendeeIds;
|
||||
|
||||
public bool InProgress { get; set; }
|
||||
}
|
||||
@@ -331,7 +331,8 @@ internal static class PresenceSystem
|
||||
var bound = Duty.IsOtherStaff(person)
|
||||
? slot.Kind != DaySlotKind.Outside
|
||||
: slot.Kind == DaySlotKind.Lesson && lessons.Any(lesson => lesson.Period == slot.Index);
|
||||
if (DirectorSummonSystem.IsSummoned(school, person.Id))
|
||||
if (DirectorSummonSystem.IsSummoned(school, person.Id)
|
||||
|| ParentMeetingSystem.IsInMeeting(school, person.Id))
|
||||
{
|
||||
bound = true;
|
||||
}
|
||||
@@ -352,9 +353,24 @@ internal static class PresenceSystem
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activity.IsActive && ActivitySystem.IsParentMeeting(activity.ActionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!presence.IsOnCampus)
|
||||
{
|
||||
intent = Intent.None;
|
||||
if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingArrive))
|
||||
{
|
||||
presence = PresenceStepper.StartWalk(
|
||||
Presence.OffCampus,
|
||||
walks,
|
||||
meetingArrive,
|
||||
headingHome: false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.AppearAt is { } appear && now >= appear && (plan.WalkHomeAt is null || now < plan.WalkHomeAt))
|
||||
{
|
||||
var dest = plan.FirstRoom ?? Duty.RoomAt(
|
||||
@@ -379,7 +395,10 @@ internal static class PresenceSystem
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.WalkHomeAt is { } leave && now >= leave && !DirectorSummonSystem.IsSummoned(school, person.Id))
|
||||
if (plan.WalkHomeAt is { } leave
|
||||
&& now >= leave
|
||||
&& !DirectorSummonSystem.IsSummoned(school, person.Id)
|
||||
&& !ParentMeetingSystem.IsInMeeting(school, person.Id))
|
||||
{
|
||||
activity = PersonActivity.Idle;
|
||||
intent = Intent.None;
|
||||
@@ -399,6 +418,11 @@ internal static class PresenceSystem
|
||||
duty = summonRoom;
|
||||
bound = true;
|
||||
}
|
||||
else if (ParentMeetingSystem.TryDutyRoom(school, person.Id, out var meetingRoom))
|
||||
{
|
||||
duty = meetingRoom;
|
||||
bound = true;
|
||||
}
|
||||
|
||||
if (duty is null)
|
||||
{
|
||||
|
||||
@@ -153,6 +153,14 @@ public sealed class School : IDisposable
|
||||
|
||||
internal int NextSummonOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active parent meetings. Worker thread only — never HTTP. Cleared on morning / skip.
|
||||
/// </summary>
|
||||
internal List<ParentMeetingSession> ActiveParentMeetings { get; } = [];
|
||||
|
||||
/// <summary>Class ids that already rolled a meeting chance today (pass or fail).</summary>
|
||||
internal HashSet<string> ParentMeetingRolledToday { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// A finished or interrupted circle wrote opinions onto the roster. <see cref="Tick"/>
|
||||
/// returns this so the worker persists <c>people.json</c> — same seam as morning dress.
|
||||
@@ -314,6 +322,7 @@ public sealed class School : IDisposable
|
||||
Clock.JumpTo(next.Value);
|
||||
TalkCircleSystem.AbandonAll(this);
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
ParentMeetingSystem.ClearAll(this);
|
||||
ResetDayLog();
|
||||
var peopleChanged = TryYearlyIntake(before, next.Value);
|
||||
peopleChanged |= TryApplicantRefresh();
|
||||
@@ -388,6 +397,7 @@ public sealed class School : IDisposable
|
||||
ResetDayLog();
|
||||
// Hung summons do not survive the work morning — same clear as skip empty.
|
||||
DirectorSummonSystem.ClearAll(this);
|
||||
ParentMeetingSystem.ClearAll(this);
|
||||
}
|
||||
|
||||
peopleChanged = TryYearlyIntake(before, Clock.Time);
|
||||
@@ -445,6 +455,29 @@ public sealed class School : IDisposable
|
||||
public string? DirectorSummonPresenceActionId(string personId) =>
|
||||
DirectorSummonSystem.PresenceActionId(DirectorSummonPresencePhase(personId));
|
||||
|
||||
/// <summary>
|
||||
/// Card label while walking to / sitting in a parent meeting without a live activity yet.
|
||||
/// </summary>
|
||||
public string? ParentMeetingPresenceActionId(string personId)
|
||||
{
|
||||
if (!ParentMeetingSystem.IsInMeeting(this, personId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var session = ActiveParentMeetings.FirstOrDefault(row =>
|
||||
row.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||
|| row.AttendeeIds.Contains(personId));
|
||||
if (session is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return session.InProgress && session.TeacherId.Equals(personId, StringComparison.Ordinal)
|
||||
? HSchool.Ai.ParentMeetings.MeetingAction
|
||||
: HSchool.Ai.ParentMeetings.GoingAction;
|
||||
}
|
||||
|
||||
private void RecordWorldEvents(DateTime before, DateTime after)
|
||||
{
|
||||
_worldEvents.AddRange(EventSystem.Detect(this, before, after));
|
||||
@@ -469,6 +502,7 @@ public sealed class School : IDisposable
|
||||
}
|
||||
|
||||
DirectorSummonSystem.Apply(this);
|
||||
ParentMeetingSystem.Apply(this);
|
||||
|
||||
PersonDayLog.Sync(this);
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ internal static class TalkCircleSystem
|
||||
|
||||
school.ApologyDebts.Clear();
|
||||
DirectorSummonSystem.ClearAll(school);
|
||||
ParentMeetingSystem.ClearAll(school);
|
||||
school.World.Query(in People, (ref PersonActivity activity) =>
|
||||
{
|
||||
if (TalkActions.IsConflict(activity.ActionId))
|
||||
|
||||
Reference in New Issue
Block a user