User:Armithaig/Spring Molting Moddability

From Caves of Qud Wiki
Jump to navigation Jump to search
This page is about modding. See the modding overview for an abstract on modding.
This page is about modding. See the modding overview for an abstract on modding.
For best results, it's recommended to have read the following topics before this one:

There's been numerous internal changes to events, parts, abilities, text, serialization, and the attitude of creatures which may be relevant to your current or future mods.

This is just a brief overlook of the larger shifts within the 2024 Spring Molting patch and won't/can't cover much beyond what has changed. If you want more detailed explanations about something mentioned here I'd be happy to field modding questions (armithaig on Discord) if I'm available.

Opinions

The feelings of creatures, towards both each other and the player, have so far been relatively simple. Each creature has a Feeling value ranging from -100 to +100 where any number below 0 normally meant the creature would be hostile. This number could be influenced in any number of ways such as reputation, attacking them, or beguiling them. But there was little opportunity to suss out why or when a creature's feeling towards another had changed (which was a nightmare for us to debug any kind of erroneous hostility). Not to mention these feeling values would be lost when the zone was frozen, a common tactic was to just run about two zones away if you pissed off the town allowing their feelings to reset like nothing had happened.

IOpinion is the base class introduced to alleviate these issues. Each kind of feeling altering event (attacking, stealing, etc), now has its own type of opinion which is entered into a history of opinions about each creature. This allows for much more nuanced modeling of relationships between creatures which can be selectively forgotten, if we need to forgive opinions only gained through combat for example.

Below is an example of a positive opinion added to a creature when it is petted.

using XRL.World;
using XRL.World.AI;

namespace ExampleMod
{

    public class OpinionPetted : IOpinionSubject
    {

        public int Level;

        /// <summary>The base feeling change from this opinion.</summary>
        public override int BaseValue => 50;

        /// <summary>The turns it takes for this opinion to abate.</summary>
        public override int Duration => Calendar.TurnsPerDay * 7;

        public override void Initialize(GameObject Actor, GameObject Subject)
        {
            Level = Subject.Level;
        }

        public override string GetText(GameObject Actor)
        {
            return $"Petted me at level {Level}.";
        }

    }

}

namespace XRL.World.Parts
{

    public class LikeWhenPetted : IPart
    {

        public override bool WantEvent(int ID, int Cascade)
        {
            return base.WantEvent(ID, Cascade)
                   || ID == AfterPetEvent.ID
                ;
        }

        public override bool HandleEvent(AfterPetEvent E)
        {
            if (E.Object == ParentObject)
            {
                E.Object.AddOpinion<ExampleMod.OpinionPetted>(E.Actor);
            }
            return base.HandleEvent(E);
        }

    }

}

Allegiances

A frequent problem with companions was that they'd revert to their original allegiances if they for one reason or another lost track of their leader, usually leading to a brawl in the stilt for example. Further I thought it would be more interesting if we could tell when and why a creature decided to switch allegiances with a flavorful blurb akin to the player's chronology.

Creatures now maintain a history of their allegiances in a linked list, with their latest one being used for attitude towards other creatures, and their original being used for water ritual reputations.

New allegiances can always be added manually, but there are two methods that make the most common changes easier: TakeAllegiance<T> for taking on the allegiance of another creature and SetAlliedLeader<T> which does the same while also setting them as our leader. The generic type T is the reason for changing allegiance. Below is an example of creatures we speak to being charmed by our poetic features.

using XRL.UI;
using XRL.World;
using XRL.World.AI;

namespace XRL
{

    public class AllyCharmed : IAllyReason
    {

        public string Feature;

        public override void Initialize(GameObject Actor, GameObject Source, AllegianceSet Set)
        {
            // Grab a random poetic feature that charmed us
            Feature = Source.GetxTag("TextFragments", "PoeticFeatures").GetRandomSubstring(',');
        }

        // "On the 13th of Tebet Ux I was charmed by their spheric hooves."
        public override string GetText(GameObject Actor)
        {
            return $"I was charmed by their {Feature}.";
        }
    }

    public class ExampleSystem : IPlayerSystem
    {

        public override void RegisterPlayer(GameObject Player, IEventRegistrar Registrar)
        {
            Registrar.Register(AfterConversationEvent.ID);
        }

