Expanded Enum Syntax and Pattern Matching - ItsDeltin/Overwatch-Script-To-Workshop GitHub Wiki

Expanded Enum Syntax

For reference, here is what a standard enum looks like:

enum NpcType {
    Basic,         // Implicitly equals 0
    ShopKeeper = 1 // Discriminant/key explicitly defined
    Enemy          // Implcitly equals 2
}

NpcType npcType = NpcType.Basic;
// ⮩ compiles to: Global.npcType = 0;

Fields can now be defined within enums to represent variants:

enum NpcType {
    Basic,
    ShopKeeper(String),
    Enemy(Number)
}

NpcType npcType = NpcType.ShopKeeper("Grayscoop");
// ⮩ compiles to: Global.npcType = 1;
//                 Global.npcType_slot0 = "Grayscoop";

By adding fields, the enum becomes a "parallel" data type. A workshop variable is used to hold the discriminant (Global.npcType = 1) and more workshop variables are used to hold the rest of the values (Global.npcType_slot0 could represent the shopkeeper's name or an enemy's damage modifier).

Enums with inner fields work in a similar fashion to structs. Read the struct page for more information how these types compile. Some concepts described in this page will be repeated here. https://github.com/ItsDeltin/Overwatch-Script-To-Workshop/wiki/Structs

The different kinds of enums

Similarly to structs, you can mark an enum as single to package all items within an array:

single enum NpcType { /* ... */ }

NpcType npcType = NpcType.ShopKeeper("Grayscoop");
// ⮩ compiles to: Global.npcType = [1, "Grayscoop"];

This makes three ways OSTW can compile an enum:

// 1️⃣ ========== No inner values ==========
enum Enum1 { A, B, C }
Enum1 e1 = Enum1.A;
// ⮩ compiles to: Global.e1 = 0;

// 2️⃣ ========== Parallel ==========
enum Enum2 { A, B(Number, Number), C(String) }
Enum2 e2 = Enum2.C("Wow!");
// ⮩ compiles to: Global.e2 = 2;
//                 Global.e2_slot0 = "Wow!"
//                 Global.e2_slot1 = 0;

// 3️⃣ ========== Single ==========
single enum Enum3 { A, B(Number, Number), C(String) }
Enum3 e3 = Enum.B(6, 7); 
// ⮩ compiles to: Global.e2 = [1, 6, 7];

If an enum is single or has no inner values, it requires only one variable slot to store it. If the enum is parallel, the number of slots needed is equal to the enum member that requires the most storage plus one. In the previous example, Enum2.B needs to store 2 numbers, causing the e2 variable declaration to use 3 workshop variable slots.

How to Get The Discriminant of an Enum Value

You can extract the key/discriminant of an enum value by using the .Key property on an enum that has inner fields:

String npc_type_as_string(in NpcType npc): ["Basic", "Shop Keeper", "Enemy"][npc.Key];

Enums with Type Arguments

Enums may also be defined using type arguments:

// For 2 options, use booleans as
// the keys to reduce element count! :)
enum Option<T> {
    None = false,
    Some(T) = true
}

// These functions are inline & generate no actions
Option<T> None<T>(): Option<T>.None;
Option<T> Some<T>(in T value): Option<T>.Some(value);

The amount of storage required to store this enum can't be known by its definition, as it is dependent on what T is:

struct Message { String a, String b, String c }

// T is number, so 2 slots!
define number_option = None<Number>();
// ⮩ compiles to: Global.number_option = false;
//                 Global.number_option_slot0 = 0;

// T is the struct defined below, so 4 slots!
define msg = Some<Message>({ a: "This is a ", b: "struct definition,", c: " yay!!" });
// ⮩ compiles to: Global.msg = true;
//                 Global.msg_slot0 = "This is a ";
//                 Global.msg_slot1 = "struct definition, ";
//                 Global.msg_slot2 = " yay!!";

In practice, it could be useful to switch between "parallel" and "single" storage depending on what is happening. An example of this in practice is below.

single struct Single<T> {
    public T Value;
}
Single<T> single<T>(in T value): { Value: value };

// Compiles as one workshop variable.
// This wraps all the values from the
// Option data type into one array.
Single<Option<Message>>[] stored_messages = [];

void process_a_message()
{
    // Unwraps a message from one value into multiple.
    // Indexer not needed to view values,
    // increasing performance & reducing element count 
    // on subsequent reads and modifications.
    Option<Message> msg = stored_messages.First.Value;
    
    expensive_operation_on(msg);
    
    // Done doing expensive stuff. Store it for later
    // in compact array.
    stored_messages.ModAppend(single(msg));
}

Pattern Matching

Pattern matching is what allows you to access the values within an enum. This is done with the is operator in the form of expression is pattern. The left-hand value in the is operator will be referred to as the "operand". The is operator will always return a boolean indicating whether the operand is successfully matched to the pattern.

At this time, the only accepted form for the right-hand pattern part is the name of an enum member.

Here is an example:

enum NpcType { Basic, ShopKeeper, Enemy }

NpcType npc = NpcType.Shopkeeper;
if (npc is ShopKeeper) {}
// ⮩ compiles to: If(Global.type == 1);

The pattern matching is done at npc is ShopKeeper. This is the shorthand version, it can also be written as npc is NpcType.Shopkeeper.

Pattern matching does not do anything interesting when the enum has no inner values (see 1️⃣) since this is the same behavior as doing type == NpcType.ShopKeeper. However, it becomes useful once fields are added to the enum.

