Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Попугаи стали еще умнее. #190

Merged
merged 6 commits into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,40 +1,74 @@
using Content.Server.Speech.EntitySystems;
using Content.Shared.Whitelist;
using Content.Server.Speech.EntitySystems;

namespace Content.Server.Speech.Components;

/// <summary>
/// This component stores the parrot's learned phrases (both single words and multi-word phrases),
/// and also controls time intervals and learning probabilities.
/// </summary>
Vonsant marked this conversation as resolved.
Show resolved Hide resolved
[RegisterComponent]
[Access(typeof(ParrotSpeechSystem))]
public sealed partial class ParrotSpeechComponent : Component
{
/// <summary>
/// The maximum number of words the parrot can learn per phrase.
/// Phrases are 1 to MaxPhraseLength words in length.
/// The maximum number of words in a generated phrase if the parrot decides to combine single words.
/// </summary>
[DataField]
public int MaximumPhraseLength = 7;

/// <summary>
/// The maximum amount of single-word phrases the parrot can store.
/// </summary>
[DataField]
public int MaximumPhraseCount = 20;
public int MaximumSingleWordCount = 60;

/// <summary>
/// The maximum amount of multi-word phrases the parrot can store.
/// </summary>
[DataField]
public int MinimumWait = 60; // 1 minutes
public int MaximumMultiWordCount = 20;

/// <summary>
/// Minimum delay (in seconds) before the next utterance.
/// </summary>
[DataField]
public int MinimumWait = 60; // 1 minute

/// <summary>
/// Maximum delay (in seconds) before the next utterance.
/// </summary>
[DataField]
public int MaximumWait = 120; // 2 minutes

/// <summary>
/// The probability that a parrot will learn from something an overheard phrase.
/// Probability that the parrot learns an overheard phrase.
/// </summary>
[DataField]
public float LearnChance = 0.2f;

/// <summary>
/// List of entities that are blacklisted from parrot listening.
/// If the entity is in the blacklist, the parrot won't learn from them.
/// </summary>
[DataField]
public EntityWhitelist Blacklist { get; private set; } = new();

[DataField]
public TimeSpan? NextUtterance;
/// <summary>
/// Set of single-word phrases (unique words) the parrot has learned.
/// </summary>
[DataField(readOnly: true)]
public HashSet<string> SingleWordPhrases = new();

/// <summary>
/// Set of multi-word phrases (2 or more words) the parrot has learned.
/// </summary>
[DataField(readOnly: true)]
public List<string> LearnedPhrases = new();
public HashSet<string> MultiWordPhrases = new();

/// <summary>
/// The next time the parrot will speak (when the current time is beyond this value).
/// </summary>
[DataField]
public TimeSpan? NextUtterance;
}
260 changes: 230 additions & 30 deletions Content.Server/_CorvaxNext/Speech/EntitySystems/ParrotSpeechSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

namespace Content.Server.Speech.EntitySystems;

/// <summary>
/// This system handles the learning (when the parrot hears a phrase) and
/// the random utterances (when the parrot speaks).
/// </summary>
public sealed class ParrotSpeechSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _timing = default!;
Expand All @@ -20,66 +24,262 @@ public override void Initialize()
base.Initialize();

SubscribeLocalEvent<ParrotSpeechComponent, ListenEvent>(OnListen);
SubscribeLocalEvent<ParrotSpeechComponent, ListenAttemptEvent>(CanListen);
SubscribeLocalEvent<ParrotSpeechComponent, ListenAttemptEvent>(OnListenAttempt);
}

public override void Update(float frameTime)
{
var query = EntityQueryEnumerator<ParrotSpeechComponent>();
while (query.MoveNext(out var uid, out var component))
{
if (component.LearnedPhrases.Count == 0)
// This parrot has not learned any phrases, so can't say anything interesting.
// If the parrot has not learned anything, skip
if (component.SingleWordPhrases.Count == 0 && component.MultiWordPhrases.Count == 0)
continue;

// If parrot is controlled by a player (has Mind), skip
if (TryComp<MindContainerComponent>(uid, out var mind) && mind.HasMind)
// Pause parrot speech when someone is controlling the parrot.
continue;

// Check the time to speak
if (_timing.CurTime < component.NextUtterance)
continue;

if (component.NextUtterance is not null)
// Construct a phrase the parrot will say
var phrase = PickRandomPhrase(component);
if (string.IsNullOrWhiteSpace(phrase))
continue;

// Send the phrase to the chat system (hidden from chat/log to avoid spam)
_chat.TrySendInGameICMessage(uid, phrase, InGameICChatType.Speak,
hideChat: true,
Vonsant marked this conversation as resolved.
Show resolved Hide resolved
hideLog: true,
checkRadioPrefix: false);

// Reset next utterance time
component.NextUtterance = _timing.CurTime +
TimeSpan.FromSeconds(_random.Next(component.MinimumWait, component.MaximumWait));
}
}

