Session Rule Implementation

From TrainzOnline
Jump to: navigation, search

This page seeks to clearly define expected script behavior for Session Rule assets. This focuses specifically on correct behavior from the code perspective, rather than addressing how rules should be designed for best gameplay outcomes.


Contents

Legacy Behavior Note

Please note that many existing rules do not behave as defined here. This is considered a bug in the individual session rules. N3V Games has a policy of updating session rules to behave correctly even where that may lead to changes of function in existing session assets. Where reasonable to achieve, emulation of the existing (buggy) behavior may occur for existing rule instances in existing sessions, however any newly created rule instances will use the corrected behavior.

These expectations were codified at the end of TS12 development due to an increasing inability of session rules to work together resulting from the unpredictable nature of their responses to various common use-cases. The systems as a whole, including most of the core rules discussed in this document, have existed for far longer than this document. The document does not redefine any existing systems, but simply presents the only reasonable implementation approach possible if rules are to be expected to work reliably in all use-cases. In some cases we also choose to mandate specific points of behavior relating to user expectations rather than technical necessities.

Certain additional API mechanisms were introduced alongside the development of this document, to allow better communication with session authors regarding the differences in behavior between certain types of rule. The correct usage for these mechanisms is also included in this document.


The Rule Hierarchy

All rules within a given session form a simple tree structure.

Rule State Flags

Each rule in the tree may have certain associated state flagged, including a combination of the following:

PAUSED

The rule is currently paused, meaning that it does not affect the game world in any way and does not respond to any changes in the game world. The parent rule (or native code) must cause the rule to become unpaused for the rule to have any effect. It is not correct behavior for a rule to unpause itself. It is not correct behavior for a rule to unpause any children while it itself is paused.

COMPLETE

This flag indicates that rule has fully completed its operation (including any child rule operations) and no longer affects the game world in any way. This flag is set by the rule itself, not by an outside entity such as the parent. This state should not be changed while the rule is PAUSED.

Being COMPLETE does not imply being PAUSED. In many scenarios, the parent rule may respond to a child becoming COMPLETE by causing it to also become PAUSED, however this is certainly not guaranteed in all cases, and is not guaranteed to be immediate in the cases where it does happen.

The rule may optionally respond to specific changes in the game world by deflagging the COMPLETE status. For example, a rule which specifically implements a condition check may choose to become COMPLETE when that condition is met, but return to an incomplete state when the condition is no longer met (again, remembering that a change in either direction should only happen while the rule itself is not PAUSED).

When a rule's COMPLETE state is changed via ScenarioBehavior.SetComplete(), a "ScenarioBehavior", "Touch" message is sent to its parent rule (if any), unless the parent is in the PAUSED state. This allows the parent to react to changes in its child rules' states. It is important to note that the message indicates a possible change which the parent may be interested in, but it is the parent rule's responsibility to determine whether the state change is worth reacting to.

WAS COMPLETE

This is typically set when a rule first becomes flagged as COMPLETE, and will stay set even if the rule later flags as incomplete. This can be used by a parent which only cares that the child rule has been flagged as COMPLETE at some point, rather than caring what its current completion status is.

DOES COMPLETE

This indicates whether the rule can ever reach the COMPLETE state. Its value is constant for a given rule configuration, and does not change based on the runtime rule state or other game state. Rules which are flagged as DOES COMPLETE are expected to become complete after some discreet operations are completed. Rules which are not flagged are expected to run indefinitely unless PAUSED by an external agent (such as the parent rule).

Initial State

All rules are initially in the PAUSED state when in Surveyor, and only become unpaused after gameplay begins in a Driver session. Since a PAUSED rule is not permitted to unpause itself, this is a stable state which will persist until the native code takes steps to begin program flow.

State Determination

Rules should always base their functionality on the state flags such as the PAUSED state, rather than attempting to detect and vary their functionality based on the state of the game.

For example, Unified Driver Surveyor makes it possible to switch between Driver and Surveyor at will, without any loading screen, and without any affect on world gameplay state. This includes Session Rule state. It is incorrect behavior for a rule to pause itself, or otherwise prevent or stop execution, based on the active interface module. APIs exist to detect such state changes where necessary, but in most cases it's simply not necessary to care whether your rule is in Surveyor or Driver - you should act based on the PAUSED state instead.