        public override bool HandleEvent(AfterConversationEvent E)
        {
            if (E.Actor.IsPlayer())
            {
                // Make whoever the player spoke to take on the allegiances of them
                E.SpeakingWith.TakeAllegiance<AllyCharmed>(E.Actor);
                if (E.SpeakingWith.PartyLeader != null)
                {
                    // Set and take on the allagience of leader
                    E.SpeakingWith.PartyLeader.SetAlliedLeader<AllyCharmed>(E.Actor);
                }
            }
            return base.HandleEvent(E);
        }

    }

}

MinEvent Registration

Prior to this patch MinEvents were restricted to only being usable within their hardcoded cascade level, which restricted the depth they would cascade to, mostly for performance reasons. For example, if you wanted to handle an object being given life with the AnimateEvent from a piece of a equipment you're wearing, you'd be out of luck as the event does not cascade to worn equipment and you would have to create some funky workaround.

Another frequent issue was the order in which event handlers were processed, if you wanted the code from your event handler to run before another's you'd similarly have to do something very hacky and try to reorder the cascading yourself.

Event handlers can now register to MinEvents from external event sources that are outside their cascade range, with an optional ordering determining the precedence it has over other handlers. For example a global game system can listen for events directly on the player, or the member of a party can listen for events from their leader, etc.

using XRL.UI;

namespace XRL.World.Parts
{

    public class ExamplePart : IPart
    {

        // IEventRegistrar is also new with this update, it contains some state to make the most common registrations less repetitive
        // Most importantly it can both register and unregister for events as needed by the game, when an object goes out of scope for example
        public override void Register(GameObject Object, IEventRegistrar Registrar)
        {
            // Listen for when the parent object gains a level, after most handlers
            Registrar.Register(AfterLevelGainedEvent.ID, EventOrder.LATE);
            // Listen for when the player dies, before most handlers
            // Notably this does not follow the player should they change bodies, and should update the registration with AfterPlayerBodyChangeEvent
            Registrar.Register(The.Player, BeforeDieEvent.ID, EventOrder.VERY_EARLY);
            // Listen for when the game changes active zones
            Registrar.Register(The.Game, ZoneActivatedEvent.ID);
        }
        
        public override bool HandleEvent(AfterLevelGainedEvent E)
        {
            Popup.Show($"{ParentObject.an()} gained a level!");
            return base.HandleEvent(E);
        }

        public override bool HandleEvent(BeforeDieEvent E)
        {
            // Make player immortal.
            return false;
        }

        public override bool HandleEvent(ZoneActivatedEvent E)
        {
            Mutation.EvilTwin.CreateEvilTwin(
                Original: ParentObject,
                Prefix: "quasi-",
                TargetCell: E.Zone.GetRandomCell()
            );
            return base.HandleEvent(E);
        }

    }

}

Event Handlers

IParts and Effects that are attached to GameObjects have for the most part had the monopoly on the handling of events so far. This patch introduces the IEventHandler interface which defines the handling of MinEvents, meaning any class can now register for and handle a MinEvent if they implement it.

The IGameSystem class which is extended to define a lot of global game behavior (such as psychic hunters, village checkpointing, and ambient sounds to name a few) has been converted to use this new interface, allowing it to seamlessly interface with the existing event system.

You can read more about interfaces and how to use them here.

using XRL;
using XRL.World;

namespace ExampleMod
{

    public class MyClass : IEventHandler
    {

        public static void Register()
        {
            var myClass = new MyClass();
            The.Player.RegisterEvent(myClass, AfterDieEvent.ID);
        }

        public bool WantEvent(int ID, int Cascade)
        {
            return ID == AfterDieEvent.ID;
        }

        public bool HandleEvent(AfterDieEvent E)
        {
            XRL.UI.Popup.Show("You died!");
            return true;
        }

    }

}

Modded MinEvents

Adding your own custom MinEvents has always been rather laborious and had significant performance drawbacks because the game had to invoke any handlers of the event using reflection.

Spring Molting introduces the IModEventHandler<T> interface for performance, as well as the ModSingletonEvent<T> and ModPooledEvent<T> classes to make things less cumbersome. All of these are generic and take your new event class as the T parameter. For most purposes the ModPooledEvent<T> is preferred, whereas the ModSingletonEvent<T> is a slightly simpler alternative when the event has no state or cannot intersect with itself.

using XRL.World;

namespace ExampleMod
{

    public class ExampleEvent : ModPooledEvent<ExampleEvent>
    {

        public static readonly int CascadeLevel = CASCADE_EQUIPMENT | CASCADE_EXCEPT_THROWN_WEAPON;

        public string Value;

