Skip to content

External Mod Module Support

Sam edited this page Nov 12, 2023 · 53 revisions

Twitch Plays contains functionality to allow mod modules to provide command response directly from within their own module code, without having to write a command response class in TP:KTaNE. There are currently two implementation options available for providing the interaction: a simple way and an advanced way.

Simple Implementation

This option is useful for issuing a simple sequence of KMSelectable object interactions, like in Keypad. The method should have the exact signature declared below (can be either public or non-public, but must be non-static):

KMSelectable[] ProcessTwitchCommand(string command)
{
}

Depending on the value of the command, return back either:

  • an array of KMSelectable objects to interact with, in the order they should be interacted with (NOTE: you can also return a List<KMSelectable> or an IEnumerable<KMSelectable>)
  • null, to indicate that nothing should happen (i.e. ignore the command)

If there is an error in the command that you wish to tell the users, throw a System.FormatException(<error message>).

If at any point a KMSelectable interaction will cause a strike, Twitch Plays will stop execution of the given KMSelectable objects mid-way automatically; you don't have to worry about this in your implementation. Also note that this method will not be invoked if the module is solved either.

Do not implement this option if:

  • You have to hold down on an interaction (like in The Button)
  • Buttons have to be pressed in a time-critical manner (like Light Cycle, Crazy Talk, Color Flash)
  • The module has a delay between the KMSelectable interaction and a strike/solve being awarded (like Two Bits, Cheap Checkout, Simon Screams)

Advanced Implementation

If you use this implementation you must read the next four paragraphs below as they contain crucial information.

This option allows for full flexibility of interaction, by asking you to implement a coroutine-like method. If you don't know how to write coroutines in Unity, it's recommended to read up on the Unity documentation to better understand how coroutines work. The method should have the exact signature declared below (can be either public or non-public, but must be non-static):

IEnumerator ProcessTwitchCommand(string command)
{
}

The coroutine must yield return something to acknowledge that the command is valid. You can yield return anything as long as it's not one of the special sendtochat methods, which are explained near the bottom of this section. Most implementations simply yield return null. If nothing is yielded Twitch Plays will assume the command was invalid and tell the user.

By default Twitch Plays will attribute a solve or strike to whoever was interacting with the module. If you have a module with a delay between the interaction with the module and the solve/strike you need to use the "solve" / "strike" API that is explained below.

You can interact directly with your own KMSelectable objects without passing them through to Twitch Plays. If you choose to do this, use the .OnInteract() method on the KMSelectable, do not use an internal method, as this can break integration with other mods.