Rules should also not assume that it is impossible to be reconfigured during gameplay. This will of course introduce some side effects, such as when an unpaused rule is modified during execution. It is the rule's responsibility to ensure that these side effects do not lead to script exceptions or to leaked state. For example, if a single thread is supposed to be running, reconfiguring the rule should not cause a second thread to run simultaneously. If a rule sniffs a target vehicle, and the target vehicle is redefined, then the rule should unsniff the original vehicle rather than now sniffing two vehicles.

Top Level Rules

All rules at the top level (ie. rules which are not a child of any other rule) simultaneously become unpaused after the session has loaded into Driver. Some of these rules may take initial setup steps and then become COMPLETE. Some of these rules may begin monitoring for specific game conditions before taking any further action. Some of these rules may begin taking action immediately. The rules are not sequenced against each other in any way, and it is the session author's responsibility to ensure that this does not cause any conflicts.

Scheduling

Since the script VM is a cooperatively-threaded environment, it is technically true that rule evaluation will occur in an ordered manner rather than truly simultaneously. However, no guarantees are made regarding the order in which the simultaneously-running rules will perform their checks or outcomes. Since rules may wait internally rather than fulfilling their entire purpose in an atomic operation, it is also not guaranteed that a first-running rule will fully complete before some other simultaneously-executing rule begins completion. In short, if order-of-execution is critical to the correct behavior of a session, then the order should be enforced by the session creator using the rule hierarchy rather than by making assumptions about execution order of simultaneously-running rules.

(Note: with the introduction of Asynchronous Route Streaming, this is even more true than previously. What may have been a near-instantaneous operation in older builds may now require a wait of several seconds while the necessary data is streamed in.)

It is important to note that some conditional rules may partially or completely rely on a polling behavior to evaluate their conditions. (An example of partial reliance is a rule which waits for an event, but then evaluates a condition to clarify whether the event should be acted upon.) In these cases, it is possible that the condition can rapidly become true and then become false again without the rule responding. This results in an uncertainty as to whether the conditional rule will correctly detect a given instance of the condition. This type of scenario should be avoided completely where possible, and should most definitely be limited to outcomes that do not significantly affect gameplay.

For a hypothetical example, if the player's train speeds for 0.5sec, and a speeding check rule was implemented that polled every second, then the overspeed event may or may not trigger on any given occurrence. A session creator should not use this outcome as a failure condition, since some players may be able to speed without penalty, whereas other players would be penalised on the first attempt. A sensible solution in this case might be to increment a counter while speeding is detected, and only take action if the counter reaches a certain threshold. If the only penalty was a warning to the player which had no actual gameplay impact, then it might be acceptable to forgo this check and simply accept that some players will receive a warning where others may not.

Child Rules

As with all other rules, child rules (ie. those that have parent rules in the hierarchy) start out in the PAUSED state. They do not become unpaused until the parent is ready for the children to start operating. Exactly when this occurs depends on the specific parent rule, but the general flow is the same for all rules:

1. Parent rule becomes unpaused and begins operation.

2. Parent rule configures any necessary world state changes.

3. Parent rule optionally waits for a specific condition to occur.

4. Parent rule begins executing its children. This involves unpausing one or more of the children (as discussed below in "Parent Rule Styles").

5. Parent waits for its children to complete (as discussed below in "Parent Rule Styles") and then pauses the children.

6. Parent flags itself as COMPLETE.

This process allows program control flow to pass from a top-level rule, to its immediate children, to their immediate children, and so on. As the bottom-level children become complete, completion flows back to the higher level children until finally the top-level rules become COMPLETE.

It is worth noting that there is nothing special about a top-level rule being flagged as COMPLETE. This does not indicate an end-of-session or any other fundamental gameplay mode change. Since the pausing of rules on completion is handled by the parent rule (see step 5 above), the top-level rules never become PAUSED on completion.

Parent Rule Styles

There are a few common techniques with which parent rules interact with their children. Rules should always fit one of these descriptions. The vast majority of rules should follow either the "Does Not Support Children" style, or the "Ordered Execution of Children" style. The style of child execution should also be indicated in the Rules list GUI, by implementing the ScenarioBehavior.GetChildRelationshipIcon() function.

Does Not Support Children

The rule is incapable of supporting children and will ignore them entirely.