        // A static method that fires your event,
        // this isn't strictly necessary but is how the game prefers to organize it
        public static string GetFor(GameObject Object)
        {
            var E = FromPool();
            Object.HandleEvent(E);

            return E.Value;
        }

        // Resets the event before it's returned to the pool
        public override void Reset()
        {
            base.Reset();
            Value = null;
        }

        // How far our event will cascade,
        // this example will cascade to equipped items
        public override int GetCascadeLevel()
        {
            return CascadeLevel;
        }

    }

    public class ExampleHandler : IPart, IModEventHandler<ExampleEvent>
    {

        public override bool WantEvent(int ID, int Cascade)
        {
            return base.WantEvent(ID, Cascade)
                   || ID == ExampleEvent.ID
                ;
        }

        public bool HandleEvent(ExampleEvent E)
        {
            E.Value = "Handled!";
            return true;
        }

    }

}

IPart Priority

An alternative to ordering your events via registrations, is giving your IPart a priority which affects its order within the list of parts of the parent object. This affects the cascade order of any events to that part, but also the order in which it is serialized which can be desirable if you have a part that is dependent on state from another. The ActivatedAbilities part is a good example of this, as many other parts add new abilities and would like to resolve them when being deserialized.

using XRL.UI;

namespace XRL.World.Parts
{

    public class ExamplePart : IPart
    {

        // A higher priority is placed earlier in the part list, receiving events before lower priorities
        public override int Priority => PRIORITY_HIGH;

        public override bool WantEvent(int ID, int Cascade)
        {
            return base.WantEvent(ID, Cascade)
                   || ID == GetMeleeAttackChanceEvent.ID
                ;
        }

        public override bool HandleEvent(GetMeleeAttackChanceEvent E)
        {
            if (!E.Primary)
            {
                // Prevent offhand attacks
                E.Multiplier = 0;
                return false;
            }
            return base.HandleEvent(E);
        }

    }

}

Text Variable Replacers

Text frequently needs to refer to, and be formatted with, various state from the game. It can be someone's pronouns, the name of an object, or the time of the day. To do this the game uses text delimited with = to define a variable which will be parsed by the game and replaced with the textual representation of the state we're interested in.

Take the description of a dog for example, =pronouns.Subjective= =verb:are:afterpronoun= a snarling mess of matted hair.. Here two variables are defined for the subjective pronoun and a verb which for a female dog would become She is a snarling mess of matted hair..

With this patch it is now possible to define your own custom variable replacers to use within your mod. To do this you simply decorate a class and one or more methods with an attribute.

using XRL.World.Text.Delegates;
using XRL.World.Text.Attributes;

namespace ExampleMod
{

    [HasVariableReplacer]
    public static class MyVariableReplacers
    {

        [VariableReplacer]
        public static string MyReplacer(DelegateContext Context)
        {
            // Context.Parameters is a list of every colon separated string in order, e.g. =replacer:one:two:three=.
            return "hiho it's " + Context.Parameters[0];
        }
        
        [VariableObjectReplacer]
        public static string MyObjectReplacer(DelegateContext Context)
        {
            // The Context.Capitalize flag is true if the variable name was capitalized.
            if (Context.Capitalize)
            {
                // Context.Target has a reference to the object from the prefix in the variable, e.g. =subject.replacer= or =object.replacer=.
                return "Big " + Context.Target.DisplayName;
            }
            else
            {
                return "small " + Context.Target.DisplayName;
            }
        }

        // A name can be manually defined in the attribute if you don't want to use the method name
        [VariablePostProcessor("customName")]
        public static void MyPostProcessor(DelegateContext Context)
        {
            // The Context.Value is a StringBuilder with the result of the variable replacement, ready for post processing.
            Context.Value.Replace("snapjaw", "snapfriend");
        }

    }

}

To this we could then feed the text Oh =subject.myObjectReplacer|customName=, =myReplacer:Ut yara Ux=. =object.MyObjectReplacer= is your friend? which would be parsed into Oh small snapfriend scavenger, hiho it's Ut yara Ux. Big Irudad is your friend?.

Text Replacement Builder

Performing text variable replacements can be quite complex for both the user to set up and the game to parse, along with being restricted to only two game objects. This has been simplified greatly with the introduction of a builder for the arguments, which can additionally add temporary replacers for parsing.

Below we call StartReplace on a string of text we'd like to do variable replacements on, and ToString to create the final string.

