Osiris Design Patterns

From Divinity Engine Wiki
Jump to: navigation, search

Introduction

Here we give an overview of a number of common design patterns that are useful when programming in Osiris.

Program Initialisation

There are several ways to initialise Osiris code, each with their own use-cases. A safe and very common pattern is as follows:

  1. Make goals that contain global helper functionality top-level goals, so they are activated as soon as Osiris is initialised. Either do not rely on functionality from other goals during the initialisation of these goals, or ensure the goals on which you depend are initalised before yours:
    • they should also be top-level goals (either in your Mod, or in another Mod)
    • their name should be alphabetically sorted before your goal's name. Underscores are an often used trick to get to the front.
  2. Per level, create one goal (e.g., named the same as this level) that completes when the RegionStarted event for this level is triggered.
  3. For every quest in that level, create a sub-goal for the level goal.

We will now have a more detailed look at the various initialisation mechanics.

INIT section

The actions in an INIT section of a goal execute as soon as the goal becomes active. Its main uses are

  • Define databases used in that same goal. E.g. in a goal about crime bribes, define databases to look up the bribe range for characters of a certain level.
  • Define databases used by other goals, but related to the current goal. E.g. in a goal about a certain quest, define the dialogs of the NPCs specific to this quest.
  • Initialise the default state of objects related to the current goal. E.g. in a goal about a quest, set a character off-stage in case it should appear only at a specific point in the quest.

Direct Dependencies

If you call PROCs from your INIT code, ensure that these are defined in goals that are guaranteed to already have been intialised. You will not get a compile-time nor run-time error if you call a routine declared in a goal that is not yet active; the call will simply not do anything.

Example: consider the screenshot of the Goal Initialisation and Completion section. Calling a PROC defined in the FTJ_Origins_Ifan goal from the INIT code (or from a PROC called from the INIT code) of the AtaraxianArtifacts goal will silently fail here. The reason is that both goals are subgoals of the _Start goal, and goals are initialised and activated in alphabetical order. So when the AtaraxianArtifacts goal gets initialsed, the FTJ_Origins_Ifan goal is not yet active.

Indirect Dependencies

If you declare databases in your INIT code, or in a PROC called from your INIT code, and another goal is supposed to react to this database declaration, ensure that this other goal gets initialised before you.

Example: you define a DB_ShovelArea() fact in your goal to create a secret dirt pile. The goal that handles the setup of these secret dirt piles is __GLO_Shovel in the Shared Mod. It sets up these dirt piles by declaring event handlers such as

IF
DB_ShovelArea((TRIGGERGUID)_Trigger, (STRING)_Reward, (ITEMGUID)_DirtMound)
THEN
..

This means that if you define such a database before the __GLO_Shovel goal has been activated, nothing will happen since the event handler that reacts to this definition is not yet active. Even if the __GLO_Shovel goal initialises and activates afterwards, the above event handler will not be called for already existing DB_ShovelArea() facts. The reason is that the DB-event only fires when the DB gets defined.

Simply ensuring that your goal has a name that is alphabetically sorted after __GLO_Shovel is not sufficient in this case to ensure that the __GLO_Shovel goal has already initialised when your goal initialises. The reason is that __GLO_Shovel is a subgoal of the Shared Mod's __Start goal. This means that it will only activate when this __Start goal completes.

The Shared mod's __Start goal completes when the GameEventSet("GAMEEVENT_GameStarted") event triggers. In practice, you should seldom use this event, and rely instead on Level Events. These level events are guaranteed to trigger after the GAMEEVENT_GameStarted event. If you follow rule 3 from the summary above and put your DB_ShovelArea declarations in the INIT sections of subgoals of your level initialisation goals, this happens automatically.

Game Events

These events are thrown when the game engine has finished initialising, but before any level has been loaded. This means that even global objects are not accessible at this point, and many Osiris calls and queries will fail because the necessary context is missing. The main purpose of these events is to allow for generic story initialisation.

Two events exist in this category.

GameEventSet 
This event also existed in DOS1. As the documentation page mentions, it is only called with one particular parameter at this time.
GameModeStarted 
This event is new in DOS2. It is thrown right after the previous one. Its parameter indicates whether the game has been started in campaign (adventure/story), game master, or arena mode. You can use this to only initialise parts of story that are relevant to the selected mode, by making all goals children of a goal that only completes if the desired game mode started. As of DOS2 Patch 6, all story goals distributed with the main game use this functionality to prevent interference with mods that can be used with more than one game mode.