This is used for trivial rules which perform a set function and then become permanently COMPLETE. Such rules are not conditional and do not wait for any world state changes except those which they initiate internally. For rules which do not support children ScenarioBehavior.GetChildRelationshipIcon() should be implemented to unconditionally return "none".

Simultaneous Execution of Children

When the rule is ready to begin executing children, it unpauses all children simultaneously. The rule then waits for all children to flag as WAS COMPLETE. Once this is detected, the rule pauses all children and flags itself as COMPLETE.

This is used for rules which have a specific need to simultaneously execute children, such as the "Simultaneous List" rule.

The rules list interface should explicitly denote that child rules will be executed simultaneously. This is done by implementing ScenarioBehavior.GetChildRelationshipIcon() to return "simultaneous" for any passed child rule.

Ordered Execution of Children

When the rule is ready to begin executing children, it iterates through the children in top-to-bottom order. If the child is flagged as WAS COMPLETE, the child is paused and iteration continues. If the child is not flagged as WAS COMPLETE, the child is unpaused and iteration ends. Whenever a child state change is detected, this process is repeated from the start. Once iteration passes over all children without leaving any child unpaused, the rule flags itself as COMPLETE.

This style is used for all rules which do not explicitly fall under one of the other styles described here. Almost all third-party rules should follow this style. Implementing one of the other styles without there being a fundamental requirement to do so is considered a programming error.

Rules which follow this style should derive from ConditionalScenarioBehavior in order to avoid re-implementing the control flow logic, as it is surprisingly complex to implement correctly, and commonly done incorrectly. Any rules which do not derive from ConditionalScenarioBehavior should be accompanied by a clear explanation of the reasons that ConditionalScenarioBehavior is an unsuitable base class for the intended behavior. Rules which do derive from ConditionalScenarioBehavior should avoid complex overrides of the default logic.

Ordered child execution is denoted in the rules list interface by rendering a number beside the child. This behavior is implemented by having ScenarioBehavior.GetChildRelationshipIcon() return an appropriate number string.

Controlled Selection of a Single Child

When the rule is ready to begin executing children, it selects a single child and unpauses it. All other children remain paused. Once the selected child becomes flagged as WAS COMPLETE, the child is paused and the rule flags itself as COMPLETE.

This style is used where complex conditional selection of child rules is required. This can be used to implement an "if-else" logic flow, a "switch-case" logic flow, or Rules such as the "Random List". This style should not be used to implement a simple "if" logic flow, because that would unnecessarily restrict the rule to a single child where the "Ordered Execution of Children" style would provide the same functionality while allowing multiple children.

As always, the rules list interface should explicitly indicate how/if/which child rules will be executed. In this case ScenarioBehavior.GetChildRelationshipIcon() may be required to return a KUID or filepath for a custom texture. The rule edit interface and description should also clearly indicate the behaviour using localisable text.

Custom Execution with Multiple Children

This style is used for very specific custom-purpose rules, such as a passenger station stop handler. Such rules should give a clear and complete explanation of their operation and their interaction with any child rules in the rule editor interface.

Individual child rules may be executed in an ad-hoc manner by the rule in response to changes in game state. Child rules may or may not be executed simultaneously and may or may not be executed repeatedly depending on the rule; any limitations or gotchas deriving from this should be carefully explained in the rule editor interface.

Child rules should be tagged appropriately in the rule editor list to avoid the user attempting to fit a child rule into the wrong role.


Early Termination via Pause

A rule must accept being paused by its parent at any time. This should cause the rule to cease any game state monitoring or other direct effects. The rule must pause all child rules immediately.

In cases where the rule is maintaining some reversible effect on the game world, pausing the rule should end this effect. For example, a rule which displays a message to the user should typically hide the message upon becoming paused, and a rule which causes a rain storm in a specific area should return the weather to normal upon becoming paused. Rules which make a single permanent change to the game world and then immediately flag as COMPLETE should not attempt to reverse the change upon becoming paused. Examples of these rules include configuration of the global weather conditions, or a rule which increments the player's score or penalty counter. It is acceptable for a single rule to support both of these cases by way of a checkbox option within the rule edit interface.