using XRL;
using XRL.UI;
using XRL.World;
using XRL.World.Text.Delegates;

namespace ExampleMod
{

    public static class ExampleClass
    {

        public static string Text1 = "=subject.T= =verb:go= beep during the =beepTime=.";
        public static string Text2 = "=object[0].An=, =object[1].an=, and =object[2].an= "
                                     + " enter =someplace:nice=.";

        public static void Test()
        {
            var scavenger = GameObject.Create("Snapjaw Scavenger");
            var forager = GameObject.Create("Naphtaali Forager");
            var mehmet = GameObject.Create("Mehmet");
            
            // "The snapjaw scavenger goes beep during the night."
            var beepResult = Text1.StartReplace()
                .AddObject(scavenger)
                .AddReplacer("beepTime", "night")
                .ToString();
            Popup.Show(beepResult);

            // "A Naphtaali forager, a snapjaw scavenger, and Mehmet enter a nice village."
            var placeResult = Text2.StartReplace()
                .AddObject(forager)
                .AddObject(scavenger)
                .AddObject(mehmet)
                .AddReplacer("someplace", Replacer)
                .ToString();
            Popup.Show(placeResult);
        }

        public static string Replacer(DelegateContext Context)
        {
            return $"a {Context.Parameters[0]} village";
        }

    }

}

Serialization

The save system has been upgraded to be able to gracefully remove missing mod data from an ongoing save without corrupting it, this works out of the box for anything derived from, or contained within, IComponent like parts, mutations, skills, and effects. Or anything that implements the new IComposite interface.

IComposite will by default use reflection to serialize the public instance fields of the implementing type, but you can optionally disable this behavior and use the defined Write and Read methods to serialize it manually.

/// <summary>
/// Contracts a type as composited entirely from public fields supported by <see cref="FastSerialization"/>
/// and/or handling its own serialization.
/// </summary>
public interface IComposite
{
    /// <summary>
    /// If true, public instance field values will be serialized via reflection.
    /// </summary>
    public bool WantFieldReflection => true;

    public void Write(SerializationWriter Writer)
    {
        
    }

    public void Read(SerializationReader Reader)
    {
        
    }
}

Object Builders

There are some cases where you'd like to mutate an object as it is being created in a way not easily done via pure XML, maybe it should have a cultural piece of equipment in its inventory, a tattoo of a handsome snail, or a historic epithet which grants it fire breathing. For this the game has had something called a builder, although they were created for a time when objects were spawned via encounter tables which are no longer in use.

The old encounter builders have now been updated into IObjectBuilder singletons (objects with a single reusable instance), to fulfill their niche better. You could always do the same with an IPart of course, but this could create a lot of garbage (which Unity does not handle very gracefully) and pollutes event cascading making performance ever so slightly worse.

<objects>
  <object Name="Snapjaw Scavenger" Load="Merge">
    <!-- ChanceOneIn is a special property of builders which determines the chance the builder will apply on object creation -->
    <builder Name="ExampleBuilder" Stats="Strength,Hitpoints,MoveSpeed" ChanceOneIn="5" />
  </object>
</objects>
using XRL.World.Parts;

namespace XRL.World.ObjectBuilders
{

    public class ExampleBuilder : IObjectBuilder
    {

        // Field set by XML blueprint
        public string Stats;

        public override void Apply(GameObject Object, string Context)
        {
            // boost a random stat
            var stat = Stats.GetRandomSubstring(',');
            Object.GetStat(stat).BoostStat(2);

            // give creature a title depending on what stat was boosted
            var title = stat switch
            {
                "MoveSpeed" => "fleet-footed",
                "Strength" => "hulking",
                "Hitpoints" => "stalwart",
                _ => "acclaimed"
            };

            Object.RequirePart<Titles>().AddTitle(title);
        }

    }

}

World Map Abilities

Most abilities have been changed to not be usable on the world map, but there are still some exceptions like recoilers and toggleables. In order to mark your ability as usable on the world map you simply flag it as IsWorldMapUsable.

using System;

namespace XRL.World.Parts.Skill
{

    public class ExampleSkill : BaseSkill
    {

        public Guid AbilityID;

        public override bool AddSkill(GameObject GO)
        {
            AbilityID = AddMyActivatedAbility(
                Name: "Example",
                Command: "CommandToggleExample",
                Class: "Skill",
                Toggleable: true,
                DefaultToggleState: true,
                IsWorldMapUsable: true
            );
            return base.AddSkill(GO);
        }

    }

}