Level Events

These events can be used to execute code when a level gets entered by players:

The difference between these two events is explained on the linked pages. Also note that these events are thrown when loading a savegame (for the level in which the game was saved), so do not tie them to rules that should be executed exactly once without adding safeguards. One way is to catch this event in a top-level goal that is then immediately completed as explained in the Program Initialisation section. All one-time initialisation can then be done in the INIT sections of the child goals.


Doing Something Once

The event-based nature of Osiris makes it easy to consistently perform actions whenever something happens, but sometimes things should happen only once. Several patterns can be used on this depending on the situation.

Goal-Based Initialisation

When a goal becomes active, all of the actions in its INIT section are executed. So you can make it a subgoal of another goal that completes when this initialiation should occur, like with the Program Initialisation above.

Breaking off a Loop

In many cases you will find yourself iterating a database checking for a condition to be fulfilled, and executing certain actions when this is the case. Often, these actions should only be executed once, for the first fact that fullfills to the conditions. For example, take this slightly simplified code from the Peeper quest, whereby we look for a chicken to hatch the Peeper egg:

DB_RC_DW_VC_Chickens((CHARACTERGUID)S_RC_DW_VC_Chicken_001_9053f0be-7d06-4e88-ab20-f411e2e790d0,"RC_DW_VC_Chicken1","RC_DW_VC_AD_Chicken1",0,(TRIGGERGUID)S_RC_DW_VC_Chicken01_DeadTrigger_51b9f0bc-04bf-4f68-9e0d-43058d4a4580); // The 0 means "currently hatching an egg"
DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_002_d8699c1d-3ca1-444b-b985-95de1dc8c708,"RC_DW_VC_Chicken2","RC_DW_VC_AD_Chicken2",0,S_RC_DW_VC_Chicken02_DeadTrigger_629dcb32-e251-48b8-8a33-e710dda8c3fc);
DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_003_bcd34f89-8ff2-4d94-8c30-fe09bdbc1163,"RC_DW_VC_Chicken3","RC_DW_VC_AD_Chicken3",0,S_RC_DW_VC_Chicken03_DeadTrigger_72400c76-d222-4996-bef1-b787f449fcc2);
DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_004_66188471-2b54-4f53-8efa-8278125e2c60,"RC_DW_VC_Chicken4","RC_DW_VC_AD_Chicken4",0,S_RC_DW_VC_Chicken04_DeadTrigger_7d824686-06ce-472c-a4ce-cd85300f57b6);
DB_RC_DW_VC_Chickens(S_RC_DW_VC_Chicken_Scared_81ef9840-7b6e-4d3d-9b60-4c7ed7a286a7,"RC_DW_VC_ChickenScared","RC_DW_VC_AD_ChickenScared",0,S_RC_DW_VC_ScaredChicken_DeadTrigger_65e32575-6cd8-44dc-9af3-ded6982d0
..
PROC
Proc_RC_DW_VC_SetNewBroodChicken()
AND
DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger)
AND
CharacterIsDead((CHARACTERGUID)_chicken,0)
AND
NOT DB_RC_DW_VC_DoneOnce(1)
THEN
DB_RC_DW_VC_DoneOnce(1);
NOT DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger);
DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,1,_trigger);
DB_RC_DW_VC_DroppingEgg(_chicken,"0_GoingToEggAsNewHatchingChicken");
ProcCharacterMoveTo(_chicken,TRIGGERGUID_S_RC_DW_VC_HelplessEggLocation_92d4c501-c342-4e25-b08e-32fb7d396ee9,0,"RC_DW_VC_ChickenWalkToEgg");
CharacterSetReactionPriority(_chicken,"DefaultWanderInTrigger",0);

// Very important: ensure we will loop again next time this proc is called!
PROC
Proc_RC_DW_VC_SetNewBroodChicken()
THEN
NOT DB_RC_DW_VC_DoneOnce(1);

When the Proc_RC_DW_VC_SetNewBroodChicken gets called, it iterates over the chickens in the DB_RC_DW_VC_Chickens() database. Next, for each such chicken it checks whether it is alive (not dead), and if so, it selects that chicken as the one that should hatch the egg. Of course, we only want one chicken to do so. Therefore, as soon as we found an available chicken, we define the DB_RC_DW_VC_DoneOnce(1) fact and we also check on this condition to ensure that we do not select additional chickens afterwards.