It is important to note that any pause is resumable, although the parent may not choose to take advantage of this. Pausing a rule followed by unpausing it should result in the rule continuing operation where it left off. The notes on Saving and Loading Driver Sessions (below) are worth considering when evaluating techniques for implementing pause.

Early Termination via Internal Logic

In some cases, a rule may begin executing child rules but then make an internal decision to abort execution. This typically occurs in conditional rules where the condition may be re-evaluated as false after it has already been evaluated as true. Such behavior should be made explicit in the rule editor interface and should usually be made optional by providing a checkbox.

There are in fact several possible behavioral variants affecting how condition-deflag is interpreted:

  • Once the condition is flagged, the rule starts executing child rules and no further evaluation is performed. The rule eventually flags itself as COMPLETE and does not later deflag this.
  • Once the condition is flagged, the rule starts executing child rules. If the condition becomes deflagged, the child rules are paused. If the condition becomes flagged again, the child rules are continued. Once the child rules have fully completed execution, the rule flags itself as COMPLETE and does not later deflag this.
  • Once the condition is flagged, the rule starts executing child rules. If the condition becomes deflagged, the child rules are paused. If the condition becomes flagged again, the children are reset and execution starts anew. The rule flags itself as COMPLETE if all children successfully complete, but it will deflag if the condition deflags.
  • Once the condition is flagged, the rule starts executing child rules. If the condition becomes deflagged, the child rules continue executing. If the condition becomes flagged again, the children are reset and execution starts anew. The rule will flag as COMPLETE if all children successfully complete, but it will deflag if the condition deflags.
  • Once the condition is flagged, the rule starts executing child rules. The child rules continue execution with no regard for whether the condition changes between flagged or deflagged in either direction. If the condition transitions from deflagged to flagged again after the children have completed, the children are reset and execution starts anew. The rule will flag as COMPLETE if all children successfully complete, but it will deflag itself if the condition deflags.

The complexity here derives from the fact that it is not possible to use the same set of rules multiple times concurrently for different trigger conditions. For example, if a set of rules is used to give demerit points and provide user notifications, and those rules live under a single conditional check which determines whether a train is speeding, it may be difficult to define what should happen if two trains are detected as speeding at the same time, or if a train stops speeding while the notification process is still in progress. It is not always possible to achieve a perfect solution to tracking multiple objects using a single set of rules, but correct selection from the above behaviors can minimise the possibility of serious side-effects.

It is important to note that these behaviors describe how the rule reacts to internal condition checks. The rule's reaction to being paused by its parent is discussed above under "Early Termination via Pause" and is not affected by the description here.

In short, there are many ways of handling this, and the important thing is for the (parent) rule to clearly explain which approach it is going to use.


Rule Reset

A rule may be reset by its parent. This causes both the COMPLETE and WAS COMPLETE states to be deflagged. Individual rules may also react to the reset by modifying their internal state to forget past or present actions. Parent rules which support child execution must also cause their children to be reset in turn.

This mechanism is used to permit rules to be used in a repeating scenario, such as responding to a condition which can occur multiple times, or in a repeating loop. At some point after a rule has become flagged as WAS COMPLETE, it will typically become paused and will not be unpaused again. If the rule is to be included in some repeating behavior, this would normally prevent the behavior from operating successfully after the first run through. To avoid this, the parent rule responsible for implementing the repeating behavior will reset all children when a repeat occurs. This effectively cleans the rules back to their initial state ready to perform their roles again.

It is important to note that a reset should cause the rule to return to its configured settings. For example, if a timer value is configured and is decremented during rule operation, the timer must be reset back to its initial value during rule reset.

It is also important to note that there is no requirement to pause a rule prior to resetting it.

Spurious Events

There are various events (typically script messages) that a rule may listen for in order to detect state changes. It is important to note that spurious events are often possible, and that an event handler should often be coupled with a condition check. As a specific example, the "ScenarioBehavior", "Touch" message typically indicates that a child state has changed, but the message may in fact be sent at any time. This message should prompt the rule to evaluate whether any state changes are necessary, but the rule should not assume that a state change is necessary without performing an evaluation.

Similarly, calls to a function such as Pause() may in some cases have no effect. For example, if a rule is already executing and its parent calls Pause(false), the rule should not pause, reset, or change state in any way at all. Similarly, if a rule is already paused and its parent calls Pause(true), the rule should not unpause, reset, fire any events, or otherwise change state in any way at all.