/// <summary>
/// Picks a random phrase to utter. May be a single word, a multi-word phrase, or
/// a combination of single words (up to MaximumPhraseLength).
/// </summary>
private string PickRandomPhrase(ParrotSpeechComponent component)
{
var singleCount = component.SingleWordPhrases.Count;
var multiCount = component.MultiWordPhrases.Count;
if (singleCount == 0 && multiCount == 0)
return string.Empty;

// 1) If we only have single words, use single approach
// 2) If we only have multi-word phrases, use multi approach
// 3) Otherwise, pick randomly among:
// a) Single word
// b) Full multi-word phrase
// c) Combined single words

bool haveSingle = singleCount > 0;
bool haveMulti = multiCount > 0;

if (haveSingle && !haveMulti)
{
// Only single words exist
return PickSingleWordOrCombine(component);
}
else if (!haveSingle && haveMulti)
{
// Only multi-word phrases exist
return PickRandomMultiWord(component);
}
else
{
// We have both single and multi, choose approach
var roll = _random.Next(3); // 0..2
switch (roll)
{
_chat.TrySendInGameICMessage(
uid,
_random.Pick(component.LearnedPhrases),
InGameICChatType.Speak,
hideChat: true, // Don't spam the chat with randomly generated messages
hideLog: true, // TODO: Don't spam admin logs either.
// If a parrot learns something inappropriate, admins can search for
// the player that said the inappropriate thing.
checkRadioPrefix: false);
case 0:
// single word
return PickSingleWord(component);
case 1:
// multi-word phrase
return PickRandomMultiWord(component);
default:
// combined single words
return CombineMultipleWords(component);
}
}
}

component.NextUtterance = _timing.CurTime + TimeSpan.FromSeconds(_random.Next(component.MinimumWait, component.MaximumWait));
/// <summary>
/// If we only have single words, we can either speak a single one or combine them.
/// </summary>
private string PickSingleWordOrCombine(ParrotSpeechComponent component)
{
// 50% chance single word, 50% chance combine
if (_random.Prob(0.5f))
{
return PickSingleWord(component);
}
else
{
return CombineMultipleWords(component);
}
}

/// <summary>
/// Picks a random single word from SingleWordPhrases.
/// </summary>
private string PickSingleWord(ParrotSpeechComponent component)
{
var list = component.SingleWordPhrases.ToList();
return _random.Pick(list);
}

/// <summary>
/// Picks a random multi-word phrase from MultiWordPhrases.
/// </summary>
private string PickRandomMultiWord(ParrotSpeechComponent component)
{
var list = component.MultiWordPhrases.ToList();
return _random.Pick(list);
}

/// <summary>
/// Combines multiple single words (up to MaximumPhraseLength) into one phrase.
/// The length is random from 1 to max, but not exceeding the total single words we have.
/// </summary>
private string CombineMultipleWords(ParrotSpeechComponent component)
{
var countAvailable = component.SingleWordPhrases.Count;
if (countAvailable == 0)
return string.Empty;

var maxCount = Math.Min(countAvailable, component.MaximumPhraseLength);
var wordsToUse = _random.Next(1, maxCount + 1);

var list = component.SingleWordPhrases.ToList();
_random.Shuffle(list);

var shuffled = list.Take(wordsToUse);
var combined = string.Join(" ", shuffled);
return combined;
}