Note that the check for NOT DB_RC_DW_VC_DoneOnce(1) is the very last condition. This is important, because as explained in the section on the Osiris rule evaluation state, semantically Osiris iterates over databases by making a local table that contains all matching facts and then iterating over those without revisiting conditions higher up. This means that the iteration would not abort if we wrote the check like this:

PROC
Proc_RC_DW_VC_SetNewBroodChicken()
AND
NOT DB_RC_DW_VC_DoneOnce(1)
AND
DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger)
AND
CharacterIsDead((CHARACTERGUID)_chicken,0)
THEN
DB_RC_DW_VC_DoneOnce(1);

The reason is that Osiris would find that DB_RC_DW_VC_DoneOnce(1) is not defined at the time Proc_RC_DW_VC_SetNewBroodChicken() gets called. Next, it will start iterating over the DB_RC_DW_VC_Chickens() database without checking the DB_RC_DW_VC_DoneOnce() database ever again.

Factoring Out Checks to Queries

Queries offer an easy way to factor out searching logic and the associated bookkeeping from a proc. For example, we could rewrite the above using a query as follows:

QRY
QRY_RC_DW_VC_FindBoodChicken()
AND
DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger)
AND
CharacterIsDead((CHARACTERGUID)_chicken,0)
AND
NOT DB_RC_DW_VC_FindBoodChicken(_)
THEN
DB_RC_DW_VC_FindBoodChicken(_chicken);
PROC
Proc_RC_DW_VC_SetNewBroodChicken()
AND
QRY_RC_DW_VC_FindBoodChicken()
AND
DB_RC_DW_VC_FindBoodChicken(_chicken)
AND
DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger)
THEN
NOT DB_RC_DW_VC_FindBoodChicken(_chicken);
NOT DB_RC_DW_VC_Chickens(_chicken,_dialog,_ad,0,_trigger);
...

In this case, there is no real gain to do this. However, if you need to look up several independent things in a loop, this pattern can make your code much clearer as all the "only once" checks are isolated in their individual queries. Be careful to never use this pattern (using a result DB from a query in the same rule that called the query) in rules whose trigger condition(s) consist exclusively of DB-checks, because otherwise you will trigger an Osiris bug.

QueryOnlyOnce

QueryOnlyOnce is a useful helper query that uses the principle above as a handy shortcut to execute something only once. Its documentation page contains a usage example. The query itself is also very simple:

QRY
QueryOnlyOnce((STRING)_OnlyOnceUUID)
AND
NOT DB_OnlyOnce(_OnlyOnceUUID)
THEN
DB_OnlyOnce(_OnlyOnceUUID);

The query will only succeed if that DB is not yet defined. When it succeeds, it defines the DB so that future invocations of this query will fail.

Trigger On Deleting a Database Fact

You cannot simply check on DB_CHECK(1) getting unset, as the following is invalid code:

IF
NOT DB_CHECK(1)
THEN
..

Events only fire when DBs get defined, not when they get removed. However, due to the fact that the database checks that are part of a rule trigger condition can be fulfilled in any order, you can create a rule that triggers on delete a database fact by using a helper:

IF
DB_EnableCrime(_Player,_Crime)
THEN
DB_CrimeEnabled(_Player,_Crime);
EnableCrime(_Player,_Crime);

IF
DB_CrimeEnabled(_Player,_Crime)
AND
NOT DB_EnableCrime(_Player,_Crime)
THEN
NOT DB_CrimeEnabled(_Player,_Crime);
DisableCrime(_Player,_Crime); 

If a DB_EnableCrime() fact is added somewhere, we enable the crime and also define a DB_CrimeEnabled() fact. Next, our second rule ensures that if the original DB_EnableCrime() fact gets undefined, will will disable the crime the again. We then also undefine the DB_CrimeEnabled() fact so that we can detect changes again should the DB_EnableCrime() fact be redefined later.

Check Whether a Database Is Empty

 IF
 CharacterDied(_Char)
 AND
 NOT DB_Check(_, _)
 THEN
 ...

You cannot specify names for the parameters of DB_Check() in this case. The reason is that this condition can only succeed if DB_Check() is empty. Hence, if you would specify names for the database fields and then use those names in subsequent conditions or actions, they would not be bound to any value at that point.