The short version is: do not assume that every event and every method call is supposed to achieve something meaningful. Check first, and if the event is meaningless to you then do nothing at all.

Saving and Loading Driver Sessions

For better or worse, the script VM does not persist across a save/load operation. This means that each rule must correctly save both its configuration state and its runtime state, and must correctly reload those such that the state after a save/load operation is indistinguishable from the original state. The following aspects of the script state must be considered:

  • Member variables in the Rule instance.
  • Which threads are running on the Rule instance, and where they are up to in the code flow.
  • Local variables on the stack of each running thread.
  • Non-persistent changes made to the state of other script objects (such as active 'sniff' statements).
  • Script Messages which might be in flight at the time of save (these are not persisted, so the net effect is that they are simply deleted between the save and the load.)

Since the game state continues normal operation after a Save, no code flow or other behavioral changes should result from the Save operation itself. Instead, the Save operation (and the design of the rule as a whole) is responsible for ensuring that sufficient data is persisted in the save file such that the Load operation is able to restore to the correct running state.

The rule base class, ScenarioBehavior, is responsible for saving and loading the rule state flags (COMPLETE, PAUSED, WAS COMPLETE, etc.) A rule which was paused at Save time will still be paused after the Load. A rule which was complete at Save time will still be complete after the Load. During a Load operation, the rule should inherit the parent class' SetProperties() method so that these flags are correctly configured, then honor the paused state during the rest of the loading process.

It is rarely appropriate for an unpaused rule to simply restart execution from the beginning. Care must be taken to ensure that execution resumes from the point that it left off at the time of saving. This process can be simplified by keeping as much state as possible in the rule member variables at all times, and only keeping reconstructible temporary values on the stack. If the member variables are correctly stored and restored, this approach will allow the loading process to detect the runtime state of the rule and resume any necessary threads, which will in turn be able to jump forward to the correct area of the code based on the stored state.

Finally, the COMPLETE, WAS COMPLETE, and PAUSED flags on child rules should be honored. This is typically sufficient information to determine the running state of child rules, so no further state will generally need to be maintained. More accurately, the child rules take care of their own state at all times, and the parent only needs to react to changes in the child state. Since no child state change occurs across a Save/Load operation, the parent does not need to become involved.

For rules which derive ConditionalScenarioBehavior, the load/save of the majority of execution logic is handled automatically. The only state information that such a rule should typically preserve is the implementation-specific configuration data. The condition-monitor thread will be automatically restarted as required, and there is generally no need to distinguish between a first-startup and any subsequent startup.

Gotchas

There are a few potential gotchas in the script environment that are worth considering when writing any non-trivial rule.

Response Latency

Scripts tend to rely heavily on message passing and threading. In both cases, there is a delay between triggering a certain behavior (eg. posting an event, or calling a threaded function) and having the receiving code react. Other code may execute during this delay.

// Calling this might be expected to start and then stop the thread; in reality it
// will likely simply start the thread and leave it running, because the message
// is sent prior to the wait() statement starting.
void DemonstrateBug1(void)
{
  Pause(false);
  Pause(true);
}

// In this case, we use a Sleep() statement to "ensure" that the thread has
// finished starting. This isn't actually reliable, but it works as long as the
// system isn't under too much load. We follow this up with a rapid pause-
// unpause sequence. As above, this doesn't work - the message is sent,
// then the function immediately checks whether the thread is still running
// (which it is) and thus the thread isn't restarted. The function completes,
// and finally the thread receives the message and exits.
void DemonstrateBug2(void)
{
  Pause(false);
  Sleep(1.0);
  Pause(true);
  Pause(false);
}