/// <summary>
/// This event is triggered when the parrot hears someone speaking. If allowed, the parrot may learn it.
/// Now we remove punctuation from the message, split into words, pick a random sub-chunk, and save both:
/// - The whole sub-chunk as single or multi-word phrase
/// - Each word from that sub-chunk as a single word
/// </summary>
private void OnListen(EntityUid uid, ParrotSpeechComponent component, ref ListenEvent args)
{
if (_random.Prob(component.LearnChance))
{
// Very approximate word splitting. But that's okay: parrots aren't smart enough to
// split words correctly.
var words = args.Message.Split(" ", StringSplitOptions.RemoveEmptyEntries);
// Prefer longer phrases
var phraseLength = 1 + (int) (Math.Sqrt(_random.NextDouble()) * component.MaximumPhraseLength);
// Random chance to learn
if (!_random.Prob(component.LearnChance))
return;

var startIndex = _random.Next(0, Math.Max(0, words.Length - phraseLength + 1));
// 1) Remove punctuation (replace it with spaces), convert to lower-case
var cleaned = RemovePunctuationAndToLower(args.Message);

var phrase = string.Join(" ", words.Skip(startIndex).Take(phraseLength)).ToLower();
// 2) Split into words
var words = cleaned.Split(" ", StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 0)
return;

while (component.LearnedPhrases.Count >= component.MaximumPhraseCount)
{
_random.PickAndTake(component.LearnedPhrases);
}
// 3) Decide how many words we pick from the overheard message
var phraseLength = 1 + (int)(Math.Sqrt(_random.NextDouble()) * component.MaximumPhraseLength);
if (phraseLength > words.Length)
phraseLength = words.Length;

// 4) Pick a random start index
var startIndex = _random.Next(0, Math.Max(1, words.Length - phraseLength + 1));
var chunk = words.Skip(startIndex).Take(phraseLength).ToArray();

component.LearnedPhrases.Add(phrase);
// 5) If chunk has only 1 word, store it as single word
// otherwise store it as a multi-word phrase
if (chunk.Length == 1)
{
LearnSingleWord(chunk[0], component);
}
else
{
var phrase = string.Join(" ", chunk);
LearnMultiWord(phrase, component);
}

// 6) Independently, store all words of that chunk as single words (no duplicates)
foreach (var w in chunk)
{
LearnSingleWord(w, component);
}
}

private void CanListen(EntityUid uid, ParrotSpeechComponent component, ref ListenAttemptEvent args)
/// <summary>
/// Checks if the source is blacklisted. If so, the parrot won't listen.
/// </summary>
private void OnListenAttempt(EntityUid uid, ParrotSpeechComponent component, ref ListenAttemptEvent args)
{
if (_whitelistSystem.IsBlacklistPass(component.Blacklist, args.Source))
args.Cancel();
}

/// <summary>
/// Adds a single word into the SingleWordPhrases set, removing a random word if we exceed the limit.
/// </summary>
private void LearnSingleWord(string word, ParrotSpeechComponent component)
{
// If we already have it, skip
if (component.SingleWordPhrases.Contains(word))
return;

// If we exceed maximum, remove a random single word
if (component.SingleWordPhrases.Count >= component.MaximumSingleWordCount)
{
var list = component.SingleWordPhrases.ToList();
var toRemove = _random.Pick(list);
component.SingleWordPhrases.Remove(toRemove);
}

component.SingleWordPhrases.Add(word);
}

/// <summary>
/// Adds a multi-word phrase into the MultiWordPhrases set, removing a random phrase if we exceed the limit.
/// </summary>
private void LearnMultiWord(string phrase, ParrotSpeechComponent component)
{
// If we already have it, skip
if (component.MultiWordPhrases.Contains(phrase))
return;

// If we exceed maximum, remove a random multi-word phrase
if (component.MultiWordPhrases.Count >= component.MaximumMultiWordCount)
{
var list = component.MultiWordPhrases.ToList();
var toRemove = _random.Pick(list);
component.MultiWordPhrases.Remove(toRemove);
}

component.MultiWordPhrases.Add(phrase);
}

/// <summary>
/// Replaces all punctuation with spaces and returns a lower-cased string.
/// E.g. "Hello, world! I'm here." => "hello world i m here "
/// then trimmed/split => "hello", "world", "i", "m", "here"
/// </summary>
private string RemovePunctuationAndToLower(string text)
{
var chars = text.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
if (char.IsPunctuation(chars[i]))
{
chars[i] = ' ';
}
}

// Convert to lower case
return new string(chars).ToLowerInvariant();
}
}
Loading
Loading