The following code will do these steps:

  1. Create NpcType of variant ShopKeeper.
  2. Check if the npc variable is a ShopKeeper. Bind the inner value to the declared name, shop_keeper_info.
  3. If it is, reduce shop_keeper_info.cost_multiplier by 0.1.
struct ShopKeeperInformation {
    public String name;
    public Number cost_multiplier;
}

struct EnemyInformation {
    public Number health;
}

enum NpcType {
    Basic,
    ShopKeeper(ShopKeeperInformation),
    Enemy(EnemyInformation)
}

NpcType npc = NpcType.ShopKeeper({
    name: "Redscoop",
    cost_multiplier: 1.0
});

/*
relationship with shopkeeper deepened ;)
get a discount!
keep flirting and maybe you will *gain* money
when you buy stuff??
*/
if (npc is ShopKeeper(shop_keeper_info)) {
    shop_keeper_info.cost_multiplier -= 0.1;
}

In the pattern expression, shop_keeper_info is a variable name we are declaring. It will not need new workshop variable slots; Instead, it will bind to the variable in the operand, npc. This means that shop_keeper_info.cost_multiplier -= 0.1 is actually modifying npc!

The variables bound to the operand are only mutable if the operand is mutable, otherwise, the binding variables will be read-only. See the difference below:

if (npc is ShopKeeper(shop_keeper_info)) {
    // This is okay, the operand (npc) is a variable!
    shop_keeper_info.cost_multiplier -= 0.1;
}

if (get_npc() is ShopKeeper(shop_keeper_info)) {
    // Not okay, the operand is not a variable!
    // ostw will whine, complain, and even cry if we
    // try to modify the bound variable.
    shop_keeper_info.cost_multiplier -= 0.1;
    
    // but we can read it no problem :)
    Number cm = shop_keeper_info.cost_multiplier;
}

An Important Note About Binding Variable Flow

One thing to keep in mind is that it is that the bound variables are simply slapped into the surrounding context's scope. This means you can do undefined behavior by writing something like this:

if (npc is ShopKeeper(shop_keeper_info)) {
    return;
}
// As of now, it is impossible for the npc to be a shopkeeper,

// ⚠ BUT... shop_keeper_info is available to access and read here!
shop_keeper_info.name = "Redscoop The Broken";
// ⮩ compiles to: Global.npc_slot0 = "Redscoop The Broken";

// The npc may be an enemy. The 'name' variable for a shopkeeper points to the same register as an enemy's health.
// we just put our game into bad state!

Ideally in this scenario OSTW should make accessing and reading the shop_keeper_info variable unavailable outside of the if statement. I do not currently have the spoons to implement something like that (imagine inverting the is operand with ! and you may see how determining flow gets complicated fast!) So, do your best to be alert about how things are flowing around your is expression to prevent a bad state from forming.

I'm not interested in ever making another breaking change in OSTW unless Overwatch forces my hand. So if I do ever end up limiting pattern-bound variables to valid context, there should also be a compiler option to revert to the current behavior. So if you are intentionally utilizing this undefined behavior, you can be confident that it will not break in the future. Here is an example if this being "useful":

// zero element way to treat an npc as a shopkeeper
ShopKeeperInformation unwrap_shopkeeper(in NpcType npc) {
    // Expression that has `is` for pattern binding
    // but generates no elements/actions by being an
    // inline variable declaration:
    define _: npc is Shopkeeper(shop_keeper_info);
    return shop_keeper_info; 
}
// ⮩ compiles to: ...nothing????

This function uses this quirk to extract ShopKeeperInformation out of an NpcType straight up. No variant checking required. Could be good if you can reasonably assume that an npc is a ShopKeeper and you are going for cutthroat optimizations. Though, more likely you have some design choices that should be reevaluated.

Variable binding with indexers & player variable target

Variable binding can include target players and array indexers, as seen below.

// An array of enums!
// (See the struct wiki page for information
// on how parallel arrays work.)
playervar NpcType[] npcs = [];

// ...

if (HostPlayer().npcs[0] is Enemy(enemy_information))
{
    // Operand is mutable, this is totally okay!
    enemy_information.health += 10;
} 

Motivation and use case of enums and pattern matchings

Classes are quite expensive to use, but they had two advantages over structs: variable usage and representing variance. The new expanded enum syntax has these benefits while behaving like OSTW's structs. This should eliminate most remaining reasons for choosing to use a class to represent and interact with data.

Consider you have a workshop game with several modes. Each of these modes needs a lot of workshop variables to operate.

struct GameOne {
    public Vector warning_zone;
    // pretend there is more going on here
}

struct GameTwo {
    // and here
}

enum GameState {
    A(GameOne) = 1,
    B(GameTwo) = 2
}

globalvar GameState game_state = GameState.A({
    warning_zone: Vector.Zero
});

rule: "Set up warning zone"
if (game_state is A(game_one))
// ⮩ compiles to: Global.game_state == 1
{
    ChaseVariableAtRate(game_one.warning_zone);
    // ⮩ compiles to: Chase Variable At Rate(
    //                    Global.game_State_slot0, ...);
}

The pattern expression in the condition enforces the state to be accessed in a safe way semantically. It's a parallel data structure so you can even use Chase Variable. It compiles like there's nothing. It's clean as hell. There is zero extra fluff.

⚠️ **GitHub.com Fallback** ⚠️