From d5d7be36a2df5184122b179d941547cc6725abce Mon Sep 17 00:00:00 2001
From: Debug <49997488+DebugOk@users.noreply.github.com>
Date: Sun, 22 Oct 2023 03:27:51 +0200
Subject: [PATCH] The Oracle (#208)
* Working oracle moment
* Update OracleSystem
Use a dynamic blacklist, so we dont have to manually specify all invalids, and do some general code cleanup
* Convert ReadWrites into ReadOnly
---
.../Tests/Nyanotrasen/Oracle/OracleTest.cs | 72 +++++
.../Research/Oracle/OracleComponent.cs | 79 ++++++
.../Research/Oracle/OracleSystem.cs | 250 ++++++++++++++++++
.../en-US/nyanotrasen/research/oracle.ftl | 15 ++
.../Entities/Structures/Research/oracle.yml | 58 ++--
5 files changed, 445 insertions(+), 29 deletions(-)
create mode 100644 Content.IntegrationTests/Tests/Nyanotrasen/Oracle/OracleTest.cs
create mode 100644 Content.Server/Nyanotrasen/Research/Oracle/OracleComponent.cs
create mode 100644 Content.Server/Nyanotrasen/Research/Oracle/OracleSystem.cs
create mode 100644 Resources/Locale/en-US/nyanotrasen/research/oracle.ftl
diff --git a/Content.IntegrationTests/Tests/Nyanotrasen/Oracle/OracleTest.cs b/Content.IntegrationTests/Tests/Nyanotrasen/Oracle/OracleTest.cs
new file mode 100644
index 0000000000..c925db3ba2
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Nyanotrasen/Oracle/OracleTest.cs
@@ -0,0 +1,72 @@
+#nullable enable
+using NUnit.Framework;
+using System.Threading.Tasks;
+using Content.Shared.Item;
+using Content.Shared.Mobs.Components;
+using Content.Server.Research.Oracle;
+using Content.Shared.Chemistry.Components;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+
+
+///
+/// The oracle's request pool is huge.
+/// We need to test everything that the oracle could request can be turned in.
+///
+namespace Content.IntegrationTests.Tests.Oracle
+{
+ [TestFixture]
+ [TestOf(typeof(OracleSystem))]
+ public sealed class OracleTest
+ {
+ [Test]
+ public async Task AllOracleItemsCanBeTurnedIn()
+ {
+ await using var pairTracker = await PoolManager.GetServerClient();
+ var server = pairTracker.Server;
+ // Per RobustIntegrationTest.cs, wait until state is settled to access it.
+ await server.WaitIdleAsync();
+
+ var mapManager = server.ResolveDependency();
+ var prototypeManager = server.ResolveDependency();
+ var entityManager = server.ResolveDependency();
+ var entitySystemManager = server.ResolveDependency();
+
+ var oracleSystem = entitySystemManager.GetEntitySystem();
+ var oracleComponent = new OracleComponent();
+
+ var testMap = await pairTracker.CreateTestMap();
+
+ await server.WaitAssertion(() =>
+ {
+ var allProtos = oracleSystem.GetAllProtos(oracleComponent);
+ var coordinates = testMap.GridCoords;
+
+ Assert.That((allProtos.Count > 0), "Oracle has no valid prototypes!");
+
+ foreach (var proto in allProtos)
+ {
+ var spawned = entityManager.SpawnEntity(proto, coordinates);
+
+ Assert.That(entityManager.HasComponent(spawned),
+ $"Oracle can request non-item {proto}");
+
+ Assert.That(!entityManager.HasComponent(spawned),
+ $"Oracle can request reagent container {proto} that will conflict with the fountain");
+
+ Assert.That(!entityManager.HasComponent(spawned),
+ $"Oracle can request mob {proto} that could potentially have a player-set name.");
+ }
+
+ // Because Server/Client pairs can be re-used between Tests, we
+ // need to clean up anything that might affect other tests,
+ // otherwise this pair cannot be considered clean, and the
+ // CleanReturnAsync call would need to be removed.
+ mapManager.DeleteMap(testMap.MapId);
+ });
+
+ await pairTracker.CleanReturnAsync();
+ }
+ }
+}
diff --git a/Content.Server/Nyanotrasen/Research/Oracle/OracleComponent.cs b/Content.Server/Nyanotrasen/Research/Oracle/OracleComponent.cs
new file mode 100644
index 0000000000..b876a06375
--- /dev/null
+++ b/Content.Server/Nyanotrasen/Research/Oracle/OracleComponent.cs
@@ -0,0 +1,79 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Research.Oracle;
+
+[RegisterComponent]
+public sealed partial class OracleComponent : Component
+{
+ public const string SolutionName = "fountain";
+
+ [ViewVariables]
+ [DataField("accumulator")]
+ public float Accumulator;
+
+ [ViewVariables]
+ [DataField("resetTime")]
+ public TimeSpan ResetTime = TimeSpan.FromMinutes(10);
+
+ [DataField("barkAccumulator")]
+ public float BarkAccumulator;
+
+ [DataField("barkTime")]
+ public TimeSpan BarkTime = TimeSpan.FromMinutes(1);
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ public EntityPrototype DesiredPrototype = default!;
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ public EntityPrototype? LastDesiredPrototype = default!;
+
+ [DataField("rewardReagents")]
+ public static IReadOnlyList RewardReagents = new[]
+ {
+ "LotophagoiOil", "LotophagoiOil", "LotophagoiOil", "LotophagoiOil", "LotophagoiOil", "Wine", "Blood", "Ichor"
+ };
+
+ [DataField("demandMessages")]
+ public IReadOnlyList DemandMessages = new[]
+ {
+ "oracle-demand-1",
+ "oracle-demand-2",
+ "oracle-demand-3",
+ "oracle-demand-4",
+ "oracle-demand-5",
+ "oracle-demand-6",
+ "oracle-demand-7",
+ "oracle-demand-8",
+ "oracle-demand-9",
+ "oracle-demand-10",
+ "oracle-demand-11",
+ "oracle-demand-12"
+ };
+
+ [DataField("rejectMessages")]
+ public IReadOnlyList RejectMessages = new[]
+ {
+ "ἄγνοια",
+ "υλικό",
+ "ἀγνωσία",
+ "γήινος",
+ "σάκλας"
+ };
+
+ [DataField("blacklistedPrototypes")]
+ [ViewVariables(VVAccess.ReadOnly)]
+ public IReadOnlyList BlacklistedPrototypes = new[]
+ {
+ "Drone",
+ "QSI",
+ "HandTeleporter",
+ "BluespaceBeaker",
+ "ClothingBackpackHolding",
+ "ClothingBackpackSatchelHolding",
+ "ClothingBackpackDuffelHolding",
+ "TrashBagOfHolding",
+ "BluespaceCrystal",
+ "InsulativeHeadcage",
+ "CrystalNormality",
+ };
+}
diff --git a/Content.Server/Nyanotrasen/Research/Oracle/OracleSystem.cs b/Content.Server/Nyanotrasen/Research/Oracle/OracleSystem.cs
new file mode 100644
index 0000000000..2548f3da2b
--- /dev/null
+++ b/Content.Server/Nyanotrasen/Research/Oracle/OracleSystem.cs
@@ -0,0 +1,250 @@
+using System.Linq;
+using Content.Server.Botany;
+using Content.Server.Chat.Managers;
+using Content.Server.Chat.Systems;
+using Content.Server.Fluids.EntitySystems;
+using Content.Server.Psionics;
+using Content.Shared.Abilities.Psionics;
+using Content.Shared.Chat;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Chemistry.EntitySystems;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Interaction;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Psionics.Glimmer;
+using Content.Shared.Research.Prototypes;
+using Robust.Server.GameObjects;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Research.Oracle;
+
+public sealed class OracleSystem : EntitySystem
+{
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly ChatSystem _chat = default!;
+ [Dependency] private readonly IChatManager _chatManager = default!;
+ [Dependency] private readonly SolutionContainerSystem _solutionSystem = default!;
+ [Dependency] private readonly GlimmerSystem _glimmerSystem = default!;
+ [Dependency] private readonly PuddleSystem _puddleSystem = default!;
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+ foreach (var oracle in EntityQuery())
+ {
+ oracle.Accumulator += frameTime;
+ oracle.BarkAccumulator += frameTime;
+ if (oracle.BarkAccumulator >= oracle.BarkTime.TotalSeconds)
+ {
+ oracle.BarkAccumulator = 0;
+ var message = Loc.GetString(_random.Pick(oracle.DemandMessages), ("item", oracle.DesiredPrototype.Name))
+ .ToUpper();
+ _chat.TrySendInGameICMessage(oracle.Owner, message, InGameICChatType.Speak, false);
+ }
+
+ if (oracle.Accumulator >= oracle.ResetTime.TotalSeconds)
+ {
+ oracle.LastDesiredPrototype = oracle.DesiredPrototype;
+ NextItem(oracle);
+ }
+ }
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnInteractHand);
+ SubscribeLocalEvent(OnInteractUsing);
+ }
+
+ private void OnInit(EntityUid uid, OracleComponent component, ComponentInit args)
+ {
+ NextItem(component);
+ }
+
+ private void OnInteractHand(EntityUid uid, OracleComponent component, InteractHandEvent args)
+ {
+ if (!HasComp(args.User) || HasComp(args.User))
+ return;
+
+ if (!TryComp(args.User, out var actor))
+ return;
+
+ var message = Loc.GetString("oracle-current-item", ("item", component.DesiredPrototype.Name));
+
+ var messageWrap = Loc.GetString("chat-manager-send-telepathic-chat-wrap-message",
+ ("telepathicChannelName", Loc.GetString("chat-manager-telepathic-channel-name")), ("message", message));
+
+ _chatManager.ChatMessageToOne(ChatChannel.Telepathic,
+ message, messageWrap, uid, false, actor.PlayerSession.ConnectedClient, Color.PaleVioletRed);
+
+ if (component.LastDesiredPrototype != null)
+ {
+ var message2 = Loc.GetString("oracle-previous-item", ("item", component.LastDesiredPrototype.Name));
+ var messageWrap2 = Loc.GetString("chat-manager-send-telepathic-chat-wrap-message",
+ ("telepathicChannelName", Loc.GetString("chat-manager-telepathic-channel-name")),
+ ("message", message2));
+
+ _chatManager.ChatMessageToOne(ChatChannel.Telepathic,
+ message2, messageWrap2, uid, false, actor.PlayerSession.ConnectedClient, Color.PaleVioletRed);
+ }
+ }
+
+ private void OnInteractUsing(EntityUid uid, OracleComponent component, InteractUsingEvent args)
+ {
+ if (HasComp(args.Used))
+ return;
+
+ if (!TryComp(args.Used, out var meta))
+ return;
+
+ if (meta.EntityPrototype == null)
+ return;
+
+ var validItem = CheckValidity(meta.EntityPrototype, component.DesiredPrototype);
+
+ var nextItem = true;
+
+ if (component.LastDesiredPrototype != null &&
+ CheckValidity(meta.EntityPrototype, component.LastDesiredPrototype))
+ {
+ nextItem = false;
+ validItem = true;
+ component.LastDesiredPrototype = null;
+ }
+
+ if (!validItem)
+ {
+ if (!HasComp(args.Used))
+ _chat.TrySendInGameICMessage(uid, _random.Pick(component.RejectMessages), InGameICChatType.Speak, true);
+ return;
+ }
+
+ EntityManager.QueueDeleteEntity(args.Used);
+
+ EntityManager.SpawnEntity("ResearchDisk5000", Transform(args.User).Coordinates);
+
+ DispenseLiquidReward(uid);
+
+ var i = _random.Next(1, 4);
+
+ while (i != 0)
+ {
+ EntityManager.SpawnEntity("MaterialBluespace1", Transform(args.User).Coordinates);
+ i--;
+ }
+
+ if (nextItem)
+ NextItem(component);
+ }
+
+ private bool CheckValidity(EntityPrototype given, EntityPrototype target)
+ {
+ // 1: directly compare Names
+ // name instead of ID because the oracle asks for them by name
+ // this could potentially lead to like, labeller exploits maybe but so far only mob names can be fully player-set.
+ if (given.Name == target.Name)
+ return true;
+
+ return false;
+ }
+
+ private void DispenseLiquidReward(EntityUid uid)
+ {
+ if (!_solutionSystem.TryGetSolution(uid, OracleComponent.SolutionName, out var fountainSol))
+ return;
+
+ var allReagents = _prototypeManager.EnumeratePrototypes()
+ .Where(x => !x.Abstract)
+ .Select(x => x.ID).ToList();
+
+ var amount = 20 + _random.Next(1, 30) + _glimmerSystem.Glimmer / 10f;
+ amount = (float) Math.Round(amount);
+
+ var sol = new Solution();
+ var reagent = "";
+
+ if (_random.Prob(0.2f))
+ reagent = _random.Pick(allReagents);
+ else
+ reagent = _random.Pick(OracleComponent.RewardReagents);
+
+ sol.AddReagent(reagent, amount);
+
+ _solutionSystem.TryMixAndOverflow(uid, fountainSol, sol, fountainSol.MaxVolume, out var overflowing);
+
+ if (overflowing != null && overflowing.Volume > 0)
+ _puddleSystem.TrySpillAt(uid, overflowing, out var _);
+ }
+
+ private void NextItem(OracleComponent component)
+ {
+ component.Accumulator = 0;
+ component.BarkAccumulator = 0;
+ var protoString = GetDesiredItem(component);
+ if (_prototypeManager.TryIndex(protoString, out var proto))
+ component.DesiredPrototype = proto;
+ else
+ Logger.Error("Oracle can't index prototype " + protoString);
+ }
+
+ private string GetDesiredItem(OracleComponent component)
+ {
+ return _random.Pick(GetAllProtos(component));
+ }
+
+
+ public List GetAllProtos(OracleComponent component)
+ {
+ var allTechs = _prototypeManager.EnumeratePrototypes();
+ var allRecipes = new List();
+
+ foreach (var tech in allTechs)
+ {
+ foreach (var recipe in tech.RecipeUnlocks)
+ {
+ var recipeProto = _prototypeManager.Index(recipe);
+ allRecipes.Add(recipeProto.Result);
+ }
+ }
+
+ var allPlants = _prototypeManager.EnumeratePrototypes().Select(x => x.ProductPrototypes[0])
+ .ToList();
+ var allProtos = allRecipes.Concat(allPlants).ToList();
+ var blacklist = component.BlacklistedPrototypes.ToList();
+
+ foreach (var proto in allProtos)
+ {
+ if (!_prototypeManager.TryIndex(proto, out var entityProto))
+ {
+ blacklist.Add(proto);
+ continue;
+ }
+
+ if (!entityProto.Components.ContainsKey("Item"))
+ {
+ blacklist.Add(proto);
+ continue;
+ }
+
+ if (entityProto.Components.ContainsKey("SolutionTransfer"))
+ {
+ blacklist.Add(proto);
+ continue;
+ }
+
+ if (entityProto.Components.ContainsKey("MobState"))
+ blacklist.Add(proto);
+ }
+
+ foreach (var proto in blacklist)
+ {
+ allProtos.Remove(proto);
+ }
+
+ return allProtos;
+ }
+}
diff --git a/Resources/Locale/en-US/nyanotrasen/research/oracle.ftl b/Resources/Locale/en-US/nyanotrasen/research/oracle.ftl
new file mode 100644
index 0000000000..a8db7f5028
--- /dev/null
+++ b/Resources/Locale/en-US/nyanotrasen/research/oracle.ftl
@@ -0,0 +1,15 @@
+oracle-demand-1 = I demand one {$item}! Do not question why!
+oracle-demand-2 = In exchange for knowledge, you must bring me one {$item}!
+oracle-demand-3 = I will grant you a gift of the divine in exchange for one {$item}!
+oracle-demand-4 = Bring me one {$item} if you ever wish to receive even one drop of my grace!
+oracle-demand-5 = If you are not ignorant, you will bring me one {$item}!
+oracle-demand-6 = The archons have a request of you: one {$item}!
+oracle-demand-7 = To ascend to a higher state of being, to achieve gnosis, your pathetic corporeal bodies must bring me one {$item}!
+oracle-demand-8 = If you wish to prolong the length of time your spark of divine light spends in that vessel, you should bring me one {$item}!
+oracle-demand-9 = Bring me one {$item} or revel in your ignorance, worms!
+oracle-demand-10 = If you ever wish to pierce the veil of this false existence, you must bring me one {$item}!
+oracle-demand-11 = I'll make sure the next vessel you inhabit in this material hell is one of those monkeys you torture if you do not bring me one {$item}!
+oracle-demand-12 = One {$item}, a step on the path to divinity.
+
+oracle-current-item = Current requested item: {$item}
+oracle-previous-item = Last requested item, still accepting: {$item}
diff --git a/Resources/Prototypes/Nyanotrasen/Entities/Structures/Research/oracle.yml b/Resources/Prototypes/Nyanotrasen/Entities/Structures/Research/oracle.yml
index 6e518d1296..f7481abf1e 100644
--- a/Resources/Prototypes/Nyanotrasen/Entities/Structures/Research/oracle.yml
+++ b/Resources/Prototypes/Nyanotrasen/Entities/Structures/Research/oracle.yml
@@ -13,32 +13,32 @@
- state: oracle-0
- map: ["enum.SolutionContainerLayers.Fill"]
state: oracle-0
-# - type: Oracle
-# - type: Speech
-# speechSounds: Tenor
-# - type: Psionic
- # - type: SolutionContainerManager
- # solutions:
- # fountain:
- # maxVol: 200
- # - type: Drink
- # isOpen: true
- # solution: fountain
- # - type: DrawableSolution
- # solution: fountain
- # - type: DrainableSolution
- # solution: fountain
- # - type: ExaminableSolution
- # solution: fountain
- # - type: Appearance
- # - type: SolutionContainerVisuals
- # maxFillLevels: 10
- # fillBaseName: oracle-
- # - type: Grammar
- # attributes:
- # gender: female
- # proper: true
- # - type: Prayable
- # - type: SpriteFade
- # - type: Tag
- # tags: []
+ - type: Oracle
+ - type: Speech
+ speechSounds: Tenor
+ - type: Psionic
+ - type: SolutionContainerManager
+ solutions:
+ fountain:
+ maxVol: 200
+ - type: Drink
+ isOpen: true
+ solution: fountain
+ - type: DrawableSolution
+ solution: fountain
+ - type: DrainableSolution
+ solution: fountain
+ - type: ExaminableSolution
+ solution: fountain
+ - type: Appearance
+ - type: SolutionContainerVisuals
+ maxFillLevels: 10
+ fillBaseName: oracle-
+ - type: Grammar
+ attributes:
+ gender: female
+ proper: true
+ - type: Prayable
+ - type: SpriteFade
+ - type: Tag
+ tags: []