This implementation option will allow you to do the following:

  • yield break; without yield return-ing something, to denote that nothing should happen (i.e. ignore the command)
  • Wait for seconds using yield return new WaitForSeconds(__);
  • Wait for the next frame using yield return null;
  • A KMSelectable object to hold down; Twitch Plays will hold down the interaction of this object until the same KMSelectable object is yielded again, either by this command or a subsequent command
  • A KMSelectable [] array. This causes ALL of the buttons to be pressed unless one of the ones pressed causes a strike, in which case, the remainder of the sequence is aborted.
  • A string to denote special methods like the following:
    • "strike" indicates that this command will cause a strike at some later point; all this does is tell Twitch Plays to attribute the strike to the author of this command
    • "solve" indicates that this command will solve the module at some later point; all this does is tell Twitch Plays to attribute the solve to the author of this command
    • "unsubmittablepenalty" indicates that the command couldn't submit an answer and should only be used to prevent users from guessing the answer. This should not be used if an answer could never be submittable for a module.
    • "strikemessage {message}" allows you to tell the user why they got a strike if it isn't clear. More information can be found here.
    • "trycancel [message]" indicates that this command is allowed to be cancelled at the given time of the yield. Just know that you won't be able to clean up if you do your cancel this way, and there is a pending !cancel or !stop. [message] is optional, but if specified, gets sent to chat when the command in cancelled.
    • "trywaitcancel {time} [message]" causes Twitch Plays to wait for the given time, and any time during the entire duration, the command may cancel. Like "trycancel", you won't be able to clean up if you cancel this way. Also like "trycancel", "[message]" is optional.
    • "trycancelsequence" - Indicates that the KMSelectable[] sequence that follows this command should be cancelled if a "!cancel" or "!stop" is issued mid-way through that sequence.
    • "cancelled" indicates that you have stopped processing the command in response to the TwitchShouldCancelCommand bool being set to true.
    • "sendtochat {message}" Send a message directly to twitch chat. More information can be found here.
    • "sendtochaterror {message}" Sends a message to the chat about why a users command was invalid. More information can be found here.
    • "senddelayedmessage {time} {message}" Sends a message to chat after {time} seconds. More information can be found here.
    • "multiple strikes" Indicates that the issued command is going to cause more than one strike, so should disable the internal strike tracker in order to avoid flooding the chat with "VoteNay Module {id} got a strike! +1 strike to {Nickname}" for as many strikes as will be awarded. This also disables the internal solve tracker as well. This allows for sending additional messages or continue processing commands regardless of the solve/strike state.
    • "end multiple strikes" Indicates that the strike tracker should be enabled. If any strikes were issued during the time it was disabled, they will be awarded and the routine stopped at that point, otherwise, it will just cancel the "VoteNay Module {id} got 0 strikes! +0 strike to {Nickname}" message that would otherwise be posted. Likewise, if the module was solved at the time this command is issued, the processing will be stopped as of that point as well.
    • "autosolve" can be used to automatically solve the module, as if it threw an exception while solving.
    • "detonate" [time] [message] can be used to explode the bomb instantly. time is specified in number of seconds before the bomb explodes. (Note, sending another detonate command will auto-cancel the previous one on the same module if it hasn't happened already.). message is the message to send to chat upon detonation. Both of the parameters are optional.
    • "cancel detonate" can be used to cancel a previously issued delayed detonation command on the same module.
    • "waiting music" can be used to play the waiting music if a command will take long to finish.
    • "end waiting music" can be used to stop the waiting music mid-command.
    • "toggle waiting music" can be used to toggle the waiting music on and off mid-command
    • "hide camera" can be used to hide the heads-up display and cameras while doing quaternion rotations, if it is expected that the camera/hud will get in the way.
    • "skiptime [seconds]" can be used to try advancing the clock to the specified time. You must put the full time you wish to skip to, and this time either needs to be less than the current time, if in normal/time mode, or greater than the current time, if in zen mode. Example, if you wanted to set the clock to 5:24, then you do "skiptime 324" or "skiptime 5:24". You can target partway through the seconds, such as "skiptime 45.28", which would then set the clock to 45.28, provided that time has NOT gone by already. You must also declare the "TwitchPlaysSkipTimeAllowed" bool, and set it to true, for this function to work.
    • "awardpoints {points}" can be used to award the user that sent the command points directly, this is currently used for mods like souvenir to give points to users that answered the questions equally.
    • "awardpointsonsolve {points}" can be used to award the last user that sent the command points when the module is solved. The module must prevent any user from sending commands afterward in order for Twitch Plays to award points to the correct user. This is currently used by the Twin module when extra points must be given but the module is not solved immediately.
  • A Quaternion object to change the orientation of the bomb; this is only useful for very specific scenarios that require re-orientation of the bomb to inspect the module (e.g. Perspective Pegs, Rubik's Cube)
  • A Quaternion[] object to change the orientation of the bomb, and the camera side view. This object should have two Quaternions in it. One is the form of (Quaternion.Euler(x,0,0) * Quaternion.Euler(0,y,0) * (Quaternion.Euler(0,0,z)), and the second one in the form of Quaternion.Euler(x,y,z) The former is for the Bomb, the latter for the camera side view.).
  • A string[] object can be used to detonate the bomb if the first string is detonate or explode. The next string can optionally be a reason why and you can also add a third string to override the module name.

Also, be aware that Twitch Plays plays the commands out in a single-queue and doesn't dispatch the actions simultaneously. To this fact, ensure that your command response doesn't halt the coroutine excessively, unless the module's design explicitly infers this style of behaviour (like releasing a button on a specific condition).

sendtochat Methods

These methods have a few details that are important to know:

Formatting

These methods allows you to include the user's name and the module's code by using {0} and {1} respectively. Example: sendtochat {0}, !{1} command could send to chat samfundev, !1 command. You can disable this functionality by using the !f flag. If you don't disable it, you should escape any { or } characters that may appear in the message since this formatting is done using string.Format().

Flags

Flags can be added to these methods by putting ! then the flags after the method name. Example: sendtochat!fh. The available flags are:

  • f disables formatting.
  • h halts command processing like yield break does.

Instant Responses

If you wish to send something to chat without focusing the module, then yield return one of the methods as the VERY first yield return. You MUST stop command processing with either the !h flag or yield break. Not doing so causes inconsistent behavior. If you need to send more than one line, break up the lines using "\n".

Additional Implementation Tasks

All of the tasks mentioned below are purely optional, but allow for a richer user-to-module interaction. Again, these can be public or non-public.

bool TwitchPlaysActive;

Declaring this field allows for Twitch Plays to inform the module that it is currently active. This is for modules that need to display different items, or use different rules if Twitch Plays is active. This field is set in Start(), therefore there's no guarantee that it'll be available there, therefore the field must be first accessed in a delegate in KMBombModule.OnActivate() or KMNeedyModule.OnActivate() or later.

void TwitchHandleForcedSolve();

Declaring this function allows you to know that a Twitch Plays admin has decided to force-solve the module, so that you can put the module’s final state into the desired state you want to present when the module is solved. Finally, you must do a HandlePass(), and ensure the module gains no strikes whatsoever. In the case of a needy module, either keep its timer at 99 seconds, or HandlePass() on activate every time, depending on the specific nature of the needy. If the Twitch Plays admin does ANOTHER !# solve or !solvebomb on the module, it will be forcibly solved and ALL co-routines within the module forcibly stopped. (This method cannot be static.)

IEnumerator TwitchHandleForcedSolve();

This version of TwitchHandleForcedSolve() allows the module to be be added into the forced solve queue. If you wish to pass the turn on to another module in the queue, for example, you have a long wait, yield return true;. You cannot have both types of TwitchHandleForcedSolve() in your code.

bool TwitchShouldCancelCommand;

Declaring this field is a way for the Advanced Implementation to be notified that it should cancel command processing. If you define this and see in the code that the value of the field is set to true, then stop processing the command, clean up, then do a yield return "cancelled" to acknowledge the cancel.

string TwitchManualCode;

Declaring this field allows you to specify the manual that is looked up on The Manual Repository when !{id} manual is entered into chat.

string TwitchHelpMessage;

Declaring this field allows you to specify the help message displaying examples of how to input the command, when !{id} help is entered into chat. Use the {0} string token to denote where the module's ID should be inserted into the help text.

bool ZenModeActive;

Declaring this field allows for Twitch Plays to inform the module that the timer is counting up instead of down, for special cases, such as controlling how to sort button release times, or whether there is a low timer event or not. This field is set in Start(), therefore there's no guarantee that it'll be available there, therefore the field must be first accessed in a delegate in KMBombModule.OnActivate() or KMNeedyModule.OnActivate() or later.

bool TimeModeActive;

Declaring this field allows for Twitch Plays to inform the module that the bomb is in Time Mode, where solves change the timer. This is useful for modules that use the timer's value. This field is set in Start(), therefore there's no guarantee that it'll be available there, therefore the field must be first accessed in a delegate in KMBombModule.OnActivate() or KMNeedyModule.OnActivate() or later.

List<KMBombModule> TwitchAbandonModule;

Declaring this field allows for Twitch Plays to let the module know that it should stop processing whatever module appears in this list. Currently, the only module that uses this capability is Souvenir.

bool TwitchPlaysSkipTimeAllowed = true;

Declaring this field as true allows for the timer to be skipped when the module it is in, as well as any other modules that would like to skip time, are the only unsolved modules left on the bomb. (Note: you don't have to use yield return "skiptime [seconds]" if you just wish to allow other modules such as Turn The Key to be solved despite your module being unsolved.)

Example ProcessTwitchCommand(string command)

Easy/Simple Option

Semaphore test code:

public string TwitchHelpMessage = "Move to the next flag with !{0} move right or !{0} press right. Move to previous flag with !{0} move left or !{0} press left. Submit with !{0} press ok.";
public KMSelectable[] ProcessTwitchCommand(string command)
{
    if (command.Equals("press left", StringComparison.InvariantCultureIgnoreCase) ||
        command.Equals("move left", StringComparison.InvariantCultureIgnoreCase))
    {
        return new KMSelectable[] { PreviousButton };
    }
    else if (command.Equals("press right", StringComparison.InvariantCultureIgnoreCase) ||
        command.Equals("move right", StringComparison.InvariantCultureIgnoreCase))
    {
        return new KMSelectable[] { NextButton };
    }
    else if (command.Equals("press ok", StringComparison.InvariantCultureIgnoreCase))
    {
        return new KMSelectable[] { OKButton };
    }

    return null;
}

Advanced Option

Colour Flash test code:

public string TwitchManualCode = "Color Flash";
public string TwitchHelpMessage = "Submit the correct response with !{0} press yes 3, or !{0} press no 5.";
public IEnumerator ProcessTwitchCommand(string command)
{
    Match modulesMatch = Regex.Match(command, "^press (yes|no|y|n) ([1-8]|any)$", RegexOptions.IgnoreCase);
    if (!modulesMatch.Success)
    {
        yield break;
    }

    KMSelectable buttonSelectable = null;

    string buttonName = modulesMatch.Groups[1].Value;
    if (buttonName.Equals("yes", StringComparison.InvariantCultureIgnoreCase) || buttonName.Equals("y", StringComparison.InvariantCultureIgnoreCase))
    {
        buttonSelectable = ButtonYes.KMSelectable;
    }
    else if (buttonName.Equals("no", StringComparison.InvariantCultureIgnoreCase) || buttonName.Equals("n", StringComparison.InvariantCultureIgnoreCase))
    {
        buttonSelectable = ButtonNo.KMSelectable;
    }

    if (buttonSelectable == null)
    {
        yield break;
    }

    string position = modulesMatch.Groups[2].Value;
    int positionIndex = int.MinValue;

    if (int.TryParse(position, out positionIndex))
    {
        yield return null;
        positionIndex--;
        while (positionIndex != _currentColourSequenceIndex)
        {
            yield return new WaitForSeconds(0.1f);
        }

        yield return buttonSelectable;
        yield return new WaitForSeconds(0.1f);
        yield return buttonSelectable;
    }
    else if (position.Equals("any", StringComparison.InvariantCultureIgnoreCase))
    {
        yield return null;
        yield return buttonSelectable;
        yield return new WaitForSeconds(0.1f);
        yield return buttonSelectable;
    }
}

Monsplode, Fight! Test code:

//In the header portion of your project, include this item, then hook it up in the prefab.
public KMBombInfo Info;

//The following line was present in the void start() routine.
    Info.OnBombExploded += BombExploded;

bool exploded;  //Used to detect when the bomb has exploded.
int strikesToExplosion;  //Used to count how many strikes it took for "BOOM" to explode the bomb.
void BombExploded()
{
    exploded = true;
}

//The following lines were present in the void OnPress(int btnID) routine to deal with the multi-strike explosion
    if (MD.specials[moveIDs[buttonID]] == "BOOM" && CD.specials[crID] != "DOC")
    {
        while (!exploded)
        {
            Module.HandleStrike();
            strikesToExplosion++;
        }
        //BOOM!
        Debug.LogFormat("[MonsplodeFight #{0}] Pressed BOOM!", _moduleId);
    }

//Now the actual ProcessTwitchCommand routine.
IEnumerator ProcessTwitchCommand(string command)
{
    int btn = -1;
    command = command.ToLowerInvariant().Trim();

    //position based
    if (Regex.IsMatch(command, @"^press [a-zA-Z]+$"))
    {
        command = command.Substring(6).Trim();
        switch (command)
        {
            case "tl": case "lt": case "topleft": case "lefttop": btn = 0; break;
            case "tr": case "rt": case "topright": case "righttop": btn = 1; break;
            case "bl": case "lb": case "buttomleft": case "leftbuttom": btn = 2; break;
            case "br": case "rb": case "bottomright": case "rightbottom": btn = 3; break;
            default: yield break;
        }
    }
    else
    {
        //direct name with "use"
        if (Regex.IsMatch(command, @"^use [a-z ]+$"))
        {
            command = command.Substring(4).Trim();
        }

        //direct name without "use"
        if (command == MD.names[moveIDs[0]].Replace('\n', ' ').ToLowerInvariant()) btn = 0;
        else if (command == MD.names[moveIDs[1]].Replace('\n', ' ').ToLowerInvariant()) btn = 1;
        else if (command == MD.names[moveIDs[2]].Replace('\n', ' ').ToLowerInvariant()) btn = 2;
        else if (command == MD.names[moveIDs[3]].Replace('\n', ' ').ToLowerInvariant()) btn = 3;
        else yield break;
    }
    if (btn == -1) yield break;

    yield return null;

    //Special case to catch when the chat command orders "BOOM" to be pressed against anyone other than DocSplode.
    if (MD.specials[moveIDs[btn]] == "BOOM" && CD.specials[crID] != "DOC")
    {
        yield return "multiple strikes";
        OnPress(btn);
        yield return string.Format("award strikes {0}",strikesToExplosion);
    }
    else
    {
        OnPress(btn);
    }
}

Example TwitchForcedSolve()

Forget Me Not

public IEnumerator HandleForcedSolve()
{
    int progress = (from x in BombInfo.GetSolvedModuleNames()
        where !AdvancedMemory.ignoredModules.Contains(x)
        select x).Count<string>();
    while(progress < Solution.Length)
    {
        yield return new WaitForSeconds(0.1f);
        progress = (from x in BombInfo.GetSolvedModuleNames()
            where !AdvancedMemory.ignoredModules.Contains(x)
            select x).Count<string>();
    }
    while(Position < Solution.Length)
    {
        Handle(Solution[Position]);
        yield return new WaitForSeconds(0.1f);
    }
}

public void TwitchHandleForcedSolve()
{
    StartCoroutine(HandleForcedSolve());
}

The Swan

void TwitchHandleForcedSolve()
{
    //For cases where Twitch plays admins do !<id> solve on the module.
    if (solved) return;
    systemResetCounter = -1;  //Just in case souvenir asks how many resets in the future. Set to zero for this case.
    StartCoroutine(failsafe());
}