// Helper code for the above functions.
void Pause(bool bIsPaused)
{
  if (bIsPaused)
  {
    PostMessage(me, "ThisRule", "Pause");
  }
  else if (!m_bIsThreadRunning)
  {
    m_bIsThreadRunning = true;
    StartThread();
  }
}
thread void StartThread(void)
{
  while (true)
  {
    wait ()
    {
    on "ThisRule", "Pause":
      break;
     ...
  }
  m_bIsThreadRunning = false;
}


Multiple Messages in Flight

It's possible to post multiple identical messages. This can be useful, but if you're expecting only one then it can be a source of errors.

In the following pseudocode, each call to SetProperties() will place an additional "Timer", "Tick" message into the cycle, effectively increasing the rate at which the IdleFunction is called. This will seem to be working for a single call to SetProperties(), which may be all that is called during a trivial testcase. It will probably give reasonable results for a few calls to SetProperties(), although if the function is time-sensitive then the results might be different to expectations. After a larger number of calls to SetProperties(), the object's message queue may become full, causing it to lose messages at random (perhaps the tick messages, or perhaps other unrelated messages that are more problematic).

void Init(void)
{
  inherited();
  
  AddHandler(me, "Timer", "Tick", "IdleFunction");
}

void EnterIdleState(void)
{
  IdleFunction();
}

void IdleFunction(void)
{
  PostMessage(me, "Timer", "Tick", 1.0);
  .. do something here ..
}

void SetProperties(Soup soup)
{
  inherited(soup);
  
  EnterIdleState();
}

A similar problem can occur if you start new threads rather than post new messages.


Thread Guard Variables

It's common to prevent a singleton thread from being started twice by using a boolean guard variable. Care must be taken to ensure that the guard variable is set and cleared at the correct locations, or they may be ineffective.

The following pseudocode demonstrates one such correct usage. The key points to note are:

  • We set 'm_bIsThreadedFunctionRunning' prior to calling the threaded function. Setting it within the threaded function is too late; multiple threads might have already been started before the first thread reaches that point.
  • We don't confuse whether the thread is actually running with whether we want the thread to be running. Once we ask a thread to start, we can't stop it starting. If we change our minds and want to stop it again, we must inform it in a way that it will realise after it finishes starting. In the worst-case scenario, we might flipflop on this multiple times before the thread actually starts.
  • Similarly, we don't rely on messages to control the thread. Enqueuing a separate start or stop message with each StartThread()/StopThread() call might be possible, and we could clear any unwanted prior messages from the object's queue, but if the thread hasn't finished starting then it won't receive the messages anyway.
  • We do rely on messages to wake the thread if it's waiting on messages, but we don't use those messages to imply any state - we just have it recheck the current state boolean to get the very latest info.
  • We clear 'm_bIsThreadedFunctionRunning' once the decision to exit has become unconditional. At this point, even though the thread is technically still running, we'll have to start a new thread if we want the thread to continue running.
bool m_bIsThreadedFunctionRunning = false;
bool m_bWantThreadedFunctionRunning = false;

void StartThread(void)
{
  m_bWantThreadedFunctionRunning = true;
  
  if (m_bIsThreadedFunctionRunning)
    return;
  
  m_bIsThreadedFunctionRunning = true;
  ThreadedFunction();
}

void StopThread(void)
{
  if (!m_bWantThreadedFunctionRunning)
    return;
  
  m_bWantThreadedFunctionRunning = false;
  PostMessage(me, "Thread", "Touch");
}

thread void ThreadedFunction(void)
{
  while (m_bWantThreadedFunctionRunning)
  {
    wait()
    {
      on "Thread", "Touch":
        break;
      .. whatever else..
    }
  }
  m_bIsThreadedFunctionRunning = false;
}


Not Waiting For Messages

As seen above, it's possible for a thread to fail to receive posted messages because it wasn't waiting for them at the time. A more commonly problematic example follows.

thread void MyThread(void)
{
  wait ()
  {
    on "Some", "Condition":
      HandleSomeCondition();
      continue;
    on "Thread", "Exit":
      break;
  }
}

void HandleSomeCondition(void)
{
  wait ()
  {
    on "Some", "End-Condition":
      break;
  }
}

In this example, the thread is waiting on both "Some", "Condition" and also "Thread", "Exit". Either one will be handled correctly assuming that the thread is already running at the time they are posted. Even if multiple are posted, the thread will simply queue them and run each in turn.

The problem occurs when HandleSomeCondition() is triggered and the thread begins waiting on "Some", "End-Condition". In this scenario, the thread is no longer waiting on "Thread", "Exit", and any attempt to post that message will simply be ignored. To reiterate: the message dispatch is NOT postponed until the function returns; the message is dropped entirely.

In many cases, the correct behaviour is to move HandleSomeCondition() to a new thread, although that may also carry gotchas that you will need to be careful of (eg. you'll probably want to terminate the second thread at the same time as the first one, and you may need to consider whether you want multiple of the second thread to run simultaneously or not).

Performing Actions After Being Told to Stop

In many cases, you may wish to perform cleanup actions when stopping your rule. If your rule is threaded, this can easily become problematic.

Consider the following pseudocode example:

void Pause(bool bIsPaused)
{
  if (!bIsPaused && !m_bIsThreadRunning)
  {
    m_bIsThreadRunning = true;
    MyThread();
  }
}

thread void MyThread(void)
{
  BeginRule();
  while (!IsPaused())
  {
    Sleep(0.5);
    UpdateRule();
  }
  EndRule();
  m_bIsThreadRunning = false;
}

There are three immediately obvious problems:

1. That BeginRule() is called when the thread starts, even if the rule has already been paused again.

2. That UpdateRule() will be called after each sleep, even if the rule was paused during the sleep.

3. That EndRule() will be called after the rule has been paused.

This is very problematic because it likely violates a core principles of the rules system: that rules will not undertake any meaningful behaviour when paused. All three functions may be called while the rule is paused, so unless the rule does nothing meaningful at all, this is a guaranteed violation.

The best approach to resolving this may depend on exactly what these functions do, but one possible solution is as follows:

void Pause(bool bIsPaused)
{
  if (bIsPaused)
  {
    if (m_bNeedsEndRule)
    {
      m_bNeedsEndRule = false;
      EndRule();
    }
  }
  else
  {
    if (!m_bNeedsEndRule)
    {
      BeginRule();
      m_bNeedsEndRule = true;
    }
    if (!m_bIsThreadRunning)
    {
      m_bIsThreadRunning = true;
      MyThread();
    }
  }
}

thread void MyThread(void)
{
  while (!IsPaused())
  {
    UpdateRule();
    Sleep(0.5);
  }
  m_bIsThreadRunning = false;
}

Handler Called while Paused

This one is pretty obvious, but can still catch you out. Handler functions created with AddHandler() will always be called, regardless of whether your rule is paused. You should generally only add your required handlers when your rule is unpaused, and remove them again when paused. If that's not feasible for some reason, then the alternative is to have you handler function confirm the paused state of the rule before doing any actual work (but keep in mind that this approach wastes CPU time).

Failing to Inherit from ConditionalScenarioBehavior

This isn't really a separate gotcha, but it can't be stressed enough that your rule should inherit from ConditionalScenarioBehavior and should minimise the number of overrides to its default behavior. Apart from the "users expect things to work like this, so that's what you should do" argument, there are some much more compelling arguments for would-be rule developers:

1. Developing a rule with correct threading behavior is hard. Just have a look at some of the above gotchas. Were you already consciously aware of them all? If not, you're probably not ready to write your own rule which involves threading. ConditionalScenarioBehavior provides a polling thread mechanism that's already built, so you can reap the benefits of that approach without having to worry about whether you made any mistakes in the threading code.

2. Developing a rule with correct load/save behavior can be hard. Did you correctly restart your threads when necessary? Did you accidentally restart your threads when you weren't supposed to? Was there a message in flight during the save which you lost by reloading, and have you handled that loss correctly? ConditionalScenarioBehavior can help with a lot of that kind of thing, leaving you to worry about your rule-specific implementation only.

3. Developing a rule with correct child-rule-handling behavior can be really hard. You need to have a thorough understand of everything listed in this document, and you need to ensure that you don't make any mistakes which could cause your rule to violate any of the principles in this document, even in edge-case behaviours. Because this is the real world, you also need to handle child rules which don't necessarily follow the rules to the letter. ConditionalScenarioBehavior does all of this automatically with no effort on your part.

4. Inheriting from ConditionalScenarioBehavior means that many future issues can be fixed automatically by N3V Games without you having to update your rule. This includes both fixes to our own code (hey, we're human too) and also new changes to adapt our code to future requirements.

5. Inheriting from ConditionalScenarioBehavior typically means that there's a lot less semi-custom boilerplate in your rule, making your rule much simpler to read and maintain. This helps you while you're debugging your own rule, helps you when you're coming back in a year's time and wondering how it all worked, and perhaps helps others in the community if they need to update your rule in 10 years time after you've moved on from developing Trainz script code.

SEE ALSO:

Personal tools