From f62a906389795fd142826524e69677ea6be32227 Mon Sep 17 00:00:00 2001 From: Ende Date: Fri, 11 Sep 2026 16:53:49 +0200 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=B0=D1=81=D1=82=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=81=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20ID-=D0=BA=D0=B0=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/_Fish/Access/IdCardReaderTest.cs | 429 ++++++++++++++++++ .../Access/ActiveIdCardReaderComponent.cs | 18 + .../_Fish/Access/IdCardReaderSystem.cs | 407 +++++++++++++++++ .../_Fish/Access/IdCardReaderComponent.cs | 96 ++++ .../SharedDeviceLinkSystem.IdCardReader.cs | 33 ++ .../structures/wallmounts/id_card_reader.ftl | 47 ++ .../en-US/_strings/_fish/id_card_reader.ftl | 22 + .../structures/wallmounts/id_card_reader.ftl | 47 ++ .../ru-RU/_strings/_fish/id_card_reader.ftl | 22 + .../DeviceLinking/id_card_reader_ports.yml | 39 ++ .../Structures/Wallmounts/id_card_reader.yml | 148 ++++++ .../Wallmounts/id_card_reader.rsi/base.png | Bin 0 -> 482 bytes .../Wallmounts/id_card_reader.rsi/meta.json | 15 + 13 files changed, 1323 insertions(+) create mode 100644 Content.IntegrationTests/Tests/_Fish/Access/IdCardReaderTest.cs create mode 100644 Content.Server/_Fish/Access/ActiveIdCardReaderComponent.cs create mode 100644 Content.Server/_Fish/Access/IdCardReaderSystem.cs create mode 100644 Content.Shared/_Fish/Access/IdCardReaderComponent.cs create mode 100644 Content.Shared/_Fish/DeviceLinking/SharedDeviceLinkSystem.IdCardReader.cs create mode 100644 Resources/Locale/en-US/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl create mode 100644 Resources/Locale/en-US/_strings/_fish/id_card_reader.ftl create mode 100644 Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl create mode 100644 Resources/Locale/ru-RU/_strings/_fish/id_card_reader.ftl create mode 100644 Resources/Prototypes/_Fish/DeviceLinking/id_card_reader_ports.yml create mode 100644 Resources/Prototypes/_Fish/Entities/Structures/Wallmounts/id_card_reader.yml create mode 100644 Resources/Textures/_Fish/Structures/Wallmounts/id_card_reader.rsi/base.png create mode 100644 Resources/Textures/_Fish/Structures/Wallmounts/id_card_reader.rsi/meta.json diff --git a/Content.IntegrationTests/Tests/_Fish/Access/IdCardReaderTest.cs b/Content.IntegrationTests/Tests/_Fish/Access/IdCardReaderTest.cs new file mode 100644 index 00000000000..c306a1d9c24 --- /dev/null +++ b/Content.IntegrationTests/Tests/_Fish/Access/IdCardReaderTest.cs @@ -0,0 +1,429 @@ +using Content.IntegrationTests.Utility; +using Content.Server._Fish.Access; +using Content.Server.DeviceLinking.Components; +using Content.Server.DeviceLinking.Systems; +using Content.Shared._Fish.Access; +using Content.Shared.Containers.ItemSlots; +using Content.Shared.DeviceLinking; +using Content.Shared.DeviceNetwork; +using Content.Shared.Doors; +using Content.Shared.Doors.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; + +namespace Content.IntegrationTests.Tests._Fish.Access; + +[TestFixture] +[TestOf(typeof(IdCardReaderSystem))] +public sealed class IdCardReaderTest +{ + [TestPrototypes] + private const string Prototypes = """ + - type: entity + id: FishIdCardReaderTestPulseSink + components: + - type: DeviceLinkSink + ports: + - InputA + - type: LogicGate + + - type: entity + id: FishIdCardReaderTestStateSink + parent: BaseLogicItem + components: + - type: DeviceLinkSink + ports: + - InputA + - type: LogicGate + + - type: entity + id: FishIdCardReaderTestDoorSource + parent: BaseLogicItem + components: + - type: DeviceLinkSource + ports: + - DoorStatus + lastSignals: + DoorStatus: false + + - type: entity + id: FishIdCardReaderTestTwoAuthorizations + parent: FishIdCardReaderTwoAuthorizations + components: + - type: IdCardReader + closeRetryDelay: 10 + + - type: entity + id: FishIdCardReaderTestHeadOfSecurityTwoAuthorizations + parent: FishIdCardReaderTwoAuthorizations + components: + - type: AccessReader + access: [["HeadOfSecurity"]] + + - type: entity + id: FishIdCardReaderTestCaptainTwoAuthorizations + parent: FishIdCardReaderTwoAuthorizations + components: + - type: AccessReader + access: [["Captain"]] + """; + + [Test] + public async Task ReaderSignals_WhenCardsInsertedAndRemoved_ShouldMatchAccess() + { + await using var pair = await PoolManager.GetServerClient(); + var server = pair.Server; + var deviceLink = server.System(); + var itemSlots = server.System(); + + EntityUid reader = default; + EntityUid insertedSink = default; + EntityUid removedSink = default; + EntityUid grantedSink = default; + EntityUid passengerInsertedSink = default; + EntityUid passengerGrantedSink = default; + EntityUid captainCard = default; + EntityUid passengerCard = default; + var captainInserted = false; + var captainInsertedSignal = false; + var captainEjected = false; + var captainRemovedSignal = false; + EntityUid? ejectedCard = null; + var passengerInserted = false; + var passengerInsertedSignal = false; + + await server.WaitPost(() => + { + reader = server.EntMan.SpawnEntity("FishIdCardReaderCaptain", MapCoordinates.Nullspace); + insertedSink = server.EntMan.SpawnEntity("FishIdCardReaderTestPulseSink", MapCoordinates.Nullspace); + removedSink = server.EntMan.SpawnEntity("FishIdCardReaderTestPulseSink", MapCoordinates.Nullspace); + grantedSink = server.EntMan.SpawnEntity("FishIdCardReaderTestStateSink", MapCoordinates.Nullspace); + passengerInsertedSink = server.EntMan.SpawnEntity("FishIdCardReaderTestPulseSink", MapCoordinates.Nullspace); + passengerGrantedSink = server.EntMan.SpawnEntity("FishIdCardReaderTestStateSink", MapCoordinates.Nullspace); + captainCard = server.EntMan.SpawnEntity("CaptainIDCard", MapCoordinates.Nullspace); + passengerCard = server.EntMan.SpawnEntity("PassengerIDCard", MapCoordinates.Nullspace); + + var sourceComponent = server.EntMan.GetComponent(reader); + deviceLink.SaveLinks( + null, + reader, + insertedSink, + [("FishIdCardInserted", "InputA")], + sourceComponent, + server.EntMan.GetComponent(insertedSink)); + deviceLink.SaveLinks( + null, + reader, + removedSink, + [("FishIdCardRemoved", "InputA")], + sourceComponent, + server.EntMan.GetComponent(removedSink)); + deviceLink.SaveLinks( + null, + reader, + grantedSink, + [("FishIdCardAccessGranted", "InputA")], + sourceComponent, + server.EntMan.GetComponent(grantedSink)); + + captainInserted = itemSlots.TryInsert( + reader, + IdCardReaderComponent.CardSlotId, + captainCard, + null); + captainInsertedSignal = server.EntMan.GetComponent(insertedSink).StateA != SignalState.Low; + }); + + await pair.RunTicksSync(2); + + await server.WaitAssertion(() => + { + Assert.That(captainInserted, Is.True); + Assert.That(captainInsertedSignal, Is.True); + Assert.That(server.EntMan.GetComponent(grantedSink).StateA, Is.EqualTo(SignalState.High)); + }); + + await server.WaitPost(() => + { + captainEjected = itemSlots.TryEject( + reader, + IdCardReaderComponent.CardSlotId, + null, + out ejectedCard); + captainRemovedSignal = server.EntMan.GetComponent(removedSink).StateA != SignalState.Low; + }); + + await pair.RunTicksSync(2); + + await server.WaitAssertion(() => + { + Assert.That(captainEjected, Is.True); + Assert.That(ejectedCard, Is.EqualTo(captainCard)); + Assert.That(captainRemovedSignal, Is.True); + Assert.That(server.EntMan.GetComponent(grantedSink).StateA, Is.EqualTo(SignalState.Low)); + }); + + await server.WaitPost(() => + { + var sourceComponent = server.EntMan.GetComponent(reader); + deviceLink.SaveLinks( + null, + reader, + passengerInsertedSink, + [("FishIdCardInserted", "InputA")], + sourceComponent, + server.EntMan.GetComponent(passengerInsertedSink)); + deviceLink.SaveLinks( + null, + reader, + passengerGrantedSink, + [("FishIdCardAccessGranted", "InputA")], + sourceComponent, + server.EntMan.GetComponent(passengerGrantedSink)); + + passengerInserted = itemSlots.TryInsert( + reader, + IdCardReaderComponent.CardSlotId, + passengerCard, + null); + passengerInsertedSignal = server.EntMan.GetComponent(passengerInsertedSink).StateA != SignalState.Low; + }); + + await pair.RunTicksSync(2); + + await server.WaitAssertion(() => + { + Assert.That(passengerInserted, Is.True); + Assert.That(passengerInsertedSignal, Is.True); + Assert.That(server.EntMan.GetComponent(passengerGrantedSink).StateA, Is.EqualTo(SignalState.Low)); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task TwoReaders_WhenBothAuthorized_ShouldScheduleCloseAndRetryUntilDoorCloses() + { + await using var pair = await PoolManager.GetServerClient(); + var server = pair.Server; + var deviceLink = server.System(); + var itemSlots = server.System(); + var readerSystem = server.System(); + + EntityUid master = default; + EntityUid secondary = default; + EntityUid grantedSink = default; + EntityUid closeSink = default; + EntityUid doorSource = default; + EntityUid firstCard = default; + EntityUid secondCard = default; + var firstInserted = false; + var secondInserted = false; + + await server.WaitPost(() => + { + master = server.EntMan.SpawnEntity("FishIdCardReaderTestTwoAuthorizations", MapCoordinates.Nullspace); + secondary = server.EntMan.SpawnEntity("FishIdCardReader", MapCoordinates.Nullspace); + grantedSink = server.EntMan.SpawnEntity("FishIdCardReaderTestStateSink", MapCoordinates.Nullspace); + closeSink = server.EntMan.SpawnEntity("FishIdCardReaderTestPulseSink", MapCoordinates.Nullspace); + doorSource = server.EntMan.SpawnEntity("FishIdCardReaderTestDoorSource", MapCoordinates.Nullspace); + firstCard = server.EntMan.SpawnEntity("PassengerIDCard", MapCoordinates.Nullspace); + secondCard = server.EntMan.SpawnEntity("PassengerIDCard", MapCoordinates.Nullspace); + + var masterSource = server.EntMan.GetComponent(master); + var secondarySource = server.EntMan.GetComponent(secondary); + var doorStatusSource = server.EntMan.GetComponent(doorSource); + var masterSink = server.EntMan.GetComponent(master); + + deviceLink.SaveLinks( + null, + secondary, + master, + [("FishIdCardLocalAuthorization", "FishIdCardReaderAuthorization")], + secondarySource, + masterSink); + deviceLink.SaveLinks( + null, + master, + grantedSink, + [("FishIdCardAccessGranted", "InputA")], + masterSource, + server.EntMan.GetComponent(grantedSink)); + deviceLink.SaveLinks( + null, + master, + closeSink, + [("FishIdCardReaderCloseRequest", "InputA")], + masterSource, + server.EntMan.GetComponent(closeSink)); + deviceLink.SaveLinks( + null, + doorSource, + master, + [("DoorStatus", "FishIdCardReaderDoorStatus")], + doorStatusSource, + masterSink); + + deviceLink.SendSignal(doorSource, "DoorStatus", true, doorStatusSource); + firstInserted = itemSlots.TryInsert( + master, + IdCardReaderComponent.CardSlotId, + firstCard, + null); + }); + + await pair.RunTicksSync(2); + + await server.WaitAssertion(() => + { + Assert.That(firstInserted, Is.True); + Assert.That(server.EntMan.GetComponent(master).AuthorizationOutput, Is.False); + Assert.That(server.EntMan.GetComponent(grantedSink).StateA, Is.EqualTo(SignalState.Low)); + }); + + await server.WaitPost(() => + { + secondInserted = itemSlots.TryInsert( + secondary, + IdCardReaderComponent.CardSlotId, + secondCard, + null); + }); + + await pair.RunTicksSync(3); + + await server.WaitAssertion(() => + { + Assert.That(secondInserted, Is.True); + var masterReader = server.EntMan.GetComponent(master); + var secondaryReader = server.EntMan.GetComponent(secondary); + Assert.That(masterReader.LocalAuthorization, Is.True); + Assert.That(secondaryReader.LocalAuthorization, Is.True); + Assert.That(secondaryReader.AuthorizationOutput, Is.True); + Assert.That(masterReader.RemoteAuthorizations, Does.Contain(secondary)); + Assert.That(masterReader.AuthorizationOutput, Is.True); + Assert.That(masterReader.OpenDoors, Does.Contain(doorSource)); + Assert.That(server.EntMan.GetComponent(grantedSink).StateA, Is.EqualTo(SignalState.High)); + + var active = server.EntMan.GetComponent(master); + Assert.That(active.NextCloseAttempt, Is.Not.Null); + Assert.That(active.NextCloseAttempt, Is.GreaterThan(server.Timing.CurTime)); + Assert.That(active.NextCloseAttempt, Is.LessThanOrEqualTo(server.Timing.CurTime + TimeSpan.FromSeconds(4))); + }); + + await server.WaitPost(() => + { + server.EntMan.GetComponent(master).NextCloseAttempt = server.Timing.CurTime; + }); + + await pair.RunTicksSync(1); + + await server.WaitAssertion(() => + { + var active = server.EntMan.GetComponent(master); + Assert.That(active.NextCloseAttempt, Is.GreaterThan(server.Timing.CurTime)); + }); + + var closeRequested = false; + var closeSignalReceived = false; + TimeSpan? nextRetry = null; + TimeSpan retryCheckedAt = default; + + await server.WaitPost(() => + { + var reader = server.EntMan.GetComponent(master); + var active = server.EntMan.GetComponent(master); + active.NextCloseAttempt = server.Timing.CurTime; + + closeRequested = readerSystem.TryRequestClose((master, reader), active); + closeSignalReceived = server.EntMan.GetComponent(closeSink).StateA != SignalState.Low; + nextRetry = active.NextCloseAttempt; + retryCheckedAt = server.Timing.CurTime; + }); + + await server.WaitAssertion(() => + { + Assert.That(closeRequested, Is.True); + Assert.That(closeSignalReceived, Is.True); + Assert.That(nextRetry, Is.GreaterThan(retryCheckedAt)); + }); + + await server.WaitPost(() => deviceLink.SendSignal(doorSource, "DoorStatus", false)); + await pair.RunTicksSync(2); + + await server.WaitAssertion(() => + { + Assert.That(server.EntMan.GetComponent(master).OpenDoors, Is.Empty); + Assert.That(server.EntMan.HasComponent(master), Is.False); + }); + + await pair.CleanReturnAsync(); + } + + [Test] + public async Task TwoReaders_WithHeadOfSecurityAndCaptainAccess_ShouldAuthorize() + { + await using var pair = await PoolManager.GetServerClient(); + var server = pair.Server; + var deviceLink = server.System(); + var itemSlots = server.System(); + + EntityUid master = default; + EntityUid secondary = default; + EntityUid headOfSecurityCard = default; + EntityUid captainCard = default; + EntityUid shutter = default; + + await server.WaitPost(() => + { + master = server.EntMan.SpawnEntity( + "FishIdCardReaderTestHeadOfSecurityTwoAuthorizations", + MapCoordinates.Nullspace); + secondary = server.EntMan.SpawnEntity( + "FishIdCardReaderTestCaptainTwoAuthorizations", + MapCoordinates.Nullspace); + headOfSecurityCard = server.EntMan.SpawnEntity("HoSIDCard", MapCoordinates.Nullspace); + captainCard = server.EntMan.SpawnEntity("CaptainIDCard", MapCoordinates.Nullspace); + shutter = server.EntMan.SpawnEntity("ShuttersNormal", MapCoordinates.Nullspace); + + deviceLink.SaveLinks( + null, + secondary, + master, + [("FishIdCardLocalAuthorization", "FishIdCardReaderAuthorization")], + server.EntMan.GetComponent(secondary), + server.EntMan.GetComponent(master)); + deviceLink.SaveLinks( + null, + master, + shutter, + [("FishIdCardAccessGranted", "Open")], + server.EntMan.GetComponent(master), + server.EntMan.GetComponent(shutter)); + }); + + await pair.RunTicksSync(2); + + await server.WaitPost(() => + { + itemSlots.TryInsert(master, IdCardReaderComponent.CardSlotId, headOfSecurityCard, null); + itemSlots.TryInsert(secondary, IdCardReaderComponent.CardSlotId, captainCard, null); + }); + + await pair.RunTicksSync(3); + + await server.WaitAssertion(() => + { + var masterReader = server.EntMan.GetComponent(master); + var secondaryReader = server.EntMan.GetComponent(secondary); + Assert.That(masterReader.LocalAuthorization, Is.True); + Assert.That(secondaryReader.LocalAuthorization, Is.True); + Assert.That(secondaryReader.AuthorizationOutput, Is.False); + Assert.That(masterReader.RemoteAuthorizations, Does.Contain(secondary)); + Assert.That(masterReader.AuthorizationOutput, Is.True); + Assert.That(server.EntMan.GetComponent(shutter).State, Is.Not.EqualTo(DoorState.Closed)); + }); + + await pair.CleanReturnAsync(); + } +} diff --git a/Content.Server/_Fish/Access/ActiveIdCardReaderComponent.cs b/Content.Server/_Fish/Access/ActiveIdCardReaderComponent.cs new file mode 100644 index 00000000000..5a12cb11857 --- /dev/null +++ b/Content.Server/_Fish/Access/ActiveIdCardReaderComponent.cs @@ -0,0 +1,18 @@ +namespace Content.Server._Fish.Access; + +/// +/// Хранит состояние отложенной обработки считывателя ID-карт. +/// +[RegisterComponent] +public sealed partial class ActiveIdCardReaderComponent : Component +{ + /// + /// Время следующей попытки закрыть подключённый затвор. + /// + public TimeSpan? NextCloseAttempt; + + /// + /// Указывает, что состояние карты и подключённых источников нужно перечитать. + /// + public bool RefreshState; +} diff --git a/Content.Server/_Fish/Access/IdCardReaderSystem.cs b/Content.Server/_Fish/Access/IdCardReaderSystem.cs new file mode 100644 index 00000000000..a3fbfea98e1 --- /dev/null +++ b/Content.Server/_Fish/Access/IdCardReaderSystem.cs @@ -0,0 +1,407 @@ +using Content.Server.DeviceLinking.Systems; +using Content.Shared._Fish.Access; +using Content.Shared.Access.Components; +using Content.Shared.Access.Systems; +using Content.Shared.Containers.ItemSlots; +using Content.Shared.DeviceLinking; +using Content.Shared.DeviceLinking.Events; +using Content.Shared.DeviceNetwork; +using Robust.Shared.Containers; +using Robust.Shared.Timing; + +namespace Content.Server._Fish.Access; + +public sealed class IdCardReaderSystem : EntitySystem +{ + [Dependency] private readonly AccessReaderSystem _accessReader = default!; + [Dependency] private readonly DeviceLinkSystem _deviceLink = default!; + [Dependency] private readonly ItemSlotsSystem _itemSlots = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + private EntityQuery _sourceQuery; + + public override void Initialize() + { + base.Initialize(); + + _sourceQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnStartup); + SubscribeLocalEvent(OnCardInserted); + SubscribeLocalEvent(OnCardRemoved); + SubscribeLocalEvent(OnAccessReaderChanged); + SubscribeLocalEvent(OnSignalReceived); + SubscribeLocalEvent(OnPortDisconnected); + } + + private void OnStartup(Entity ent, ref ComponentStartup args) + { + TryScheduleRefresh(ent); + } + + private void OnCardInserted(Entity ent, ref EntInsertedIntoContainerMessage args) + { + if (args.Container.ID != IdCardReaderComponent.CardSlotId) + return; + + TryReadCard(ent, args.Entity); + } + + private void OnCardRemoved(Entity ent, ref EntRemovedFromContainerMessage args) + { + TryReportCardRemoved(ent, args.Container.ID); + } + + private void OnAccessReaderChanged( + Entity ent, + ref AccessReaderConfigurationChangedEvent args) + { + TryScheduleRefresh(ent); + } + + private void OnSignalReceived(Entity ent, ref SignalReceivedEvent args) + { + TryReceiveSignal(ent, args.Port, args.Trigger, args.Data); + } + + private void OnPortDisconnected(Entity ent, ref PortDisconnectedEvent args) + { + if (args.Port != ent.Comp.AuthorizationPort && args.Port != ent.Comp.DoorStatusPort) + return; + + TryScheduleRefresh(ent); + } + + /// + /// Пытается считать ID-карту и обновить итоговую авторизацию. + /// + public bool TryReadCard(Entity ent, EntityUid card) + { + if (!CanReadCard(ent, card, out var accessReader)) + return false; + + DoReadCard(ent, card, accessReader); + return true; + } + + /// + /// Проверяет, может ли сущность быть считана как ID-карта. + /// + public bool CanReadCard( + Entity ent, + EntityUid card, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out AccessReaderComponent? accessReader) + { + accessReader = null; + + if (!HasComp(card)) + return false; + + return TryComp(ent, out accessReader); + } + + /// + /// Пытается сообщить об извлечении ID-карты из нужного слота. + /// + public bool TryReportCardRemoved(Entity ent, string slotId) + { + if (!CanReportCardRemoved(slotId)) + return false; + + DoReportCardRemoved(ent); + return true; + } + + /// + /// Проверяет, относится ли извлечение к слоту считывателя ID-карт. + /// + public bool CanReportCardRemoved(string slotId) + { + return slotId == IdCardReaderComponent.CardSlotId; + } + + /// + /// Пытается применить устойчивый сигнал другого считывателя или затвора. + /// + public bool TryReceiveSignal( + Entity ent, + string port, + EntityUid? source, + NetworkPayload? data) + { + if (!CanReceiveSignal(ent, port, source, data, out var state)) + return false; + + DoReceiveSignal(ent, port, source!.Value, state); + return true; + } + + /// + /// Проверяет, является ли входной сигнал пригодным устойчивым состоянием. + /// + public bool CanReceiveSignal( + Entity ent, + string port, + EntityUid? source, + NetworkPayload? data, + out SignalState state) + { + state = SignalState.Momentary; + + if (port != ent.Comp.AuthorizationPort && port != ent.Comp.DoorStatusPort) + return false; + + if (source == null || source == ent.Owner || data == null) + return false; + + return data.TryGetValue(DeviceNetworkConstants.LogicState, out state) && state != SignalState.Momentary; + } + + /// + /// Пытается запланировать перечитывание карты и подключённых источников. + /// + public bool TryScheduleRefresh(Entity ent) + { + if (!CanScheduleRefresh(ent)) + return false; + + DoScheduleRefresh(ent); + return true; + } + + /// + /// Проверяет, можно ли запланировать перечитывание состояния. + /// + public bool CanScheduleRefresh(Entity ent) + { + return !Deleted(ent); + } + + /// + /// Пытается перечитать карту и устойчивые состояния подключённых источников. + /// + public bool TryRefreshState(Entity ent) + { + if (!CanRefreshState(ent)) + return false; + + DoRefreshState(ent); + return true; + } + + /// + /// Проверяет наличие компонентов, необходимых для перечитывания состояния. + /// + public bool CanRefreshState(Entity ent) + { + return TryComp(ent, out _); + } + + /// + /// Пытается отправить очередную команду закрытия. + /// + public bool TryRequestClose( + Entity ent, + ActiveIdCardReaderComponent active) + { + if (!CanRequestClose(active)) + return false; + + DoRequestClose(ent, active); + return true; + } + + /// + /// Проверяет, наступило ли время очередной команды закрытия. + /// + public bool CanRequestClose(ActiveIdCardReaderComponent active) + { + return active.NextCloseAttempt != null && active.NextCloseAttempt <= _timing.CurTime; + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var active, out var reader)) + { + if (active.RefreshState) + { + active.RefreshState = false; + TryRefreshState((uid, reader)); + } + + TryRequestClose((uid, reader), active); + TryFinishProcessing(uid, active); + } + } + + private void DoReadCard( + Entity ent, + EntityUid card, + AccessReaderComponent accessReader) + { + _deviceLink.InvokePort(ent, ent.Comp.CardInsertedPort); + SetLocalAuthorization(ent, _accessReader.IsAllowed(card, ent, accessReader)); + UpdateAuthorization(ent); + } + + private void DoReportCardRemoved(Entity ent) + { + _deviceLink.InvokePort(ent, ent.Comp.CardRemovedPort); + SetLocalAuthorization(ent, false); + UpdateAuthorization(ent); + } + + private void DoReceiveSignal( + Entity ent, + string port, + EntityUid source, + SignalState state) + { + var sources = port == ent.Comp.AuthorizationPort + ? ent.Comp.RemoteAuthorizations + : ent.Comp.OpenDoors; + var changed = state == SignalState.High + ? sources.Add(source) + : sources.Remove(source); + + if (!changed) + return; + + if (port == ent.Comp.AuthorizationPort) + { + UpdateAuthorization(ent); + return; + } + + if (ent.Comp.OpenDoors.Count > 0) + ScheduleClose(ent, restart: false); + else + CancelClosing(ent); + } + + private void DoScheduleRefresh(Entity ent) + { + EnsureComp(ent).RefreshState = true; + } + + private void DoRefreshState(Entity ent) + { + var hadOpenDoors = ent.Comp.OpenDoors.Count > 0; + ent.Comp.RemoteAuthorizations.Clear(); + ent.Comp.OpenDoors.Clear(); + + RefreshLocalAuthorization(ent); + RefreshLinkedStates(ent); + UpdateAuthorization(ent); + + if (ent.Comp.OpenDoors.Count > 0) + ScheduleClose(ent, restart: false); + else if (hadOpenDoors) + CancelClosing(ent); + } + + private void DoRequestClose( + Entity ent, + ActiveIdCardReaderComponent active) + { + _deviceLink.InvokePort(ent, ent.Comp.CloseRequestPort); + + if (ent.Comp.OpenDoors.Count == 0) + { + active.NextCloseAttempt = null; + return; + } + + var retryDelay = ent.Comp.CloseRetryDelay > TimeSpan.Zero + ? ent.Comp.CloseRetryDelay + : TimeSpan.FromSeconds(1); + active.NextCloseAttempt = _timing.CurTime + retryDelay; + } + + private void RefreshLocalAuthorization(Entity ent) + { + var authorized = false; + + if (_itemSlots.TryGetSlot(ent, IdCardReaderComponent.CardSlotId, out var slot) && + slot.Item is { } card && + CanReadCard(ent, card, out var accessReader)) + { + authorized = _accessReader.IsAllowed(card, ent, accessReader); + } + + SetLocalAuthorization(ent, authorized); + } + + private void SetLocalAuthorization(Entity ent, bool authorized) + { + ent.Comp.LocalAuthorization = authorized; + _deviceLink.SendSignal(ent, ent.Comp.LocalAuthorizationPort, authorized); + } + + private void RefreshLinkedStates(Entity ent) + { + if (!TryComp(ent, out var sink)) + return; + + foreach (var sourceUid in sink.LinkedSources) + { + if (sourceUid == ent.Owner || !_sourceQuery.TryComp(sourceUid, out var source)) + continue; + + if (_deviceLink.IsSendingHighTo((sourceUid, source), ent, ent.Comp.AuthorizationPort)) + ent.Comp.RemoteAuthorizations.Add(sourceUid); + + if (_deviceLink.IsSendingHighTo((sourceUid, source), ent, ent.Comp.DoorStatusPort)) + ent.Comp.OpenDoors.Add(sourceUid); + } + } + + private void UpdateAuthorization(Entity ent) + { + var authorizationCount = ent.Comp.RemoteAuthorizations.Count; + if (ent.Comp.LocalAuthorization) + authorizationCount++; + + var authorized = authorizationCount >= Math.Max(1, ent.Comp.RequiredAuthorizations); + if (authorized == ent.Comp.AuthorizationOutput) + return; + + ent.Comp.AuthorizationOutput = authorized; + _deviceLink.SendSignal(ent, ent.Comp.AccessGrantedPort, authorized); + + if (authorized) + ScheduleClose(ent, restart: true); + } + + private void ScheduleClose(Entity ent, bool restart) + { + var active = EnsureComp(ent); + if (!restart && active.NextCloseAttempt != null) + return; + + var closeDelay = ent.Comp.CloseDelay > TimeSpan.Zero + ? ent.Comp.CloseDelay + : TimeSpan.Zero; + active.NextCloseAttempt = _timing.CurTime + closeDelay; + } + + private void CancelClosing(Entity ent) + { + if (!TryComp(ent, out var active)) + return; + + active.NextCloseAttempt = null; + TryFinishProcessing(ent, active); + } + + private void TryFinishProcessing(EntityUid uid, ActiveIdCardReaderComponent active) + { + if (active.RefreshState || active.NextCloseAttempt != null) + return; + + RemCompDeferred(uid); + } +} diff --git a/Content.Shared/_Fish/Access/IdCardReaderComponent.cs b/Content.Shared/_Fish/Access/IdCardReaderComponent.cs new file mode 100644 index 00000000000..0c542125c97 --- /dev/null +++ b/Content.Shared/_Fish/Access/IdCardReaderComponent.cs @@ -0,0 +1,96 @@ +using Content.Shared.DeviceLinking; +using Robust.Shared.Prototypes; + +namespace Content.Shared._Fish.Access; + +/// +/// Хранит настройки настенного считывателя ID-карт. +/// +[RegisterComponent] +public sealed partial class IdCardReaderComponent : Component +{ + /// + /// Идентификатор слота для вставляемой ID-карты. + /// + public const string CardSlotId = "IdCardReader-card"; + + /// + /// Порт, вызываемый после вставки ID-карты. + /// + [DataField] + public ProtoId CardInsertedPort = "FishIdCardInserted"; + + /// + /// Порт, вызываемый после извлечения ID-карты. + /// + [DataField] + public ProtoId CardRemovedPort = "FishIdCardRemoved"; + + /// + /// Порт, передающий высокий уровень, пока во вставленной ID-карте есть требуемый доступ. + /// + [DataField] + public ProtoId AccessGrantedPort = "FishIdCardAccessGranted"; + + /// + /// Порт, передающий высокий уровень, пока собственная вставленная карта имеет требуемый доступ. + /// + [DataField] + public ProtoId LocalAuthorizationPort = "FishIdCardLocalAuthorization"; + + /// + /// Порт, вызываемый для закрытия подключённого затвора. + /// + [DataField] + public ProtoId CloseRequestPort = "FishIdCardReaderCloseRequest"; + + /// + /// Порт, принимающий авторизацию от другого считывателя. + /// + [DataField] + public ProtoId AuthorizationPort = "FishIdCardReaderAuthorization"; + + /// + /// Порт обратной связи о состоянии подключённого затвора. + /// + [DataField] + public ProtoId DoorStatusPort = "FishIdCardReaderDoorStatus"; + + /// + /// Количество одновременно подтверждённых считывателей, необходимое для открытия. + /// + [DataField] + public int RequiredAuthorizations = 1; + + /// + /// Задержка перед первой попыткой закрыть затвор после открытия. + /// + [DataField] + public TimeSpan CloseDelay = TimeSpan.FromSeconds(4); + + /// + /// Задержка между повторными попытками закрыть незакрывшийся затвор. + /// + [DataField] + public TimeSpan CloseRetryDelay = TimeSpan.FromSeconds(1); + + /// + /// Указывает, что карта в собственном слоте прошла проверку доступа. + /// + public bool LocalAuthorization; + + /// + /// Считыватели, от которых сейчас получен высокий уровень авторизации. + /// + public HashSet RemoteAuthorizations = []; + + /// + /// Подключённые затворы, сообщающие, что они не закрыты. + /// + public HashSet OpenDoors = []; + + /// + /// Последнее переданное состояние итоговой авторизации. + /// + public bool AuthorizationOutput; +} diff --git a/Content.Shared/_Fish/DeviceLinking/SharedDeviceLinkSystem.IdCardReader.cs b/Content.Shared/_Fish/DeviceLinking/SharedDeviceLinkSystem.IdCardReader.cs new file mode 100644 index 00000000000..14ff9bc5c00 --- /dev/null +++ b/Content.Shared/_Fish/DeviceLinking/SharedDeviceLinkSystem.IdCardReader.cs @@ -0,0 +1,33 @@ +using Robust.Shared.Prototypes; + +#pragma warning disable IDE0130 // Пространство имён соответствует расширяемой системе DeviceLink. +namespace Content.Shared.DeviceLinking; + +public abstract partial class SharedDeviceLinkSystem +{ + /// + /// Проверяет, передаёт ли источник высокий устойчивый сигнал в указанный вход приёмника. + /// + public bool IsSendingHighTo( + Entity source, + EntityUid sink, + ProtoId sinkPort) + { + if (!Resolve(source, ref source.Comp)) + return false; + + if (!source.Comp.LinkedPorts.TryGetValue(sink, out var links)) + return false; + + foreach (var (sourcePort, linkedSinkPort) in links) + { + if (linkedSinkPort != sinkPort) + continue; + + if (source.Comp.LastSignals.TryGetValue(sourcePort, out var high) && high) + return true; + } + + return false; + } +} diff --git a/Resources/Locale/en-US/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl b/Resources/Locale/en-US/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl new file mode 100644 index 00000000000..02f83b2bd94 --- /dev/null +++ b/Resources/Locale/en-US/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl @@ -0,0 +1,47 @@ +ent-FishIdCardReader = ID card reader + .desc = A wall-mounted device that checks inserted ID cards and transmits logic signals. + .suffix = Custom + +ent-FishIdCardReaderTwoAuthorizations = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Custom, 2 authorizations + +ent-FishIdCardReaderThreeAuthorizations = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Custom, 3 authorizations + +ent-FishIdCardReaderCommand = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Command + +ent-FishIdCardReaderCaptain = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Captain + +ent-FishIdCardReaderHeadOfPersonnel = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Head of Personnel + +ent-FishIdCardReaderHeadOfSecurity = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Head of Security + +ent-FishIdCardReaderChiefEngineer = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Chief Engineer + +ent-FishIdCardReaderChiefMedicalOfficer = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Chief Medical Officer + +ent-FishIdCardReaderResearchDirector = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Research Director + +ent-FishIdCardReaderQuartermaster = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Quartermaster + +ent-FishIdCardReaderCentralCommand = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Central Command diff --git a/Resources/Locale/en-US/_strings/_fish/id_card_reader.ftl b/Resources/Locale/en-US/_strings/_fish/id_card_reader.ftl new file mode 100644 index 00000000000..1106d27ba5c --- /dev/null +++ b/Resources/Locale/en-US/_strings/_fish/id_card_reader.ftl @@ -0,0 +1,22 @@ +id-card-reader-slot-name = ID card + +signal-port-name-fish-id-card-inserted = Card inserted +signal-port-description-fish-id-card-inserted = Invoked whenever an ID card is inserted into the reader. + +signal-port-name-fish-id-card-removed = Card removed +signal-port-description-fish-id-card-removed = Invoked whenever an ID card is removed from the reader. + +signal-port-name-fish-id-card-access-granted = Access granted +signal-port-description-fish-id-card-access-granted = High while the configured number of readers are authorized; low otherwise. + +signal-port-name-fish-id-card-local-authorization = Card authorized +signal-port-description-fish-id-card-local-authorization = High while this reader's inserted ID card has the required access. + +signal-port-name-fish-id-card-reader-close-request = Close request +signal-port-description-fish-id-card-reader-close-request = Invoked after the opening delay and repeated while a linked door reports that it is open. + +signal-port-name-fish-id-card-reader-authorization = Reader authorization +signal-port-description-fish-id-card-reader-authorization = Accepts the persistent access-granted state from another ID card reader. + +signal-port-name-fish-id-card-reader-door-status = Door status +signal-port-description-fish-id-card-reader-door-status = Accepts a persistent door-status signal so failed closing attempts can be repeated. diff --git a/Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl b/Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl new file mode 100644 index 00000000000..bac41e1e659 --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/wallmounts/id_card_reader.ftl @@ -0,0 +1,47 @@ +ent-FishIdCardReader = считыватель ID-карт + .desc = Настенное устройство, проверяющее вставленные ID-карты и передающее логические сигналы. + .suffix = Настраиваемый + +ent-FishIdCardReaderTwoAuthorizations = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Настраиваемый, 2 авторизации + +ent-FishIdCardReaderThreeAuthorizations = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Настраиваемый, 3 авторизации + +ent-FishIdCardReaderCommand = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Командование + +ent-FishIdCardReaderCaptain = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Капитан + +ent-FishIdCardReaderHeadOfPersonnel = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Глава персонала + +ent-FishIdCardReaderHeadOfSecurity = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Глава службы безопасности + +ent-FishIdCardReaderChiefEngineer = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Старший инженер + +ent-FishIdCardReaderChiefMedicalOfficer = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Главный врач + +ent-FishIdCardReaderResearchDirector = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Научный руководитель + +ent-FishIdCardReaderQuartermaster = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Квартирмейстер + +ent-FishIdCardReaderCentralCommand = { ent-FishIdCardReader } + .desc = { ent-FishIdCardReader.desc } + .suffix = Центральное командование diff --git a/Resources/Locale/ru-RU/_strings/_fish/id_card_reader.ftl b/Resources/Locale/ru-RU/_strings/_fish/id_card_reader.ftl new file mode 100644 index 00000000000..8a3cfdb6bad --- /dev/null +++ b/Resources/Locale/ru-RU/_strings/_fish/id_card_reader.ftl @@ -0,0 +1,22 @@ +id-card-reader-slot-name = ID-карта + +signal-port-name-fish-id-card-inserted = Карта вставлена +signal-port-description-fish-id-card-inserted = Вызывается при вставке ID-карты в считыватель. + +signal-port-name-fish-id-card-removed = Карта извлечена +signal-port-description-fish-id-card-removed = Вызывается при извлечении ID-карты из считывателя. + +signal-port-name-fish-id-card-access-granted = Доступ подтверждён +signal-port-description-fish-id-card-access-granted = Передаёт высокий уровень, пока настроенное количество считывателей подтверждает доступ; иначе передаёт низкий. + +signal-port-name-fish-id-card-local-authorization = Карта подошла +signal-port-description-fish-id-card-local-authorization = Передаёт высокий уровень, пока собственная вставленная ID-карта имеет требуемый доступ. + +signal-port-name-fish-id-card-reader-close-request = Команда закрытия +signal-port-description-fish-id-card-reader-close-request = Вызывается после задержки открытия и повторяется, пока подключённый затвор сообщает, что он открыт. + +signal-port-name-fish-id-card-reader-authorization = Авторизация считывателя +signal-port-description-fish-id-card-reader-authorization = Принимает устойчивое состояние подтверждения доступа от другого считывателя ID-карт. + +signal-port-name-fish-id-card-reader-door-status = Состояние затвора +signal-port-description-fish-id-card-reader-door-status = Принимает устойчивый сигнал состояния затвора, чтобы повторять неудачные попытки закрытия. diff --git a/Resources/Prototypes/_Fish/DeviceLinking/id_card_reader_ports.yml b/Resources/Prototypes/_Fish/DeviceLinking/id_card_reader_ports.yml new file mode 100644 index 00000000000..8f7cd6c5cbf --- /dev/null +++ b/Resources/Prototypes/_Fish/DeviceLinking/id_card_reader_ports.yml @@ -0,0 +1,39 @@ +- type: sourcePort + id: FishIdCardInserted + name: signal-port-name-fish-id-card-inserted + description: signal-port-description-fish-id-card-inserted + defaultLinks: [ Toggle, Trigger, Timer ] + +- type: sourcePort + id: FishIdCardRemoved + name: signal-port-name-fish-id-card-removed + description: signal-port-description-fish-id-card-removed + defaultLinks: [ Toggle, Trigger, Timer ] + +- type: sourcePort + id: FishIdCardAccessGranted + name: signal-port-name-fish-id-card-access-granted + description: signal-port-description-fish-id-card-access-granted + defaultLinks: [ Open, Toggle, Trigger, Timer ] + +- type: sourcePort + id: FishIdCardLocalAuthorization + name: signal-port-name-fish-id-card-local-authorization + description: signal-port-description-fish-id-card-local-authorization + defaultLinks: [ FishIdCardReaderAuthorization ] + +- type: sourcePort + id: FishIdCardReaderCloseRequest + name: signal-port-name-fish-id-card-reader-close-request + description: signal-port-description-fish-id-card-reader-close-request + defaultLinks: [ Close ] + +- type: sinkPort + id: FishIdCardReaderAuthorization + name: signal-port-name-fish-id-card-reader-authorization + description: signal-port-description-fish-id-card-reader-authorization + +- type: sinkPort + id: FishIdCardReaderDoorStatus + name: signal-port-name-fish-id-card-reader-door-status + description: signal-port-description-fish-id-card-reader-door-status diff --git a/Resources/Prototypes/_Fish/Entities/Structures/Wallmounts/id_card_reader.yml b/Resources/Prototypes/_Fish/Entities/Structures/Wallmounts/id_card_reader.yml new file mode 100644 index 00000000000..1e484c671aa --- /dev/null +++ b/Resources/Prototypes/_Fish/Entities/Structures/Wallmounts/id_card_reader.yml @@ -0,0 +1,148 @@ +- type: entity + parent: BaseWallmountMetallic + id: FishIdCardReader + name: ID card reader + description: A wall-mounted device that checks inserted ID cards and transmits logic signals. + suffix: Custom + components: + - type: WallMount + arc: 360 + - type: Sprite + drawdepth: WallMountedItems + sprite: _Fish/Structures/Wallmounts/id_card_reader.rsi + state: base + - type: IdCardReader + - type: AccessReader + - type: ItemSlots + slots: + IdCardReader-card: + name: id-card-reader-slot-name + insertSound: /Audio/Machines/id_insert.ogg + ejectSound: /Audio/Machines/id_swipe.ogg + ejectOnInteract: true + ejectOnBreak: true + swap: false + whitelist: + components: + - IdCard + - type: ContainerContainer + containers: + IdCardReader-card: !type:ContainerSlot + - type: DeviceNetwork + deviceNetId: Wireless + receiveFrequencyId: BasicDevice + - type: WirelessNetworkConnection + range: 200 + - type: DeviceLinkSource + ports: + - FishIdCardInserted + - FishIdCardRemoved + - FishIdCardAccessGranted + - FishIdCardLocalAuthorization + - FishIdCardReaderCloseRequest + lastSignals: + FishIdCardAccessGranted: false + FishIdCardLocalAuthorization: false + - type: DeviceLinkSink + ports: + - FishIdCardReaderAuthorization + - FishIdCardReaderDoorStatus + - type: Destructible + thresholds: + - trigger: + !type:DamageTrigger + damage: 80 + behaviors: + - !type:DoActsBehavior + acts: [ Destruction ] + - !type:PlaySoundBehavior + sound: + collection: MetalBreak + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderTwoAuthorizations + suffix: Custom, 2 authorizations + components: + - type: IdCardReader + requiredAuthorizations: 2 + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderThreeAuthorizations + suffix: Custom, 3 authorizations + components: + - type: IdCardReader + requiredAuthorizations: 3 + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderCommand + suffix: Command + components: + - type: AccessReader + access: [["Command"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderCaptain + suffix: Captain + components: + - type: AccessReader + access: [["Captain"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderHeadOfPersonnel + suffix: Head of Personnel + components: + - type: AccessReader + access: [["HeadOfPersonnel"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderHeadOfSecurity + suffix: Head of Security + components: + - type: AccessReader + access: [["HeadOfSecurity"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderChiefEngineer + suffix: Chief Engineer + components: + - type: AccessReader + access: [["ChiefEngineer"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderChiefMedicalOfficer + suffix: Chief Medical Officer + components: + - type: AccessReader + access: [["ChiefMedicalOfficer"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderResearchDirector + suffix: Research Director + components: + - type: AccessReader + access: [["ResearchDirector"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderQuartermaster + suffix: Quartermaster + components: + - type: AccessReader + access: [["Quartermaster"]] + +- type: entity + parent: FishIdCardReader + id: FishIdCardReaderCentralCommand + suffix: Central Command + components: + - type: AccessReader + access: [["CentralCommand"]] diff --git a/Resources/Textures/_Fish/Structures/Wallmounts/id_card_reader.rsi/base.png b/Resources/Textures/_Fish/Structures/Wallmounts/id_card_reader.rsi/base.png new file mode 100644 index 0000000000000000000000000000000000000000..efa52d48aded2249792c553803b0a6b6b5c1badc GIT binary patch literal 482 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gk-%_=LFjPn_)F=#!sczIyG31BcG2X&QU`#(M;X0OkEAdrkmSoFzei!T+Iv zA=tk{2qakG5n0T@z;_UY8Fx&~ehakB+SA1`B*Xdb^v1qs1p${xme>F53mw0m^IqV? zvhCTV%FfJPD{aCh*1R)lV>(eUVDV9@>dN7>+<(OM%+_Cx7vose|C|4!L&1*y8E=ys z7x1%AP-odC#}y^LT<&0Y8rRib`@Ee6^n*IKSos7`*f!NisH4*%d46;L@gdm)1pV+#`p zs{ogRh=WE$0K-CtPb&_He~A|UGr8|T>lgmGN6+l$y}vu{eo)ej-bM3yV=UI|d0cUF zoucqEwj?G%?|i+O%I|%rdlcsKS