# Copyright (C) 2011-2021 A S Lewis
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU
# General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
# even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with this program. If not,
# see <http://www.gnu.org/licenses/>.
#
#
# Games::Axmud::Task::XXX
# Objects handling built-in tasks

{ package Games::Axmud::Task::Advance;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Advance task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new initial task because \'' . $profName . '\' profile doesn\'t'
                    . ' exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'advance_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'advance_task';
        $self->{prettyName}             = 'Advance';
        $self->{shortName}              = 'Av';
        $self->{shortCutIV}             = 'advanceTask';    # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Advances character\'s guild skills';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = FALSE;            # Start with ';advance' command
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = FALSE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = FALSE;
        $self->{winPreferList}          = [];
        $self->{winmap}                 = undef;
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # The skills to advance. A list in groups of two, with each group in the format
        #   ('skill', 'number') where <skill> is the skill to advance, and <number> the times to
        #   advance it.
        # If 'skill' is 'undef', the next skill in the current character's skill order/cycle list is
        #   used
        # If 'number' is -1, the skill is advanced as many times as possible, before moving onto the
        #   next group
        # If the list is empty, it's treated the same way as a 2-element list ('undef', -1) meaning
        #   advance the pre-defined list as many times as possible
        $self->{skillList}              = [];
        # The name of the current skill being advanced now
        $self->{currentSkill}           = undef;
        # How many MORE times to advance the skill (-1 means indefinitely), 0 means this is the last
        #   time we're advancing this skill. Set to 'undef' when no skill being advanced
        $self->{currentSkillNum}        = undef;

        # Advance mode:
        #   'named_skill' - advancing a named skill
        #   'order_list' - advancing a pre-defined skill from the order list
        #   'cycle_list' - advancing a pre-defined skill from the cycle list
        # In modes 'order_list' and 'cycle_list', if the advance fails, the skill is re-inserted
        #   into the character's order/cycle list so, the next time the task runs, the same skill
        #   can be advanced again (hopefully when the character has more XP/cash)
        $self->{advanceMode}            = 'named_skill';
        # When a success message is seen, $self->advanceSuccessSeen sets this flag to TRUE (set to
        #   FALSE otherwise)
        $self->{advanceSuccessFlag}     = TRUE;
        # When a failure message is seen, $self->advanceFailSeen sets this flag to TRUE (set to
        #   FALSE otherwise)
        $self->{advanceFailFlag}        = FALSE;
        # How long to wait (in seconds) for each advance to produce a response (a timeout)
        $self->{waitTime}               = 30;
        # Next time to check (matches GA::Session->sessionTime);
        $self->{nextCheckTime}          = undef;

        # Flag set to TRUE at stage 2, when the character hasn't logged in and a warning message is
        #   shown (we only want to show it once)
        $self->{warningFlag}            = FALSE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{skillList}             = [$self->skillList];
        $clone->{currentSkill}          = $self->currentSkill;
        $clone->{currentSkillNum}       = $self->currentSkillNum;

        $clone->{advanceMode}           = $self->advanceMode;
        $clone->{advanceSuccessFlag}    = $self->advanceSuccessFlag;
        $clone->{advanceFailFlag}       = $self->advanceFailFlag;
        $clone->{waitTime}              = $self->waitTime;
        $clone->{nextCheckTime}         = $self->nextCheckTime;

        $clone->{warningFlag}           = $self->warningFlag;

        # Cloning complete
        return $clone;
    }

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

#   sub doFirstStage {}         # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            my ($worldObj, $charObj);

            # No point in continuing if there is no current guild or character
            if (! $self->session->currentGuild || ! $self->session->currentChar) {

                if (! $self->session->currentGuild) {

                    $self->writeError(
                        'Advance task halting because there is no current guild profile',
                        $self->_objClass . '->doStage',
                    );

                } else {

                    $self->writeError(
                        'Advance task halting because there is no current character profile',
                        $self->_objClass . '->doStage',
                    );
                }

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;

            # However, if the character hasn't logged in yet, wait indefinitely. The first time,
            #   show a warning; on subsequent times, just wait
            } elsif (! $self->session->loginFlag) {

                if (! $self->warningFlag) {

                    $self->writeWarning(
                        'Advance task waiting for the character to log in to the world',
                        $self->_objClass . '->doStage',
                    );

                    $self->ivPoke('warningFlag', TRUE);
                }

                # Repeat this stage indefinitely
                return $self->ivPoke('stage', 2);
            }

            # Import current profiles
            $worldObj = $self->session->currentWorld;
            $charObj = $self->session->currentChar;

            # If the current character's skill IVs have never been reset (i.e. never had default
            #   values imported from the current guild profile), reset them now
            if (! $charObj->resetSkillsFlag) {

                if (! $charObj->resetSkills($self->session)) {

                    $self->writeError(
                        'Advance task halting because the current character profile can\'t reset'
                        . ' its skill IVs',
                        $self->_objClass . '->doStage',
                    );

                    $self->ivPoke('shutdownFlag', TRUE);
                    return undef;
                }
            }

            # If this guild's skill lists are completely empty, show a complaint (unless the user
            #   has specified a skill to advance)
            if (
                ! defined $self->ivFirst('skillList')
                && (
                    ($charObj->advanceMethod eq 'order' && ! $charObj->advanceOrderList)
                    || ($charObj->advanceMethod eq 'cycle' && ! $charObj->advanceCycleList)
                    || (
                        $charObj->advanceMethod eq 'combo'
                        && $charObj->advanceMethodStatus eq 'order'
                        && ! $charObj->advanceOrderList
                    )
                )
            ) {
                $self->writeError(
                    'Advance task halting because the current character\'s skill list is completely'
                    . ' empty',
                    $self->_objClass . '->doStage',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return FALSE;
            }

            # Create triggers to capture the messages for skills when successfully advanced, and
            #   when failing to advance
            $self->resetTriggers();

            # If $self->skillList is empty, it means that we have to advance pre-defined skills as
            #   often as possible. Create an entry in the list (a group of 2 elements) that means
            #   'advance pre-defined skills as often as possible'
            if (! $self->skillList) {

                $self->ivPush('skillList', undef, -1);
            }

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            my ($charObj, $skill, $number);

            # Import the current character profile
            $charObj = $self->session->currentChar;

            # When there are no more skills to advance, $self->skillList is empty
            if (! $self->skillList) {

                $self->writeText('Advance task: Finished advancing skills');

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # Get the next skill from the list
            $skill = $self->ivShift('skillList');
            $number = $self->ivShift('skillList');

            if (defined $skill) {

                # Mark this as a named skill, not a pre-defined one
                $self->ivPoke('advanceMode', 'named_skill');

            # If $skill is set to 'undef', it means use the next pre-defined skill (stored in
            #   the character profile)
            } else {

                # Find the next pre-defined skill, and store it in $skill 'order' mode
                if (
                    $charObj->advanceMethod eq 'order'
                    || (
                        $charObj->advanceMethod eq 'combo'
                        && $charObj->advanceMethodStatus eq 'order'
                    )
                ) {
                    # If the list is empty...
                    if (! $charObj->currentAdvanceOrderList) {

                        if ($charObj->advanceMethod eq 'combo') {

                            # In 'combo' mode, once the order is exhausted, switch to the cycle list
                            $self->writeText(
                                'Advance task: Character\'s skill list (\'order\' mode) exhausted;'
                                . ' switching to \'cycle\'',
                            );

                            # (We'll process the cycle list in a moment)
                            $charObj->ivPoke('advanceMethodStatus', 'cycle');

                        } else {

                            # Nothing more to do
                            $self->writeText(
                                'Advance task: Character\'s skill list (\'order\' mode) exhausted;'
                                . ' cannot advance any more skills',
                            );

                            $self->ivPoke('shutdownFlag', TRUE);
                            return undef;
                        }

                    # Otherwise, extract the first skill to be advanced
                    } else {

                        $skill = $charObj->ivShift('currentAdvanceOrderList');
                        # Mark this as a pre-defined skill from the order list
                        $self->ivPoke('advanceMode', 'order_list');
                    }
                }

                # 'cycle' mode
                if (
                    $charObj->advanceMethod eq 'cycle'
                    || (
                        $charObj->advanceMethod eq 'combo'
                        && $charObj->advanceMethodStatus eq 'cycle'
                    )
                ) {
                    # If the list is empty, refresh it (ie start advancing skills from the
                    #   beginning of the cycle again)
                    if (! $charObj->currentAdvanceCycleList) {

                        $charObj->currentAdvanceCycleList = $charObj->ivPoke(
                            'currentAdvanceCycleList',
                            $charObj->advanceCycleList,
                        );
                    }

                    # Extract the next skill to be advanced
                    $skill = $charObj->ivShift('currentAdvanceCycleList');
                    # Mark this as a pre-defined skill from the order list
                    $self->ivPoke('advanceMode', 'cycle_list');

                }

                # Failsafe
                if (! $skill) {

                    $self->writeError(
                        'General error in selecting a skill to advance',
                        $self->_objClass . '->doStage',
                    );

                    # Can't continue
                    $self->ivPoke('shutdownFlag', TRUE);
                    return undef;
                }

                # Re-insert an instruction at the beginning of $self->skillList so that, when it's
                #   time to advance the next skill, another pre-defined skill is selected
                if ($number > 1) {

                    $self->ivUnshift('skillList', undef, ($number - 1));

                } elsif ($number == -1) {

                    # Continue advancing pre-defined skills as long as possible
                    $self->ivUnshift('skillList', undef, -1);
                }
            }

            # If we know how much XP/Cash we need to advance this skill, check whether it's going to
            #   be possible
            if (! $self->checkAdvancePossible($skill)) {

                # (A message has already been displayed)
                if ($self->advanceMode eq 'order_list') {

                    # Replace the current skill in the character's skill order list, so it can be
                    #   advanced at the next attempt (when the character has more XP/Cash)
                    $charObj->ivUnshift('currentAdvanceOrderList', $skill);

                } elsif ($self->advanceMode eq 'cycle_list') {

                    # Replace the current skill in the character's skill cycle list
                    $charObj->ivUnshift('currentAdvanceCycleList', $skill);
                }

                # Give up advancing skills
                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # Record which skill we're advancing
            $self->ivPoke('currentSkill', $skill);
            $self->ivPoke('currentSkillNumber', $number);

            # Try to advance this skill
            $self->sendCmd('advance', 'text', $skill);
            # Don't wait forever for a response - set a timeout
            $self->ivPoke('nextCheckTime', ($self->session->sessionTime + $self->waitTime));

            return $self->ivPoke('stage', 4);

        } elsif ($self->stage == 4) {

            my ($charObj, $num, $skillObj);

            # Import the current character profile
            $charObj = $self->session->currentChar;

            # Wait for something to happen - one of the triggers to fire, or the maximum time per
            #   advance to elapse
            if ($self->advanceSuccessFlag) {

                $self->writeText('Advanced skill \'' . $self->currentSkill . '\'');
                # Reset the flag, ready for the next trigger to fire
                $self->ivPoke('advanceSuccessFlag', FALSE);

                # The skill was advanced. Update the character profile's skill statistics
                # (Assume the skill level has gone up by 1, and not by more)
                if ($charObj->ivExists('skillLevelHash', $self->currentSkill)) {

                    $num = $charObj->ivShow('skillLevelHash', $self->currentSkill);
                    $num++;
                    $charObj->ivAdd('skillLevelHash', $self->currentSkill, $num);

                    $num = $charObj->ivShow(
                        'skillAdvanceCountHash',
                        $self->currentSkill,
                    );
                    $num++;
                    $charObj->ivAdd('skillAdvanceCountHash', $self->currentSkill, $num);

                    # NB we don't know ->skillTotalXPHash, ->skillNextXPHash, ->skillTotalCashHash
                    #   or ->skillNextCashHash yet
                }

                # Create a new skill history object to record the successful advance
                $skillObj
                    = Games::Axmud::Obj::SkillHistory->new($self->session, $self->currentSkill);

                if (! $skillObj) {

                    $self->writeWarning(
                        'Could not create a skill history object for this advance',
                        $self->_objClass . '->doStage',
                    );

                } else {

                    if ($self->advanceMode eq 'named_skill') {
                        $skillObj->ivPoke('advanceMethod', 'manual');
                    } elsif ($self->advanceMode eq 'order_list') {
                        $skillObj->ivPoke('advanceMethod', 'order');
                    } elsif ($self->advanceMode eq 'cycle_list') {
                        $skillObj->ivPoke('advanceMethod', 'cycle');
                    }

                    if ($charObj->ivExists('skillLevelHash', $self->currentSkill)) {

                        $skillObj->ivPoke(
                            'skillLevel',
                            $charObj->ivShow('skillLevelHash', $self->currentSkill),
                        );

                        $skillObj->ivPoke(
                            'skillAdvanceCount',
                            $charObj->ivShow('skillAdvanceCountHash', $self->currentSkill),
                        );

                        # NB We don't know ->skillThisXP, ->skillNextXP, ->skillThisCash or
                        #   ->skillNextCash yet
                    }
                }

                # Run checks
                if ($self->currentSkillNumber == 1) {

                    # We have finished advancing this skill
                    $self->ivUndef('currentSkill');
                    $self->ivUndef('currentSkillNumber');

                    # Find the next skill to advance
                    return $self->ivPoke('stage', 3);

                } elsif ($self->currentSkillNumber > 1) {

                    # Update the number of times the skill must be advanced
                    $self->ivDecrement('currentSkillNumber');
                }

                # The current skill, $self->currentSkill, must be advanced again.
                # If we know how much XP/Cash we need to advance this skill, check whether it's
                #   going to be possible
                if (! $self->checkAdvancePossible($self->currentSkill)) {

                    # (A message has already been displayed)
                    if ($self->advanceMode eq 'order_list') {

                        # Replace the current skill in the character's skill order list, so it can
                        #   be advanced at the next attempt (when the character has more XP/Cash)
                        $charObj->ivUnshift('currentAdvanceOrderList', $self->currentSkill);

                    } elsif ($self->advanceMode eq 'cycle_list') {

                        # Replace the current skill in the character's skill cycle list
                        $charObj->ivUnshift('currentAdvanceCycleList', $self->currentSkill);
                    }

                } else {

                    # Otherwise, advance the skill
                    $self->ivUnshift('skillList', $self->currentSkill, $self->currentSkillNumber);
                }

                return $self->ivPoke('stage', 3);

            } elsif (
                $self->advanceFailFlag
                || $self->session->sessionTime > $self->nextCheckTime
            ) {
                if ($self->advanceMode eq 'order_list') {

                    # Replace the current skill in the character's skill order list, so it can be
                    #   advanced at the next attempt (when the character has more XP/Cash)
                    $charObj->ivUnshift('currentAdvanceOrderList', $self->currentSkill);

                } elsif ($self->advanceMode eq 'cycle_list') {

                    # Replace the current skill in the character's skill cycle list
                    $charObj->ivUnshift('currentAdvanceCycleList', $self->currentSkill);
                }

                if ($self->advanceFailFlag) {

                    $self->writeText(
                        'Tried to advance skill \'' . $self->currentSkill . '\', but failed',
                    );

                } else {

                    $self->writeText(
                        'Timed out trying to advance skill \'' . $self->currentSkill . '\'',
                    );
                }

                # Give up advancing skills
                $self->ivPoke('shutdownFlag', TRUE);
                return undef;

            } else {

                # Keep waiting
                return $self->ivPoke('stage', 4);
            }

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub resetTriggers {

        # Called by $self->main, stage 2, to create (dependent) triggers that capture the world's
        #   response to an attempt to advance skills
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $worldObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetTriggers', @_);
        }

        # Import the current world
        $worldObj = $self->session->currentWorld;

        # Advance success patterns
        OUTER: foreach my $pattern ($worldObj->advanceSuccessPatternList) {

            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'advanceSuccessSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->advanceSuccessPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Advance fail patterns
        OUTER: foreach my $pattern ($worldObj->advanceFailPatternList) {

            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'advanceFailSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->advanceFailPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Trigger reset complete
        return 1;
    }

    sub checkAdvancePossible {

        # Called by $self->doStage, stages 3 & 4
        # When supplied with a skill that's about to be advanced, check whether it's possible (so
        #   far as we can tell)
        #
        # Expected arguments
        #   $skill      - The skill to be advanced
        #
        # Return values
        #   'undef' on improper arguments, or if we are sure that this skill can't be advanced
        #       (because of lack of XP, cash, or it's already at the maximum number of advances)
        #   1 if we are sure this skill can be advanced, or if we assume it is possible, or if we're
        #   not sure

        my ($self, $skill, $check) = @_;

        # Local variables
        my ($guildObj, $charObj);

        # Check for improper arguments
        if (! defined $skill || defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkAdvancePossible', @_);
        }

        # Import current profiles and the Status task
        $guildObj = $self->session->currentGuild;
        $charObj = $self->session->currentChar;

        # Check if the skill is already at the maximum level for this guild
        if (
            $guildObj
            && $guildObj->ivExists('skillMaxLevelHash', $skill)
            && $guildObj->ivShow('skillMaxLevelHash', $skill)
                <= $charObj->ivShow('skillLevelHash', $skill)
        ) {
            return $self->writeWarning(
                'Can\'t advance the skill \'' . $skill . '\' - it\'s already at the maximum level'
                . ' for this guild',
                $self->_objClass . '->checkAdvancePossible',
            );
        }

        # Check that the character has enough XP, if the amount of XP needed is known
        if (
            $charObj
            && $charObj->xpCurrent                                  # Character's XP known
            && $charObj->ivShow('skillNextXPHash', $skill)
            && $charObj->xpCurrent  < $charObj->ivShow('skillNextXPHash', $skill)
        ) {
            return $self->writeWarning(
                'Can\'t advance the skill \'' . $skill . '\' - not enough XP (have '
                . $charObj->xpCurrent . ', need '
                . $charObj->ivShow('skillNextXPHash', $skill),
                $self->_objClass . '->checkAdvancePossible',
            );
        }

        # Check that the character has enough cash, if the amount of cash needed is known
        if (
            $charObj
            && defined $charObj->purseContents                      # Character's purse known
            && $charObj->ivShow('skillNextCashHash', $skill)
            && $charObj->purseContents < $charObj->ivShow('skillNextCashHash', $skill)
        ) {
            return $self->writeWarning(
                'Can\'t advance the skill \'' . $skill . '\' - not enough cash (have '
                . $charObj->purseContents . ', need '
                . $charObj->ivShow('skillNextCashHash', $skill),
                $self->_objClass . '->checkAdvancePossible',
            );
        }

        # Otherwise assume the advance is possible
        return 1;
    }

    ##################
    # Response methods

    sub advanceSuccessSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   the world's response to a successful advance
        #   e.g. 'You improve your skills'
        #
        # The function sets a flag IV to true, enabling $self->doStage to take action when the
        #   task loop next spins. Group substrings and the interface's ->propertyHash are not used
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->advanceSuccessSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Set the flag; on the next task loop, $self->doStage (at stage 4) will check it, and
        #   respond
        $self->ivPoke('advanceSuccessFlag', TRUE);

        return 1;
    }

    sub advanceFailSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   the world's response to a failed advance
        #   e.g. 'You don't have enough experience to improve your skills'
        #
        # The function sets a flag IV to true, enabling $self->doStage to take action when the
        #   task loop next spins. Group substrings and the interface's ->propertyHash are not used
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->advanceFailSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Set the flag; on the next task loop, $self->doStage (at stage 4) will check it, and
        #   respond
        $self->ivPoke('advanceFailFlag', TRUE);

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub skillList
        { my $self = shift; return @{$self->{skillList}}; }
    sub currentSkill
        { $_[0]->{currentSkill} }
    sub currentSkillNum
        { $_[0]->{currentSkillNum} }

    sub advanceMode
        { $_[0]->{advanceMode} }
    sub advanceSuccessFlag
        { $_[0]->{advanceSuccessFlag} }
    sub advanceFailFlag
        { $_[0]->{advanceFailFlag} }
    sub waitTime
        { $_[0]->{waitTime} }
    sub nextCheckTime
        { $_[0]->{nextCheckTime} }

    sub warningFlag
        { $_[0]->{warningFlag} }
}

{ package Games::Axmud::Task::Attack;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Attack task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'attack_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'attack_task';
        $self->{prettyName}             = 'Attack';
        $self->{shortName}              = 'At';
        $self->{shortCutIV}             = 'attackTask';     # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Monitors attacks involving the current character';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = FALSE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = FALSE;
        $self->{winPreferList}          = [];
        $self->{winmap}                 = undef;
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'attack';
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {
            'fight'                     => FALSE,
            'interact'                  => FALSE,
            'interaction'               => FALSE,
        };
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters

        # A list of commands to send whenever a fight succeeds (i.e., the character kills an
        #   opponent) - typically 'get coins from corpses', 'score', etc
        # (These commands are sent with GA::Session->relayCmd, so don't get interpolated)
        $self->{fightCmdList}           = [];
        # A list of commands to send whenever an interaction succeeds
        # (These commands are sent with GA::Session->relayCmd, so don't get interpolated)
        $self->{interactCmdList}        = [];
        # Flag set to TRUE if fight/interaction results should be announced in the 'main' window,
        #   FALSE if not
        $self->{announceFlag}           = TRUE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{fightCmdList}          = [$self->fightCmdList];
        $clone->{interactCmdList}       = [$self->interactCmdList];
        $clone->{announceFlag}          = $self->announceFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the command lists
        $newTask->ivPoke('fightCmdList', $self->fightCmdList);
        $newTask->ivPoke('interactCmdList', $self->interactCmdList);
        $newTask->ivPoke('announceFlag', $self->announceFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # If triggers have already been created for this task, remove them before replacing them
        #   with new triggers
        $self->session->tidyInterfaces($self);

        # Set up dependent triggers for fights
        $self->resetFightTriggers();
        # Set up dependent triggers for interactions
        $self->resetInteractionTriggers();

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    sub resetFightTriggers {

        # Called by $self->doInit to set up triggers for fights, based on the patterns stored in the
        #   current world profile
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetFightTriggers', @_);
        }

        # Set up triggers for fights
        @list = $self->session->currentWorld->targetKilledPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $grpNum) {

                $self->writeWarning(
                    'Missing arguments in current world profile\'s ->targetKilledPatternList IV',
                    $self->_objClass . '->resetFightTriggers',
                );

                last OUTER;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'targetKilledSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->targetKilledPatternList IV',
                    $self->_objClass . '->resetFightTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->barPatternSeen to decide
                #   what to do when the trigger fires (specifically, which group substring contains
                #   the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
            }
        }

        return 1;
    }

    sub resetInteractionTriggers {

        # Called by $self->doInit to set up triggers for interactions, based on the patterns stored
        #   in the current world profile
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->resetInteractionTriggers',
                @_,
            );
        }

        # Set up triggers for successful interactions
        @list = $self->session->currentWorld->interactionSuccessPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $grpNum) {

                $self->writeWarning(
                    'Missing arguments in current world profile\'s'
                    . ' ->interactionSuccessPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'interactionSuccessSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->interactionSuccessPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->interactionSuccessSeen to
                #   decide what to do when the trigger fires (specifically, which group substring
                #   contains the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
            }
        }

        # Set up triggers for failed interactions
        @list = $self->session->currentWorld->interactionFailPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $grpNum) {

                $self->writeWarning(
                    'Missing arguments in current world profile\'s'
                    . ' ->interactionFailPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'interactionFailSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->interactionFailPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->interactionFailSeen to
                #   decide what to do when the trigger fires (specifically, which group substring
                #   contains the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
            }
        }

        # Set up triggers for interactions that lead to a fight
        @list = $self->session->currentWorld->interactionFightPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $grpNum) {

                $self->writeWarning(
                    'Missing arguments in current world profile\'s'
                    . ' ->interactionFightPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'interactionFightSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->interactionFightPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->interactionFightSeen to
                #   decide what to do when the trigger fires (specifically, which group substring
                #   contains the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
            }
        }

        # Set up triggers for disastrous interaction
        @list = $self->session->currentWorld->interactionDisasterPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $grpNum) {

                $self->writeWarning(
                    'Missing arguments in current world profile\'s'
                    . ' ->interactionDisasterPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'interactionDisasterSeen',
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->interactionDisasterPatternList IV',
                    $self->_objClass . '->resetInteractionTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->interactionDisasterSeen to
                #   decide what to do when the trigger fires (specifically, which group substring
                #   contains the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
            }
        }

        return 1;
    }

    ##################
    # Response methods

    sub targetKilledSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetFightTriggers function creates some triggers to capture strings
        #   matching the text received, when the character wins a fight
        # e.g. ('^You kill (.*)$',  1)
        #
        # The world profile's target killed list occurs in groups of 2 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #
        # This function checks the appropriate group substring and updates IVs for this task and/or
        #   the Status task. If there are any commands in $self->fightCmdList, they are sent to the
        #   world.
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the victim can't be extracted from the matching text or if
        #       the received line of text is an exception to the normal rules
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $victim, $charObj, $taskObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->targetKilledSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        # Get the victim
        $victim = $$grpStringListRef[$grpNum];
        if (! defined $victim) {

            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $victim = $axmud::CLIENT->trimWhitespace($victim);
            # Remove any punctuation from the end of the string
            $victim =~ s/\W+$//;
        }

        # Import the current character and Status task
        $charObj = $session->currentChar;
        $taskObj = $session->statusTask;

        # Check that $modLine isn't an exception to the usual rules
        foreach my $pattern ($session->currentWorld->noTargetKilledPatternList) {

            if ($modLine =~ m/$pattern/) {

                # Ignore this line
                return undef;
            }
        }

        # Fight complete. Update the current character profile's IVs (via the Status task)
        if ($charObj) {

            # ->fightVictimHash is similarly not updated, but we can update the base string hash
            if ($charObj->ivExists('fightVictimStringHash', lc($victim))) {
                $charObj->ivIncHash('fightVictimStringHash', lc($victim));
            } else {
                $charObj->ivAdd('fightVictimStringHash', lc($victim), 1);
            }

            if ($taskObj) {

                # Updates ->fightCount, ->killCount
                $taskObj->inc_fightCount();
            }
        }

        # Write something in the 'main' window, if allowed
        if ($self->announceFlag) {

            $self->writeText(
                '\'' . $self->uniqueName . '\' task : Detected killed target \'' . $victim . '\'',
            );
        }

        # Write something to the 'attack' logfile, if allowed
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'attack',   # Write to this logfile
        );

        # Play a sound effect (if allowed)
        $axmud::CLIENT->playSound('kill');

        # Read a text-to-speech (TTS) message, if required
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->targetKilledSeen');
        if ($self->ivShow('ttsFlagAttribHash', 'fight')) {

            $self->ttsQuick('Killed ' . $victim);
        }

        # Send the list of victim commands (if any)
        foreach my $cmd ($self->fightCmdList) {

            $session->relayCmd($cmd);
        }

        return 1;
    }

    sub interactionSuccessSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetInteractionTriggers function creates some triggers to capture strings
        #   matching the text received, when the character experiences a successful interaction
        # e.g. ('^You cast a spell on (.*)$', 1)
        #
        # The world profile's successful interaction list occurs in groups of 2 elements,
        #   representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #
        # This function checks the appropriate group substring and updates IVs for this task and/or
        #   the Status task. If there are any commands in $self->interactCmdList, they are sent to
        #   the world
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the victim can't be extracted from the matching text or if
        #       the received line of text is an exception to the normal rules
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $victim, $charObj, $taskObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->interactionSuccessSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        # Get the victim
        $victim = $$grpStringListRef[$grpNum];
        if (! defined $victim) {

            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $victim = $axmud::CLIENT->trimWhitespace($victim);
            # Remove any punctuation from the end of the string
            $victim =~ s/\W+$//;
        }

        # Import the current character and Status task
        $charObj = $session->currentChar;
        $taskObj = $session->statusTask;

        # Check that $modLine isn't an exception to the usual rules
        foreach my $pattern ($session->currentWorld->noInteractionSuccessPatternList) {

            if ($modLine =~ m/$pattern/) {

                # Ignore this line
                return undef;
            }
        }

        # Update the current character profile's IVs (via the Status task)
        if ($charObj) {

            # ->interactionVictimHash can't be updated because we don't have a main noun; but
            #   we can still update the base string hash
            if ($charObj->ivExists('interactionVictimStringHash', lc($victim))) {
                $charObj->ivIncHash('interactionVictimStringHash', lc($victim));
            } else {
                $charObj->ivAdd('interactionVictimStringHash', lc($victim), 1);
            }

            if ($taskObj) {

                # Updates ->interactCount, ->interactSuccessCount
                $taskObj->inc_interactSuccessCount();
            }
        }

        # Write something in the 'main' window, if allowed
        if ($self->announceFlag) {

            $self->writeText(
                '\'' . $self->uniqueName . '\' task : Detected successful interaction with \''
                . $victim . '\'',
            );
        }

        # Write something to the 'attack' logfile, if allowed
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'attack',   # Write to this logfile
        );

        # Play a sound effect (if allowed)
        $axmud::CLIENT->playSound('notify');

        # Read a text-to-speech (TTS) message, if required
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->interactionSuccessSeen');
        if (
            $self->ivShow('ttsFlagAttribHash', 'interact')
            || $self->ivShow('ttsFlagAttribHash', 'interaction')
        ) {
            $self->ttsQuick('Successful interaction with ' . $victim);
        }

        # Send the list of victim commands (if any)
        foreach my $cmd ($self->interactCmdList) {

            $session->relayCmd($cmd);
        }

        return 1;
    }

    sub interactionFailSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetInteractionTriggers function creates some triggers to capture strings
        #   matching the text received, when the character experiences a failed interaction
        # e.g. ('^You fail to cast a spell on (.*)$', 1)
        #
        # The world profile's failed interaction list occurs in groups of 2 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #
        # This function checks the appropriate group substring and updates IVs for this task and/or
        #   the Status task. If there are any commands in $self->interactCmdList, they are sent to
        #   the world
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the victim can't be extracted from the matching text or if
        #       the received line of text is an exception to the normal rules
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $victim, $charObj, $taskObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->interactionFailSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        # Get the victim
        $victim = $$grpStringListRef[$grpNum];
        if (! defined $victim) {

            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $victim = $axmud::CLIENT->trimWhitespace($victim);
            # Remove any punctuation from the end of the string
            $victim =~ s/\W+$//;
        }

        # Import the current character and Status task
        $charObj = $session->currentChar;
        $taskObj = $session->statusTask;

        # Check that $modLine isn't an exception to the usual rules
        foreach my $pattern ($session->currentWorld->noInteractionFailPatternList) {

            if ($modLine =~ m/$pattern/) {

                # Ignore this line
                return undef;
            }
        }

        # Interaction complete. Update the current character profile's IVs (via the Status task)
        if ($charObj && $taskObj) {

            # Updates ->interactCount, ->interactFailCount
            $taskObj->inc_interactFailCount();
        }

        # Write something in the 'main' window, if allowed
        if ($self->announceFlag) {

            $self->writeText(
                '\'' . $self->uniqueName . '\' task : Detected failed interaction with \''
                . $victim . '\'',
            );
        }

        # Write something to the 'attack' logfile, if allowed
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'attack',   # Write to this logfile
        );

        # Play a sound effect (if allowed)
        $axmud::CLIENT->playSound('notify');

        # Read a text-to-speech (TTS) message, if required
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->interactionFailSeen');
        if (
            $self->ivShow('ttsFlagAttribHash', 'interact')
            || $self->ivShow('ttsFlagAttribHash', 'interaction')
        ) {
            $self->ttsQuick('Failed interaction with ' . $victim);
        }

        # Send the list of victim commands (if any)
        foreach my $cmd ($self->interactCmdList) {

            $session->relayCmd($cmd);
        }

        return 1;
    }

    sub interactionFightSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetInteractionTriggers function creates some triggers to capture strings
        #   matching the text received, when the character experiences an interaction which leads to
        #   a fight
        # e.g. ('^The (.*) dislikes your spell and attacks you!$',  1)
        #
        # The world profile's interaction fight list occurs in groups of 2 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #
        # This function checks the appropriate group substring and updates IVs for this task and/or
        #   the Status task. If there are any commands in $self->interactCmdList, they are sent to
        #   the world
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the victim can't be extracted from the matching text or if
        #       the received line of text is an exception to the normal rules
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $victim, $charObj, $taskObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->interactionFightSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        # Get the victim
        $victim = $$grpStringListRef[$grpNum];
        if (! defined $victim) {

            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $victim = $axmud::CLIENT->trimWhitespace($victim);
            # Remove any punctuation from the end of the string
            $victim =~ s/\W+$//;
        }

        # Import the current character and Status task
        $charObj = $session->currentChar;
        $taskObj = $session->statusTask;

        # Check that $modLine isn't an exception to the usual rules
        foreach my $pattern ($session->currentWorld->noInteractionFightPatternList) {

            if ($modLine =~ m/$pattern/) {

                # Ignore this line
                return undef;
            }
        }

        # Interaction complete. Update the current character profile's IVs (via the Status task)
        if ($charObj && $taskObj) {

            # Updates ->interactCount, ->interactFightCount
            $taskObj->inc_interactFightCount();
        }

        # Write something in the 'main' window, if allowed
        if ($self->announceFlag) {

            $self->writeText(
                '\'' . $self->uniqueName . '\' task : Detected interaction leading to fight with \''
                . $victim . '\'',
            );
        }

        # Write something to the 'attack' logfile, if allowed
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'attack',   # Write to this logfile
        );

        # Play a sound effect (if allowed)
        $axmud::CLIENT->playSound('notify');

        # Read a text-to-speech (TTS) message, if required
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->interactionFightSeen');
        if (
            $self->ivShow('ttsFlagAttribHash', 'interact')
            || $self->ivShow('ttsFlagAttribHash', 'interaction')
        ) {
            $self->ttsQuick('Interaction with ' . $victim . ' has become a fight');
        }

        # Send the list of victim commands (if any)
        foreach my $cmd ($self->interactCmdList) {

            $session->relayCmd($cmd);
        }

        return 1;
    }

    sub interactionDisasterSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetInteractionTriggers function creates some triggers to capture strings
        #   matching the text received, when the character experiences a disastrous interaction
        # e.g. ('^The (.*) is enraged and chops your arms off!$',  1)
        #
        # The world profile's failed interaction list occurs in groups of 2 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #
        # This function checks the appropriate group substring and updates IVs for this task and/or
        #   the Status task. If there are any commands in $self->interactCmdList, they are sent to
        #   the world
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the victim can't be extracted from the matching text or if
        #       the received line of text is an exception to the normal rules
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $victim, $charObj, $taskObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->interactionDisasterSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        # Get the victim
        $victim = $$grpStringListRef[$grpNum];
        if (! defined $victim) {

            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $victim = $axmud::CLIENT->trimWhitespace($victim);
            # Remove any punctuation from the end of the string
            $victim =~ s/\W+$//;
        }

        # Import the current character and Status task
        $charObj = $session->currentChar;
        $taskObj = $session->statusTask;

        # Check that $modLine isn't an exception to the usual rules
        foreach my $pattern ($session->currentWorld->noInteractionDisasterPatternList) {

            if ($modLine =~ m/$pattern/) {

                # Ignore this line
                return undef;
            }
        }

        # Interaction complete. Update the current character profile's IVs (via the Status task)
        if ($charObj && $taskObj) {

            # Updates ->interactCount, ->interactDisasterCount
            $taskObj->inc_interactDisasterCount();
        }

        # Write something in the 'main' window, if allowed
        if ($self->announceFlag) {

            $self->writeText(
                '\'' . $self->uniqueName . '\' task : Detected disastrous interaction with \''
                . $victim . '\'',
            );
        }

        # Write something to the 'attack' logfile, if allowed
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'attack',   # Write to this logfile
        );

        # Play a sound effect (if allowed)
        $axmud::CLIENT->playSound('notify');

        # Read a text-to-speech (TTS) message, if required
        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->interactionDisasterSeen');
        if (
            $self->ivShow('ttsFlagAttribHash', 'interact')
            || $self->ivShow('ttsFlagAttribHash', 'interaction')
        ) {
            $self->ttsQuick('Disastrous interaction with ' . $victim);
        }

        # Send the list of victim commands (if any)
        foreach my $cmd ($self->interactCmdList) {

            $session->relayCmd($cmd);
        }

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub fightCmdList
        { my $self = shift; return @{$self->{fightCmdList}}; }
    sub interactCmdList
        { my $self = shift; return @{$self->{interactCmdList}}; }
    sub announceFlag
        { $_[0]->{announceFlag} }
}

{ package Games::Axmud::Task::Channels;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Channels task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'channels_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'channels_task';
        $self->{prettyName}             = 'Channels';
        $self->{shortName}              = 'Cl';
        $self->{shortCutIV}             = 'channelsTask';   # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Displays text received from the world in multiple tabs';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = 'restoreWin';
        $self->{tabMode}                = 'empty';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'channels';
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {
            'channels'                  => FALSE,
        };
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # Multiple triggers can match a single line, but this task only displays a single line in
        #   its task window once. The display buffer line number of the last line that matched a
        #   tell, social or custom pattern
        $self->{lastLine}               = 0;
        # The task window's pane object (GA::Table::Pane) has multiple tabs, one for each channel.
        # A hash of tabs in use, in the form
        #   $tabHash{channel_name} = blessed_reference_to_tab_object
        $self->{tabHash}                = {},

        # Summary mode
        #   'single' - Use a single tab, in which all text for all channels is displayed. Any
        #               channels specified in $self->initChannelList are ignored
        #   'multi' - Every channel has its own tab and, in addition, there is a 'summmary' tab
        #               which duplicates text displayed in every channel
        #   'default' - Every channel has its own tab and there is no 'summary' channel
        $self->{summaryMode}            = 'default',
        # The channel name to use for the 'summary' channel
        $self->{summaryChannel}         = 'all',

        # List of channels for which a tab should be added when the window opens, even though there
        #   is no text to display yet
        $self->{initChannelList}        = [
            # Three of the four 'channels' for which the Divert task has an assigned background
            #   colour/sound effect
            'tell', 'social', 'custom',
        ];
        # List of channels that should be ignored (i.e. no tab is created for the channel, and when
        #   text is received to display in one of these channels, it is not displayed, not even in
        #   the summary channel)
        # The summary channel and any channels in $self->initChannelList can't be ignored. If
        #   any of them are added to this list, they are not ignored
        $self->{ignoreChannelList}      = [],

        # In these hashes, the keys are affected channels names, and the corresponding values are
        #   'undef' or FALSE if the effect shouldn't be applied (so the user can disable it,
        #   without emptying the hash of channel names)
        #
        # Hash of sound effects that should be played when text is displayed in a channel. The keys
        #   are channel names; the corresponding value is a sound effect
        $self->{soundEffectHash}        = {
            'tell'                      => 'greeting',
            'social'                    => 'notify',
            'custom'                    => 'notify',
            'warning'                   => 'alarm',
        },
        # Hash of flags. The keys are channel names, the corresponding value is TRUE if the task
        #   window's urgency hint should be displayed when the channel receives text, FALSE if not
        $self->{urgencyHash}            = {
            'tell'                      => FALSE,
            'social'                    => FALSE,
            'custom'                    => FALSE,
            'warning'                   => FALSE,
        },

        # Flag set to TRUE if the tabs should have a close button, FALSE if not
        $self->{tabCloseButtonFlag}     = FALSE;
        # Flag set to TRUE if channel names, displayed in the tab labels, should be capitalised
        $self->{capitaliseFlag}         = TRUE;
        # Flag set to TRUE if the original line's colour/style tags should be preserved in the task
        #   window
        $self->{useColourStyleFlag}     = TRUE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{lastLine}              = $self->lastLine;
        $clone->{tabHash}               = {$self->tabHash};

        $clone->{summaryMode}           = $self->summaryMode;
        $clone->{summaryChannel}        = $self->summaryChannel;

        $clone->{initChannelList}       = [$self->initChannelList];
        $clone->{ignoreChannelList}     = [$self->ignoreChannelList];

        $clone->{soundEffectHash}       = {$self->soundEffectHash};
        $clone->{urgencyHash}           = {$self->urgencyHash};

        $clone->{tabCloseButtonFlag}    = $self->tabCloseButtonFlag;
        $clone->{capitaliseFlag}        = $self->capitaliseFlag;
        $clone->{useColourStyleFlag}    = $self->useColourStyleFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve summary channel IVs
        $newTask->ivPoke('summaryMode', $self->summaryMode);
        $newTask->ivPoke('summaryChannel', $self->summaryChannel);

        # Preserve initial and ignorable channels
        $newTask->ivPoke('initChannelList', $self->initChannelList);
        $newTask->ivPoke('ignoreChannelList', $self->ignoreChannelList);

        # Preserve sound effect and urgency settings
        $newTask->ivPoke('soundEffectHash', $self->soundEffectHash);
        $newTask->ivPoke('urgencyHash', $self->urgencyHash);

        # Preserve other customisation settings
        $newTask->ivPoke('tabCloseButtonFlag', $self->tabCloseButtonFlag);
        $newTask->ivPoke('capitaliseFlag', $self->capitaliseFlag);
        $newTask->ivPoke('useColourStyleFlag', $self->useColourStyleFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if a Divert task is running
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # The Channels and Divert tasks can't run at the same time
        if ($self->session->divertTask) {

            $self->writeError(
                'The Channels and Divert tasks cannot run at the same time, so halting the'
                . ' Channels task',
                $self->_objClass . '->doStage',
            );

            # Mark the task to be shutdown
            $self->ivPoke('shutdownFlag', TRUE);
            return undef;
        }

        # If triggers have already been created for this task, remove them before replacing them
        #   with new triggers
        $self->session->tidyInterfaces($self);

        if (! $self->resetTriggers()) {

            $self->writeError(
                'Could not create ' . $self->prettyName . ' task triggers, so halting the task',
                $self->_objClass . '->doStage',
            );

            # Mark the task to be shutdown
            $self->ivPoke('shutdownFlag', TRUE);
            return undef;
        }

        # If the task window has actually opened...
        if ($self->winObj) {

            # Set up the initial set of tabs specified by various IVs
            $self->refreshTabs();
        }

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

    sub doReset {

        # Called just before the task completes a reset
        # For process tasks, called by $self->main. For activity tasks, called by $self->reset
        #
        # Removes existings tabs and replaces them with the initial set of tabs specified by various
        #   IVs
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # Close all of the pane's existing tabs
        foreach my $tabObj ($self->ivValues('tabHash')) {

            $tabObj->paneObj->removeTab($tabObj);
        }

        return 1;
    }

#   sub doFirstStage {}         # Inherited from generic task

    sub resetTriggers {

        # Called by $self->doInit, GA::Cmd::AddChannelPattern->do and
        #   GA::Cmd::DeleteChannelPattern->do
        # Removes all of this task's dependent triggers and replaces them with new ones
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, or if there's an error replacing or creating the triggers
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @channelList;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetTriggers', @_);
        }

        # First remove any existing triggers. ->tidyInterfaces returns the number of triggers
        #   removed, or 'undef' it there's an error
        if (! defined $self->session->tidyInterfaces($self)) {

            return undef;
        }

        # Import the list of patterns (groups of 3)
        @channelList = $self->session->currentWorld->channelList;
        if (@channelList) {

            do {

                my ($pattern, $channel, $flag, $interfaceObj);

                $pattern = shift @channelList;
                $channel = shift @channelList;
                $flag = shift @channelList;

                # Create dependent trigger
                $interfaceObj = $self->session->createDependentInterface(
                    'trigger',
                    $pattern,
                    $self,
                    'channelsPatternSeen',
                    'gag',
                    $flag,
                );

                if (! $interfaceObj) {

                    # If there's an error creating any triggers, remove any triggers already created
                    $self->session->tidyInterfaces($self);
                    return undef;

                } else {

                    # Give the trigger some properties that will tell $self->channelsPatternSeen
                    #   which channel to use when the trigger fires
                    $interfaceObj->ivAdd('propertyHash', 'channel', $channel);
                }

            } until (! @channelList);
        }

        return 1;
    }

    sub displayText {

        # Called by $self->channelsPatternSeen to display text in a tab in the task window
        # Can also be called by any other code which needs to display text in one of the task's
        #   tabs. This capability has been introduced so that any code can display text in the
        #   'warning' channel's tab; artificially adding text to any other channel's tab would be
        #   rude
        #
        # Expected arguments
        #   $channel        - The channel name, e.g. 'tell', 'social', 'custom', 'warning'
        #   $text           - The text to display in this channel. When called by
        #                       $self->channelsPatternSeen, it's the (whole) line of text received
        #                       from the world, stripped of control sequences, possibly after being
        #                       modified by rewriter triggers have acted on it (equivalent to
        #                       GA::Buffer::Display->modLine)
        #
        # Optional arguments
        #   $triggerFlag    - Set to TRUE when called by $self->channelsPatternSeen (only), so that
        #                       this function can reintroduce colour/style tags that were stripped
        #                       away from $text. Must be FALSE (or 'undef') when called by anything
        #                       else
        #
        # Return values
        #   'undef' on improper arguments or if a tab for the channel doesn't exist and can't be
        #       created
        #   1 otherwise

        my ($self, $channel, $text, $triggerFlag, $check) = @_;

        # Local variables
        my (
            $paneObj, $origText, $bufferObj, $tabObj, $checkObj, $summaryTabObj, $visibleTabObj,
            $effect,
        );

        # Check for improper arguments
        if (! defined $channel || ! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayText', @_);
        }

        # Import the task window's GA::Table::Pane object (for convenience)
        if ($self->defaultTabObj) {

            $paneObj = $self->defaultTabObj->paneObj;
        }

        # Preserve the original text, in case we convert the text to speech below
        $origText = $text;

        # Display the text (if there is any)
        if ($self->taskWinFlag && $text ne '') {

            # If called by $self->channelsPatternSeen, get the line's display buffer object
            if ($triggerFlag) {

                $bufferObj = $self->session->ivShow(
                    'displayBufferHash',
                    $self->session->displayBufferLast,
                );

                if (! $bufferObj) {

                    # If the buffer object is missing (extremely unlikely), behave as though some
                    #   other part of the code had called this function
                    $triggerFlag = FALSE;
                }
            }

            if ($self->summaryMode eq 'multi' || $self->summaryMode eq 'default') {

                # Each tab in the task window has a unique tab number; however, tab numbers are
                #   reset if all the tabs are closed
                # Get the tab corresponding to $channel, and make sure it's still visible in the
                #   task window
                $tabObj = $self->ivShow('tabHash', $channel);
                if (defined $tabObj) {

                    # Check the tab is still visible in the pane. $self->closeTabCallback takes care
                    #   of updating $self->tabHash when a tab is manually closed, but it's better to
                    #   be safe than sorry
                    $checkObj = $paneObj->ivShow('tabObjHash', $tabObj->number);
                    if (! $checkObj || $checkObj ne $tabObj) {

                        $tabObj = undef;
                    }
                }

                # If the tab corresponding to $channel doesn't exist, create it (unless it's a
                #   channel that should be ignored)
                if (! $tabObj) {

                    if (defined $self->ivFind('ignoreChannelList', $channel)) {

                        # Text on this channel should be ignored
                        return undef;

                    } else {

                        $tabObj = $self->addTab(
                            $self->getLabelText($channel, FALSE),
                        );

                        if (! $tabObj) {

                            # Nothing we can do to display this message
                            return undef;

                        } else {

                            $self->ivAdd('tabHash', $channel, $tabObj);
                            $paneObj = $tabObj->textViewObj->paneObj;
                        }
                    }
                }
            }

            if ($self->summaryMode eq 'single' || $self->summaryMode eq 'multi') {

                # Get the summary channel. If it doesn't exist (because the user has closed the
                #   tab manually), don't create it
                $summaryTabObj = $self->ivShow('tabHash', $self->summaryChannel);
                if (defined $summaryTabObj) {

                    # Check the tab is still visible in the pane
                    $checkObj = $paneObj->ivShow('tabObjHash', $tabObj->number);
                    if (! $checkObj || $checkObj ne $tabObj) {

                        $summaryTabObj = undef;
                    }
                }
            }

            # Display the text in one or both tabs. We can use the original text colours/styles if
            #   called by $self->channelsPatternSeen and if the flag allows us
            if (! $bufferObj || ! $self->useColourStyleFlag) {

                # Display the text without applying any colour/style tags
                if ($tabObj) {

                    $tabObj->textViewObj->insertWithLinks($text);
                }

                if ($summaryTabObj) {

                    $summaryTabObj->textViewObj->insertWithLinks($text);
                }

            } else {

                # Display the text, preserving original colour/style tags in the task window's
                #   textview
                if ($tabObj) {

                    $bufferObj->copyLine($tabObj->textViewObj);
                }

                if ($summaryTabObj) {

                    $bufferObj->copyLine($summaryTabObj->textViewObj);
                }
            }

            # If the tab isn't the visible one, make its label red (but don't change the colour of
            #   the summary tab's label, and don't change the colour at all if the summary tab is
            #   the visible one)
            $visibleTabObj = $paneObj->getVisibleTab();
            if (
                $tabObj
                && $visibleTabObj
                && $tabObj ne $visibleTabObj
                && (! $summaryTabObj || $visibleTabObj ne $summaryTabObj)
            ) {
                $paneObj->setTabLabel(
                    $tabObj->number,
                    $self->getLabelText($channel, TRUE),
                );
            }

            # If the text was actually displayed in a tab...
            if ($tabObj || $summaryTabObj) {

                # Play a sound effect, if required
                $effect = $self->ivShow('soundEffectHash', $channel);
                if (defined $effect) {

                    $axmud::CLIENT->playSound($effect);
                }

                # Set the task window's urgency hint, if required
                if ($self->ivShow('urgencyHash', $channel)) {

                    $self->winObj->setUrgent();
                }
            }
        }

        # If there are any Watch tasks running - in any session - update them
        foreach my $otherSession ($axmud::CLIENT->listSessions()) {

            my ($world, $char);

            if ($otherSession->watchTask) {

                $world = $self->session->currentWorld->name;
                if ($self->session->currentChar) {

                    $char = $self->session->currentChar->name;  # Otherwise 'undef'
                }

                $otherSession->watchTask->displayText('divert', $world, $char, $text);
            }
        }

        # Also write the text to the logs, as if it had appeared in the 'main' window (if allowed)
        if ($triggerFlag) {

            $self->session->writeIncomingDataLogs($bufferObj->stripLine, $bufferObj->modLine);
        }

        # Read out a TTS message, if required
        if ($self->ivShow('ttsFlagAttribHash', 'channels')) {

            $self->ttsQuick($origText);
        }

        return 1;
    }

    sub resetWin {

        # Called by GA::Cmd::EmptyChannelsWindow->do and $self->doReset
        # Resets the task window - removes all tabs (and the text they contain), and replaces them
        #   with the initial set
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $paneObj;

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetWin', @_);
        }

        if ($self->winObj) {

            $paneObj = $self->winObj->findTableObj('pane');
        }

        if ($paneObj) {

            # Close all of the pane's existing tabs
            foreach my $tabObj ($paneObj->ivValues('tabObjHash')) {

                $paneObj->removeTab($tabObj);
            }

            # Update IVs
            $self->ivEmpty('tabHash');

            # Restore the initial set of tabs specified by various IVs
            $self->refreshTabs();

            # If the task window's urgency hint is set, then reset it
            $self->winObj->resetUrgent();
        }

        return 1;
    }

    sub refreshTabs {

        # Called by $self->doInit and ->resetWin
        # Assuming that the task window's pane object contain no tabs, sets up an initial set of
        #   tabs specified by various IVs
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($paneObj, $summaryTabObj, $firstTabObj);

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refreshTabs', @_);
        }

        # Mark the task window's tabs as closeable, or not
        $paneObj = $self->winObj->findTableObj('pane');
        if ($paneObj) {

            $paneObj->set_canCloseFlag($self->tabCloseButtonFlag);
        }

        # Add a 'summary' tab, if one is specified
        if ($self->summaryMode eq 'single' || $self->summaryMode eq 'multi') {

            $summaryTabObj = $self->addTab(
                $self->getLabelText($self->summaryChannel, FALSE),
            );

            if ($summaryTabObj) {

                $self->ivAdd('tabHash', $self->summaryChannel, $summaryTabObj);
                $firstTabObj = $summaryTabObj;
            }
        }

        if ($self->summaryMode ne 'single') {

            # Add tabs for any channels which should be opened immediately, even before text to
            #   display in those channels has been received
            foreach my $channel ($self->initChannelList) {

                my $tabObj = $self->addTab(
                    $self->getLabelText($channel, FALSE),
                );

                if ($tabObj) {

                    $self->ivAdd('tabHash', $channel, $tabObj);
                    if (! $firstTabObj) {

                        $firstTabObj = $tabObj;
                    }
                }
            }
        }

        # Make the first tab created the visible one
        if ($firstTabObj) {

            $paneObj->setVisibleTab($firstTabObj);
        }

        return 1;
    }

    sub getLabelText {

        # Called by various functions in this task
        # Change the text and/or colour of one of the task window's tab labels
        #
        # Expected arguments
        #   $text       - The new label text; for this task, the name of the channel
        #   $colourFlag - If TRUE, the label text is a different colour (signifying that the tab's
        #                   textview has received new text since it was last visible). If FALSE, the
        #                   label text is the normal colour
        #
        # Return values
        #   'undef' on improper arguments or if the tab's text can't be updated
        #   1 otherwise

        my ($self, $text, $colourFlag, $check) = @_;

        # Check for improper arguments
        if (! defined $text || ! defined $colourFlag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getLabelText', @_);
        }

        # Capitalise the channel name, if required
        if ($self->capitaliseFlag) {

            $text = ucfirst($text);
        }

        # If tabs are not using a close button, add extra space to make the tab label a bit wider
        if (! $self->tabCloseButtonFlag) {

            $text = '   ' . $text . '   ';
        }

        # Change the text colour, if required
        if ($colourFlag) {

            $text = '<span foreground="red">' . $text . '</span>';
        }

        return $text;
    }

    ##################
    # Response methods

    sub channelsPatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   patterns in the world profile's ->patternList
        #   e.g. Gandalf tells you, Give me the ring!
        #
        # The function diverts the line to the task window, displaying it in a tab matching the
        #   pattern's corresponding channel. Group substrings are ignored
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   channel         - Which channel to use
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the corresponding IV can't be found or if the received
        #       line of text matches one of the exception patterns for this type of message
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->channelsPatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Ignore the trigger, if this line has already been diverted
        if ($self->lastLine && $self->lastLine == $self->session->displayBufferLast) {

            return undef;

        } else {

            # Don't act on any more triggers from this line
            $self->ivPoke('lastLine', $self->session->displayBufferLast);
        }

        # Check this line of text doesn't match one of the patterns in the exception list,
        #   GA::Profile::World->noChannelList
        # Any line of text which matches a pattern in that list is diverted back to the 'main'
        #   window, and is not displayed in the task window
        # Check that $modLine doesn't contain one of those exception patterns
        foreach my $pattern ($self->session->currentWorld->noChannelList) {

            if ($modLine =~ m/$pattern/i) {

                # Divert the text back into the 'main' window, where it belongs (but only if a gag
                #   trigger was used)
                if ($obj->ivShow('attribHash', 'gag')) {

                    $self->session->defaultTabObj->textViewObj->insertText(
                        $modLine,
                        'after',           # Assume that the line would have ended in a newline char
                    );

                    # Write to logs, if allowed
                    $self->session->writeIncomingDataLogs($stripLine, $modLine);
                }

                # (Don't bother checking the other exception patterns)
                return undef;
            }
        }

        # Display the text in the task window
        $self->displayText(
            $obj->ivShow('propertyHash', 'channel'),
            $modLine,
            $stripLine,
        );

        # Write the message to a logfile (if possible)
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'channels', # Write to this logfile
        );

        return 1;
    }

    sub switchTabCallback {

        # Usually called GA::Table::Pane->respondVisibleTab whenever the visible tab in the task
        #   window changes
        # Resets the colour of the tab's label text
        #
        # Expected arguments
        #   $paneObj    - The GA::Table::Pane object for the task window
        #   $tabObj     - The GA::Obj::Tab for the newly-visible tab
        #
        # Optional arguments
        #   $id         - A value passed by the pane object; for tasks, set to this task's ->name
        #                   (in general, might be 'undef')
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $paneObj, $tabObj, $id, $check) = @_;

        # Local variables
        my (
            $channel,
            %reverseHash,
        );

        # Check for improper arguments
        if (! defined $paneObj || ! defined $tabObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->switchTabCallback', @_);
        }

        %reverseHash = reverse $self->tabHash;
        $channel = $reverseHash{$tabObj};

        if (defined $channel) {

            $paneObj->setTabLabel(
                $tabObj->number,
                $self->getLabelText($channel, FALSE),
            );
        }

        return 1;
    }

    sub closeTabCallback {

        # Usually called GA::Table::Pane->removeTab whenever a tab in the task window is manually
        #   closed by the user
        # Updates $self->tabHash
        #
        # Expected arguments
        #   $paneObj    - The GA::Table::Pane object for the task window
        #   $tabObj     - The GA::Obj::Tab for the closed tab
        #
        # Optional arguments
        #   $id         - A value passed by the pane object; for tasks, set to this task's ->name
        #                   (in general, might be 'undef')
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $paneObj, $tabObj, $id, $check) = @_;

        # Local variables
        my $chooseTabObj;

        # Check for improper arguments
        if (! defined $paneObj || ! defined $tabObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->closeTabCallback', @_);
        }

        OUTER: foreach my $channel ($self->ivKeys('tabHash')) {

            my $otherTabObj = $self->ivShow('tabhash', $channel);

            if ($otherTabObj eq $tabObj) {

                $self->ivDelete('tabHash', $channel);

                last OUTER;
            }
        }

        if ($self->defaultTabObj && $self->defaultTabObj eq $tabObj) {

            # The first tab opened has been closed. Choose another default tab
            foreach my $tabObj ($self->ivValues('tabHash')) {

                if (! $chooseTabObj || $tabObj->number < $chooseTabObj->number) {

                    $chooseTabObj = $tabObj;
                }
            }

            # If there are no tabs left then, of course, $self->defaultTabObj is set to 'undef'
            $self->ivPoke('defaultTabobj', $chooseTabObj);
        }

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub lastLine
        { $_[0]->{lastLine} }
    sub tabHash
        { my $self = shift; return %{$self->{tabHash}}; }

    sub summaryMode
        { $_[0]->{summaryMode} }
    sub summaryChannel
        { $_[0]->{summaryChannel} }

    sub initChannelList
        { my $self = shift; return @{$self->{initChannelList}}; }
    sub ignoreChannelList
        { my $self = shift; return @{$self->{ignoreChannelList}}; }

    sub soundEffectHash
        { my $self = shift; return %{$self->{soundEffectHash}}; }
    sub urgencyHash
        { my $self = shift; return %{$self->{urgencyHash}}; }

    sub tabCloseButtonFlag
        { $_[0]->{tabCloseButtonFlag} }
    sub capitaliseFlag
        { $_[0]->{capitaliseFlag} }
    sub useColourStyleFlag
        { $_[0]->{useColourStyleFlag} }
}

{ package Games::Axmud::Task::Chat;

    # Chat task, based on the Kildclient plugin by Eduardo M Kalinowski

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Chat task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'chat_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        # All IVs are private (but as a convenience, Chat tasks are allowed to set the IVs of other
        #   Chat tasks)
        $self->{_privFlag}              = TRUE,

        $self->{name}                   = 'chat_task';
        $self->{prettyName}             = 'Chat';
        $self->{shortName}              = 'Ch';
        $self->{shortCutIV}             = undef;        # Set by $self->doInit, if required

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Instant messenger task using zChat/MudMaster protocols';
        $self->{jealousyFlag}           = FALSE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = FALSE;        # Start with ';listen'
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = FALSE;
        $self->{winPreferList}          = ['entry', 'grid'];
        $self->{winmap}                 = 'entry_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'chat';
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {
            'chat'                      => FALSE,
            'chatout'                   => FALSE,
            'chatin'                    => FALSE,
            'chatecho'                  => FALSE,
            'chatsystem'                => FALSE,
            'chatremote'                => FALSE,
            'chatsnoop'                 => FALSE,
        };
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;         # Task can't be activated/disactivated

        # Task parameters

        # Is this Chat task the lead Chat task (the one that listens out for incoming connections)?
        #   (TRUE yes, FALSE no)
        $self->{leadTaskFlag}           = FALSE;
        # Is this Chat task actually connected to someone? (TRUE yes, FALSE no)
        $self->{sessionFlag}            = FALSE;
        # Flag set to TRUE when the connection is closed (set to FALSE up until that moment)
        $self->{connectionClosedFlag}   = FALSE;
        # If this chat session is with someone in your contacts list (i.e. a GA::Obj::ChatContact
        #   object), the blessed reference of the object (set to 'undef' for chat sessions with
        #   someone else)
        $self->{chatContactObj}         = undef;

        # The user's IP address, set when the task starts. If GA::Client->ipv4Get can't find it,
        #   it uses a backup value of '127.0.0.1' (which is what we would get from
        #   IO::Socket::INET->sockhost, if a socket were open)
        $self->{ip}                     = undef;
        # The port currently used for accepting incoming connections ('undef' when not accepting
        #   any)
        $self->{port}                   = undef;
        # The IO::Socket used for incoming connections ('undef' when not accepting any)
        $self->{acceptSocket}           = undef;
        # The Glib::IO watch used for incoming connections ('undef' when not accepting any)
        $self->{acceptID}               = undef;
        # A timeout to kill an incoming connection which isn't established quickly enough (in
        #   seconds)
        $self->{incomingTimeout}        = 60;
        # A timeout to kill an outgoing connection which isn't established quickly enough (in
        #   seconds)
        $self->{outgoingTimeout}        = 10;

        # The socket used for communications
        $self->{clientSocket}           = undef;
        # Chat protocol: 0 for MudMaster, 1 for zChat (matches a key in $self->constOptHash)
        $self->{chatType}               = undef;
        # ID for the IO Watch callback
        $self->{ioWatchID}              = undef;
        # ID for the timeout callback (to close connections that are not established after 60 secs)
        $self->{timeoutID}              = undef;
        # Operation code that has been read, but that has not been processed
        $self->{pendingOpCode}          = undef;
        # Last invalid opcode received (stored so that we don't send endless error messages to the
        #   chat contact)
        $self->{lastInvalidOpCode}      = undef;
        # Size of data for the pending operation
        $self->{pendingDataSize}        = undef;
        # Buffer to hold incoming data for the connection
        $self->{dataBuffer}             = undef;
        # The character set to use by default
        $self->{defaultEncoding}        = 'iso-8859-1';
        # The character set actually used
        $self->{encoding}               = undef;

        # Two hooks used for snooping, which start disabled - one for the 'receive_text' event,
        #   another for the 'send_cmd' event. Stored by the lead Chat task only
        $self->{receiveHook}            = undef;
        $self->{sendHook}               = undef;
        # The lead Chat task uses a timer interface to check for idle users periodically. The
        #   interface object created
        $self->{idleTimer}              = undef;
        # How long the timer should wait (in seconds) before checking whether the user is idle
        $self->{idleCheckTime}          = 5;
        # How long the user should be idle, before the lead Chat task sends out a 'STATUS' message
        #   to all connections
        $self->{idleSeconds}            = 300;      # 5 minutes

        # Flag set by ';chataccept' when there is no Chat task running. $self->acceptCalls can't be
        #   called right away; instead, we instruct ->init to call it by setting the flag to TRUE
        #   (and setting the port, if one was specified)
        $self->{acceptCallsOnInitFlag}  = FALSE;
        $self->{acceptCallsOnInitPort}  = undef;
        # Flag set by ';chatcall' or ';chatcall'. $self->genericCall can't be called right away,
        #   instead, we instruct ->init to call it by setting the flag to TRUE (and setting the
        #   following Ivs - $port is optional)
        $self->{makeCallOnInitFlag}     = FALSE;
        $self->{makeCallOnInitType}     = undef;    # 'MM' or 'ZCHAT'
        $self->{makeCallOnInitIP}       = undef;
        $self->{makeCallOnInitPort}     = undef;
        # Flags set when the lead Chat task, which already has its own connection, receives another
        #   incoming chat request. The lead Chat task creates this task, and sets the following IVs
        #   so that ->init can respond to the request
        $self->{receiveCallOnInitFlag}  = FALSE;
        $self->{receiveCallOnInitSocket}
                                        = undef;

        # The name used in chat sessions. Set to the current character's name (albeit capitalised),
        #   unless changed by the user
        $self->{localName}              = undef;
        # The Windows .bmp file being used as the chat icon ('undef' if no icon used)
        $self->{localIconFile}          = undef;
        # Your email address, if it has been broadcast to the chat contact - otherwise set to
        #   'undef'
        $self->{localEmail}             = undef;
        # The name of the chat group, if there is one
        $self->{localGroup}             = undef;
        # Our status, used in zChat sessions only.
        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        #   0 - no status
        #   1 - Normal status
        #   2 - InActive status
        #   3 - AFK status
        # The Chat task uses values of '0' for MudMaster connections, and 1/3 (only) for zChat
        #   connections
        $self->{localStatus}            = 0;
        # zChat's stamp is a random 32-bit number
        $self->{localZChatStamp}        = int(rand(2**32 - 1));

        # When the chat contact sends their own icon, we store it in this IV as a Gtk3::Gdk::Pixbuf,
        #   so that it can be saved in the contacts list, where necessary
        $self->{remoteIcon}             = undef;
        # The same icon scaled to 16x16 (required by GA::Obj::ChatContact)
        $self->{remoteIconScaled}       = undef;
        # Nickname of the connected peer
        $self->{remoteName}             = undef;
        # Connected peer's advertised IP
        $self->{remoteIP}               = undef;
        # Connected peer's advertised port (used for incoming connections)
        $self->{remotePort}             = undef;
        # Connected peer's email (only displayed, not used by the task)
        $self->{remoteEmail}            = undef;
        # Connected peer's zChat stamp (if any)
        $self->{remoteStamp}            = undef;
        # Version of the connected peer's client
        $self->{remoteVersion}          = undef;
        # Status of the connected peer's client
        $self->{remoteStatus}           = 0;
        # The zChat ID (i.e. zMUD registration number) of the connected peer
        $self->{remoteZChatID}          = undef;

        # Can the connected peer snoop on us? (TRUE yes, FALSE no)
        $self->{allowSnoopFlag}         = FALSE;
        # Is the connected peer snooping on us now? (TRUE yes, FALSE no)
        $self->{isSnoopedFlag}          = FALSE;
        # The number of snoopers (stored by the lead Chat task only)
        $self->{snooperCount}           = 0;
        # Chat contacts can request to see a list of chat connections. This flag is set to TRUE if
        #   this connection is 'public', meaning that it's included in the list; or set to FALSE if
        #   this connection is 'private', meaning that it's not included in the list
        $self->{publicConnectionFlag}   = FALSE;
        # Flag set to TRUE in zChat sessions after a ';submit' chat command, allowing the chat
        #   contact to send world commands remotely. Set to FALSE by default or in a MudMaster
        #   session
        $self->{allowRemoteCmdFlag}     = FALSE;
        # What happens when the user enters text into the task window's entry box:
        #   'chat' - it's a chat message
        #   'emote' - it's an emote
        #   'cmd' - it's a remote command
        $self->{entryMode}              = 'chat';
        # Flag set to TRUE in zChat sessions when this chat session is 'serving' - i.e.
        #   CHAT_TEXT_EVERYBODY messages received in one chat session are echoed to every other chat
        #   session, and CHAT_TEXT_GROUP  messages received in one chat session are echoed to every
        #   other chat session in the named group
        # Otherwise, set to FALSE (and set to FALSE in MudMaster sessions)
        $self->{servingFlag}            = FALSE;

        # Time that the last ping was sent
        $self->{pingTime}               = undef;
        # A random value sent in each ping to detect the correct reply when several pings are sent
        $self->{pingStamp}              = undef;
        # Is the file being sent or received? (according to $self->constOptHash, set to 2 for
        #   sending, 1 for receiving)
        $self->{fileDir}                = undef;
        # Name of the file being transferred
        $self->{fileName}               = undef;
        # Filehandle of the file being transferred
        $self->{fileHandle}             = undef;
        # Total size of the file being transferred
        $self->{fileTotalSize}          = undef;
        # How many bytes already transferred
        $self->{fileSize}               = undef;

        # The colours in which to display various things. If set to 'undef', use the normal colour
        #   (white on black), otherwise use the specified colour (which must be a standard Axmud
        #   colour tag)
        # Sent chats/emotes
        $self->{chatOutColour}          = 'white';
        # Received chats/emotes
        $self->{chatInColour}           = 'green';
        # Echoed CHAT_TEXT_EVERYBODY and CHAT_TEXT_GROUP messages
        $self->{chatEchoColour}         = 'magenta';
        # Local system messages
        $self->{systemColour}           = 'cyan';
        # Remote system messages
        $self->{remoteColour}           = 'RED';
        # Snooping colour
        $self->{snoopColour}            = 'yellow';
        # Flag set to TRUE (briefly) by ->showInfo, instructing ->writeText to display all messages
        #   in the colour for local system messages
        $self->{allSystemColourFlag}    = FALSE;
        # Flag set to TRUE if smileys be displayed in the task window, FALSE otherwise
        $self->{allowSmileyFlag}        = TRUE;
        # The size of smileys, relative to the font size used in the window. A factor of 2 seems to
        #   look the best; the icon's size is $fontSize * $self->smileySizeFactor
        $self->{smileySizeFactor}       = 2;

        # IVs that will be used when PGP encryption is implemented (for the zChat protocol)
        # Our own public and secret keys
        $self->{localPublicKey}         = undef;
        $self->{localSecretKey}         = undef;
        # The chat contact's public key
        $self->{remotePublicKey}        = undef;

        # Constants used in the original chat.pl plugin
        $self->{constOptHash}           = {
            # Chat types
            'MM'                        => 0,
            'ZCHAT'                     => 1,
            # Chat commands for both protocols
            'NAME_CHANGE'               => 1,
            'REQUEST_CONNECTIONS'       => 2,
            'CONNECTION_LIST'           => 3,
            'TEXT_EVERYBODY'            => 4,
            'TEXT_PERSONAL'             => 5,
            'TEXT_GROUP'                => 6,
            'MESSAGE'                   => 7,
            'VERSION'                   => 19,
            'FILE_START'                => 20,
            'FILE_DENY'                 => 21,
            'FILE_BLOCK_REQUEST'        => 22,
            'FILE_BLOCK'                => 23,
            'FILE_END'                  => 24,
            'FILE_CANCEL'               => 25,
            'PING_REQUEST'              => 26,
            'PING_RESPONSE'             => 27,
            'PEEK_CONNECTIONS'          => 28,
            'PEEK_LIST'                 => 29,
            'SNOOP'                     => 30,
            'SNOOP_DATA'                => 31,
            # Chat commands specific to the MM chat protocol
            'DO_NOT_DISTURB'            => 8,
            'END_OF_COMMAND'            => 255,
            # Chat commands specific to the zChat protocol
            'ICON'                      => 100,
            'STATUS'                    => 101,
            'EMAIL'                     => 102,
            'REQUEST_PGP_KEY'           => 103,     # Not implemented
            'PGP_KEY'                   => 104,     # Not implemented
            'SEND_COMMAND'              => 105,
            'STAMP'                     => 106,
            # Chat commands For file transfer
            'MM_FILE_BLOCK_SIZE'        => 500,
            'ZCHAT_FILE_BLOCK_SIZE'     => 1024,
            # File directions
            'RECEIVING'                 => 1,
            'SENDING'                   => 2,
            # Chat destinations
            'PERSONAL'                  => 0,
            'EVERYBODY'                 => 1,
            'GROUP'                     => 2,
        };
        # Opcodes which can't be sent until the connection has been negotiated (prevents us from
        #   sending strange messages like 'You chat to , 'Hello!''
        # NB $self->receiveFile will refuse any incoming 'FILE_START' opcodes when the connection
        #   hasn't been negotiated yet
        $self->{restrictedHash}         = {
            4                           => 'TEXT_EVERYBODY',
            5                           => 'TEXT_PERSONAL',
            6                           => 'TEXT_GROUP',
            20                          => 'FILE_START',
            30                          => 'SNOOP',
            # Chat commands specific to the zChat protocol
            105                         => 'SEND_COMMAND',
        };
        # Commands to control the chat sessions can be entered into the task window's entry box
        #   (when preceded by the client command sigil, ';')
        # A hash of commands that can be entered, in the form
        #   $hash{chat_command} = method_to_call
        $self->{cmdHash}                = {
            'name'                      => 'setName',
            'group'                     => 'setGroup',
            'chat'                      => 'chat',          # Added for completeness
            'emote'                     => 'emote',
            'chatgroup'                 => 'chatToGroup',
                'cg'                    => 'chatToGroup',   # Abbreviation for chat.pl compatibility
            'emotegroup'                => 'emoteToGroup',
                'eg'                    => 'emoteToGroup',  # Abbreviation for chat.pl compatibility
            'chatall'                   => 'chatToAll',
                'ca'                    => 'chatToAll',
            'emoteall'                  => 'emoteToAll',
                'ea'                    => 'emoteToAll',
            'ping'                      => 'pingPeer',
            'dnd'                       => 'sendDoNotDisturb',
            'submit'                    => 'submitToCmds',
            'escape'                    => 'escapeFromCmds',
            'cmd'                       => 'sendRemoteCmd',
            'sendfile'                  => 'sendFile',
            'stopfile'                  => 'stopFile',
            'snoop'                     => 'requestSnoop',
            'allowsnoop'                => 'acceptSnoop',
                'allow'                 => 'acceptSnoop',
            'forbidsnoop'               => 'refuseSnoop',
                'forbid'                => 'refuseSnoop',
                'noallowsnoop'
                                        => 'refuseSnoop',   # Alias for chat.pl compatibility
            'hangup'                    => 'hangUp',        # Alias for chat.pl compatibility
                'halt'                  => 'hangUp',
                'close'                 => 'hangUp',
            'public'                    => 'setPublic',
            'private'                   => 'setPrivate',
            'peek'                      => 'peekRemoteConnections',
            'request'                   => 'requestRemoteConnections',
            'serve'                     => 'setServing',
            'noserve'                   => 'setNotServing',
            'info'                      => 'showInfo',
            'icon'                      => 'setIcon',
            'email'                     => 'setEmail',
            'smiley'                    => 'setSmiley',
            'save'                      => 'saveContact',
            'help'                      => 'winHelp',
            'mode'                      => 'setMode',
        };

        # List of strings displayed in response to the task's internal 'help' command
        $self->{helpList}               = [
            'List of commands for the Chat task window:',
            '  ;name <nick>       - Sets chat nickname to <nick>',
            '  ;name              - Sets default chat nickname',
            '  ;group <name>      - Joins a group called <name>',
            '  ;group             - Leaves the current group',
            '  ;chat <text>       - Sends some text',
            '  ;emote <text>      - Sends some text as an emote',
            '  ;chatgroup <text>  - Sends text to the group',
            '  ;emotegroup <text> - Sends an emote to the group',
            '  ;chatall <text>    - Sends text to everyone',
            '  ;emoteall <text>   - Sends an emote to everyone',
            '  ;ping              - Pings the chat contact',
            '  ;dnd               - Sends \'Do not disturb\' (MM)',
            '  ;submit            - Allow remote world commands (zC)',
            '  ;escape            - Forbid remote world commands (zC)',
            '  ;cmd <text>        - Send a remote world command (zC)',
            '  ;sendfile <file>   - Sends a file',
            '  ;sendfile          - Chooses a file to send',
            '  ;stopfile          - Stops a file transfer',
            '  ;allowsnoop        - Allow the chat contact to snoop you',
            '  ;forbidsnoop       - Forbids chat contact from snooping',
            '  ;snoop             - Asks to start/stop snooping',
            '  ;hangup            - Terminate this session',
            '  ;public            - Marks the connection as public',
            '  ;private           - Marks the connection as private',
            '  ;peek              - View connections of other contacts',
            '  ;request           - Requests a conference call',
            '  ;serve             - Relay text to other chat contacts',
            '  ;noserve           - Stop relaying text',
            '  ;info              - Display info about the session',
            '  ;icon <file>       - Sets your icon (must be .bmp, zC)',
            '  ;icon              - Chooses your icon (zC)',
            '  ;email <email>     - Sets the email to broadcast',
            '  ;email             - Stops broadcasting your email',
            '  ;smiley on         - Turns on smileys',
            '  ;smiley off        - Turns off smileys',
            '  ;save <contact>    - Saves chat contact in contacts list',
            '  ;save              - Update existing contact',
            '  ;help              - Show this list of commands',
            'Typed text not beginning with ; is treated like this:',
            '  ;mode chat         - Treat the text as chat',
            '  <text>             - Sends some text',
            '  ;mode emote        - Treat the text as an emote',
            '  <emote>            - Sends some text as an emote',
            '  ;mode cmd          - Send the text as a remote command',
            '  <cmd>              - Send a remote command (zC)',
            'Available in all modes:',
            '  \'<text>            - Sends some text',  # Escape char, so extra space
            '  :<emote>           - Sends some text as an emote',
            '  #<cmd>             - Send a remote command (zC)',
        ];

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{leadTaskFlag}          = $self->leadTaskFlag;
        $clone->{sessionFlag}           = $self->sessionFlag;
        $clone->{connectionClosedFlag}  = $self->connectionClosedFlag;
        $clone->{chatContactObj}        = $self->chatContactObj;

        $clone->{port}                  = $self->port;
        $clone->{acceptSocket}          = $self->acceptSocket;
        $clone->{acceptID}              = $self->acceptID;
        $clone->{incomingTimeout}       = $self->incomingTimeout;
        $clone->{outgoingTimeout}       = $self->outgoingTimeout;

        $clone->{clientSocket}          = $self->clientSocket;
        $clone->{chatType}              = $self->chatType;
        $clone->{ioWatchID}             = $self->ioWatchID;
        $clone->{timeoutID}             = $self->timeoutID;
        $clone->{pendingOpCode}         = $self->pendingOpCode;
        $clone->{lastInvalidOpCode}     = $self->lastInvalidOpCode;
        $clone->{pendingDataSize}       = $self->pendingDataSize;
        $clone->{dataBuffer}            = $self->dataBuffer;
        $clone->{defaultEncoding}       = $self->defaultEncoding;
        $clone->{encoding}              = $self->encoding;

        $clone->{receiveHook}           = $self->receiveHook;
        $clone->{sendHook}              = $self->sendHook;
        $clone->{idleTimer}             = $self->idleTimer;
        $clone->{idleCheckTime}         = $self->idleCheckTime;
        $clone->{idleSeconds}           = $self->idleSeconds;

        $clone->{acceptCallsOnInitFlag} = $self->acceptCallsOnInitFlag;
        $clone->{acceptCallsOnInitPort} = $self->acceptCallsOnInitPort;
        $clone->{makeCallOnInitFlag}    = $self->makeCallOnInitFlag;
        $clone->{makeCallOnInitType}    = $self->makeCallOnInitType;
        $clone->{makeCallOnInitIP}      = $self->makeCallOnInitIP;
        $clone->{makeCallOnInitPort}    = $self->makeCallOnInitPort;
        $clone->{receiveCallOnInitFlag} = $self->receiveCallOnInitFlag;
        $clone->{receiveCallOnInitSocket}
                                        = $self->receiveCallOnInitSocket;

        $clone->{localName}             = $self->localName;
        $clone->{localIconFile}         = $self->localIconFile;
        $clone->{localEmail}            = $self->localEmail;
        $clone->{localGroup}            = $self->localGroup;
        $clone->{localStatus}           = $self->localStatus;
        $clone->{localZChatStamp}       = $self->localZChatStamp;

        $clone->{remoteIcon}            = $self->remoteIcon;
        $clone->{remoteIconScaled}      = $self->remoteIconScaled;
        $clone->{remoteName}            = $self->remoteName;
        $clone->{remoteIP}              = $self->remoteIP;
        $clone->{remotePort}            = $self->remotePort;
        $clone->{remoteEmail}           = $self->remoteEmail;
        $clone->{remoteStamp}           = $self->remoteStamp;
        $clone->{remoteVersion}         = $self->remoteVersion;
        $clone->{remoteStatus}          = $self->remoteStatus;
        $clone->{remoteZChatID}         = $self->remoteZChatID;

        $clone->{allowSnoopFlag}        = $self->allowSnoopFlag;
        $clone->{isSnoopedFlag}         = $self->isSnoopedFlag;
        $clone->{snooperCount}          = $self->snooperCount;
        $clone->{publicConnectionFlag}  = $self->publicConnectionFlag;
        $clone->{allowRemoteCmdFlag}
                                        = $self->allowRemoteCmdFlag;
        $clone->{entryMode}             = $self->entryMode;
        $clone->{servingFlag}           = $self->servingFlag;

        $clone->{pingTime}              = $self->pingTime;
        $clone->{pingStamp}             = $self->pingStamp;
        $clone->{fileDir}               = $self->fileDir;
        $clone->{fileName}              = $self->fileName;
        $clone->{fileHandle}            = $self->fileHandle;
        $clone->{fileTotalSize}         = $self->fileTotalSize;
        $clone->{fileSize}              = $self->fileSize;

        $clone->{chatOutColour}         = $self->chatOutColour;
        $clone->{chatInColour}          = $self->chatInColour;
        $clone->{chatEchoColour}        = $self->chatEchoColour;
        $clone->{systemColour}          = $self->systemColour;
        $clone->{remoteColour}          = $self->remoteColour;
        $clone->{snoopColour}           = $self->snoopColour;
        $clone->{allSystemColourFlag}   = $self->allSystemColourFlag;
        $clone->{allowSmileyFlag}       = $self->allowSmileyFlag;
        $clone->{smileySizeFactor}      = $self->smileySizeFactor;

        $clone->{localPublicKey}        = $self->localPublicKey;
        $clone->{localSecretKey}        = $self->localSecretKey;
        $clone->{remotePublicKey}       = $self->remotePublicKey;

        $clone->{constOptHash}          = {$self->constOptHash};
        $clone->{restrictedHash}        = {$self->restrictedHash};
        $clone->{cmdHash}               = {$self->cmdHash};

        $clone->{helpList}              = [$self->helpList];

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the flag that says whether this is the lead chat task, or not
        $newTask->ivPoke('leadTaskFlag', $self->leadTaskFlag);
        # Preserve the GA::Obj::ChatContact, if there is one
        $newTask->ivPoke('chatContactObj', $self->chatContactObj);
        # Preserve colours
        $newTask->ivPoke('chatOutColour', $self->chatOutColour);
        $newTask->ivPoke('chatInColour', $self->chatInColour);
        $newTask->ivPoke('chatEchoColour', $self->chatEchoColour);
        $newTask->ivPoke('systemColour', $self->systemColour);
        $newTask->ivPoke('remoteColour', $self->remoteColour);
        $newTask->ivPoke('snoopColour', $self->snoopColour);
        # Preserve the smiley settings
        $newTask->ivPoke('allowSmileyFlag', $self->allowSmileyFlag);
        $newTask->ivPoke('smileySizeFactor', $self->smileySizeFactor);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

    sub ttsSwitchFlagAttrib {

        # Called by GA::Cmd::Switch->do and PermSwitch->do
        # Users can use the client command ';switch' to interact with individual tasks, typically
        #   telling them to turn on/off the automatic reading out of information (e.g. the Locator
        #   task can be told to start or stop reading out room titles as they are received from
        #   the world)
        # The ';switch' command is in the form ';switch <flag_attribute>'. The ';switch' command
        #   looks up the <flag_attribute> (which is a string, not a TRUE/FALSE value) in
        #   GA::Client->ttsFlagAttribHash, which tells it which task to call
        #
        # Expected arguments
        #   $flagAttrib - The TTS flag attribute specified by the calling function. Must be one of
        #                   the keys in $self->ttsFlagAttribHash
        #
        # Optional arguments
        #   $noSpecialFlag
        #               - Set to TRUE when called by GA::Cmd::PermSwitch->do, in which case only
        #                   this task's hash of flag attributes is updated. Otherwise set to FALSE
        #                   (or 'undef'), in which case other things can happen when a flag
        #                   attribute is switched. For all built-in tasks, there is no difference
        #                   in behaviour
        #
        # Return values
        #   'undef' on improper arguments or if the $flagAttrib doesn't exist in this task's
        #       ->ttsFlagAttribHash
        #   Otherwise returns a confirmation message for the calling function to display

        my ($self, $flagAttrib, $noSpecialFlag, $check) = @_;

        # Local variables
        my $msg;

        # Check for improper arguments
        if (! defined $flagAttrib || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->ttsSwitchFlagAttrib', @_);
        }

        # TTS flag attributes are case-insensitive
        $flagAttrib = lc($flagAttrib);

        # Check that the specified flag attribute is actually used by this task (';switch' or
        #   ';permswitch' should carry out this check, but better safe than sorry)
        if (! $self->ivExists('ttsFlagAttribHash', $flagAttrib)) {

            return undef;

        } else {

            # If a current task performs some kind of action, when a flag attribute is switched,
            #   the code for the action should be placed here. (Tasks in the global initial
            #   tasklist can't perform an action, of course.)
            if (! $noSpecialFlag) {

                # (no actions to perform)
            }

            $msg = '\'' . $self->prettyName . '\' flag attribute \'' . $flagAttrib
                            . '\' switched to ';

            # One change from the generic ->ttsSwitchFlagAttrib function: if the 'chat' attribute
            #   is switched on/off, switch the other six attributes on/off, too
            if ($flagAttrib eq 'chat') {

                if ($self->ivShow('ttsFlagAttribHash', 'chat')) {

                    $self->ivAdd('ttsFlagAttribHash', 'chat', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatout', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatin', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatecho', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatsystem', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatremote', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatsnoop', FALSE);
                    $msg .= 'OFF';

                } else {

                    $self->ivAdd('ttsFlagAttribHash', 'chat', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatout', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatin', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatecho', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatsystem', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatremote', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'chatsnoop', TRUE);
                    $msg .= 'ON';
                }

            } else {

                if ($self->ivShow('ttsFlagAttribHash', $flagAttrib)) {

                    $self->ivAdd('ttsFlagAttribHash', $flagAttrib, FALSE);
                    $msg .= 'OFF';

                } else {

                    $self->ivAdd('ttsFlagAttribHash', $flagAttrib, TRUE);
                    $msg .= 'ON';
                }
            }

            return $msg;
        }
    }

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if there is an error
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($localIP, $result);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # If there is no 'lead' Chat task, then this task must be the lead task
        if (! $self->session->chatTask) {

            # This task should be stored in the current session's ->chatTask IV
            $self->ivPoke('shortCutIV', 'chatTask');
            $self->session->add_standardTask($self);

            $self->ivPoke('leadTaskFlag', TRUE);

        } else {

            # Make sure this isn't the lead Chat task
            $self->ivPoke('leadTaskFlag', FALSE);
        }

        # Fetch the user's IP address
        $localIP = $axmud::CLIENT->ipv4Get();
        if (! defined $localIP) {

            # Emergency failsafe; use what we would get from IO::Socket::INET->sockhost, if it were
            #   open
            $self->ivPoke('ip', '127.0.0.1');

        } else {

            $self->ivPoke('ip', $localIP);
        }

        # Check that there is a current character. If not, change this task's ->status back to
        #   'wait_init'. $self->init will be called repeatedly when the task loop spins until there
        #   is a current character
        if (! $self->session->currentChar) {

            $self->ivPoke('status', 'wait_init');
            return 1;
        }

        # If there was no Chat task running when the user typed the ';chataccept' command, a flag
        #   has been set which instructs us to call $self->acceptCalls right away
        if ($self->acceptCallsOnInitFlag) {

            if ($self->acceptCallsOnInitPort) {
                $result = $self->acceptCalls($self->acceptCallsOnInitPort);
            } else {
                $result = $self->acceptCalls();
            }

            if (! $result) {

                $self->ivPoke('shutdownFlag', TRUE);

                return $self->writeError(
                    '\'' . $self->uniqueName . '\' task is halting because it couldn\'t open a port'
                    . ' to listen for incoming connections',
                    $self->_objClass . '->doInit'
                );
            }

        # If this Chat task was created by the lead Chat task which received an incoming call, but
        #   which was already dealing with a connection, call ->acceptConnection to deal with it
        } elsif ($self->receiveCallOnInitFlag) {

            $result = $self->receiveConnection($self->receiveCallOnInitSocket);
            if (! $result) {

                $self->ivPoke('shutdownFlag', TRUE);

                return $self->writeError(
                    '\'' . $self->uniqueName . '\' task is halting because it couldn\'t process an'
                    . ' incoming connection',
                    $self->_objClass . '->doInit'
                );
            }

        # If this Chat task was created by a ';chatcall' or a ';chatzcall' command, a flag has been
        #   set which instructs us to call someone right away
        } elsif ($self->makeCallOnInitFlag) {

            if ($self->makeCallOnInitType eq 'MM') {
                $result = $self->mcall($self->makeCallOnInitIP, $self->makeCallOnInitPort);
            } elsif ($self->makeCallOnInitType eq 'ZCHAT') {
                $result = $self->zcall($self->makeCallOnInitIP, $self->makeCallOnInitPort);
            }

            if (! $result) {

                $self->ivPoke('shutdownFlag', TRUE);

                # In rare circumstances, $self->makeCallOnInitIP won't be set
                if ($self->makeCallOnInitIP) {

                    return $self->writeError(
                        '\'' . $self->uniqueName . '\' task is halting because it couldn\'t call '
                        . $self->makeCallOnInitIP . ' ' . $self->makeCallOnInitPort,
                        $self->_objClass . '->doInit'
                    );

                } else {

                    # An error message has already been displayed
                    return undef;
                }
            }
        }

        if ($self->leadTaskFlag) {

            # If interfaces have already been created for this task (unlikely), remove them before
            #   replacing them with new ones
            $self->session->tidyInterfaces($self);

            # Create new interfaces
            if (! $self->resetHooks || ! $self->resetTimers) {

                $self->writeError(
                    'Could not create ' . $self->prettyName . ' task interfaces, so halting the'
                    . ' task',
                    $self->_objClass . '->doInit',
                );
            }
        }

        return 1;
    }

    sub doShutdown {

        # Called just before the task completes a shutdown
        # For process tasks, called by $self->main. For activity tasks, called by $self->shutdown
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doShutdown', @_);
        }

        # Halt the current chat session, if it is open
        if ($self->sessionFlag) {

            $self->terminateChatSession();
        }

        # If this is the 'lead' Chat task, then we need to assign another Chat task as the 'lead'
        #   Chat task
        if ($self->leadTaskFlag) {

            # Choose a new task
            $self->assignLeadTask();
        }

        # If the socket for listening to incoming connections is open, close it
        if ($self->acceptID) {

            $self->refuseCalls();
        }

        # If this is the lead Chat task (even after the call to ->assignLeadTask, because this task
        #   is the only Chat task running), then reset the GA::Session IV
        if ($self->session->chatTask && $self->session->chatTask eq $self) {

            $self->ivUndef('shortCutIV');
            $self->session->del_standardTask($self);
        }

        return 1;
    }

    sub doReset {

        # Called just before the task completes a reset
        # For process tasks, called by $self->main. For activity tasks, called by $self->reset
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Local variables
        my $acceptID;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # If this is the lead Chat task, then reset the GA::Session IV
        if ($self->leadTaskFlag) {

            # Transfer the Accept ID to the new task, while retaining the old socket
            if ($self->acceptID) {

                Glib::Source->remove($self->acceptID);

                $acceptID = Glib::IO->add_watch(
                    $self->acceptSocket->fileno,
                    'in',
                    sub { $newTaskObj->acceptConnection(@_); },
                    $self->acceptSocket,
                );

                $newTaskObj->ivPoke('port', $self->port);
                $newTaskObj->ivPoke('acceptSocket', $self->acceptSocket);
                $newTaskObj->ivPoke('acceptID', $acceptID);
            }

            # Mark the other task as the new lead Chat task
            $newTaskObj->ivPoke('leadTaskFlag', TRUE);
            $self->ivPoke('leadTaskFlag', FALSE);

            # Inform the GA::Session of the change of lead Chat task
            $self->ivUndef('shortCutIV');
            $newTaskObj->ivPoke('shortCutIV', 'chatTask');
            $self->session->add_standardTask($newTaskObj);
        }

        return 1;
    }

    sub resetHooks {

        # Called by $self->doInit to create dependent hook interfaces for the lead Chat task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if any of the interfaces can't be created
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($receiveObj, $sendObj);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetHooks', @_);
        }

        # Create two hooks, initially disabled, to allow snooping
        $receiveObj = $self->session->createDependentInterface(
            'hook',
            'receive_text',         # Stimulus
            $self,
            'hookCallback',
            'enabled',
            0,
        );

        if (! $receiveObj) {

            return undef;
        }

        $sendObj = $self->session->createDependentInterface(
            'hook',
            'send_cmd',             # Stimulus
            $self,
            'hookCallback',
            'enabled',
            0,
        );

        if (! $sendObj) {

            return undef;
        }

        # Update IVs
        $self->ivPoke('receiveHook', $receiveObj);
        $self->ivPoke('sendHook', $sendObj);

        return 1;
    }

    sub resetTimers {

        # Called by $self->doInit to a create dependent timer interfaces for the lead Chat task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if any of the interfaces can't be created
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $timerObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetTimers', @_);
        }

        # Create a timer, initially enabled, to watch out for idle users
        $timerObj = $self->session->createDependentInterface(
            'timer',
            $self->idleCheckTime,   # Stimulus
            $self,
            'timerCallback',
        );

        if (! $timerObj) {

            return undef;
        }

        # Update IVs
        $self->ivPoke('idleTimer', $timerObj);

        return 1;
    }

    sub assignLeadTask {

        # Called by $self->doShutdown or $self->terminateChatSession when this, the lead Chat task,
        #   is shutting down in order to select a new lead Chat task (if there are any others)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $newTaskObj, $acceptID, $result,
            @list, @sortedList, @nameList,
            %hash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->assignLeadTask', @_);
        }

        # Compile a list of all the Chat tasks in the current tasklist, except this one
        @list = $self->findOtherTasks();
        if (@list == 1) {

            # There is only one remaining Chat task, so make that the lead Chat task
            $newTaskObj = $list[0];

        } elsif (@list) {

            # Choose a new lead task. Compile a sorted list of Chat task names, and a parallel hash
            @sortedList = sort {lc($a->uniqueName) cmp lc($b->uniqueName)} (@list);
            foreach my $taskObj (@sortedList) {

                push (@nameList, $taskObj->uniqueName);
                $hash{$taskObj->uniqueName} = $taskObj;
            }

            $result = $self->session->mainWin->showComboDialogue(
                'Chat task',
                'Choose a new lead Chat task',
                \@nameList,
            );

            if ($result) {

                $newTaskObj = $hash{$result};
            }
        }

        if ($newTaskObj) {

            # Transfer the Accept ID to the new task, while retaining the old socket
            if ($self->acceptID) {

                Glib::Source->remove($self->acceptID);

                $acceptID = Glib::IO->add_watch(
                    $self->acceptSocket->fileno,
                    'in',
                    sub { $newTaskObj->acceptConnection(@_); },
                    $self->acceptSocket,
                );

                $newTaskObj->ivPoke('port', $self->port);
                $newTaskObj->ivPoke('acceptSocket', $self->acceptSocket);
                $newTaskObj->ivPoke('acceptID', $acceptID);
            }

            # Mark the other task as the new lead Chat task
            $newTaskObj->ivPoke('leadTaskFlag', TRUE);
            $self->ivPoke('leadTaskFlag', FALSE);

            # Inform the GA::Session of the change of lead Chat task
            $self->ivUndef('shortCutIV');
            $newTaskObj->ivPoke('shortCutIV', 'chatTask');
            $self->session->add_standardTask($newTaskObj);

            # If there are currently any snooping sessions, destroy the old hooks, and create
            #   new ones for the new lead task
            if ($self->sendHook) {

                # Destroy this task's hooks
                $self->session->tidyInterfaces($self);
                # Create hooks for the new task
                $newTaskObj->resetHooks();
            }
        }

        return 1;
    }

    sub writeText {

        # Called to write text to the task window
        #
        # Expected arguments
        #   $text   - The text to write
        #
        # Optional arguments
        #   $type   - What kind of message this is (which determines the colour used) - should be
        #               one of 'chat_out', 'chat_in', 'echo', 'system', 'remote' or 'snoop'. If
        #               'undef' (or an invalid value), the standard colour is used. However,
        #               if $self->allSystemColourFlag is set to TRUE, $type is ignored and all
        #               messages are displayed in the colour specified by $self->systemColour
        #
        # Return values
        #   'undef' on improper arguments or if the task window is closed
        #   1 otherwise

        my ($self, $text, $type, $check) = @_;

        # Local variables
        my (
            $count, $modText, $fontSize,
            %smileyHash, %ttsHash,
        );

        # Check for improper arguments
        if (! defined $text || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->writeText', @_);
        }

        # Don't write anything to the task window if there isn't one open
        if (! $self->taskWinFlag) {

            return undef;
        }

        # If $text is an empty string, then we need to convert it into a string containing a space
        #   (because GA::Obj::TextView->insertText won't do anything with an empty string)
        if (! $text) {

            $text = ' ';

        # The original chat.pl sends strings which begin with a newline character. We don't want
        #   that in our task window, thank you very much.
        } elsif (substr($text, 0, 1) eq "\n") {

            $text = substr($text, 1);
        }

        # Import the hash of smileys
        %smileyHash = $axmud::CLIENT->chatSmileyHash;

        # If the text contains smileys (matching keys in GA::Client->chatSmileyHash), and if we're
        #   allowed to show smileys, we have to break $modText into portions, each containing either
        #   a smiley or non-smiley text. Show one portion at a time, repeating until there are non
        #   left
        $count = 0;
        $modText = $text;
        do {

            my ($portion, $posn, $smiley, $nlString, $pixbuf);

            # The first portion must be prepended by a newline character; any remaining portions are
            #   not
            $count++;
            if ($count == 1) {
                $nlString = 'before';
            } else {
                $nlString = 'echo';
            }

            if (! $self->allowSmileyFlag) {

                # Display the text without converting any smileys into icons
                $portion = $modText;
                $modText = '';

            } else {

                # Find the position of the first smiley in $modText
                foreach my $thisSmiley (keys %smileyHash) {

                    my $index = index($modText, $thisSmiley);
                    if ($index > -1) {

                        # We found a smiley. Do a greedy match, meaning that we take the first
                        #   smiley we found, but that we prefer longer smileys over shorter ones
                        if (
                            ! defined $posn
                            || $index < $posn
                            || ($index == $posn && length ($smiley) > length ($thisSmiley))
                        ) {
                            # Check that the smiley's icon file still exists
                            if (-e $smileyHash{$thisSmiley}) {

                                # Use this smiley
                                $posn = $index;
                                $smiley = $thisSmiley;
                            }
                        }
                    }
                }

                if (! defined $posn) {

                    # No smiley found. Display the rest of the text
                    $portion = $modText;
                    $modText = '';

                } elsif ($posn == 0) {

                    # Smiley found at the beginning of $modText. Show only the smiley
                    $portion = '';
                    $modText = substr($modText, length($smiley));

                } else {

                    # Smiley found, but not at the start of $modText. Display all the text up until
                    #   the smiley, then display the smiley
                    $portion = substr($modText, 0, $posn);
                    $modText = substr($modText, $posn + length ($smiley));
                }
            }

            if ($portion) {

                # Display a portion of text

                # Use a colour, if $type was specified (or if all messages must be displayed in the
                #   colour specified by $self->systemColour)
                if (defined $type || $self->allSystemColourFlag) {

                    if (
                        ($self->allSystemColourFlag || $type eq 'system')
                        && defined $self->systemColour
                    ) {
                        $self->insertWithLinks($portion, $self->systemColour, $nlString);

                    } elsif ($type eq 'chat_out' && defined $self->chatOutColour) {

                        $self->insertWithLinks($portion, $self->chatOutColour, $nlString);

                    } elsif ($type eq 'chat_in' && defined $self->chatInColour) {

                        $self->insertWithLinks($portion, $self->chatInColour, $nlString);

                    } elsif ($type eq 'echo' && defined $self->chatEchoColour) {

                        $self->insertWithLinks(
                            'ECHO: ' . $portion,
                            $self->chatEchoColour,
                            $nlString,
                        );

                    } elsif ($type eq 'remote' && defined $self->remoteColour) {

                        $self->insertWithLinks($portion, $self->remoteColour, $nlString);

                    } elsif ($type eq 'snoop' && defined $self->snoopColour) {

                        $self->insertWithLinks($portion, $self->snoopColour, $nlString);

                    } else {

                        # By default, use the task window's standard text colour
                        $self->insertWithLinks($portion, $nlString);
                    }

                } else {

                    # Colour not specified, so just use the task window's standard text colour
                    $self->insertWithLinks($portion, $nlString);
                }
            }

            if ($smiley) {

                if ($portion) {

                    # The smiley is coming after a portion of text, so we don't prepend the image
                    #   with a newline character
                    $nlString = 'echo';
                }

                # Display a smiley. Convert the image to a pixbuf
                $fontSize = $self->defaultTabObj->textViewObj->fontSize;
                if (! defined $fontSize) {

                    $fontSize = $axmud::CLIENT->constFontSize;
                }

                $pixbuf = Gtk3::Gdk::Pixbuf->new_from_file_at_scale(
                    $smileyHash{$smiley},
                    # Width/height comparable to font size
                    ($fontSize * $self->smileySizeFactor),
                    ($fontSize * $self->smileySizeFactor),
                    TRUE,                           # Preserve aspect ratio
                );

                if (! $pixbuf) {

                    # Emergency fallback - display the smiley as text
                    $self->insertText($smiley, $nlString);

                } else {

                    # Display the smiley icon
                    $self->showImage($pixbuf, undef, undef, undef, $nlString);
                }
            }

        } until (! $modText);

        # If there are any Watch tasks running - in any session - update them
        foreach my $otherSession ($axmud::CLIENT->listSessions()) {

            my ($world, $char);

            if ($otherSession->watchTask) {

                $world = $self->session->currentWorld->name;
                if ($self->session->currentChar) {

                    $char = $self->session->currentChar->name;  # Otherwise 'undef'
                }

                $otherSession->watchTask->displayText('chat', $world, $char, $text);
            }
        }

        # Write the message to a logfile (if possible)
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $text,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'chat',     # Write to this logfile
        );

        # Read out a TTS message, if required
        %ttsHash = $self->ttsFlagAttribHash;
        if (
            $self->ivShow('ttsFlagAttribHash', 'chat')
            || (
                defined $type
                && (
                    ($type eq 'chat_out' && $ttsHash{'chatout'})
                    || ($type eq 'chat_in' && $ttsHash{'chatin'})
                    || ($type eq 'echo' && $ttsHash{'chatecho'})
                    || ($type eq 'system' && $ttsHash{'chatsystem'})
                    || ($type eq 'remote' && $ttsHash{'chatremote'})
                    || ($type eq 'snoop' && $ttsHash{'chatsnoop'})
                )
            )
        ) {
            $self->ttsQuick($text);
        }

        return 1;
    }

    sub writeImproper {

        # Called whenever the user types a chat command with the wrong (i.e. missing or extra)
        #   arguments
        #
        # Expected arguments
        #   $inputString    - The (whole) chat command typed
        #
        # Return values
        #   'undef'

        my ($self, $inputString, $check) = @_;

        # Check for improper arguments
        if (! defined $inputString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->writeImproper', @_);
        }

        $self->writeText(
            'Invalid chat command (try \';help\')',
            'system',
        );

        # Read out a TTS message, if required
        if (
            $self->ivShow('ttsFlagAttribHash', 'chat')
            || $self->ivShow('ttsFlagAttribHash', 'chatimproper')
        ) {
            $self->ttsQuick('Invalid chat task command');
        }

        return undef;
    }

    sub refuseOpCode {

        # Called by several functions (e.g. $self->chat, ->emote, etc) when the connection hasn't
        #   yet been negotiated
        # Displays a system message in the task window
        #
        # Expected arguments
        #   $opCode     - The restricted opcode (a key in $self->restrictedHash) which can't be used
        #                   until the connection has been negotiated
        #
        # Return values
        #   'undef'

        my ($self, $opCode, $check) = @_;

        # Check for improper arguments
        if (! defined $opCode || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refuseOpCode', @_);
        }

        if ($self->chatContactObj) {

            $self->writeText(
                'Not yet connected to \'' . $self->chatContactObj->name . '\'',
                'system',
            );

        } else {

            $self->writeText(
                'Not yet connected to the chat contact',
                'system',
            );
        }

        return undef;
    }

    sub setChatWinTitle {

        # Called by $self->setName, ->changeRemoteName and ->newChatWin
        # Sets the title of the task window (if there is one) to show who is calling whom
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setChatWinTitle', @_);
        }

        if (! $self->sessionFlag || ! $self->localName || ! $self->remoteName) {

            # Reset the window's title
            $self->setTaskWinTitle();

        } else {

            $self->setTaskWinTitle(
                $self->prettyName . ': ' . $self->localName . ' > ' . $self->remoteName,
            );
        }

        return 1;
    }

    # Client commands

    sub acceptCalls {

        # Called by GA::Cmd::ChatAccept->do and $self->reset
        # Starts listening out for incoming calls
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $port   - The port to use for incoming calls. If not specified, the default port is used
        #
        # Return values
        #   'undef' on improper arguments, if this task isn't the lead Chat task, if this task is
        #       already listening out for incoming connections or if a socket can't be opened
        #   1 otherwise

        my ($self, $port, $check) = @_;

        # Local variables
        my ($acceptSocket, $acceptID);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->acceptCalls', @_);
        }

        if (! $self->leadTaskFlag) {

            return $self->writeError(
                'The \'' .$self->uniqueName . '\' task is not the lead Chat task, so cannot listen'
                . ' out for incoming connections',
                $self->_objClass . '->acceptCalls',
            );

        } elsif ($self->acceptSocket) {

            $self->writeText(
                'Already listening for incoming calls on port ' . $self->port,
                'system',
            );

            return undef;
        }

        # Set the port to use
        if (! $port) {

            $port = $axmud::CLIENT->constChatPort;
        }

        # Open a new TCP socket
        $acceptSocket = IO::Socket::INET->new(
            'Listen'        => 5,
            'LocalPort'     => $port,
            'Proto'         => 'tcp',
            'ReuseAddr'     => 1,
        );

        if ($acceptSocket) {

            # Listen for incoming connections
            $acceptID = Glib::IO->add_watch(
                $acceptSocket->fileno,
                'in',
                sub { $self->acceptConnection(@_); },
                $acceptSocket,
            );
        }

        if (! $acceptSocket || ! $acceptID) {

            return $self->writeError(
                'The \'' .$self->prettyName . '\' task could not open a socket to listen for'
                . ' incoming connections',
                $self->_objClass . '->acceptCalls',
            );

        } else {

            # Store IVs
            $self->ivPoke('port', $port);
            $self->ivPoke('acceptSocket', $acceptSocket);
            $self->ivPoke('acceptID', $acceptID);

            # (A confirmation message is displayed by the calling function)
            return 1;
        }
    }

    sub refuseCalls {

        # Called by GA::Cmd::ChatRefuse->do, $self->shutdown and $self->reset
        # Stops listening out for incoming calls. If this is the only Chat task and there isn't a
        #   chat session in progress, then it's safe to halt the task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if this task isn't the lead Chat task or if this task is
        #       already not listening out for incoming connections
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $count;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refuseCalls', @_);
        }

        if (! $self->leadTaskFlag) {

            return $self->writeError(
                'The \'' .$self->uniqueName . '\' task is not the lead Chat task, so cannot listen'
                . ' out for incoming connections',
                $self->_objClass . '->acceptCalls',
            );

        } elsif (! $self->acceptSocket || ! $self->acceptID) {

            return $self->writeText(
                'Already not listening for incoming calls on port ' . $self->port,
                'system',
            );
        }

        # Stop listening for incoming connections
        Glib::Source->remove($self->acceptID);
        close $self->acceptSocket;

        # Reset IVs
        $self->ivUndef('acceptSocket');
        $self->ivUndef('acceptID');

        # If there are no other Chat tasks running, it's safe to shut down this task
        if (! $self->sessionFlag && ! $self->findOtherTasks()) {

            # Halt this task
            $self->ivPoke('shutdownFlag', TRUE);
        }

        # (A confirmation message is displayed by the calling function)
        return 1;
    }

    sub mcall {

        # Called by GA::Cmd::ChatCall->do or $self->init
        # Initiates a call with someone using the MudMaster protocol
        #
        # Expected arguments
        #   $ip     - The IP address to call
        #
        # Optional arguments
        #   $port   - The port to call. If 'undef', GA::Client->constChatPort is used
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the result of $self->requestConnection

        my ($self, $ip, $port, $check) = @_;

        # Check for improper arguments
        if (! defined $ip || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mcall', @_);
        }

        # Use the default port, if $port was not specified
        if (! defined $port) {

            $port = $axmud::CLIENT->constChatPort;
        }

        # Place the call
        return $self->requestConnection($ip, $port, $self->ivShow('constOptHash', 'MM'));
    }

    sub zcall {

        # Called by GA::Cmd::ChatZCall->do or $self->init
        # Initiates a call with someone using the zChat protocol
        #
        # Expected arguments
        #   $ip     - The IP address to call
        #
        # Optional arguments
        #   $port   - The port to call. If 'undef', GA::Client->constChatPort is used
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the result of $self->requestConnection

        my ($self, $ip, $port, $check) = @_;

        # Check for improper arguments
        if (! defined $ip || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->zcall', @_);
        }

        # Use the default port, if $port was not specified
        if (! defined $port) {

            $port = $axmud::CLIENT->constChatPort;
        }

        # Place the call
        return $self->requestConnection($ip, $port, $self->ivShow('constOptHash', 'ZCHAT'));
    }

    sub findName {

        # Called by GA::Cmd::Chat->do, Emote->do, etc
        # Returns a list of all chat connections with a contact whose ->remoteName matches a
        #   specified name. The match is case-insensitive.
        #
        # Expected arguments
        #   $name       - The name to match
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a list of each Chat task with a matching ->remoteName IV

        my ($self, $name, $check) = @_;

        # Local variables
        my (@emptyList, @matchList);

        # Check for improper arguments
        if (! defined $name || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->findName', @_);
            return @emptyList;
        }

        # Compile a list of Chat tasks, including this one, with a matching ->remoteName IV
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            if (
                $taskObj->name eq 'chat_task'
                && defined $taskObj->remoteName
                && lc($taskObj->remoteName) eq lc($name)
            ) {
                push (@matchList, $taskObj);
            }
        }

        return @matchList;
    }

    sub findGroup {

        # Called by GA::Cmd::ChatGroup->do, EmoteGroup->do, etc
        # Returns a list of all chat connections with a contact whose ->localGroup matches a
        #   specified name. The match is case-insensitive.
        #
        # Expected arguments
        #   $group      - The group name to match
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a list of each Chat task with a matching ->localGroup IV

        my ($self, $group, $check) = @_;

        # Local variables
        my (@emptyList, @matchList);

        # Check for improper arguments
        if (! defined $group || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->findGroup', @_);
            return @emptyList;
        }

        # Compile a list of Chat tasks, including this one, with a matching ->remoteName IV
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            if (
                $taskObj->name eq 'chat_task'
                && defined $taskObj->localGroup
                && lc($taskObj->localGroup) eq lc($group)
            ) {
                push (@matchList, $taskObj);
            }
        }

        return @matchList;
    }

    sub findAllTasks {

        # Called by GA::Cmd::ChatAll->do, GA::Cmd::EmoteAll->do, etc. Also called by this task's
        #   functions
        # Returns a list of all Chat tasks in the current tasklist, including this one
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a list of all Chat tasks

        my ($self, $check) = @_;

        # Local variables
        my (@emptyList, @matchList);

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->findAllTasks', @_);
            return @emptyList;
        }

        # Compile a list of all Chat tasks, including this one
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            if ($taskObj->name eq 'chat_task') {

                push (@matchList, $taskObj);
            }
        }

        return @matchList;
    }

    sub findOtherTasks {

        # Called by this task's functions
        # Returns a list of all Chat tasks in the current tasklist, except this one
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise returns a list of Chat tasks (may be an empty list)

        my ($self, $check) = @_;

        # Local variables
        my (@emptyList, @matchList);

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->findAllTasks', @_);
            return @emptyList;
        }

        # Compile a list of all Chat tasks, including this one
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            if ($taskObj ne $self && $taskObj->name eq 'chat_task') {

                push (@matchList, $taskObj);
            }
        }

        return @matchList;
    }

    # Task window commands

    sub setName {

        # Sets the name used in chat sessions
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if an invalid name is specified
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setName', @_);
        }

        if ($argString) {

            if (length ($argString) < 3 || length ($argString) > 16) {

                $self->writeText(
                    'Your chat nickname must be between 3-16 characters',
                    'system',
                );

                return undef;

            } else {

                # Use the specified name
                $self->ivPoke('localName', $argString);
            }

        } else {

            # Use the default name
            if ($self->session->currentChar) {

                $self->ivPoke('localName', ucfirst($self->session->currentChar->name));

            } else {

                # Emergency failsafe - 'Axmud user'
                $self->ivPoke('localName', $axmud::SCRIPT . ' user');
            }
        }

        $self->broadcast(
            $self->ivShow('constOptHash', 'NAME_CHANGE'),
            $self->localName,
        );

        $self->writeText(
            'You change your chat nickname to \'' . $self->localName . '\'',
            'system',
        );

        # Update the window title to show the new name
        $self->setChatWinTitle();

        # $self->broadcast sends a message to every chat contact about the change of nickname, so
        #   every current Chat task besides this one must also be informed
        @list = $self->findOtherTasks();
        foreach my $taskObj (@list) {

            $taskObj->ivPoke('localName', $self->localName);

            $taskObj->writeText(
                'Your chat nickname has been changed to \'' . $taskObj->localName . '\'',
                'system',
            );

            # Also update the window's title to show the new name
            $taskObj->setChatWinTitle();
        }

        return 1;
    }

    sub setGroup {

        # Defines the group to which this chat session belongs
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my $group;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setGroup', @_);
        }

        if ($argString) {

            # Group name is maximum 15 characters
            $group = substr($argString, 0, 15);

            $self->ivPoke('localGroup', $group);

            # Display a confirmation
            $self->writeText(
                'Joined the group \'' . $group . '\'',
                'system',
            );

        } elsif (! $self->localGroup) {

            return $self->writeText(
                'This session is not in a group (try \';group <name>\')',
                'system',
            );

        } else {

            $self->writeText(
                'Left the group \'' . $self->localGroup . '\'',
                'system',
            );

            $self->ivPoke('localGroup', '');
        }

        return 1;
    }

    sub chat {

        # Sends something to a chat session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection hasn't been negotiated yet
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my $result;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->chat', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'TEXT_PERSONAL'));
        }

        # Don't do anything if $argString is empty (we don't want to send a line of text like
        #   <You chat to Bob, ''> )
        if ($argString) {

            $result = $self->sendCmd(
                $self->ivShow('constOptHash', 'TEXT_PERSONAL'),
                $self->formatText($argString, FALSE, $self->ivShow('constOptHash', 'PERSONAL')),
            );

            # If the opcode wasn't restricted...
            if ($result) {

                # Don't enclose a recognised smiley in quotes
                if ($axmud::CLIENT->ivExists('constChatSmileyHash', $argString)) {

                    $self->writeText(
                        'You chat to ' . $self->remoteName . ', ' . $argString,
                        'chat_out',
                    );

                } else {

                    $self->writeText(
                        'You chat to ' . $self->remoteName . ', \'' . $argString . '\'',
                        'chat_out',
                    );
                }
            }
        }

        return 1;
    }

    sub emote {

        # Sends something as an emote to a chat session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection hasn't been negotiated yet
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->emote', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'TEXT_PERSONAL'));
        }

        # Don't do anything if $argString is empty (we don't want to send a line of text like
        #   <You emote to Bob, ''> )
        if ($argString) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'TEXT_PERSONAL'),
                $self->formatText($argString, TRUE, $self->ivShow('constOptHash', 'PERSONAL')),
            );

            $self->writeText(
                'You emote to ' . $self->remoteName . ': ' . $self->localName . ' ' . $argString,
                'chat_out',
            );
        }

        return 1;
    }

    sub chatToGroup {

        # Sends something to the group
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments, if a ->localGroup hasn't been defined or if the
        #       connection hasn't been negotiated yet
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my $groupFormat;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->chatToGroup', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'TEXT_GROUP'));
        }

        # Don't do anything if $argString is empty (we don't want to send a line of text like
        #   <You chat to Bob, ''> )
        if ($argString) {

            if (! $self->localGroup) {

                return $self->writeText(
                    'This session is not in a group (try \';group <name>\')',
                    'system',
                );

            } else {

                $groupFormat = $self->formatGroup($self->localGroup);
            }

            $self->broadcast(
                $self->ivShow('constOptHash', 'TEXT_GROUP'),
                $groupFormat . $self->formatText(
                    $argString,
                    FALSE,
                    $self->ivShow('constOptHash', 'GROUP'),
                ),
                $self->localGroup,
            );

            # Don't enclose a recognised smiley in quotes
            if ($axmud::CLIENT->ivExists('constChatSmileyHash', $argString)) {

                $self->writeText(
                    'You chat to the group ' . $self->localGroup . ', ' . $argString,
                    'chat_out',
                );

            } else {

                $self->writeText(
                    'You chat to the group ' . $self->localGroup . ', \'' . $argString . '\'',
                    'chat_out',
                );
            }
        }

        return 1;
    }

    sub emoteToGroup {

        # Sends something to the group
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments, if a ->localGroup hasn't been defined or if the
        #       connection hasn't been negotiated yet
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my $groupFormat;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->emoteToGroup', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'TEXT_GROUP'));
        }

        # Don't do anything if $argString is empty (we don't want to send a line of text like
        #   <You emote to Bob, ''> )
        if ($argString) {

            if (! $self->localGroup) {

                return $self->writeText(
                    'This session is not in a group (try \';group <name>\')',
                    'system',
                );

            } else {

                $groupFormat = $self->formatGroup($self->localGroup);
            }

            $self->broadcast(
                $self->ivShow('constOptHash', 'TEXT_GROUP'),
                $groupFormat . $self->formatText(
                    $argString,
                    TRUE,
                    $self->ivShow('constOptHash', 'GROUP'),
                ),
                $self->localGroup,
            );

            $self->writeText(
                'You emote to the group ' . $self->localGroup . ': ' . $self->localName . ' '
                . $argString,
                'chat_out',
            );
        }

        return 1;
    }

    sub chatToAll {

        # Sends something to all open connections
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection hasn't been negotiated yet
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->chatToAll', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'TEXT_EVERYBODY'));
        }

        # Don't do anything if $argString is empty (we don't want to send a line of text like
        #   <You chat to Bob, ''> )
        if ($argString) {

            $self->broadcast(
                $self->ivShow('constOptHash', 'TEXT_EVERYBODY'),
                $self->formatText($argString, FALSE, $self->ivShow('constOptHash', 'EVERYBODY')),
            );

            # Don't enclose a recognised smiley in quotes
            if ($axmud::CLIENT->ivExists('constChatSmileyHash', $argString)) {

                $self->writeText(
                    'You chat to everybody, ' . $argString,
                    'chat_out',
                );

            } else {

                $self->writeText(
                    'You chat to everybody, \'' . $argString . '\'',
                    'chat_out',
                );
            }
        }

        return 1;
    }

    sub emoteToAll {

        # Emotes something to all open connections
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection hasn't been negotiated yet
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->emoteToAll', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'TEXT_EVERYBODY'));
        }

        # Don't do anything if $argString is empty (we don't want to send a line of text like
        #   <You emote to Bob, ''> )
        if ($argString) {

            $self->broadcast(
                $self->ivShow('constOptHash', 'TEXT_EVERYBODY'),
                $self->formatText($argString, TRUE, $self->ivShow('constOptHash', 'EVERYBODY')),
            );

            $self->writeText(
                'You emote to everybody: ' . $self->localName . ' ' . $argString,
                'chat_out',
            );
        }

        return 1;
    }

    sub pingPeer {

        # Pings the chat contact
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->pingPeer', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        $self->ivPoke('pingStamp', int(rand(2**32 - 1)));

        # Update IVs
        $self->ivPoke('pingTime', $axmud::CLIENT->getTime());

        $self->sendCmd(
            $self->ivShow('constOptHash', 'PING_REQUEST'),
            $self->pingStamp,
        );

        return 1;
    }

    sub sendDoNotDisturb {

        # Sends a DO_NOT_DISTURB message to the chat contact (in MudMaster connections only)
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if called in a zChat session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sendDoNotDisturb', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

            $self->writeText(
                'Only the MudMaster protocol supports \'do not disturb\' messages.',
                'system',
            );

            return undef;
        }

        $self->sendCmd(
            $self->ivShow('constOptHash', 'DO_NOT_DISTURB'),
            '',
        );

        $self->writeText(
            'Sent \'do not disturb\' request to ' . $self->remoteName,
            'system',
        );

        return 1;
    }

    sub submitToCmds {

        # Allows the chat contact to send world commands to this world
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if called in a MudMaster session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->submitToCmds', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {

            $self->writeText(
                'Only the zChat protocol supports remote commands.',
                'system',
            );

            return undef;

        } elsif ($self->allowRemoteCmdFlag) {

            $self->writeText(
                'Remote commands are already allowed in this session.',
                'system',
            );

        } else {

            $self->ivPoke('allowRemoteCmdFlag', TRUE);

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                'You can now send remote commands to ' . $self->localName,
            );

            $self->writeText(
                $self->remoteName . ' can now send you remote commands.',
                'system',
            );
        }

        return 1;
    }

    sub escapeFromCmds {

        # Forbids the chat contact from sending world commands to this world
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if called in a MudMaster session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->escapeFromCmds', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {

            $self->writeText(
                'Only the zChat protocol supports remote commands.',
                'system',
            );

            return undef;

        } elsif (! $self->allowRemoteCmdFlag) {

            $self->writeText(
                'Remote commands are already forbidden in this session.',
                'system',
            );

        } else {

            $self->ivPoke('allowRemoteCmdFlag', FALSE);

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                'You can no longer send remote commands to ' . $self->localName,
            );

            $self->writeText(
                $self->remoteName . ' can no longer send you remote commands.',
                'system',
            );
        }

        return 1;
    }

    sub sendRemoteCmd {

        # Sends a remote world command to the chat contact's world
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments, if the connection has not been negotiated yet or if
        #       called in a MudMaster session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sendRemoteCmd', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'SEND_COMMAND'));
        }

        # The user must specify a command to send
        if (! $argString) {

            return $self->writeImproper();
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {

            $self->writeText(
                'Only the zChat protocol supports remote commands.',
                'system',
            );

            return undef;

        # Don't do anything if $argString is empty (we don't want to send a null command)
        } elsif ($argString) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'SEND_COMMAND'),
                $argString,
            );

            $self->writeText(
                'Send remote command: ' . $argString,
                'system',
            );
        }

        return 1;
    }

    sub sendFile {

        # Sends a file to the chat contact
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments, if the connection hasn't been negotiated yet, if a file
        #       transfer is already in progress, if the user cancels when prompted to choose a file
        #       or if a filehandle for the file can't be opened (presumably because it doesn't
        #       exist)
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my ($result, $file, $baseFileName);

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sendFile', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'FILE_START'));
        }

        if ($self->fileDir) {

            $self->writeText(
                'A file transfer is already in progress.',
                'system',
            );

            return undef;
        }

        # If the user didn't specify a file to send, prompt them to choose one
        if (! $argString) {

            if ($self->winObj && ! $self->tableObj) {

                $file = $self->winObj->showFileChooser(
                    'Select file',
                    'open',
                );

            } else {

                $file = $self->session->mainWin->showFileChooser(
                    'Select file',
                    'open',
                );
            }

            if (! $file) {

                # User cancelled
                return undef;
            }

        } else {

            # The user specified a file to get
            $file = $argString;
        }

        # Try to open the filehandle
        if(! open($self->{fileHandle}, $file)) {

            $self->writeText(
                'Could not open \'' . $file . '\': ' . $!,
                'system',
            );

            return undef;
        }

        # Prepare to send the file
        $self->ivPoke('fileName', $file);
        $self->ivPoke('fileDir', $self->ivShow('constOptHash', 'SENDING'));
        $self->ivPoke('fileTotalSize', -s $file);
        $self->ivPoke('fileSize', 0);

        # Extract the file name from the path
        if ($file =~ m{.*/([^/]+)$}) {
            $baseFileName = $1;
        } else {
            $baseFileName = $file;
        }

        $self->sendCmd(
            $self->ivShow('constOptHash', 'FILE_START'),
            $baseFileName . ',' . $self->fileTotalSize,
        );

        $self->writeText(
            'Sending file \'' . $file . '\' to ' . $self->remoteName,
            'system',
        );

        return 1;
    }

    sub stopFile {

        # Stops a file transfer
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if no file is being transferred
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopFile', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if (! $self->fileDir) {

            $self->writeText(
                'No file is being transferred.',
                'system',
            );

            return undef;

        } else {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_CANCEL'),
                '',
            );

            $self->fileStop('Transfer of file \'' . $self->fileName . '\' aborted.');

            return 1;
        }
    }

    sub acceptSnoop {

        # Allows the chat contact to snoop on our session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->acceptSnoop', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        $self->ivPoke('allowSnoopFlag', TRUE);

        $self->sendCmd(
            $self->ivShow('constOptHash', 'MESSAGE'),
            'You can now snoop on ' . $self->localName,
        );

        $self->writeText(
            'Snooping of this session is now allowed.',
            'system',
        );

        return 1;
    }

    sub refuseSnoop {

        # Allows the chat contact to snoop on our session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if no file is being transferred
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refuseSnoop', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        $self->ivPoke('allowSnoopFlag', FALSE);

        $self->sendCmd(
            $self->ivShow('constOptHash', 'MESSAGE'),
            'You can no longer snoop on ' . $self->localName,
        );

        $self->writeText(
            'Snooping of this session is now forbidden.',
            'system',
        );

        $self->stopSnooped();

        return 1;
    }

    sub requestSnoop {

        # Tries to start snooping the chat contact
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments, if the connection has not been negotiated yet or if using
        #       the wrong chat protocol
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->requestSnoop', @_);
        }

        # Don't comply if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            return $self->refuseOpCode($self->ivShow('constOptHash', 'SNOOP'));
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        $self->writeText(
            'Asked to start/stop snooping on ' . $self->remoteName . '.',
            'system',
        );

        $self->sendCmd(
            $self->ivShow('constOptHash', 'SNOOP'),
            '',
        );

        return 1;
    }

    sub hangUp {

        # Closes a chat connection
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if there is no current chat session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->hangUp', @_);
        }

        # (There should not be any arguments to the chat command itself, but we don't check them:
        #   it's important to allow the user to hang up quickly, without fussing over their typing
        #   skills)

        # Don't hang up if there is no connection
        if (! $self->sessionFlag) {

            # No connection made
            return undef;
        }

        $self->terminateChatSession();

        $self->writeText(
            'Chat session closed.',
            'system',
        );

        return 1;
    }

    sub setPublic {

        # Set this chat connection as 'public' - visible to all chat contacts
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection is already marked as 'public'
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setPublic', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if ($self->publicConnectionFlag) {

            $self->writeText(
                'This connection is already set to \'public\'.',
                'system',
            );

            return undef;

        } else {

            $self->ivPoke('publicConnectionFlag', TRUE);

            $self->writeText(
                'This connection is now set to \'public\'.',
                'system',
            );

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                $self->localName . ' has marked this connection as \'public\'.',
            );

            return 1;
        }
    }

    sub setPrivate {

        # Set this chat connection as 'private' - not visible to all chat contacts (default)
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection is already marked as 'private'
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setPrivate', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if (! $self->publicConnectionFlag) {

            $self->writeText(
                'This connection is already set to \'private\'.',
                'system',
            );

            return undef;

        } else {

            $self->ivPoke('publicConnectionFlag', FALSE);

            $self->writeText(
                'This connection is now set to \'private\'.',
                'system',
            );

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                $self->localName . ' has marked this connection as \'private\'.',
            );

            return 1;
        }
    }

    sub peekRemoteConnections {

        # Sends a request to the chat contact to send a list of all their public connections; when
        #   the response is received, the list is displayed in the task window
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->peekRemoteConnections', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        $self->sendCmd(
            $self->ivShow('constOptHash', 'PEEK_CONNECTIONS'),
            '',
        );

        $self->writeText(
            'A request has been sent to \'' . $self->remoteName . '\' for a list of their public'
            . ' connections.',
            'system',
        );

        return 1;
    }

    sub requestRemoteConnections {

        # Sends a request to the chat contact to send a list of all their public connections; when
        #   the response is received, this Chat task then attemps to connect to any of them who
        #   aren't already connected to us
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->requestRemoteConnections',
                @_,
            );
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        $self->sendCmd(
            $self->ivShow('constOptHash', 'REQUEST_CONNECTIONS'),
            '',
        );

        $self->writeText(
            'A request has been sent to \'' . $self->remoteName . '\' for a list of their public'
            . ' connections.',
            'system',
        );

        return 1;
    }

    sub setServing {

        # Set this chat connection as 'serving' - echoes CHAT_TEXT_EVERYBODY to all other sessions,
        #   and CHAT_TEXT_GROUP messages to all other chat sessions in the same group
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection is already marked as 'serving'
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setServing', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if ($self->servingFlag) {

            $self->writeText(
                'This connection is already set to \'serving\'.',
                'system',
            );

            return undef;

        } else {

            $self->ivPoke('servingFlag', TRUE);

            $self->writeText(
                'This connection is now set to \'serving\'.',
                'system',
            );

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                $self->localName . ' has marked this connection as \'serving\'.',
            );

            return 1;
        }
    }

    sub setNotServing {

        # Set this chat connection as 'not serving' - no longer echoes CHAT_TEXT_EVERYBODY and
        #   CHAT_TEXT_GROUP to other chat sessions
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the connection is already marked as 'not serving'
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setNotServing', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        if (! $self->servingFlag) {

            $self->writeText(
                'This connection is already set to \'not serving\'.',
                'system',
            );

            return undef;

        } else {

            $self->ivPoke('servingFlag', FALSE);

            $self->writeText(
                'This connection is now set to \'not serving\'.',
                'system',
            );

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                $self->localName . ' has marked this connection as \'not serving\'.',
            );

            return 1;
        }
    }

    sub showInfo {

        # Shows information about the current connection
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if there is no current chat session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my (
            $header, $line, $type, $public, $serving, $version, $group, $email, $allowSmiley,
            $canSnoop, $isSnoop, $bytes, $cmd, $status,
        );

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showInfo', @_);
        }

        # There should not be any arguments to the chat command itself
        if ($argString) {

            return $self->writeImproper();
        }

        # Set a flag, just for this function, which causes ->writeText to display everything in the
        #   colour for local system messages
        $self->ivPoke('allSystemColourFlag', TRUE);

        if ($self->remoteName) {
            $header = 'Chat session with ' . $self->remoteName;
        } else {
            $header = 'Chat session';
        }
        $self->writeText($header);
        $line .= '-' x length($header);
        $self->writeText($line);

        $self->writeText('Your IP            : ' . $self->ip);
        $self->writeText('Incoming IP        : ' . $self->clientSocket->peerhost());
        $self->writeText('IP advertised as   : ' . $self->remoteIP);
        $self->writeText('Port advertised as : ' . $self->remotePort);

        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {
            $type = 'MudMaster';
        } else {
            $type = 'zChat';
        }
        $self->writeText('Chat protocol      : ' . $type);

        if ($self->publicConnectionFlag) {
            $public = 'Public';
        } else {
            $public = 'Private';
        }
        $self->writeText('Privacy            : ' . $public);

        if ($self->servingFlag) {
            $serving = 'Yes';
        } else {
            $serving = 'No';
        }
        $self->writeText('Serving messages   : ' . $serving);

        if ($self->localGroup) {
            $group = $self->localGroup;
        } else {
            $group = '<none>';
        }
        $self->writeText('Group              : ' . $group);

        if ($self->chatType != $self->ivShow('constOptHash', 'MM')) {

            if ($self->allowRemoteCmdFlag) {
                $cmd = 'Allowed';
            } else {
                $cmd = 'Not allowed';
            }
            $self->writeText('Remote commands    : ' . $cmd);

            if ($self->localStatus == 0) {
                $status = '0 (no status)';
            } elsif ($self->localStatus == 1) {
                $status = '1 (normal)';
            } elsif ($self->localStatus == 2) {
                $status = '2 (inactive)';
            } elsif ($self->localStatus == 3) {
                $status = '3 (away from keys)';
            }
            $self->writeText('zChat status       : ' . $status);

            if ($self->remoteStatus == 0) {
                $status = '0 (no status)';
            } elsif ($self->remoteStatus == 1) {
                $status = '1 (normal)';
            } elsif ($self->remoteStatus == 2) {
                $status = '2 (inactive)';
            } elsif ($self->remoteStatus == 3) {
                $status = '3 (away from keys)';
            }
            $self->writeText('Remote status      : ' . $status);
        }

        if ($self->remoteVersion) {
            $version = $self->remoteVersion;
        } else {
            $version = '<unknown>';
        }
        $self->writeText('Remote version     : ' . $version);

        if ($self->localEmail) {
            $email = $self->localEmail;
        } else {
            $email = '<not set>';
        }
        $self->writeText('Local email        : ' . $email);

        if ($self->remoteEmail) {
            $email = $self->remoteEmail;
        } else {
            $email = '<unknown>';
        }
        $self->writeText('Remote email       : ' . $email);

        if ($self->allowSmileyFlag) {
            $allowSmiley = 'on';
        } else {
            $allowSmiley = 'off';
        }
        $self->writeText('Smileys on         : ' . $allowSmiley);

        if ($self->allowSnoopFlag) {
            $canSnoop = 'yes';
        } else {
            $canSnoop = 'no';
        }
        $self->writeText('Peer can snoop     : ' . $canSnoop);

        if ($self->isSnoopedFlag) {
            $isSnoop = 'yes';
        } else {
            $isSnoop = 'no';
        }
        $self->writeText('Peer is snooping   : ' . $isSnoop);

        if ($self->fileDir) {

            if ($self->fileDir == $self->ivShow('constOptHash', 'SENDING')) {
                $self->writeText('Sending file       : ' . $self->fileName);
            } else {
                $self->writeText('Receiving file     : ' . $self->fileName);
            }

            $self->writeText('File size          : ' . $self->fileTotalSize);
            $self->writeText('Bytes transferred  : ' . $self->fileSize);

            $bytes = $self->fileTotalSize - $self->fileSize;
            $self->writeText(
                'Bytes left         : ' . $bytes . ' ('
                    . sprintf("%.0f", 100 * ($bytes/$self->fileTotalSize)) . '%)'
            );
        }

        # Stop displaying everything in the local system colour
        $self->ivPoke('allSystemColourFlag', FALSE);

        return 1;
    }

    sub setIcon {

        # Sets the icon for this chat session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments, if called in a MudMaster session, if the user cancels
        #       when prompted to choose an icon or if the selected file isn't in Window bitmap
        #       (.bmp) format
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my ($file, $fileHandle, $data);

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setIcon', @_);
        }

        if ($self->chatType != $self->ivShow('constOptHash', 'ZCHAT')) {

            $self->writeText(
                'Only the zChat protocol supports icons.',
                'system',
            );

            return undef;
        }

        # If the user didn't specify an icon file, prompt them to choose one
        if (! $argString) {

            if ($self->winObj && ! $self->tableObj) {

                $file = $self->winObj->showFileChooser(
                    'Select an icon file (must be .bmp)',
                    'open',
                );

            } else {

                $file = $self->session->mainWin->showFileChooser(
                    'Select an icon file (must be .bmp)',
                    'open',
                );
            }

            if (! $file) {

                # User cancelled
                return undef;
            }

        } else {

            # The user specified a file to get
            $file = $argString;
        }

        # File must be in .bmp format
        if (substr($file, -4) ne '.bmp') {

            $self->writeText(
                'Icons must be Windows .bmp files',
                'system',
            );

            return undef;
        }

        # Send the icon to the chat contact with an 'ICON' instruction
        $self->prepareIcon($file);

        $self->writeText(
            'Using icon \'' . $file . '\'',
            'system',
        );

        return 1;
    }

    sub setEmail {

        # Sets the email address to broadcast for this chat session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if an invalid email is specified
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setEmail', @_);
        }

        # If no email specified, don't broadcast one
        if (! $argString) {

            $self->ivUndef('localEmail');

            $self->sendCmd(
                $self->ivShow('constOptHash', 'EMAIL'),
                '<none>',
            );

            $self->writeText(
                'Stopped broadcasting your email',
                'system',
            );

            return 1;
        }

        # Email must be no more than 128 characters, and contain an @
        if (length $argString < 3 || length $argString > 128 || index($argString, '@') == -1) {

            $self->writeText(
                'Invalid chat email address (must be between 3 and 128 characters (and contain an'
                . ' \'@\' character)',
                'system',
            );

            return undef;

        } else {

            $self->ivPoke('localEmail', $argString);

            $self->sendCmd(
                $self->ivShow('constOptHash', 'EMAIL'),
                $self->localEmail,
            );

            $self->writeText(
                'Changed your email to \'' . $self->localEmail . '\'',
                'system',
            );

            return 1;
        }
    }

    sub setSmiley {

        # Turns smileys on/off for this chat session
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if an invalid email is specified
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setSmiley', @_);
        }

        # If 'on' or 'off' not specified, just show the current setting
        if (! $argString || ($argString ne 'on' && $argString ne 'off')) {

            if ($self->allowSmileyFlag) {

                $self->writeText(
                    'Smileys turned on for this chat session (try \';smiley off\')',
                    'system',
                );

                return 1;

            } else {

                $self->writeText(
                    'Smileys turned off for this chat session (try \';smiley on\')',
                    'system',
                );

                return 1;
            }

        } elsif ($argString eq 'on') {

            if ($self->allowSmileyFlag) {

                $self->writeText(
                    'Smileys already turned on for this chat session',
                    'system',
                );

                return 1;

            } else {

                $self->ivPoke('allowSmileyFlag', TRUE);

                $self->writeText(
                    'Smileys now turned on for this chat session',
                    'system',
                );

                return 1;
            }

        } else {

            if (! $self->allowSmileyFlag) {

                $self->writeText(
                    'Smileys already turned off for this chat session',
                    'system',
                );

                return 1;

            } else {

                $self->ivPoke('allowSmileyFlag', FALSE);

                $self->writeText(
                    'Smileys now turned off for this chat session',
                    'system',
                );

                return 1;
            }
        }
    }

    sub winHelp {

        # Displays a list of Chat task commands in the task window
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->winHelp', @_);
        }

        # (There should not be any arguments to the chat command itself, but we don't check them:
        #   it's important to allow the user to read help, without fussing over their typing skills)
        if ($self->taskWinFlag) {

            foreach my $string ($self->helpList) {

                $self->writeText($string, 'system');
            }
        }

        return 1;
    }

    sub saveContact {

        # Saves the chat contact in our contacts list or, if we're talking to someone already in the
        #   contact list, updates their info
        # NB A contact's icon is automatically updated as soon as it is received by ->processIcon
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments or if the chat contact can't be saved in our contacts list
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Local variables
        my ($contactObj, $updateFlag, $tempFile, $fileHandle);

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->saveContact', @_);
        }

        if ($argString) {

            # Does this contact already exist?
            if ($axmud::CLIENT->ivExists('chatContactHash', $argString)) {

                # Use it
                $contactObj = $axmud::CLIENT->ivShow('chatContactHash', $argString);
                $updateFlag = TRUE;

            # If not, create a new GA::Obj::ChatContact
            } else {

                $contactObj = Games::Axmud::Obj::ChatContact->new(
                    $self->session,
                    $argString,
                    $self->chatType,
                );

                if (! $contactObj) {

                    $self->writeText(
                        'Failed to save the chat contact.',
                        'system',
                    );

                    return undef;
                }
            }

        } else {

            if (! $self->chatContactObj) {

                # User typed ';save' but needs to type ';save <contact>'
                $self->writeText(
                    'This chat contact not yet saved (try \';save <contact>\').',
                    'system',
                );

                return undef;

            } else {

                # Use the existing contact
                $contactObj = $self->chatContactObj;
                $updateFlag = TRUE;
            }
        }

        # Update (or set for the first time) the contact's IVs (NB we don't set ->protocol; the
        #   default chat protocol is for the user to decide manually)
        $contactObj->ivPoke('ip', $self->remoteIP);
        $contactObj->ivPoke('port', $self->remotePort);
        $contactObj->ivPoke('email', $self->remoteEmail);
        # If the chat contact hasn't sent an icon, don't write over the default icon already stored
        #   in the GA::Obj::ChatContact
        if ($self->remoteIcon) {

            $contactObj->ivPoke('lastIcon', $self->remoteIcon);
            $contactObj->ivPoke('lastIconScaled', $self->remoteIconScaled);
        }

        # Operation complete
        $self->ivPoke('chatContactObj', $contactObj);

        if ($updateFlag) {

            $self->writeText(
                'Contact \'' . $contactObj->name . '\' updated.',
                'system',
            );

        } else {

            $self->writeText(
                'New contact \'' . $contactObj->name . '\' created.',
                'system',
            );
        }

        return 1;
    }

    sub setMode {

        # Set $self->entryMode, which specifies what happens when the user types raw text (i.e. text
        #   not beginning with the sigils ; or #)
        #
        # Expected arguments
        #   $argString - The text typed, minus the initial command sigil (if any) and the initial
        #                   chat command (if any)
        #
        # Return values
        #   'undef' on improper arguments,  if the user specifies an invalid mode or if 'cmd' mode
        #       is specified, but this isn't a zChat session
        #   1 otherwise

        my ($self, $argString, $check) = @_;

        # Check for improper arguments
        if (! defined $argString || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setMode', @_);
        }

        # The user must specify a mode
        if (! $argString) {

            return $self->writeImproper();
        }

        if ($argString eq 'chat') {

            $self->ivPoke('entryMode', 'chat');

            $self->writeText(
                'Typed text will now be treated as chat.',
                'system',
            );

            return 1;

        } elsif ($argString eq 'emote') {

            $self->ivPoke('entryMode', 'emote');

            $self->writeText(
                'Typed text will now be treated as an emote.',
                'system',
            );

            return 1;

        } elsif ($argString eq 'cmd') {

            if ($self->chatType != $self->ivShow('constOptHash', 'ZCHAT')) {

                $self->writeText(
                    'Only the zChat protocol supports remote commands.',
                    'system',
                );

                return undef;

            } else {

                $self->ivPoke('entryMode', 'cmd');

                $self->writeText(
                    'Typed text will now be sent as a remote command.',
                    'system',
                );
            }

        } else {

            $self->writeText(
                'Valid modes are \'chat\', \'emote\' and \'cmd\'.',
                'system',
            );

            return undef;
        }
    }

    # Supplementary functions

    sub requestConnection {

        # Called by $self->mcall and $self->zcall
        # Makes an outgoing chat call of the specified type
        #
        # Expected arguments
        #   $ip     - The IP address to contact
        #   $port   - The port
        #   $type   - Which chat protocol to use, a value in $self->constOptHash matching the key
        #               'ZCHAT' or 'MM'
        #
        # Return values
        #   'undef' on improper arguments or if the connection can't be made
        #   1 otherwise

        my ($self, $ip, $port, $type, $check) = @_;

        # Local variables
        my ($socket, $request, $localName, $listenPort);

        # Check for improper arguments
        if (! defined $ip || ! defined $port || ! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->requestConnection', @_);
        }

        # Open a new socket (filehandle)
        $socket = IO::Socket::INET->new(
            'PeerAddr'  => $ip,
            'PeerPort'  => $port,
            'Proto'     => 'tcp',
            'Timeout'   => $self->outgoingTimeout,      # Default 10 seconds
        );

        if (! defined $socket) {

            return $self->writeError(
                $self->prettyName . ' task could not connect to ' . $ip . ', port ' . $port,
                $self->_objClass . '->requestConnection'
            );
        }

        # Set IVs at the start of the chat session
        $self->ivPoke('sessionFlag', TRUE);
        $self->ivPoke('clientSocket', $socket);
        $self->ivPoke('chatType', $type);
        $self->ivPoke('remoteIP', $ip);
        $self->ivPoke('remotePort', $port);
        $self->ivPoke('localGroup', '');
        $self->ivPoke('encoding', $self->defaultEncoding);

        # Respond to a Glib::IO event to process the response to our connection request by calling
        #   $self->callEstablishmentData()
        $self->ivPoke(
            'ioWatchID',
            Glib::IO->add_watch(
                $socket->fileno,
                'in',
                sub { $self->callEstablishmentData(@_); },
            ),
        );

        # Respond to a Glib::Timeout event to abandon a pending connection after 60 seconds (by
        #   default) by calling $self->killPendingConnection(). The call to Glib::Timeout->add
        #   must convert the value to milliseconds
        $self->ivPoke(
            'timeoutID',
            Glib::Timeout->add(
                ($self->incomingTimeout * 1000),            # Default value is 60 seconds
                sub { $self->killPendingConnection(); },
            ),
        );

        # Open a task window
        $self->newChatWin();
        $self->writeText(
            'Calling ' . $ip . ' on port ' . $port . '...',
            'system',
        );

        # Decide which port is advertised to the chat peer as the one we're using for incoming
        #   connections
        # If $self->port is defined, use it. Otherwise, tell the chat peer that we're listening on
        #   the default port - even though the port is closed
        if ($self->port) {
            $listenPort = $self->port;
        } else {
            $listenPort = $axmud::CLIENT->constChatPort;
        }

        # Set the chat name to use. Use the value stored in GA::Client, if it's been set; otherwise
        #   use the current character
        if ($axmud::CLIENT->chatName) {

            $self->ivPoke('localName', $axmud::CLIENT->chatName);

        } elsif ($self->session->currentChar) {

            $self->ivPoke('localName', ucfirst($self->session->currentChar->name));

        } else {

            # Emergency failsafe - 'Axmud user'
            $self->ivPoke('localName', $axmud::SCRIPT . ' user');
        }

        # Set the email to broadcast. Use the value stored in GA::Client, if it's been set;
        #   otherwise don't broadcast an email. (NB The email is only broadcast in zChat sessions.)
        if ($axmud::CLIENT->chatEmail) {

            $self->ivPoke('localEmail', $axmud::CLIENT->chatEmail);
        }

        # Request a connection from the chat peer
        $localName = $self->localName;
        if ($type == $self->ivShow('constOptHash', 'MM')) {

            # (Quoted from http://tintin.sourceforge.io/manual/chatprotocol.php)
            # Once a connection is made the caller sends a connection string which looks like:
            #   "CHAT:<chat name>\n<ip address><port>".
            # The sprintf syntax is:
            #   "CHAT:%s\n%s%-5u"
            # The port must be 5 characters, padded on the right side with spaces. Once this string
            #   has been sent it waits for a response from the other side. If a "NO" is received the
            #   call is cancelled. If the call was accepted the string "YES:<chat name>\n" is
            #   received.
            $request = sprintf(
                "CHAT:$localName\n"     # CHAT:<chat name>\n
                . "%s"                  # <ip address>
                . "%-5s",               # <port>
                $self->ip,
                $listenPort,
            );

        } else {

            # (Quoted from http://www.zuggsoft.com/zchat/zchatprot.htm)
            # Send the following data through a normal TCP/IP packet. The TCP/IP packet contains the
            #   IP address and Port number of the Server being called. The data in the connection
            #   request packet is:
            #   ZCHAT:<ChatName>\t<ChatID>\lf<ClientIPAddress><ClientPort>\lf<SecurityInfo>
            # The ClientPort is formatted as a five-digit number with leading zeros.  The sprintf
            #   syntax is:
            #   "ZCHAT:%s\t%s\n%s%-5u\n%s".
            # Optional information can be included in the connection request by adding another \lf
            #   after the SecurityInfo and then sending the optional data.
            #
            # NB <ChatID> is a unique number generated when zMUD users register their product.
            #   Earlier versions of zMUD's chat facility did not support this, sending a zero
            #   instead; so shall we
            $request = sprintf(
                "ZCHAT:$localName\t"    # ZCHAT:<ChatName>\t
                . "0\n"                 # <ChatID>\lf
                . "%s"                  # <ClientIPAddress>
                . "%05u",               # <ClientPort>
                $self->ip,
                $listenPort,
            );
        }

        syswrite($socket, $request);

        return 1;
    }

    sub acceptConnection {

        # Called in response to a Glib::IO event when we receive a connection request from a chat
        #   contact
        # $socket->accept() won't block because the connection because it is only called when there
        #   is an incoming connection (because of the IO watch)
        #
        # Expected arguments
        #   $fileNo, $condition
        #               - Arguments supplied by the Glib::IO event (not required by this function)
        #               - $fileNo is the file descriptor, $self->acceptSocket->fileno
        #               - $condition is the Glib::IOCondition, namely 'in' / 'G_IO_IN'
        #   $socket     - The socket used; matches the socket stored in the lead Chat task's
        #                   ->acceptSocket IV
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $fileNo, $condition, $socket, $check) = @_;

        # Local variables
        my ($clientSocket, $newTaskObj);

        # Check for improper arguments
        if (! defined $fileNo || ! defined $condition || ! defined $socket || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->acceptConnection', @_);
        }

        # Accept the connection, creating a new socket to handle it
        $clientSocket = $socket->accept();

        if (! $self->sessionFlag) {

            # This (lead) Chat task has no connection, so use it for the incoming connection

            # Set IVs at the start of the chat session
            $self->ivPoke('connectionClosedFlag', FALSE);
            $self->ivPoke('sessionFlag', TRUE);
            $self->ivPoke('clientSocket', $clientSocket);
            $self->ivPoke('localGroup', '');
            $self->ivPoke('encoding', $self->defaultEncoding);

            # Respond to a Glib::IO event to process incoming connection requests by calling
            #   $self->receiveEstablishmentData()
            $self->ivPoke(
                'ioWatchID',
                Glib::IO->add_watch(
                    $clientSocket->fileno,
                    'in',
                    sub { $self->receiveEstablishmentData(@_); },
                ),
            );

            # Respond to a Glib::Timeout event to abandon a pending connection after 60 seconds
            #   (by default) by calling $self->killPendingConnection(). The call to
            #   Glib::Timeout->add must convert the value to milliseconds
            $self->ivPoke(
                'timeoutID',
                Glib::Timeout->add(
                    ($self->incomingTimeout * 1000),            # Default value is 60 seconds
                    sub { $self->killPendingConnection(); },
                ),
            );

        } else {

            # This Chat task already has a connection, so we need to create a new Chat task to
            #   handle it
            $newTaskObj = Games::Axmud::Task::Chat->new($self->session, 'current');
            if ($newTaskObj) {

                # Tell the task to handle an incoming call
                $newTaskObj->ivPoke('receiveCallOnInitFlag', TRUE);
                $newTaskObj->ivPoke('receiveCallOnInitSocket', $clientSocket);
            }
        }

        return 1;
    }

    sub receiveConnection {

        # Called by $self->init, after the lead Chat task (which already has its own connection)
        #   receives another incoming call, which it passes to this task
        #
        # Expected arguments
        #   $clientSocket       - The socket (filehandle) of the incoming connection
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $clientSocket, $check) = @_;

        # Check for improper arguments
        if (! defined $clientSocket || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->receiveConnection', @_);
        }

        # Set IVs at the start of the chat session
        $self->ivPoke('sessionFlag', TRUE);
        $self->ivPoke('clientSocket', $clientSocket);
        $self->ivPoke('localGroup', '');
        $self->ivPoke('encoding', $self->defaultEncoding);

        # Respond to a Glib::IO event to process incoming connection requests by calling
        #   $self->receiveEstablishmentData()
        $self->ivPoke(
            'ioWatchID',
            Glib::IO->add_watch(
                $clientSocket->fileno,
                'in',
                sub { $self->receiveEstablishmentData(@_); },
            ),
        );

        # Respond to a Glib::Timeout event to abandon a pending connection after 60 seconds
        #   (by default) by calling $self->killPendingConnection(). The call to Glib::Timeout->add
        #   must convert the value to milliseconds
        $self->ivPoke(
            'timeoutID',
            Glib::Timeout->add(
                ($self->incomingTimeout * 1000),            # Default value is 60 seconds
                sub { $self->killPendingConnection(); },
            ),
        );

        return 1;
    }

    sub callEstablishmentData {

        # Called by $self->acceptConnection when data is ready in a chat socket for which no
        #   connection has been established, when we are making the call
        #
        # Expected arguments
        #   $fileNo, $condition
        #               - Arguments supplied by the Glib::IO event (not required by this function)
        #               - $fileNo is the file descriptor, $self->clientSocket->fileno
        #               - $condition is the Glib::IOCondition, namely 'in' / 'G_IO_IN'
        #
        # Return values
        #   'undef' on improper arguments, if the connection is established or if it fails to be
        #       established
        #   1 if we're still waiting

        my ($self, $fileNo, $condition, $check) = @_;

        # Local variables
        my ($tempBuffer, $bytesRead);

        # Check for improper arguments
        if (! defined $fileNo || ! defined $condition || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->callEstablishmentData', @_);
        }

        # Read 1024 bytes of data into $tempBuffer from the filehandle $self->clientSocket
        $bytesRead = sysread($self->clientSocket, $tempBuffer, 1024);

        if (
            ! defined $bytesRead        # There was an error
            || $bytesRead <= 0          # End of file
        ) {
            $self->terminateChatSession();

            $self->writeText(
                'Chat session closed.',
                'system',
            );

            return undef;
        }

        # Add the incoming data to the buffer
        $self->{dataBuffer} .= $tempBuffer;

        # Process the incoming data, which is the same for both MudMaster and zChat
        # MudMaster (http://tintin.sourceforge.io/manual/chatprotocol.php):
        #   If a "NO" is received the call is cancelled. If the call was accepted the string
        #       "YES:<chat name>\n" is received.
        # zChat (http://www.zuggsoft.com/zchat/zchatprot.htm)
        #   If the Server is not accepting connections or if the security tests fail, or if the
        #       Server wants to reject the request for any other reason, it sends a TCP/IP packet
        #       back to the client with the data: NO
        while (1) {

            my ($nlPos, $result);

            # Find the end-of-line, whose presence tells us that we've received the whole response
            #   to our chat request
            $nlPos = index($self->dataBuffer, "\n");
            if ($nlPos == -1) {

                # Wait for the whole response
                return 1;
            }

            if ($self->dataBuffer =~ /^YES:(.*)/) {

                # Extract the chat peer's nickname
                $self->changeRemoteName($1, 1);
                substr($self->{dataBuffer}, 0, $nlPos + 1) = '';

                # As soon as the connection is negotiated, the server and the client exchange
                #   information about each other by sending commands
                $self->sendCmd(
                    $self->ivShow('constOptHash', 'VERSION'),
                    $self->getVersion(),
                );

                if ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

                    # Our initial status is 1 ('Normal status') unless the user has been idle for
                    #   a minimum time, in which case it is 3 ('AFK')
                    $self->ivPoke('localStatus', 1);
                    $self->sendCmd(
                        $self->ivShow('constOptHash', 'STATUS'),
                        chr(1),
                    );

                    # Send the icon to the chat contact with an 'ICON' instruction
                    if (-e $axmud::CLIENT->chatIcon) {

                        $self->prepareIcon($axmud::CLIENT->chatIcon);
                    }

                    if ($self->localEmail) {

                        $self->sendCmd(
                            $self->ivShow('constOptHash', 'EMAIL'),
                            $self->localEmail,
                        );
                    }

                    $self->sendCmd(
                        $self->ivShow('constOptHash', 'STAMP'),
                        pack("L", $self->localZChatStamp),
                    );
                }

                # Respond to a Glib::IO event to process incoming data from the chat peer by calling
                #   $self->mmChatDataReady() or $self->zChatDataReady
                # (This replaces the callback used to call this function)
                $self->ivPoke(
                    'ioWatchID',
                    Glib::IO->add_watch(
                        $self->clientSocket->fileno,
                        'in',
                        $self->chatType == $self->ivShow('constOptHash', 'MM')
                            ? sub { $self->mmChatDataReady(@_); }
                            : sub { $self->zChatDataReady(@_); },
                    ),
                );

                # Now that the connection is established, remove the pending connection timeout
                Glib::Source->remove($self->timeoutID);

                $self->writeText(
                    'Chat session to ' . $self->remoteName . ' at ' . $self->remoteIP . ' port '
                    . $self->remotePort . ' established.',
                    'system',
                );

                return undef;

            } elsif ($self->dataBuffer =~ /^NO/) {

                $self->writeError(
                    '\'' . $self->prettyName . '\' task: remote party declined to chat',
                    $self->_objClass . '->callEstablishmentData',
                );

                Glib::Source->remove($self->timeoutID);
                $self->terminateChatSession();

                return undef;

            } else {

                # Invalid reply. Ignore this line and wait for the next one. (If we're waiting too
                #   long, the timeout stored in $self->timeoutID will close the connection anyway)
                substr($self->{dataBuffer}, 0, $nlPos + 1) = '';

                return 1;
            }
        }

        # Continue processing
        return 1;
    }

    sub receiveEstablishmentData {

        # Called by $self->acceptConnection when data is ready in a chat socket for which no
        #   connection has been established, when we are accepting an incoming call
        #
        # Expected arguments
        #   $fileNo, $condition
        #               - Arguments supplied by the Glib::IO event (not required by this function)
        #               - $fileNo is the file descriptor, $self->clientSocket->fileno
        #               - $condition is the Glib::IOCondition, namely 'in' / 'G_IO_IN'
        #
        # Return values
        #   'undef' on improper arguments, if the connection is established or if it fails to be
        #       established
        #   1 if we're still waiting

        my ($self, $fileNo, $condition, $check) = @_;

        # Local variables
        my ($tempBuffer, $bytesRead);

        # Check for improper arguments
        if (! defined $fileNo || ! defined $condition || defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->receiveEstablishmentData',
                @_,
            );
        }

        # Read 1024 bytes of data into $tempBuffer from the filehandle $self->clientSocket
        $bytesRead = sysread($self->clientSocket, $tempBuffer, 1024);

        if (
            ! defined $bytesRead    # There was an error
            || $bytesRead <= 0      # End of file
        ) {
            $self->terminateChatSession();

            $self->writeText(
                'Chat session closed.',
                'system',
            );

            return undef;
        }

        # Add the incoming data to the buffer
        $self->{dataBuffer} .= $tempBuffer;

        # Process the incoming header
        #
        # (Quoted from http://tintin.sourceforge.io/manual/chatprotocol.php)
        # Once a connection is made the caller sends a connection string which looks like:
        #   "CHAT:<chat name>\n<ip address><port>".
        # The sprintf syntax is:
        #   "CHAT:%s\n%s%-5u"
        # The port must be 5 characters, padded on the right side with spaces.
        #
        # (Quoted from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # Send the following data through a normal TCP/IP packet. The TCP/IP packet contains the IP
        #   address and Port number of the Server being called. The data in the connection request
        #   packet is:
        #   ZCHAT:<ChatName>\t<ChatID>\lf<ClientIPAddress><ClientPort>\lf<SecurityInfo>
        # The ClientPort is formatted as a five-digit number with leading zeros.  The sprintf
        #   syntax is:
        #   "ZCHAT:%s\t%s\n%s%-5u\n%s".
        # Optional information can be included in the connection request by adding another \lf after
        #   the SecurityInfo and then sending the optional data.
        #
        # NB <ChatID> is a unique number generated when zMUD users register their product. Earlier
        #   versions of zMUD's chat facility did not support this, sending a zero instead; so shall
        #   we
        while (1) {

            my (
                $nlPos, $nlPos2, $result, $addressString, $sessionContactObj, $msg,
                @matchList,
            );

            # Process everything up until the first newline character
            if (! defined $self->chatType) {

                # Both the MudMaster and zChat headers contain a first newline characters
                $nlPos = index($self->dataBuffer, "\n");
                if ($nlPos == -1) {

                    # Wait for the whole header
                    return 1;
                }

                if ($self->dataBuffer =~ /^CHAT:(.*)/) {

                    # It's a MudMaster header
                    $self->ivPoke('chatType', $self->ivShow('constOptHash', 'MM'));
                    $self->ivPoke('remoteName', $1);
                    $self->ivPoke('remoteZChatID', 0);

                    # Remove everything up to and including the newline character
                    substr($self->{dataBuffer}, 0, $nlPos + 1) = '';

                } elsif ($self->dataBuffer =~ /^ZCHAT:(.*)\t(.*)/) {

                    # It's a zChat header
                    $self->ivPoke('chatType', $self->ivShow('constOptHash', 'ZCHAT'));
                    $self->ivPoke('remoteName', $1);
                    $self->ivPoke('remoteZChatID', $2);

                    # Remove everything up to and including the newline character
                    substr($self->{dataBuffer}, 0, $nlPos + 1) = '';

                } else {

                    # Wrong header. Let's ignore this line and wait for another one
                    substr($self->{dataBuffer}, 0, $nlPos + 1) = '';
                }

            # MM: Process the rest of the header
            #   <ip address><port>
            # zChat: Process everything up until the second newline character
            #   <ClientIPAddress><ClientPort>\lf<SecurityInfo>
            } else {

                # Play a sound effect for incoming calls (if allowed)
                $axmud::CLIENT->playSound('call');

                # Check to see whether any of our contacts matches the incoming call
                foreach my $contactObj ($axmud::CLIENT->ivValues('chatContactHash')) {

                    if ($contactObj->ip && $contactObj->ip eq $self->clientSocket->peerhost()) {

                        push (@matchList, $contactObj);
                    }
                }

                if (@matchList == 1) {

                    # The incoming call is probably from one of our contacts
                    $sessionContactObj = $matchList[0];
                }

                if (
                    $axmud::CLIENT->chatAcceptMode eq 'prompt'
                    || ($axmud::CLIENT->chatAcceptMode eq 'accept_contact' && ! $sessionContactObj)
                ) {
                    $msg = 'Accept chat call with \'' . $self->remoteName . '\' from '
                    . $self->clientSocket->peerhost();

                    if ($sessionContactObj) {
                        $msg .= ' (matches known contact \'' . $sessionContactObj->name . '\')?';
                    } else {
                        $msg .= '?';
                    }

                    $result = $self->session->mainWin->showMsgDialogue(
                        'Chat task',
                        'question',
                        $msg,
                        'yes-no',
                        'no',
                    );

                    # Check that the connection hasn't already timed out
                    if ($self->connectionClosedFlag) {

                        # Display an explanatory message, if the user accepted the connection
                        if ($result eq 'yes') {

                            $self->writeError(
                                '\'' . $self->prettyName . '\' task: incoming chat connection has'
                                . ' already closed',
                                $self->_objClass . '->receiveEstablishmentData',
                            );
                        }

                        # In either case, it's the end of the process
                        return undef;
                    }

                    if ($result ne 'yes') {

                        # Refuse the connection ("NO" for MudMaster, "NO\n" for zChat)
                        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {
                            syswrite($self->clientSocket, "NO");
                        } else {
                            syswrite($self->clientSocket, "NO\n");
                        }

                        Glib::Source->remove($self->timeoutID);
                        $self->terminateChatSession();

                        return undef;
                    }
                }

                # Set $addressString to <ip address><port> or <ClientIPAddress><ClientPort>
                if ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

                    # Only the zChat header contains a second newline character
                    $nlPos2 = index($self->dataBuffer, "\n");
                    if ($nlPos2 == -1) {

                        # Neither this Chat task, nor Kildclient's chat.pl plugin send the
                        #   '\lf<SecurityInfo>' component
                        # Assume it is missing and just work with the <ClientIPAddress><ClientPort>
                        #   component
                        $addressString = $self->dataBuffer;

                    } else {

                        # Extract everything beore
                        $addressString = substr($self->dataBuffer, 0, $nlPos2 + 1);
                        substr($self->{dataBuffer}, 0, $nlPos2 + 1) = '';
                    }

                } else {

                    # The MudMaster header doesn't contain a second newline character
                    $addressString = $self->dataBuffer;
                    $self->ivPoke('dataBuffer', '');
                }

                # Extract the IP address and port
                $self->ivPoke(
                    'remoteIP',
                    substr(
                        $addressString,
                        0,
                        length($addressString) - 5,
                    ),
                );

                $self->ivPoke('remotePort', int(substr($addressString, -5, 5)));

                # FOR NOW: we don't process zChat's <SecurityInfo>
                $self->ivPoke('dataBuffer', '');

                # Set the chat name to use. Use the value stored in GA::Client, if it's been set;
                #   otherwise use the current character
                if ($axmud::CLIENT->chatName) {

                    $self->ivPoke('localName', $axmud::CLIENT->chatName);

                } elsif ($self->session->currentChar) {

                    $self->ivPoke('localName', ucfirst($self->session->currentChar->name));

                } else {

                    # Emergency failsafe - 'Axmud user'
                    $self->ivPoke('localName', $axmud::SCRIPT . ' user');
                }

                # Set the email to broadcast. Use the value stored in GA::Client, if it's been set;
                #    otherwise don't broadcast an email. (NB The email is only broadcast in zChat
                #   sessions.)
                if ($axmud::CLIENT->chatEmail) {

                    $self->ivPoke('localEmail', $axmud::CLIENT->chatEmail);
                }

                # Accept the connection
                # (Quoted from http://tintin.sourceforge.io/manual/chatprotocol.php)
                #   If a "NO" is received the call is cancelled. If the call was accepted the string
                #   "YES:<chat name>\n" is received.
                #
                # (Quoted from http://www.zuggsoft.com/zchat/zchatprot.htm)
                # To accept a connection, the Server sends a TCP/IP packet back to the client with
                #   the data:
                #   YES:<ServerChatName>\lf
                # If the Server is not accepting connections or if the security tests fail, or if
                #   the Server wants to reject the request for any other reason, it sends a TCP/IP
                #   packet back to the client with the data: NO
                syswrite($self->clientSocket, 'YES:' . $self->localName . "\n");

                # As soon as the connection is negotiated, the server and the client exchange
                #   information about each other by sending commands
                $self->sendCmd(
                    $self->ivShow('constOptHash', 'VERSION'),
                    $self->getVersion(),
                );

                if ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

                    # Our initial status is 1 ('Normal status') unless the user has been idle for
                    #   a minimum time, in which case it is 3 ('AFK')
                    $self->ivPoke('localStatus', 1);
                    $self->sendCmd(
                        $self->ivShow('constOptHash', 'STATUS'),
                        chr(1)
                    );

                    # Send the icon to the chat contact with an 'ICON' instruction
                    if (-e $axmud::CLIENT->chatIcon) {

                        $self->prepareIcon($axmud::CLIENT->chatIcon);
                    }

                    if ($self->localEmail) {

                        $self->sendCmd(
                            $self->ivShow('constOptHash', 'EMAIL'),
                            $self->localEmail,
                        );
                    }

                    $self->sendCmd(
                        $self->ivShow('constOptHash', 'STAMP'),
                        pack("L", $self->localZChatStamp)
                    );
                }

                # Open a new task window
                $self->newChatWin();

                # Respond to a Glib::IO event to process incoming data from the chat peer by calling
                #   $self->mmChatDataReady() or $self->zChatDataReady
                # (This replaces the callback used to call this function)
                $self->ivPoke(
                    'ioWatchID',
                    Glib::IO->add_watch(
                        $self->clientSocket->fileno,
                        'in',
                        $self->chatType == $self->ivShow('constOptHash', 'MM')
                            ? sub { $self->mmChatDataReady(@_); }
                            : sub { $self->zChatDataReady(@_); },
                    ),
                );

                # Now that the connection is established, remove the pending connection timeout
                Glib::Source->remove($self->timeoutID);

                if ($sessionContactObj) {

                    $self->ivPoke('chatContactObj', $sessionContactObj);

                    if ($sessionContactObj->name eq $self->remoteName) {

                        $self->writeText(
                            'Chat session with contact \'' . $self->remoteName . '\'',
                            'system',
                        )

                    } else {

                        $self->writeText(
                            'Chat session with contact \'' . $sessionContactObj->name
                            . '\' (using name \'' . $self->remoteName . '\')',
                            'system',
                        )
                    }

                } else {

                    $self->writeText(
                        'Chat session with \'' . $self->remoteName . '\' from ' . $self->remoteIP
                        . ' port ' . $self->remotePort,
                        'system',
                    );
                }

                return undef;
            }
        }

        return 1;                 # Continue processing
    }

    sub killPendingConnection {

        # Called by $self->requestConnection and ->acceptConnection
        # Kills connections that have been open for some time but have not yet been successfully
        #   established (i.e. when the Glib::Timeout stored in $self->timeoutID expires)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef'

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->killPendingConnection', @_);
        }

        if ($self->taskWinFlag) {

            $self->writeError(
                '\'' . $self->prettyName . '\' task: no reply from remote party, connection closed',
                $self->_objClass . '->killPendingConnection',
            );
        }

        $self->terminateChatSession();

        return undef;
    }

    sub mmChatDataReady {

        # Called by anonymous subs in $self->callEstablishmentData and
        #   $self->receiveEstablishmentData
        # Data has been received from the chat peer in a MudMaster connection
        #
        # Expected arguments
        #   $fileNo, $condition
        #               - Arguments supplied by the Glib::IO event (not required by this function)
        #               - $fileNo is the file descriptor, $self->clientSocket->fileno
        #               - $condition is the Glib::IOCondition, namely 'in' / 'G_IO_IN'
        #
        # Return values
        #   'undef' on improper arguments, if there's an error reading from the filehandle, if the
        #       filehandle sends an 'end of file'
        #   1 otherwise

        my ($self, $fileNo, $condition, $check) = @_;

        # Local variables
        my ($bytesRead, $tempBuffer, $endDataPos);

        # Check for improper arguments
        if (! defined $fileNo || ! defined $condition || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->mmChatDataReady', @_);
        }

        # Read 1024 bytes of data into $tempBuffer from the filehandle $self->clientSocket
        $bytesRead = sysread($self->clientSocket, $tempBuffer, 1024);

        if (
            ! defined $bytesRead    # There was an error
            || $bytesRead <= 0      # End of file
        ) {
            $self->terminateChatSession();

            $self->writeText(
                'Chat session closed.',
                'system',
            );

            return undef;
        }

        # Add the incoming data to the buffer
        $self->{dataBuffer} .= $tempBuffer;

        # (http://tintin.sourceforge.io/manual/chatprotocol.php)
        # Once the MudMaster connection is open, instructions are sent in the form
        #    <COMMAND BYTE><data><END OF COMMAND>
        #
        # Extract instructions and process them, one by one, until there are none left
        while (1) {

            if (! defined($self->pendingOpCode)) {

                # Extract the op code <COMMAND BYTE>, a 1-byte value
                if (length($self->dataBuffer) < 1) {

                    # Wait for more data before extracting the op code
                    return 1;
                }

                $self->ivPoke('pendingOpCode', ord($self->dataBuffer));
                substr($self->{dataBuffer}, 0, 1) = '';

            } else {

                if ($self->pendingOpCode == $self->ivShow('constOptHash', 'FILE_BLOCK')) {

                    # "A file block is ALWAYS 500 bytes so no CHAT_END_OF_COMMAND should be added"
                    # FILE_BLOCK instructions are supposed to be in the format:
                    #   <CHAT_FILE_BLOCK><block of data>
                    # However, MudMaster does actually send a CHAT_END_OF_COMMAND after the block,
                    #   regardless of what the specs say, so the format is actually
                    #   <CHAT_FILE_BLOCK><block of data><END OF COMMAND>
                    # Therefore, the size of the file block is (MM_FILE_BLOCK_SIZE + 1)
                    if (
                        length($self->dataBuffer)
                        < ($self->ivShow('constOptHash', 'MM_FILE_BLOCK_SIZE') + 1)
                    ) {
                        # Wait for more data before processing the instruction
                        return 1;
                    }

                    # Process the file block
                    $self->fileBlockReceived(
                        substr(
                            $self->dataBuffer,
                            0,
                            $self->ivShow('constOptHash', 'MM_FILE_BLOCK_SIZE'),
                        ),
                    );

                    substr(
                        $self->{dataBuffer},
                        0,
                        ($self->ivShow('constOptHash', 'MM_FILE_BLOCK_SIZE') + 1)
                    ) = '';

                    $self->ivUndef('pendingOpCode');

                } else {

                    # Find the position of the <END OF COMMAND> component
                    $endDataPos = index(
                        $self->dataBuffer,
                        chr($self->ivShow('constOptHash', 'END_OF_COMMAND'))
                    );

                    if ($endDataPos == -1) {

                        # <END OF COMMAND> not received yet; wait for more data before processing
                        #   the instruction
                        return 1;
                    }

                    # Extract the <data> component (i.e. everything before $endDataPos) and send it
                    #   for processing
                    $self->dispatchCmd(substr($self->dataBuffer, 0, $endDataPos));

                    # Remove the <data> component from the buffer
                    substr($self->{dataBuffer}, 0, ($endDataPos + 1)) = '';
                    # Once the command has been processed, we don't need to remember the opcode
                    $self->ivUndef('pendingOpCode');
                }
            }
        }

        # Continue processing
        return 1;
    }

    sub zChatDataReady {

        # Called by anonymous subs in $self->callEstablishmentData and
        #   $self->receiveEstablishmentData
        # Data has been received from the chat peer in a zChat connection
        #
        # Expected arguments
        #   $fileNo, $condition
        #               - Arguments supplied by the Glib::IO event (not required by this function)
        #               - $fileNo is the file descriptor, $self->clientSocket->fileno
        #               - $condition is the Glib::IOCondition, namely 'in' / 'G_IO_IN'
        #
        # Return values
        #   'undef' on improper arguments, if there's an error reading from the filehandle, if the
        #       filehandle sends an 'end of file'
        #   1 otherwise

        my ($self, $fileNo, $condition, $check) = @_;

        # Local variables
        my ($bytesRead, $tempBuffer);

        # Check for improper arguments
        if (! defined $fileNo || ! defined $condition || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->zChatDataReady', @_);
        }

        # Read 1024 bytes of data into $tempBuffer from the filehandle $self->clientSocket
        $bytesRead = sysread($self->clientSocket, $tempBuffer, 1024);

        if (
            ! defined $bytesRead    # There was an error
            || $bytesRead <= 0      # End of file
        ) {
            $self->terminateChatSession();

            $self->writeText(
                'Chat session closed.',
                'system',
            );

            return undef;
        }

        # Add the incoming data to the buffer
        $self->{dataBuffer} .= $tempBuffer;

        # (http://www.zuggsoft.com/zchat/zchatprot.htm)
        # Once the zChat connection is open, instructions are sent in the form
        #   <CommandID><Length><Data>
        #
        # Extract instructions and process them, one by one, until there are none left
        while (1) {

            if (! defined($self->pendingOpCode)) {

                # Extract the op code <CommandID>, "a 16-bit value, with the lower 8-bits sent
                #   first, followed by the upper 8-bits"
                if (length($self->dataBuffer) < 2) {

                    # Wait for more data before extracting the op code
                    return 1;
                }

                # 'v' template = 'An unsigned short (16-bit) in "VAX" (little-endian) order'
                $self->ivPoke(
                    'pendingOpCode',
                    unpack(
                        'v',
                        substr($self->dataBuffer, 0, 2),
                    )
                );
                substr($self->{dataBuffer}, 0, 2) = '';

            } elsif (! defined($self->pendingDataSize)) {

                # Get the size of the data component <Length>, "is a 16-bit value, with the lower
                #   8-bits sent first , followed by the upper 8-bits"
                if (length($self->dataBuffer) < 2) {

                    # Wait for more data before extracting the length
                    return 1;
                }

                # 'v' template = 'An unsigned short (16-bit) in "VAX" (little-endian) order'
                $self->ivPoke(
                    'pendingDataSize',
                    unpack(
                        'v',
                        substr($self->dataBuffer, 0, 2),
                    )
                );

                substr($self->{dataBuffer}, 0, 2) = '';

            } elsif (length($self->dataBuffer) >= $self->pendingDataSize) {

                # Extract the <Data> component (i.e. the first $self->pendingDataSize bytes left in
                #   the buffer) and send it for processing.
                $self->dispatchCmd(substr($self->dataBuffer, 0, $self->pendingDataSize));

                # Remove the <Data> component from the buffer
                substr($self->{dataBuffer}, 0, $self->pendingDataSize) = '';
                # Once the command has been processed, we don't need to remember the opcode or its
                #   data
                $self->ivUndef('pendingOpCode');
                $self->ivUndef('pendingDataSize');

            } else {

                # There are no more complete instructions to process
                return 1;
            }
        }

        # Continue processing
        return 1;
    }

    sub sendCmd {

        # Called by various functions
        # Packs a command in the appropriate representation and sends it
        #
        # Expected arguments
        #   $opCode     - The opcode (command) to broadcast, matches one of the values in
        #                   $self->constOptHash
        #   $data       - The data to broadcast alongside the opcode. For some opcodes, the $data
        #                   component is an empty string (but never 'undef')
        #
        # Return values
        #   'undef' on improper arguments or if a restricted $opCode is used when the connection
        #       hasn't yet been negotiated
        #   1 otherwise

        my ($self, $opCode, $data, $check) = @_;

        # Local variables
        my (
            $cmd,
            @taskList,
        );

        # Check for improper arguments
        if (! defined $opCode || ! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sendCmd', @_);
        }

        # Check for restricted opcodes
        if (! $self->remoteName && $self->ivExists('restrictedHash', $opCode)) {

            # This is a failsafe; the calling function ought to have already filtered out the
            #   restricted opcode
            $self->writeText(
                'Restricted opcode error (' . $self->ivShow('restrictedHash', $opCode) . ')',
                'system',
            );

            return undef;
        }

        # The data component is encoded using the character set in $self->encoding - the default is
        #   'iso-8859-1' - unless we're sending files or parts of files ('FILE_BLOCK', 'ICON')
        if (
            $opCode != $self->ivShow('constOptHash', 'FILE_BLOCK')
            && $opCode != $self->ivShow('constOptHash', 'ICON')
        ) {
            $data = Encode::encode($self->encoding, $data);
        }

        # zChat instructions Text Everybody (4), Text Personal (5) and Text Group (6) must be
        #   preceded by $self->localZChatStamp
        #
        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # StampedText has the format
        #   <Stamp><Text>
        # where Stamp is a 4-byte session ID number.  When Sending, send your Stamp Code, which is
        #   the random number you generated and broadcast when zChat first started.  If the received
        #   Stamp value matches the Receivers Stamp ID, the message is ignored (since it represents
        #   a message loop).
        #
        # The original chat.pl also adds a stamp to 'SNOOP_DATA'. I'll assume that's not an error...
        if (
            $self->chatType == $self->ivShow('constOptHash', 'ZCHAT')
            && (
                $opCode == $self->ivShow('constOptHash', 'TEXT_PERSONAL')
                || $opCode == $self->ivShow('constOptHash', 'TEXT_GROUP')
                || $opCode == $self->ivShow('constOptHash', 'TEXT_EVERYBODY')
                || $opCode == $self->ivShow('constOptHash', 'SNOOP_DATA')
            )
        ) {
            $data = pack("L", $self->localZChatStamp) . $data;
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {

            # (from http://tintin.sourceforge.io/manual/chatprotocol.php)
            # A chat data block looks like this: <COMMAND BYTE><data><END OF COMMAND>. All data
            #   dealing with needs to follow this format with a couple exceptions. The connection
            #   process doesn't use the data blocks and the file transfer blocks are a fixed size
            #   and don't need the <END OF COMMAND> byte.
            #
            # NB According to the chat.pl source, MudMaster does, in fact, send <END OF COMMAND>
            #   after a 'FILE_BLOCK', regardless of what the quote above says - and so shall we
            $cmd = chr($opCode) . $data . chr($self->ivShow('constOptHash', 'END_OF_COMMAND'));

        } elsif ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

            # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
            # Commands are normal TCP/IP packets that contain the following data format:
            #   <CommandID><Length><Data>
            #
            # CommandID
            #   is a 16-bit value, with the lower 8-bits sent first, followed by the upper 8-bits.
            #       It represents the ID of the command being sent.  Each Command ID is described
            #       below in more detail.
            # Length
            #   is a 16-bit value, with the lower 8-bits sent first , followed by the upper 8-bits.
            #       It represents the length of the Data field in bytes.  By using a Length field
            #       instead of a terminating character after the Data, any data value can be easily
            #       sent.
            # Data
            #   is the data required by the command.
            $cmd = pack('vv', $opCode, length($data)) . $data;
        }

        # If the filehandle is still open (we can avoid a rare error by checking), send the command
        #   to the chat contact
        if (fileno $self->clientSocket) {

            syswrite($self->clientSocket, $cmd);
        }

        return 1;
    }

    sub broadcast {

        # Called by various functions
        # Broadcasts a command to all open chat sessions or (optionally) to all open sessions with a
        #   particular group
        #
        # Expected arguments
        #   $opCode     - The opcode (command) to broadcast, matches one of the values in
        #                   $self->constOptHash
        #   $data       - The data to broadcast alongside the opcode. For some opcodes, the $data
        #                   component is an empty string
        #
        # Optional arguments
        #   $group      - If defined, the command is broadcast only to chat sessions in this group.
        #                   $group matches $self->localGroup. If 'undef', the command is broadcast
        #                   to all open sessions
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $opCode, $data, $group, $check) = @_;

        # Check for improper arguments
        if (! defined $opCode || ! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->broadcast', @_);
        }

        if ($group) {

            # Group names have a maximum size. If users specify longer group names, the names are
            #   always reduced to 15 characters
            $group = substr($group, 0, 15);
        }

        # Check every Chat task
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            if (
                $taskObj->name eq 'chat_task'
                && $taskObj->sessionFlag
                && ! $taskObj->connectionClosedFlag
                && ! (
                    $taskObj->chatType == $self->ivShow('constOptHash', 'MM')
                    && $opCode >= $self->ivShow('constOptHash', 'PEEK_CONNECTIONS')
                ) && ! (
                    $group
                    && $taskObj->localGroup ne $group
                )
            ) {
                # Broadcast the command to this Chat task session
                $taskObj->sendCmd($opCode, $data);
            }
        }

        return 1;
    }

    sub dispatchCmd {

        # Called by several functions
        # Processes an incoming MudMaster or zChat instruction
        #
        # Expected arguments
        #   $data   - In a MudMaster session, the <data> component in an instruction in the form
        #               <COMMAND BYTE><data><END OF COMMAND>
        #           - In a zChat session, the <data> component in an instruction in the form
        #               <CommandID><Length><Data>
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my (
            $opCode,
            %hash,
        );

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->dispatchCmd', @_);
        }

        # Import $self->constOptHash for quick lookup
        %hash = $self->constOptHash;

        # Process the instruction
        $opCode = $self->pendingOpCode;

        # MM:   CHAT_NAME_CHANGE (1)
        # zC:   Name Change (1)
        if ($opCode == $hash{'NAME_CHANGE'}) {

            $self->changeRemoteName($data);

        # MM:   CHAT_REQUEST_CONNECTIONS (2)
        # zC:   Request Connections (2)
        } elsif ($opCode == $hash{'REQUEST_CONNECTIONS'}) {

            $self->sendConnectionList();

        # MM:   CHAT_CONNECTION_LIST (3)
        # zC:   Connection List (3)
        } elsif ($opCode == $hash{'CONNECTION_LIST'}) {

            $self->reviewConnectionList($data);

        # MM:   CHAT_TEXT_EVERYBODY (4)
        # MM:   CHAT_TEXT_PERSONAL (5)
        # MM:   CHAT_TEXT_GROUP (6)
        # zC:   Text Everybody (4)
        # zC:   Text Personal (5)
        # zC:   Text Group (6)
        } elsif (
            $opCode == $hash{'TEXT_EVERYBODY'}
            || $opCode == $hash{'TEXT_PERSONAL'}
            || $opCode == $hash{'TEXT_GROUP'}
        ) {
            $self->processTextMsg($data);

        # MM:   CHAT_MESSAGE (7)
        # zC:   Message (7)
        } elsif ($opCode == $hash{'MESSAGE'}) {

            $self->displayRemoteMsg($data);

        # MM:   CHAT_DO_NOT_DISTURB (8)
        } elsif ($opCode == $hash{'DO_NOT_DISTURB'}) {

            # This opcode is not well documented, but just writes a 'please don't disturb me'
            #   message to the task window
            $self->writeText(
                $self->remoteName . ' has sent you a \'do not disturb\' request.',
                'remote',
            );

        # MM:   CHAT_VERSION (19)
        # zC:   Version (19)
        } elsif ($opCode == $hash{'VERSION'}) {

            $self->ivPoke('remoteVersion', $data);

        # MM:   CHAT_FILE_START (20)
        # zC:   File Start (20)
        } elsif ($opCode == $hash{'FILE_START'}) {

            $self->receiveFile($data);

        # MM:   CHAT_FILE_DENY (21)
        # zC:   File Deny (21)
        } elsif ($opCode == $hash{'FILE_DENY'}) {

            $self->fileStop();

        # MM:   CHAT_FILE_BLOCK_REQUEST (22)
        # zc:   File Block Request (22)
        } elsif ($opCode == $hash{'FILE_BLOCK_REQUEST'}) {

            $self->fileSendNextBlock();

        # MM:   CHAT_FILE_BLOCK (23)
        # zC:   File Block (23)
        } elsif ($opCode == $hash{'FILE_BLOCK'}) {

            $self->fileBlockReceived($data);

        # MM:   CHAT_FILE_END (24)
        # zC:   File End (24)
        } elsif ($opCode == $hash{'FILE_END'}) {

            $self->fileEnd();

        # MM:   CHAT_FILE_CANCEL (25)
        # zC:   File Cancel (25)
        } elsif ($opCode == $hash{'FILE_CANCEL'}) {

            $self->fileStop();

        # MM:   CHAT_PING_REQUEST (26)
        # zC:   Ping Request (26)
        } elsif ($opCode == $hash{'PING_REQUEST'}) {

            $self->sendCmd($hash{'PING_RESPONSE'}, $data);

        # MM:   CHAT_PING_RESPONSE (27)
        # zC:   Ping Response (27)
        } elsif ($opCode == $hash{'PING_RESPONSE'}) {

            $self->pingResponse($data);

        # MM:   CHAT_PEEK_CONNECTIONS (28)
        # zC:   Peek Connections (28)
        } elsif ($opCode == $hash{'PEEK_CONNECTIONS'}) {

            $self->sendConnectionList();

        # MM:   CHAT_PEEK_LIST (29)
        # zC:   Peek List (29)
        } elsif ($opCode == $hash{'PEEK_LIST'}) {

            $self->showConnectionList($data);

        # MM:   CHAT_SNOOP_START (30)
        # zC:   Snoop (30)
        } elsif ($opCode == $hash{'SNOOP'}) {

            if ($self->isSnoopedFlag) {
                $self->stopSnooped();
            } else {
                $self->startSnooped();
            }

        # MM:   CHAT_SNOOP_DATA  (31)
        # zC:   Snoop Data (31)
        } elsif ($opCode == $hash{'SNOOP_DATA'}) {

            $self->processSnoopedData($data);

        # zC:   Icon (100)
        } elsif ($opCode == $hash{'ICON'}) {

            $self->processIcon($data);

        # zC:   Status (101)
        } elsif ($opCode == $hash{'STATUS'}) {

            $self->processStatus($data);

        # zC:   Email Address (102)
        } elsif ($opCode == $hash{'EMAIL'}) {

            $self->ivPoke('remoteEmail', $data);
            if ($self->chatContactObj) {

                $self->chatContactObj->ivPoke('email', $data);
            }

        # zC:   Send command (105)
        } elsif ($opCode == $hash{'SEND_COMMAND'}) {

            $self->relayRemoteCmd($data);

        # zC:   Stamp (106)
        } elsif ($opCode == $hash{'STAMP'}) {

            $self->ivPoke('remoteStamp', unpack("L", $data));

            # Sometimes (for unknown reasons) the data component $data is empty
            if ($self->remoteStamp) {

                if ($self->remoteStamp == $self->localZChatStamp) {

                    $self->ivPoke('localZChatStamp', int(rand(2**32 - 1)));
                    $self->broadcast($hash{'STAMP'}, pack('L', $self->localZChatStamp));
                }
            }

        # Unsupported or invalid opcode. Ignore it completely, if it's the same as the last
        #   invalid opcode we received
        } elsif (! defined $self->lastInvalidOpCode || $self->lastInvalidOpCode != $opCode) {

            $self->ivPoke('lastInvalidOpCode', $opCode);

            #  Request PGP Key (103)
            if ($opCode == 103) {

                $self->writeText(
                    'Received unsupported opcode #103 (REQUEST_PGP_KEY)',
                    'system',
                );

                $self->sendCmd(
                    $hash{'MESSAGE'},
                    'Sorry, ' . $axmud::SCRIPT . ' does not support \'REQUEST_PGP_KEY\''
                    . ' (opcode 103) requests',
                );

            # PGP Key (104)
            } elsif ($opCode == 104) {

                $self->writeText(
                    'Received unsupported opcode #104 (PGP_KEY)',
                    'system',
                );

                $self->sendCmd(
                    $hash{'MESSAGE'},
                    'Sorry, ' . $axmud::SCRIPT . ' does not support \'PGP_KEY\' (opcode 104)'
                    . ' requests',
                );


            # Invalid opcodes
            } else {

                $self->writeText(
                    'Received invalid opcode #' . $opCode,
                    'system',
                );

                $self->sendCmd(
                    $hash{'MESSAGE'},
                    'Sorry, request denied (invalid opcode #' . $opCode . ')',
                );
            }
        }

        return 1;
    }

    sub formatText {

        # Called by various task functions
        # Formats a message to be sent in a 'TEXT_PERSONAL', 'TEXT_GROUP' or 'TEXT_EVERYBODY'
        #   instruction
        #
        # Expected arguments
        #   $text           - The text to be sent
        #   $isEmoteFlag    - Is this an emote? (TRUE yes, FALSE no)
        #   $dest           - A value in $self->constOptHash matching the key 'PERSONAL',
        #                       'EVERYBODY' or 'GROUP'
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the message to be sent

        my ($self, $text, $isEmoteFlag, $dest, $check) = @_;

        # Local variables
        my ($destName, $string);

        # Check for improper arguments
        if (! defined $text || ! defined $isEmoteFlag || ! defined $dest || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->formatText', @_);
        }

        if ($dest == $self->ivShow('constOptHash', 'PERSONAL')) {
            $destName = 'you';
        } elsif ($dest == $self->ivShow('constOptHash', 'GROUP')) {
            $destName = 'the group';
        } elsif ($dest == $self->ivShow('constOptHash', 'EVERYBODY')) {
            $destName = 'everybody';
        }

        if (! $isEmoteFlag) {

            # e.g. 'Alice chats to Bob, 'hello''

            # Don't enclose a recognised smiley in quotes
            if ($axmud::CLIENT->ivExists('constChatSmileyHash', $text)) {
                $string = $self->localName . ' chats to ' . $destName . ', ' . $text;
            } else {
                $string = $self->localName . ' chats to ' . $destName . ', \'' . $text . "'";
            }

        } elsif ($destName eq 'you') {

            # e.g. 'Alice laughs.'
            $string = $self->localName . ' ' . $text . "";

        } else {

            # e.g. '(To Bob) Alice laughs.'
            $string = "To " . $destName . ') ' . $self->localName . ' ' . $text . "";
        }

        return $string;
    }

    sub formatGroup {

        # Called by $self->chatToGroup, ->emoteToGroup and ->processTextMsg
        # Processes $self->localGroup into a 15-character string padded with spaces. (MM pads with
        #   spaces on the right; zChat pads with spaces on the left)
        #
        # Expected arguments
        #   $group      - The string to format (normally matches $self->localGroup)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the formatted text

        my ($self, $group, $check) = @_;

        # Local variables
        my $groupFormat;

        # Check for improper arguments
        if (! defined $group || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->formatGroup', @_);
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {

            # (from http://tintin.sourceforge.io/manual/chatprotocol.php)
            # Used when you send text to a specific group of connections. Works basically the same
            #   as the other text commands. The group name is a 15 character string. It *must* be 15
            #   characters long and pad it on the right with spaces to fill it out.
            $groupFormat = sprintf('%-15s', $group);

        } else {

            # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
            # Like the Text Everybody command, but the Text is sent to a specific group of
            #   connections.  The Data field is:
            #   <Stamp><GroupName><Text>
            # where GroupName is a 15-character string, padded with spaces on the left, that gives
            #   the Name of the group.
            $groupFormat = sprintf('%15s', $group);
        }

        return $groupFormat;
    }

    sub newChatWin {

        # Called by various functions
        # Opens a task window for this task, by calling the usuaul ->openWin()
        # Also calls $self->setChatWinTitle to change the window's title to something like
        #   'Chat: Alice > Bob'
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the result of the call to $self->openWin

        my ($self, $check) = @_;

        # Local variables
        my $result;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->newChatWin', @_);
        }

        $result = $self->openWin($self->winmap, 'grid');
        if ($result) {

            # Update the window title to show who is calling whom
            $self->setChatWinTitle();
        }

        return $result;
    }

    sub getVersion {

        # Called by various functions
        # Returns a string in the form:
        #   'Axmud v1.0.0'
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the string

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getVersion', @_);
        }

        return $axmud::SCRIPT . ' v' . $axmud::VERSION;
    }

    sub prepareIcon {

        # Called by $self->setIcon, ->callEstablishmentData and ->receiveEstablishmentData
        # Loads the contents of an icon file, and sends it the chat contact via an 'ICON'
        #   instruction
        # We assume that the file really exists and is really in Window bitmap format
        #
        # Expected arguments
        #   $filePath   - The full filepath of the icon to send
        #
        # Return values
        #   'undef' on improper arguments or if the file can't be opend
        #   1 otherwise

        my ($self, $filePath, $check) = @_;

        # Local variables
        my ($fileHandle, $data);

        # Check for improper arguments
        if (! defined $filePath || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareIcon', @_);
        }

        # Try to open a filehandle to read the file
        if(! open($fileHandle, $filePath)) {

            $self->writeText(
                'Could not open \'' . $filePath . '\': ' . $!,
                'system',
            );

            return undef;
        }

        # Read the contents of the file
        sysread($fileHandle, $data, -s $filePath);

        # Send the contents to the chat contact via a 'ICON' opcode
        $self->sendCmd(
            $self->ivShow('constOptHash', 'ICON'),
            $data,
        );

        # Tidy up
        close($fileHandle);

        # Store which icon file is being used in this chat session
        $self->ivPoke('localIconFile', $filePath);

        return 1;
    }

    sub terminateChatSession {

        # Called by various functions
        # Terminates the chat session. Closes sockets and the task window, updates IVs and halts
        #   the task (unless it is the lead Chat task, which should continue running in order to
        #   listen out for incoming calls)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef'

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->terminateChatSession', @_);
        }

        # Close the connection
        close $self->clientSocket;

        # Remove the Glib::IO callback
        if ($self->ioWatchID) {

            Glib::Source->remove($self->ioWatchID);
        }

        # Shut down snooping
        $self->stopSnooped();

        # Mark the session as closed
        $self->ivPoke('sessionFlag', FALSE);
        $self->ivPoke('connectionClosedFlag', TRUE);
        $self->ivUndef('chatContactObj');

        # Discard information about the connection
        $self->ivUndef('clientSocket');
        $self->ivUndef('chatType');
        $self->ivUndef('ioWatchID');
        $self->ivUndef('timeoutID');
        $self->ivUndef('pendingOpCode');
        $self->ivUndef('lastInvalidOpCode');
        $self->ivUndef('pendingDataSize');
        $self->ivUndef('dataBuffer');

        $self->ivUndef('remoteIcon');
        $self->ivUndef('remoteIconScaled');
        $self->ivUndef('remoteName');
        $self->ivUndef('remoteIP');
        $self->ivUndef('remotePort');
        $self->ivUndef('remoteEmail');
        $self->ivUndef('remoteStamp');
        $self->ivUndef('remoteVersion');
        $self->ivPoke('remoteStatus', 0);
        $self->ivUndef('remoteZChatID');
        $self->ivUndef('remotePublicKey');

        # Reset other IVs - in case this is the lead Chat task, we don't want settings from this
        #   chat session interfering with the next one
        $self->ivPoke('entryMode', 'chat');
        $self->ivPoke('servingFlag', FALSE);

        $self->ivUndef('pingTime');
        $self->ivUndef('pingStamp');
        $self->ivUndef('fileDir');
        $self->ivUndef('fileName');
        $self->ivUndef('fileHandle');
        $self->ivUndef('fileTotalSize');
        $self->ivUndef('fileSize');

        # If this is the lead Chat task, assign a new one (if there are any other lead tasks)
        if ($self->leadTaskFlag) {

            $self->assignLeadTask();
            # Close the task window, if it is open (which it should be)
            if ($self->taskWinFlag) {

                $self->closeWin();
            }
        }

        # If this not the lead Chat task (or is no longer the lead Chat task), halt the task.
        # (Chat tasks which aren't the lead task don't run when there is no chat session in
        #   progress)
        if (! $self->leadTaskFlag) {

            $self->ivPoke('shutdownFlag', TRUE);

        } else {

            # Reset the IV ->chatType (must be 'undef', or $self->receiveEstablishmentData won't
            #   know that it's supposed to be looking for a new header)
            $self->ivUndef('chatType');
            # Also reset the last invalid opcode received - it's of no relevance in the next chat
            #   session that might open
            $self->ivUndef('lastInvalidOpCode');
        }

        return undef;
    }

    # Supplementary functions called by $self->dispatchCmd

    sub changeRemoteName {

        # Called by ->dispatchCmd in response to the 'NAME_CHANGE' opcode
        # Also called by $self->callEstablishmentData
        #
        # Stores the nickname of the chat contact
        #
        # Expected arguements
        #   $name       - The chat contact's nickname
        #
        # Optional arguments
        #   $quietFlag  - If set to TRUE, does not print anything in the task window (otherwise
        #                   'undef')
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $name, $quietFlag, $check) = @_;

        # Local variables
        my $oldName;

        # Check for improper arguments
        if (! defined $name || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->changeRemoteName', @_);
        }

        $oldName = $self->remoteName;
        $self->ivPoke('remoteName', $name);

        if (! $quietFlag) {

            $self->writeText(
                $oldName . ' is now known as ' . $name,
                'system',
            );
        }

        # Update the window title to show who is calling whom
        $self->setChatWinTitle();

        return 1;
    }

    sub sendConnectionList {

        # Called by $self->dispatchCmd in response to the 'REQUEST_CONNECTIONS' and
        #   'PEEK_CONNECTIONS' opcodes
        # Sends a list of connections which are marked as 'public' (not including this connection)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $data;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->sendConnectionList', @_);
        }

        # Find all Chat tasks whose connections are marked as 'public'. Don't include this task's
        #   connection, because this is the chat peer that sent the CHAT_REQUEST_CONNECTIONS opcode
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            if (
                $taskObj->name eq 'chat_task'
                && $taskObj ne $self
                && $taskObj->publicConnectionFlag
                # Special case: 3 chat clients, all operating from the same IP address (with the
                #   same default port)
                # Alice, is listening on port 4050. Both Bob and Charlie establish public
                #   connections with Alice, but neither Bob nor Charlie are listening for incoming
                #   calls
                # Bob asks Alice for her list of public connections. Alice replies that she's
                #   talking to Charlie, whose remote IP/port is advertised as 127.0.0.1 4050
                #   (because when Charlie is not listening for calls, he advertises his port as
                #   4050)
                # However, 127.0.0.1 4050 is also Bob's IP and port, so Bob receives his own IP and
                #   port (which could be somewhat confusing)
                # The solution is not to send details of Charlie's advertised IP/port if they are
                #   the same as the one used by Bob
                && (
                    # Bob's / Charlie's advertised IPs not the same (or either is unknown)
                    ! $taskObj->remoteIP
                    || ! $self->remoteIP
                    || $taskObj->remoteIP ne $self->remoteIP
                    # Bob's / Charlie's advertised ports not the same (or either is unknown)
                    || ! $taskObj->remotePort
                    || ! $self->remotePort
                    || $taskObj->remotePort != $self->remotePort
                )
            ) {
                # (from http://tintin.sourceforge.io/manual/chatprotocol.php)
                # <CHAT_CONNECTION_LIST><address>,<port>,<address>,<port><CHAT_END_OF_COMMAND>
                # The receiver needs to put all the IPs and port numbers of the public connections
                #   in a comma delimited string and send them back as a connection list.
                #
                # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
                # The response to the Request Connections command above.  The Data sent by the
                #   receiver is:
                #   <IPAddr>,<Port>,<IPAddr>,<Port> etc
                # If there is no IP Address for a connection, the string "<Unknown>" is sent in
                #   place of the IP Address field.  For example, the Data field might look like:
                #   192.168.0.2,4050,<Unknown>,4050,192.168.0.1,4000
                if ($data) {
                    $data .= ',';
                } else {
                    $data = '';
                }

                if ($taskObj->remoteIP) {
                    $data .= $taskObj->remoteIP;
                } else {
                    $data .= '<Unknown>';
                }

                if ($taskObj->remotePort) {
                    $data .= ',' . $taskObj->remotePort;
                } else {
                    $data .= ',' . '<Unknown>';
                }
            }
        }

        # If there is at least one public connection, send it; otherwise, don't send any response at
        #   all
        if ($data) {

            if ($self->pendingOpCode == $self->ivShow('constOptHash', 'REQUEST_CONNECTIONS')) {

                $self->sendCmd(
                    $self->ivShow('constOptHash', 'CONNECTION_LIST'),
                    $data,
                );

            } else {

                $self->sendCmd(
                    $self->ivShow('constOptHash', 'PEEK_LIST'),
                    $data,
                );
            }
        }

        return 1;
    }

    sub reviewConnectionList {

        # Called by $self->dispatchCmd in response to the 'CONNECTION_LIST' opcode
        # Reviews a list of the chat contact's public connections which we've just received
        # Try to connect to any of them with which we're not already connected
        #
        # Expected arguments
        #   $data   - A string containing the chat contact's public connections, in the form
        #               described below
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my (@taskList, @dataList);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reviewConnectionList', @_);
        }

        # Get a list of all tasks, including this one
        @taskList = $self->findAllTasks();

        # (from http://tintin.sourceforge.io/manual/chatprotocol.php)
        # <CHAT_CONNECTION_LIST><address>,<port>,<address>,<port><CHAT_END_OF_COMMAND>
        # The receiver needs to put all the IPs and port numbers of the public connections in a
        #   comma delimited string and send them back as a connection list.
        #
        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # The response to the Request Connections command above.  The Data sent by the receiver is:
        #   <IPAddr>,<Port>,<IPAddr>,<Port> etc
        # If there is no IP Address for a connection, the string "<Unknown>" is sent in place of the
        #   IP Address field.  For example, the Data field might look like:
        #   192.168.0.2,4050,<Unknown>,4050,192.168.0.1,4000

        # Split $data into a list in the form (ip, port, ip, port...)
        @dataList = split(m/,/, $data);

        do {

            my ($ip, $port, $connectedFlag, $newTaskObj);

            $ip = shift @dataList;
            $port = shift @dataList;

            if ($ip && $ip ne '<Unknown>' && $port && $port ne '<Unknown>') {

                # Are we already connected to someone at this address?
                OUTER: foreach my $taskObj (@taskList) {

                    if (
                        $taskObj->sessionFlag
                        && $taskObj->remoteIP
                        && $taskObj->remoteIP eq $ip
                        && $taskObj->remotePort
                        && $taskObj->remotePort == $port
                    ) {
                        # We are already connected
                        $connectedFlag = TRUE;
                        last OUTER;
                    }
                }

                if (! $connectedFlag) {

                    # We're not already connected to this address. Create a new Chat task which,
                    #   when it initialises, will make the connection
                    $newTaskObj = Games::Axmud::Task::Chat->new($self->session, 'current');
                    if ($newTaskObj) {

                        # Tell the new task to make a call to the specified ip/port, when its
                        #   ->init function is called
                        # Use the same protocol as the current connection
                        $newTaskObj->ivPoke('makeCallOnInitFlag', TRUE);
                        if ($self->chatType == $self->ivShow('constOptHash', 'MM')) {
                            $newTaskObj->ivPoke('makeCallOnInitType', 'MM');
                        } else {
                            $newTaskObj->ivPoke('makeCallOnInitType', 'ZCHAT');
                        }

                        $newTaskObj->ivPoke('makeCallOnInitIP', $ip);
                        $newTaskObj->ivPoke('makeCallOnInitPort', $port);

                        $self->writeText(
                            'Making conference call to ' . $ip . ' ' . $port . '...',
                            'system',
                        );
                    }
                }
            }

        } until (! @dataList);

        return 1;
    }

    sub processTextMsg {

        # Called by $self->dispatchCmd for the opcodes 'TEXT_EVERYBODY', 'TEXT_PERSONAL' and
        #   'TEXT_GROUP'
        #
        # Processes a text communication (i.e. a chat or an emote) which has been received from the
        #   chat contact
        # In zChat sessions, checks the stamp. Also, if $self->servingFlag is set to TRUE, echoes
        #   TEXT_EVERYBODY and TEXT_GROUP communications to other Chat tasks
        #
        # Expected arguments
        #   $data       - The text received
        #
        # Return values
        #   'undef' on improper arguments or if the message originated from this Chat task, in which
        #       case we don't use it
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my (
            $comm, $stamp, $group, $text, $stripText,
            @taskList,
        );

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processTextMsg', @_);
        }

        if ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

            # zChat (from http://www.zuggsoft.com/zchat/zchatprot.htm)
            # StampedText has the format
            #   <Stamp><Text>
            # where Stamp is a 4-byte session ID number.  When Sending, send your Stamp Code, which
            #   is the random number you generated and broadcast when zChat first started.  If the
            #   received Stamp value matches the Receivers Stamp ID, the message is ignored (since
            #   it represents a message loop).
            #
            # In effect this means that, as communications get passed around from one zChat client
            #   to the next, a communication we sent may end up getting sent back to us. If the
            #   incoming communication uses our own stamp, ignore it
            $stamp = unpack('L', substr($data, 0, 4));
            if ($stamp == $self->localZChatStamp) {

                # This message was produced by us, so ignore it
                return undef;

            } else {

                # Remote the stamp from the incoming message
                $comm = substr($data, 4);
            }

        } else {

            # MudMaster protocol doesn't use stamps
            $comm = $data;
        }

        if ($self->pendingOpCode == $self->ivShow('constOptHash', 'TEXT_GROUP')) {

            # MM (from http://tintin.sourceforge.io/manual/chatprotocol.php)
            # <CHAT_TEXT_GROUP><group><text to send><CHAT_END_OF_COMMAND>
            # Used when you send text to a specific group of connections. Works basically the same
            #   as the other text commands. The group name is a 15 character string. It *must* be 15
            #   characters long and pad it on the right with spaces to fill it out.
            #
            # zChat (from http://www.zuggsoft.com/zchat/zchatprot.htm)
            # Like the Text Everybody command, but the Text is sent to a specific group of
            #   connections.  The Data field is:
            #   <Stamp><GroupName><Text>
            # where GroupName is a 15-character string, padded with spaces on the left, that gives
            #   the Name of the group. The Sender formats the entire message to be displayed (like
            #   "<Name> chats to the group <GroupName>: hello").
            # The Receiver simply displays the message.

            # Extract the <group> and <text_to_send> components
            $group = substr($comm, 0, 15);
            $text = substr($comm, 15);

            # $group contains exactly 15 characters, padded with spaces on the left or right.
            #   Trim the whitespace away
            $group = $axmud::CLIENT->trimWhitespace($group);

        } else {

            $text = $comm;
        }

        # Display the communication in the task window
        Encode::from_to($text, $self->encoding, 'utf-8-strict');
        $stripText = $self->stripAnsi($text);

        # If an Axmud user types a smiley like :), Axmud will send <You chat to Alice, :)> rather
        #   than <You chat to Alice, ':)>
        # Other clients probably won't do that, so check $stripText against smileys. If it ends in
        #   a smiley, surrounded by quotes, strip the quotes
        OUTER: foreach my $smiley ($axmud::CLIENT->ivKeys('constChatSmileyHash')) {

            my $modSmiley = quotemeta($smiley);
            if ($stripText =~ s/\'$modSmiley\'\s*$/$smiley/) {

                # Once one smiley is found, no point checking the others
                last OUTER;
            }
        }

        $self->writeText(
            $stripText,
            'chat_in',
        );

        # In zChat sessions, echo CHAT_TEXT_EVERYBODY and CHAT_TEXT_GROUP to other Chat tasks that
        #   are running, if allowed
        if ($self->servingFlag) {

            if ($self->pendingOpCode == $self->ivShow('constOptHash', 'TEXT_EVERYBODY')) {

                # Get a list of all running Chat tasks, except this one
                @taskList = $self->findOtherTasks();

            } elsif ($self->pendingOpCode == $self->ivShow('constOptHash', 'TEXT_GROUP')) {

                # Get a list of all running Chat tasks in the specified group, except this one
                foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

                    if (
                        $taskObj->name eq 'chat_task'
                        && $taskObj ne $self
                        && $taskObj->localGroup && $taskObj->localGroup eq $group
                    ) {
                        push (@taskList, $taskObj);
                    }
                }
            }

            # Echo the communication to each selected Chat task
            foreach my $taskObj (@taskList) {

                my $groupFormat;

                if ($self->pendingOpCode == $self->ivShow('constOptHash', 'TEXT_EVERYBODY')) {

                    $taskObj->sendCmd(
                        $self->pendingOpCode,   # Value matching 'TEXT_EVERYBODY'
                        $text,                  # Preserve ANSI formatting
                    );

                    $taskObj->writeText(
                        $stripText,
                        'echo',
                    );

                } else {

                    # Produce a string for the group, padded with spaces to the left or the right,
                    #   as appropriate (see comments above)
                    $groupFormat = $self->formatGroup($group);

                    $taskObj->sendCmd(
                        $self->pendingOpCode,   # Value matching 'TEXT_GROUP'
                        $groupFormat . $text,   # Preserve ANSI formatting
                    );

                    $taskObj->writeText(
                        $stripText,
                        'echo',
                    );
                }
            }
        }

        return 1;
    }

    sub displayRemoteMsg {

        # Called by $self->dispatchCmd for the opcode 'MESSAGE'
        # Displays a message sent by the chat contact's client for our attention in a colour used
        #   only for this purpose
        #
        # Expected arguments
        #   $data       - The text of the message received
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my $stripData;

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayRemoteMsg', @_);
        }

        # Reencode the text and remove ANSI codes
        Encode::from_to($data, $self->encoding, 'utf-8-strict');
        $stripData = $self->stripAnsi($data);

        # Display the remote message
        $self->writeText(
            'Message from ' . $self->remoteName . ' : ' . $stripData,
            'remote',
        );

        return 1;
    }

    sub receiveFile {

        # Called by $self->dispatchCmd in response to the 'FILE_START' opcode
        # Asks the user if they want to accept a file. If so, start the transfer
        #
        # Expected arguments
        #   $data    - The data component of the received message, containing details about the file
        #
        # Return values
        #   'undef' on improper arguments, if the connection hasn't been negotiated yet, if a file
        #       transfer is already in progress, if the user rejects the file transfer or cancels
        #       when asked where it should be saved
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my ($file, $length, $result, $fileName);

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->receiveFile', @_);
        }

        # Refuse to accept the file if the connection hasn't been negotiated yet
        if (! $self->remoteName) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_DENY'),
                'Waiting to negotiate connection.',
            );

            return undef;
        }

        # It shouldn't be possible to be asked to receive a file, while another transfer is still in
        #   progress. But we'll check anyway
        if ($self->fileDir) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_DENY'),
                'A file transfer is already in progress.',
            );

            return undef;
        }

        ($file, $length) = split(',', $data);

        # Prompt the user for permission to receive the file
        if ($self->winObj && ! $self->tableObj) {

            $result = $self->winObj->showMsgDialogue(
                'Chat task',
                'question',
                $self->remoteName . ' wants to send you \'' . $file . '\' (length ' . $length
                . ' bytes). Accept it?',
                'yes-no',
                'no',
            );

        } else {

            $result = $self->session->mainWin->showMsgDialogue(
                'Chat task',
                'question',
                $self->remoteName . ' wants to send you \'' . $file . '\' (length ' . $length
                . ' bytes). Accept it?',
                'yes-no',
                'no',
            );
        }

        if ($result ne 'yes') {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_DENY'),
                $self->localName . ' rejected the file transfer.',
            );

            return undef;
        }

        # Prompt the user to choose where to save the file
        if ($self->winObj && ! $self->tableObj) {

            $fileName = $self->winObj->showFileChooser(
                'Save file as',
                'save',
            );

        } else {

            $fileName = $self->session->mainWin->showFileChooser(
                'Save file as',
                'save',
            );
        }

        if (! $fileName) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_DENY'),
                $self->localName . ' rejected the file transfer.',
            );

            return undef;
        }

        if (! open ($self->{fileHandle}, ">$fileName")) {

            $self->writeText(
                'Could not open \'' . $fileName . '\': ' . $!,
                'system',
            );

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_DENY'),
                $self->localName . ' rejected the file transfer.',
            );

            return undef;
        }

        # Start the transfer of the file
        $self->ivPoke('fileDir', $self->ivShow('constOptHash', 'RECEIVING'));
        $self->ivPoke('fileName', $fileName);
        $self->ivPoke('fileTotalSize', $length);
        $self->ivPoke('fileSize', 0);

        $self->writeText(
            'Started transfer of file \'' . $fileName . '\'',
            'system',
        );

        $self->sendCmd(
            $self->ivShow('constOptHash', 'FILE_BLOCK_REQUEST'),
            '',
        );

        return 1;
    }

    sub fileSendNextBlock {

        # Called by $self->dispatchCmd in response to the 'FILE_BLOCK_REQUEST' opcode
        # Sends the next portion of the file to the chat contact
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if there is no file transfer in progress or if there is
        #       an error reading the file
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($remainingSize, $blockSize, $bytesToRead, $buffer, $nRead, $block);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->fileSendNextBlock', @_);
        }

        # It shouldn't be possible to receive a block when a file transfer isn't in progress, but
        #   we'll check anyway
        if (! defined $self->fileDir) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_CANCEL'),
                '',
            );

            return undef;
        }

        # Update our variables
        $remainingSize = $self->fileTotalSize - $self->fileSize;
        if ($remainingSize == 0) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_END'),
                '',
            );

            $self->fileStop(
                'Finished transfer of file \'' . $self->fileName . '\'',
            );

            return 1;
        }

        $blockSize = $self->chatType == $self->ivShow('constOptHash', 'MM')
            ? $self->ivShow('constOptHash', 'MM_FILE_BLOCK_SIZE')
            : $self->ivShow('constOptHash', 'ZCHAT_FILE_BLOCK_SIZE');

        $bytesToRead = $remainingSize < $blockSize
            ? $remainingSize
            : $blockSize;

        $nRead = sysread($self->{fileHandle}, $buffer, $bytesToRead);
        if ($nRead != $bytesToRead) {

            # Houston, we have a problem
            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                'Error while reading file',
            );
            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_CANCEL'),
                '',
            );

            $self->fileStop(
                'Error while reading file. File transfer cancelled.',
            );

            return undef;

        } else {

            $block = $buffer . chr(0) x ($blockSize - $bytesToRead);

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_BLOCK'),
                $block,
            );

            $self->ivPoke('fileSize', $self->fileSize + $bytesToRead);

            return 1;
        }
    }

    sub fileBlockReceived {

        # Called by $self->dispatchCmd in response to the 'FILE_BLOCK' opcode
        # Receives a block of the file, adds it to previously received blocks, and asks for the next
        #   block
        #
        # Expected arguments
        #   $block      - The file block received
        #
        # Return values
        #   'undef' on improper arguments or if there is no file transfer in progress
        #   1 otherwise

        my ($self, $block, $check) = @_;

        # Local variables
        my ($length, $remainingSize, $bytesToWrite);

        # Check for improper arguments
        if (! defined $block || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->fileBlockReceived', @_);
        }

        # It shouldn't be possible to receive a block when a file transfer isn't in progress, but
        #   we'll check anyway
        if (! defined $self->fileDir) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'FILE_CANCEL'),
                '',
            );

            return undef;
        }

        # Update our variables
        $length = length($block);
        $remainingSize = $self->fileTotalSize - $self->fileSize;
        $bytesToWrite = $remainingSize < $length
            ? $remainingSize
            : $length;

        # Add the received data to the file
        syswrite($self->{fileHandle}, $block, $bytesToWrite);
        $self->ivPoke('fileSize', $self->fileSize + $length);

        # Ask for the next block
        $self->sendCmd(
            $self->ivShow('constOptHash', 'FILE_BLOCK_REQUEST'),
            '',
        );

        return 1;
    }

    sub fileEnd {

        # Called by $self->dispatchCmd in response to the 'FILE_END' opcode
        # Closes the filehandle used to create the local copy of the file and resets IVs
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->fileEnd', @_);
        }

        $self->writeText(
            'Transfer of file ' . $self->fileName . ' finished.',
            'system',
        );

        # Tidy up
        close($self->fileHandle);

        $self->ivUndef('fileHandle');
        $self->ivUndef('fileName');
        $self->ivUndef('fileDir');

        return 1;
    }

    sub fileStop {

        # Called by $self->dispatchCmd in response to the 'FILE_CANCEL' and 'FILE_DENY' opcodes
        # The file transfer must be stopped, either because one of the parties aborted it or because
        #   of an error
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $errorMsg   - The error message to display, if the calling function specified one
        #                   (if 'undef', a standard message is displayed)
        #
        # Return values
        #   'undef' on improper arguments or if we receive a 'FILE_CANCEL' message we don't need
        #   1 otherwise

        my ($self, $errorMsg, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->fileStop', @_);
        }

        # At least one kind of client sends a 'FILE_CANCEL' in reply to our refusal to accept a
        #   file. In this case, the file transfer was not initiated from here
        if (! defined $self->fileDir) {

            return undef;
        }

        # Display a confirmation when we are the ones sending the file
        if ($self->fileDir) {

            if ($errorMsg) {

                $self->writeText($errorMsg, 'system');

            } else {

                $self->writeText(
                    'Transfer of file ' . $self->fileName . ' aborted by remote party.',
                    'system',
                );
            }
        }

        # Delete the (incomplete) portion of the local copy of the file when we are the ones
        #   receiving the file
        if ($self->fileDir == $self->ivShow('constOptHash', 'RECEIVING')) {

            unlink($self->fileName);
        }

        # Tidy up
        close($self->fileHandle);

        $self->ivUndef('fileHandle');
        $self->ivUndef('fileName');
        $self->ivUndef('fileDir');

        return 1;
    }

    sub pingResponse {

        # Called by $self->dispatchCmd in response to the 'PING_RESPONSE' opcode
        # Displays a message in the task window, confirming how long the ping took to arrive back
        #
        # Expected arguments
        #   $data       - The details sent by the chat contact's client (see below)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my ($elapsedSecs, $remoteStatus);

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->pingResponse', @_);
        }

        # (from http://tintin.sourceforge.io/manual/chatprotocol.php)
        # <CHAT_PING_RESPONSE><timing data><CHAT_END_OF_COMMAND>
        #   When receiving a CHAT_PING_REQUEST you should send back the timing data.
        #
        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # Sent in response to the Ping Request.  The Data field is the same data received from the
        #   Ping Request.  When the Sender of the Ping Request gets this data back, it can compare
        #   the Clock value in the data field that is returned (which is the time when the initial
        #   Ping Request was sent) to the current Clock value to determine how much time passed in
        #   transit.

        # It's possible there might be several pings going around. Check this is the one we're
        #   expecting
        if ($data == $self->pingStamp) {

            # Work out the elapsed time
            $elapsedSecs = $axmud::CLIENT->getTime() - $self->pingTime;

            # Display confirmation, rounding to 3 decimal places
            if ($elapsedSecs < 0.001) {

                $self->writeText(
                    'Ping received reply in less than 0.001 second(s)',
                    'system',
                );

            } else {

                $self->writeText(
                    'Ping received reply in ' . sprintf('%.3f', $elapsedSecs) . ' second(s)',
                    'system',
                );
            }
        }

        return 1;
    }

    sub showConnectionList {

        # Called by $self->dispatchCmd in response to the 'PEEK_LIST' opcode
        # Shows a list of the chat contact's public connections in the task window
        #
        # Expected arguments
        #   $data   - A string containing the chat contact's public connections, in the form
        #               described below
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my (@taskList, @dataList);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showConnectionList', @_);
        }

        # Get a list of all tasks, including this one
        @taskList = $self->findAllTasks();

        # (from http://tintin.sourceforge.io/manual/chatprotocol.php)
        # <CHAT_CONNECTION_LIST><address>,<port>,<address>,<port><CHAT_END_OF_COMMAND>
        # The receiver needs to put all the IPs and port numbers of the public connections in a
        #   comma delimited string and send them back as a connection list.
        #
        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # The response to the Request Connections command above.  The Data sent by the receiver is:
        #   <IPAddr>,<Port>,<IPAddr>,<Port> etc
        # If there is no IP Address for a connection, the string "<Unknown>" is sent in place of the
        #   IP Address field.  For example, the Data field might look like:
        #   192.168.0.2,4050,<Unknown>,4050,192.168.0.1,4000

        $self->writeText('List of public connections for \'' . $self->remoteName . '\'');

        # Split $data into a list in the form (ip, port, ip, port...)
        @dataList = split(m/,/, $data);
        do {

            my ($ip, $port);

            $ip = shift @dataList;
            $port = shift @dataList;

            $self->writeText(sprintf(' %-16.16s %-5.5s', $ip, $port));

        } until (! @dataList);

        return 1;
    }

    sub startSnooped {

        # Called by $self->dispatchCmd in response to the 'SNOOP' opcode
        # Starts a snooping session, in which we send all received data to the chat contact(s)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if snooping isn't currently allowed
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startSnooped', @_);
        }

        if (! $self->allowSnoopFlag) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                $self->localName . ' has declined to allow you to snoop on their session.',
            );

            $self->writeText(
                $self->remoteName . ' tried to snoop you.',
                'system',
            );

            return undef;

        } else {

            $self->ivPoke('isSnoopedFlag', TRUE);

            # The lead Chat task stores the number of snoopers
            if (! $self->session->chatTask->snooperCount) {

                # This is the first snooper; enable the two hook interfaces that capture local text
                $self->receiveHook->modifyAttribs($self->session, 'enabled', 1);
                $self->sendHook->modifyAttribs($self->session, 'enabled', 1);
            }

            $self->session->chatTask->ivIncrement('snooperCount');

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                'You are now snooping ' . $self->localName,
            );

            $self->writeText(
                $self->remoteName . ' is snooping you.',
                'system',
            );

            return 1;
        }
    }

    sub stopSnooped {

        # Called by $self->dispatchCmd in response to the 'SNOOP' opcode, and also by several other
        #   functions
        # Terminates a snooping session. No more snoop data is sent to the chat contact
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if there is currently no snooping session
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopSnooped', @_);
        }

        if (! $self->isSnoopedFlag) {

            # There is no snooping session
            return undef;

        } else {

            $self->ivPoke('isSnoopedFlag', FALSE);
            # The lead Chat task stores the number of snoopers
            $self->session->chatTask->ivDecrement('snooperCount');

            if (! $self->session->chatTask->snooperCount) {

                # There are no more snoopers; disable the two hook interfaces that capture local
                #   text
                $self->receiveHook->modifyAttribs($self->session, 'enabled', 0);
                $self->sendHook->modifyAttribs($self->session, 'enabled', 0);
            }

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                'You stop snooping ' . $self->localName,
            );

            $self->writeText(
                $self->remoteName . ' has stopped snooping you.',
                'system',
            );

            return 1;
        }
    }

    sub processSnoopedData {

        # Called by $self->dispatchCmd in response to the 'SNOOP_DATA' opcode
        # Displays text from the chat contact's session in this task's window
        #
        # Expected arguments
        #   $data       - The text sent
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my $stripData;

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processSnoopedData', @_);
        }

        # The original chat.pl sends the zChat stamp at the beginning of data. I'll assume this is
        #   not an error
        if ($self->chatType == $self->ivShow('constOptHash', 'ZCHAT')) {

            $data = substr($data, 4);
        }

        # Display the communication in the task window
        Encode::from_to($data, $self->encoding, 'utf-8-strict');
        $stripData = $self->stripAnsi($data);

        $self->writeText(
            $stripData,
            'snoop',
        );

        return 1;
    }

    sub processIcon {

        # Called by $self->dispatchCmd in response to the 'ICON' opcode
        # Processes the chat contact's icon sent during a zChat session
        #
        # Expected arguments
        #   $data       - A string containing the raw Window .bmp file
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my ($file, $fileHandle);

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processIcon', @_);
        }

        # Save the incoming file, temporarily, in the Axmud data directory
        $file = $axmud::DATA_DIR . '/.tempicon_' . int(rand(1_000_000)) . '.bmp';

        open ($fileHandle, ">$file");
        syswrite($fileHandle, $data, length ($data));
        close($fileHandle);

        # Use this file to set the task window's icon
        if ($self->winObj && ! $self->tableObj) {

            $self->winObj->winWidget->set_icon_from_file($file);
        }

        # Store the icon as a pixbuf, so that it can be stored in the contacts list, if necessary
        my ($pixBuffer, $pixBuffer2);

        $pixBuffer = Gtk3::Gdk::Pixbuf->new_from_file($file);
        if ($pixBuffer) {

            # Also create a scaled 16x16 copy of the icon for display in a simple list
            $pixBuffer2 = Gtk3::Gdk::Pixbuf->new_from_file_at_scale(
                $file,
                16, 16,     # Width, height
                1,          # Scaled
            );
        }

        if ($pixBuffer && $pixBuffer2) {

            # If this session's chat contact is in our contacts list, update their stored icons
            #   (actually, it's the pixbufs which are stored)
            if ($self->chatContactObj) {

                $self->chatContactObj->ivPoke('lastIcon', $pixBuffer);
                $self->chatContactObj->ivPoke('lastIconScaled', $pixBuffer2);
            }

            # Store our own copies
            $self->ivPoke('remoteIcon', $pixBuffer);
            $self->ivPoke('remoteIconScaled', $pixBuffer2);
        }

        # Delete the temporary file
        unlink $file;

        return 1;
    }

    sub processStatus {

        # Called by $self->dispatchCmd in response to the 'STATUS' opcode
        # Processes the chat contact's status sent during a zChat session
        #
        # Expected arguments
        #   $data       - The status sent (see comments below)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Local variables
        my $remoteStatus;

        # Check for improper arguments
        if (! defined $data || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processStatus', @_);
        }

        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # Used to broadcast your current status to all your connections. Data is a single byte, with
        #   the value:
        #   0 - no status
        #   1 - Normal status
        #   2 - InActive status
        #   3 - AFK status
        # Whenever the status of the chat client changes, this command is broadcast to all chat
        #   connections. Normal means the user is actively using the chat client.  InActive means
        #   that the user is using some other program on their system and the chat client is not
        #   necessarily visible.  AFK means that no keyboard or mouse activity has been detected in
        #   a while and the user is probably away from the computer
        $remoteStatus = ord($data);

        if (! $self->remoteStatus) {

            # Don't do anything if the status is 'no status', and don't bother displaying a message
            #   at the start of the connection to show that the chat peer's status is 'Normal'
            if ($remoteStatus == 2) {

                $self->writeText(
                    $self->remoteName . '\'s status is \'inactive\'',
                    'remote',
                );

            } elsif ($remoteStatus == 3) {

                $self->writeText(
                    $self->remoteName . '\'s status is \'away from keys\'',
                    'remote',
                );
            }

        } else {

            # Don't do anything if the status is 'no status'
            if ($remoteStatus == 1) {

                $self->writeText(
                    $self->remoteName . '\'s status is now \'normal\'',
                    'remote',
                );

            } elsif ($remoteStatus == 2) {

                $self->writeText(
                    $self->remoteName . '\'s status is now \'inactive\'',
                    'remote',
                );

            } elsif ($remoteStatus == 3) {

                $self->writeText(
                    $self->remoteName . '\'s status is now \'away from keys\'',
                    'remote',
                );
            }
        }

        # Update the IV
        $self->ivPoke('remoteStatus', $remoteStatus);

        return 1;
    }

    sub relayRemoteCmd {

        # Called when a SEND_COMMAND message is received
        #
        # Expected arguments
        #   $data   - The command to send
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $data, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->relayRemoteCmd', @_);
        }

        # (from http://www.zuggsoft.com/zchat/zchatprot.htm)
        # Used to send a zMUD command line to another user.  The Data field is the entire zMUD
        #   command line.  The Receiver of this command can choose to execute the command line, or
        #   it can send a Message denying the request if commands are not allowed from this user.
        if (! $self->allowRemoteCmdFlag) {

            $self->sendCmd(
                $self->ivShow('constOptHash', 'MESSAGE'),
                $self->localName . ' does not currently accept remote commands.',
            );

            $self->writeText(
                'Ignored a remote command sent by ' . $self->remoteName,
                'system',
            );

        } else {

            # Send the command to the world
            $self->session->worldCmd($data);

            # If the chat contact is not snooping us, send a confirmation
            if (! $self->isSnoopedFlag) {

                $self->sendCmd(
                    $self->ivShow('constOptHash', 'MESSAGE'),
                    'Used command \'' . $data . '\'',
                );
            }

            # Display a local confirmation, regardless
            $self->writeText(
                'Received command: ' . $data,
                'remote',
            );
        }

        return 1;
    }

    sub stripAnsi {

        # Called when $self->processTextMsg, ->displayRemoteMsg and ->processSnoopedData
        # Strips escape sequences from a string received during the chat session. Returns the
        #   stripped string
        #
        # Expected arguments
        #   $string     - A string, possibly containing escape sequences
        #
        # Return values
        #   'undef' on improper arguments
        #   The stripped string, otherwise

        my ($self, $string, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stripAnsi', @_);
        }

        # Use the same regex as GA::Session->extractANSISequences uses
        $string =~ s/(\x1b\x5b.*?[HfABCDsuJKmhIp])//g;

        return $string;
    }

    ##################
    # Response methods

    sub entryCallback {

        # Usually called by ->signal_connect in GA::Strip::Entry->setEntrySignals or in
        #   GA::Table::Entry->setActivateEvent, when the user types something in the strip/table
        #   object's Gtk3::Entry and presses RETURN
        #
        # Text that begins with the client command sigil ';' is treated as a Chat task command, e.g.
        #   ;ping
        #   ;snoop
        # (All Chat task commands are stored as keys in $self->cmdHash)
        #
        # Chat messages can start with an initial ' character, emotes can start with an initial ':'
        #   character and remote commands can start with an initial '#' character
        # Otherwise, the text entered is treated as if it were preceded by ;chat, ;emote or ;cmd
        #   (depending on the value of $self->entryMode)
        #
        # Expected arguments
        #   $obj        - The strip or table object whose Gtk3::Entry was used
        #   $entry      - The Gtk3::Entry itself
        #
        # Optional arguments
        #   $id         - A value passed to the table object that identifies the particular
        #                   Gtk3::Entry used (in case the table object uses multiple entries). By
        #                   default, $self->openWin sets $id to the same as $self->uniqueName;
        #                   could be an 'undef' value otherwise
        #   $text       - The text typed in the entry by the user (should not be 'undef')
        #
        # Return values
        #   'undef' on improper arguments or if an unrecognised Chat command is used
        #   Otherwise, returns the result of the function called in response

        my ($self, $obj, $entry, $id, $text, $check) = @_;

        # Local variables
        my (
            $smileyFlag, $sigilFlag, $initChar, $inputString, $cmd, $argumentString, $method,
            @inputWord,
        );

        # Check for improper arguments
        if (! defined $obj || ! defined $entry || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->entryCallback', @_);
        }

        # First thing is to remove the command sigil, if there is one. $inputString is set to the
        #   value of $text, minus the command sigil
        # Exception: if $text begings with a smiley, treat it as a smiley, not as a sigil
        OUTER: foreach my $smiley ($axmud::CLIENT->ivKeys('chatSmileyHash')) {

            if (substr($text, 0, length($smiley)) eq $smiley) {

                $smileyFlag = TRUE;
                last OUTER;
            }
        }

        if (! $smileyFlag) {

            $initChar = substr($text, 0, 1);
            if ($initChar eq ';' || $initChar eq '\'' || $initChar eq ':' || $initChar eq '#') {

                $inputString = substr($text, 1);
                $sigilFlag = TRUE;

            } else {

                # It's not a Chat task command
                $inputString = $text;
            }

        } else {

            $inputString = $text;
        }

        if ($sigilFlag) {

            if ($initChar eq ';') {

                # Parse the Chat task command into a list of distinct words (or groups of words),
                #   exactly as it would be parsed if it were typed as a client command in the 'main'
                #   window
                @inputWord = $self->session->parseCmd($inputString);

                # Remember the actual Chat task command typed - and make it lower-case
                $cmd = lc(shift (@inputWord));

                # Remember everything EXCEPT the actual command typed
                $argumentString = substr($inputString, length($cmd));
                # Trim whitespace from the beginning of that string, so that the first letter of
                #   $argumentString is the first letter of the first word typed, after the command
                #   itself
                $argumentString =~ s/^\s+//;

                # If the command matches one in $self->cmdHash, call the corresponding function.
                # If it doesn't, do nothing
                if ($self->ivExists('cmdHash', $cmd)) {

                    $method = $self->ivShow('cmdHash', $cmd);

                    return $self->$method($argumentString);

                } else {

                    # Not a valid chat command
                    $self->writeText(
                        'Unrecognised chat command \'' . $cmd . '\' (try \';help\')',
                        'system',
                    );

                    return undef;
                }

            } elsif ($initChar eq '\'') {

                # Send the entered text as chat
                return $self->chat($inputString);

            } elsif ($initChar eq ':') {

                # Send the entered text as an emote
                return $self->emote($inputString);

            } elsif ($initChar eq '#') {

                # Send the entered text as a remote command
                return $self->sendRemoteCmdd($inputString);
            }

        } else {

            # Otherwise, the entered text is treated as chat, an emote or a remote command,
            #   depending on the value of $self->entryMode
            if ($self->entryMode eq 'emote') {

                # Send the entered text as an emote
                return $self->emote($inputString);

            } elsif ($self->entryMode eq 'cmd') {

                # Send the entered text as a remote command
                return $self->sendRemoteCmd($inputString);

            } else {

                # Entry mode 0 - send the entered text as chat
                return $self->chat($inputString);
            }
        }

        return 1;
    }

    sub hookCallback {

        # Called by GA::Session->checkHooks
        #
        # This task's ->resetHooks function creates some hooks which fire when text is received from
        #   the world, and when a command is sent to it. The hooks are only enabled during a
        #   snooping session.
        # This function passes the received text or the command to the chat contact. The interface's
        #   ->propertyHash is not used
        #
        # Expected arguments (standard args from GA::Session->checkHooks)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active hook interface that fired
        #   $hookEvent      - The hook event, which should be 'receive_text' or 'send_cmd'
        #
        # Optional arguments
        #   $hookVar       - The line of text received, or the command sent. An optional argument
        #                       in the call from GA::Session->checkHooks, but never set to 'undef'
        #                       for this task's hooks
        #   $hookVal        - A second optional argument in the call from GA::Session->checkHooks;
        #                       always set to 'undef' for this task's hooks
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $hookEvent, $hookVar, $hookVal, $check) = @_;

        # Local variables
        my ($obj, $opCode);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $hookEvent || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->hookCallback', @_);
        }

        # Basic check - the hook should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired hook

        # Import the opcode for 'SNOOP_DATA'
        $opCode = $self->ivShow('constOptHash', 'SNOOP_DATA');

        # Look for running Chat tasks
        foreach my $taskObj ($self->session->ivValus('currentTaskHash')) {

            if (
                $taskObj->name eq 'chat_task'
                && $taskObj->status eq 'running'       # i.e. not paused
                && ! $taskObj->connectionClosedFlag
                && $taskObj->isSnoopedFlag
            ) {
                $taskObj->sendCmd(
                    $opCode,
                    $hookVar,  # Text received, or command sent
                );
            }
        }

        return 1;
    }

    sub timerCallback {

        # Called by GA::Session->checkTimers
        #
        # This task's ->resetTimers function creates a timer which fires periodically.
        # Every time the timer fires, this function checks whether the user is idle, and takes
        #   action if so
        #
        # Expected arguments (standard args from GA::Session->checkTimers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active timer interface that fired
        #   $actualTime     - The time (matches GA::Session->sessionTime) at which the timer
        #                       actually fired, in seconds
        #   $dueTime        - The time (matches GA::Session->sessionTime) at which the timer was
        #                       due to fire, in seconds
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $actualTime, $dueTime, $check) = @_;

        # Local variables
        my (
            $obj, $idleTime,
            @taskList,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $actualTime
            || ! defined $dueTime || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->timerCallback', @_);
        }

        # Basic check - the timer should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired timer

        # Check how long the user has been idle, in seconds
        if (defined $self->session->lastInstructTime) {

            $idleTime = int($self->session->sessionTime - $self->session->lastInstructTime);

        } else {

            # No instruction executed yet
            $idleTime = int($self->session->sessionTime);
        }

        # If it's a zChat session and the user's status is 'Normal'...
        if ($self->localStatus == 1) {

            # If the user is idle longer than the allowed time, send a new status to all connections
            if ($idleTime > $self->idleSeconds) {

                @taskList = $self->findAllTasks();
                foreach my $taskObj (@taskList) {

                    # Broadcast the change to all Chat tasks with active connections
                    if ($taskObj->sessionFlag) {

                        $taskObj->ivPoke('localStatus', 3);

                        $taskObj->broadcast(
                            $self->ivShow('constOptHash', 'STATUS'),
                            chr(3),     # 'AFK'
                        );

                        $taskObj->writeText(
                            'Your status is now \'away from keys\'',
                            'system',
                        );
                    }
                }
            }

        # If it's a zChat session and the user's status is already 'AFK'...
        } elsif ($self->localStatus == 3) {

            # If the user's idle time is less than the timer's own delay time, then the user has
            #   interacted with the 'main' window since we last checked
            # NB $self->sendCmd() also performs this check
            if ($idleTime <= $self->idleCheckTime) {

                @taskList = $self->findAllTasks();
                foreach my $taskObj (@taskList) {

                    $taskObj->ivPoke('localStatus', 1);

                    # Broadcast the change to all Chat tasks with active connections
                    if ($taskObj->sessionFlag) {

                        $taskObj->broadcast(
                            $self->ivShow('constOptHash', 'STATUS'),
                            chr(1),     # 'Normal'
                        );

                        $taskObj->writeText(
                            'Your status is now \'normal\'',
                            'system',
                        );
                    }
                }
            }
        }

        return 1;
    }

    ##################
    # Accessors - set

    sub del_winObj {

        # Called by GA::Win::Generic->winDestroy
        #
        # This task's behaviour is unusual because there can be a 'lead' Chat task and any number
        #   of other Chat tasks), so we can't use the default ->del_winObj function
        # When this Chat task is the lead Chat task, it can both start and stop without a task
        #   window. Otherwise, when the user manually closes the window, the task must halt

        my ($self, $winObj, $check) = @_;

        # Local variables
        my $stripObj;

        # Check for improper arguments
        if (! defined $winObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_winObj', @_);
        }

        # Do nothing if the task already knows the window is closed, or if the task is shutting down
        #   anyway
        if ($self->winObj && $self->winObj eq $winObj && ! $self->shutdownFlag) {

            # Mark the window as closed
            $self->ivUndef('winObj');
            $self->ivPoke('taskWinFlag', FALSE);
            $self->ivPoke('taskWinEntryFlag', FALSE);
            $self->ivUndef('defaultTabObj');

            # For pseudo-windows, this function should probably not be called; nevertheless, if the
            #   parent table object is still open, close it
            if ($winObj->pseudoWinTableObj) {

                $stripObj = $winObj->pseudoWinTableObj->stripObj;
                if ($stripObj->ivExists('tableObjHash', $winObj->pseudoWinTableObj->number)) {

                    $stripObj->removeTableObj($winObj->pseudoWinTableObj);
                }
            }

            if (! $self->leadTaskFlag) {

                # This is not the lead Chat task, so halt the task
                $self->ivPoke('shutdownFlag', TRUE);
                $self->writeText(
                    'Window for \'' . $self->prettyName . '\' task closed (task will also halt)',
                );

            } else {

                # The task can continue, in order to listen for incoming connections
                if ($self->sessionFlag) {

                    $self->terminateChatSession();

                    if ($self->remoteName) {

                        $self->writeText(
                            'Chat session with ' . $self->remoteName . ' closed (task will'
                            . ' continue)',
                        );

                    } else {

                        $self->writeText(
                            'Chat session closed (task will continue)',
                        );
                    }

                } else {

                    $self->writeText(
                        'Window for \'' . $self->prettyName . '\' task closed (task will continue)',
                    );
                }
            }
        }

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub leadTaskFlag
        { $_[0]->{leadTaskFlag} }
    sub sessionFlag
        { $_[0]->{sessionFlag} }
    sub connectionClosedFlag
        { $_[0]->{connectionClosedFlag} }
    sub chatContactObj
        { $_[0]->{chatContactObj} }

    sub ip
        { $_[0]->{ip} }
    sub port
        { $_[0]->{port} }
    sub acceptSocket
        { $_[0]->{acceptSocket} }
    sub acceptID
        { $_[0]->{acceptID} }
    sub incomingTimeout
        { $_[0]->{incomingTimeout} }
    sub outgoingTimeout
        { $_[0]->{outgoingTimeout} }

    sub clientSocket
        { $_[0]->{clientSocket} }
    sub chatType
        { $_[0]->{chatType} }
    sub ioWatchID
        { $_[0]->{ioWatchID} }
    sub timeoutID
        { $_[0]->{timeoutID} }
    sub pendingOpCode
        { $_[0]->{pendingOpCode} }
    sub lastInvalidOpCode
        { $_[0]->{lastInvalidOpCode} }
    sub pendingDataSize
        { $_[0]->{pendingDataSize} }
    sub dataBuffer
        { $_[0]->{dataBuffer} }
    sub defaultEncoding
        { $_[0]->{defaultEncoding} }
    sub encoding
        { $_[0]->{encoding} }

    sub receiveHook
        { $_[0]->{receiveHook} }
    sub sendHook
        { $_[0]->{sendHook} }
    sub idleTimer
        { $_[0]->{idleTimer} }
    sub idleCheckTime
        { $_[0]->{idleCheckTime} }
    sub idleSeconds
        { $_[0]->{idleSeconds} }

    sub acceptCallsOnInitFlag
        { $_[0]->{acceptCallsOnInitFlag} }
    sub acceptCallsOnInitPort
        { $_[0]->{acceptCallsOnInitPort} }
    sub makeCallOnInitFlag
        { $_[0]->{makeCallOnInitFlag} }
    sub makeCallOnInitType
        { $_[0]->{makeCallOnInitType} }
    sub makeCallOnInitIP
        { $_[0]->{makeCallOnInitIP} }
    sub makeCallOnInitPort
        { $_[0]->{makeCallOnInitPort} }
    sub receiveCallOnInitFlag
        { $_[0]->{receiveCallOnInitFlag} }
    sub receiveCallOnInitSocket
        { $_[0]->{receiveCallOnInitSocket} }

    sub localName
        { $_[0]->{localName} }
    sub localIconFile
        { $_[0]->{localIconFile} }
    sub localEmail
        { $_[0]->{localEmail} }
    sub localGroup
        { $_[0]->{localGroup} }
    sub localStatus
        { $_[0]->{localStatus} }
    sub localZChatStamp
        { $_[0]->{localZChatStamp} }

    sub remoteIcon
        { $_[0]->{remoteIcon} }
    sub remoteIconScaled
        { $_[0]->{remoteIconScaled} }
    sub remoteName
        { $_[0]->{remoteName} }
    sub remoteIP
        { $_[0]->{remoteIP} }
    sub remotePort
        { $_[0]->{remotePort} }
    sub remoteEmail
        { $_[0]->{remoteEmail} }
    sub remoteStamp
        { $_[0]->{remoteStamp} }
    sub remoteVersion
        { $_[0]->{remoteVersion} }
    sub remoteStatus
        { $_[0]->{remoteStatus} }
    sub remoteZChatID
        { $_[0]->{remoteZChatID} }

    sub allowSnoopFlag
        { $_[0]->{allowSnoopFlag} }
    sub isSnoopedFlag
        { $_[0]->{isSnoopedFlag} }
    sub snooperCount
        { $_[0]->{snooperCount} }
    sub publicConnectionFlag
        { $_[0]->{publicConnectionFlag} }
    sub allowRemoteCmdFlag
        { $_[0]->{allowRemoteCmdFlag} }
    sub entryMode
        { $_[0]->{entryMode} }
    sub servingFlag
        { $_[0]->{servingFlag} }

    sub pingTime
        { $_[0]->{pingTime} }
    sub pingStamp
        { $_[0]->{pingStamp} }
    sub fileDir
        { $_[0]->{fileDir} }
    sub fileName
        { $_[0]->{fileName} }
    sub fileHandle
        { $_[0]->{fileHandle} }
    sub fileTotalSize
        { $_[0]->{fileTotalSize} }
    sub fileSize
        { $_[0]->{fileSize} }

    sub chatOutColour
        { $_[0]->{chatOutColour} }
    sub chatInColour
        { $_[0]->{chatInColour} }
    sub chatEchoColour
        { $_[0]->{chatEchoColour} }
    sub systemColour
        { $_[0]->{systemColour} }
    sub remoteColour
        { $_[0]->{remoteColour} }
    sub snoopColour
        { $_[0]->{snoopColour} }
    sub allSystemColourFlag
        { $_[0]->{allSystemColourFlag} }
    sub allowSmileyFlag
        { $_[0]->{allowSmileyFlag} }
    sub smileySizeFactor
        { $_[0]->{smileySizeFactor} }

    sub localPublicKey
        { $_[0]->{localPublicKey} }
    sub localSecretKey
        { $_[0]->{localSecretKey} }
    sub remotePublicKey
        { $_[0]->{remotePublicKey} }

    sub constOptHash
        { my $self = shift; return %{$self->{constOptHash}}; }
    sub restrictedHash
        { my $self = shift; return %{$self->{restrictedHash}}; }
    sub cmdHash
        { my $self = shift; return %{$self->{cmdHash}}; }

    sub helpList
        { my $self = shift; return @{$self->{helpList}}; }
}

{ package Games::Axmud::Task::Compass;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Compass task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'compass_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'compass_task';
        $self->{prettyName}             = 'Compass';
        $self->{shortName}              = 'Co';
        $self->{shortCutIV}             = 'compassTask';    # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Creates a task window with a clickable compass';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pseudo', 'grid'];
        $self->{winmap}                 = 'basic_empty';
        $self->{winUpdateFunc}          = 'createWidgets';
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # Flag set to TRUE if the task window is enabled (this task sends a direction or a world
        #   command when a keypad key is pressed); set to FALSE when they are disabled (keypad keys
        #   behave normally)
        # If this flag is TRUE when the task starts, the task window is automatically enabled
        $self->{enabledFlag}            = TRUE;

        # The table objects (GA::Table::Button) created for each Gtk3::Button
        # A hash so we can work out which compass button was pressed, in the form
        #   $compassHash{table_object} = standard_primary_direction
        $self->{compassHash}            = {};
        # A similar hash for the other buttons (which produce commands like 'kill all' or 'look'),
        #   in the form
        #   $cmdHash{table_object} = command_string
        $self->{cmdHash}                = {};
        # A second hash for these other buttons, linking the Gtk3::Button object to an Axmud
        #   standard keycode. Hash in the form
        #   $hash{standard_keycode} = table_object
        $self->{cmdKeycodeHash}         = {};

        # Table objects created for various Gtk3 widgets, stored here because various functions
        #   need them
        $self->{radioTableObj}          = undef;        # GA::Table::RadioButton
        $self->{radioTableObj2}         = undef;        # GA::Table::RadioButton
        $self->{comboTableObj}          = undef;        # GA::Table::ComboBox

        # A list of the independent macro interfaces created, one for each button on the keypad
        #   (e.g. so that pressing '7' on the keypad sends the command 'northwest')
        $self->{macroList}              = [],
        # A hash of Axmud interfaces for independent macros matching the non-compass buttons only,
        #   in the form
        #   $macroHash{standard_keycode} = interface_object
        $self->{macroHash}              = {},

        # When keypad macros are turned on, which standard keycode corresponds to which standard
        #   direction. A hash in the form
        #   $keypadDirHash{standard_keycode} = standard_direction
        # In order to disable a particular keycode, set the corresponding standard direction to
        #   'undef'. Do not delete the key-value pair from the hash and don't add extra keys. For
        #   example:
        #   ->keypadDirHash{'kp_1'} = 'undef'
        $self->{keypadDirHash}          = {
            'kp_8'                      => 'north',
            'kp_9'                      => 'northeast',
            'kp_6'                      => 'east',
            'kp_3'                      => 'southeast',
            'kp_2'                      => 'south',
            'kp_1'                      => 'southwest',
            'kp_4'                      => 'west',
            'kp_7'                      => 'northwest',
            'kp_add'                    => 'up',
            'kp_subtract'               => 'down',
        };
        # Other keypad keys correspond to some common commands like 'inventory'. Here, we use a hash
        #   in the form
        #   $keypadCmdHash{standard_keycode} = command_string
        # In order to disable a particular keycode, set the corresponding command string to 'undef'.
        #   Do not delete the key-value pair from the hash and don't add extra keys. For example:
        #   ->keypadCmdHash{'kp_enter'} = 'undef'
        #
        # command_string is processed in the following way:
        # First, the string is split into a list, using a comma as the delimiter
        #   e.g. 'kill,victim,mall' => ('kill', 'victim', 'mall')
        #   e.g. 'amuse orc'    => ('amuse orc')
        #
        # Now, if the FIRST item on the list is a standard command - one of those specified by the
        #   command cages - it is treated like a standard command. The remaining items on the list
        #   are in pairs, in the form
        #   ('word', 'replacement', 'word', 'replacement')
        # The command cage is expecting (by default) a string like 'kill victim' - this is
        #   interpolated into 'kill mall' ('victim' is the 'word', and 'mall' is the 'replacement'
        #
        # If the FIRST item on the list is not a standard command, then the whole command_string is
        #   sent to the world
        #   e.g. 'kill,victim,mall' => 'kill mall' is sent to the world, because 'kill' is a
        #                                   standard world command (as specified by the command
        #                                   cages)
        #   e.g. 'amuse orc'        => 'amuse orc' is sent to the world, uninterpolated, because
        #                                   'amuse' is not a standard world command
        $self->{keypadCmdHash}          = {
            # 'kill', 'inventory', 'sc', 'look', 'get' and 'loot' are all standard world commands in
            #   the command cages
            'kp_0'                      => ';kill',
            'kp_5'                      => 'look',
            'kp_divide'                 => 'inventory',
            'kp_multiply'               => 'sc',                # Brief score
            'kp_full_stop'              => 'get,object,all',
            'kp_enter'                  => 'loot_room',
        };
        # A hash to link each standard keycode used to the button actually pressed
        #   (e.g. links 'kp_multiply' to '* key')
        # In each key-value pair, the key is a standard keycode, the corresponding value is a hint
        #   displayed as a tooltip for each button
        # The hash is in the form
        #   $keypadHintHash{standard_keycode} = hint
        $self->{keypadHintHash}         = {
            'kp_0'                      => '0 key',
            'kp_1'                      => '1 key',
            'kp_2'                      => '2 key',
            'kp_3'                      => '3 key',
            'kp_4'                      => '4 key',
            'kp_5'                      => '5 key',
            'kp_6'                      => '6 key',
            'kp_7'                      => '7 key',
            'kp_8'                      => '8 key',
            'kp_9'                      => '9 key',
            'kp_add'                    => '+ key',
            'kp_subtract'               => '- key',
            'kp_multiply'               => '* key',
            'kp_divide'                 => '/ key',
            'kp_full_stop'              => '. key',
            'kp_enter'                  => 'ENTER key',
        };

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{enabledFlag}           = $self->enabledFlag;
        $clone->{compassHash}           = {$self->compassHash};
        $clone->{cmdHash}               = {$self->cmdHash};
        $clone->{cmdKeycodeHash}        = {$self->cmdKeycodeHash};

        $clone->{radioTableObj}         = $self->radioTableObj;
        $clone->{radioTableObj2}        = $self->radioTableObj2;
        $clone->{comboTableObj}         = $self->comboTableObj;

        $clone->{macroList}             = [$self->macroList];
        $clone->{macroHash}             = {$self->macroHash};

        $clone->{keypadDirHash}         = {$self->keypadDirHash};
        $clone->{keypadCmdHash}         = {$self->keypadCmdHash};
        $clone->{keypadHintHash}        = {$self->keypadHintHash};

        # Cloning complete
        return $clone;
    }

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # Set up the keyboard macros
        $self->resetMacros();

        # If the task has recently been updated, quickest way to update the window is to remove all
        #   the current table objects, before creating new ones
        if ($self->hasResetFlag) {

            if (! defined $self->winObj->tableStripObj->removeAllTableObjs()) {

                # Operation failed; task must close
                $self->ivPoke('shutdownFlag', TRUE);
                return 1;
            }
        }

        # Draw widgets for the task window
        $self->createWidgets();

        # If the task window should start enabled, then enable it (by pseudo-clicking the second
        #   radio button)
        if ($self->enabledFlag) {

            $self->enable();
        }

        return 1;
    }

    sub doShutdown {

        # Called just before the task completes a shutdown
        # For process tasks, called by $self->main. For activity tasks, called by $self->shutdown
        #
        # Destroys any macros created by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doShutdown', @_);
        }

        # Remove macros, ready for $self->resetMacros to be called to create new ones
        foreach my $interfaceObj ($self->macroList) {

            # The TRUE argument means 'don't show an error message if the interface doesn't exist'
            $self->session->deleteInterface($interfaceObj->name, TRUE);
        }

        return 1;
    }

    sub doReset {

        # Called just before the task completes a reset
        # For process tasks, called by $self->main. For activity tasks, called by $self->reset
        #
        # Destroys any macros created by this task
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # Remove macros, ready for $self->resetMacros to be called to create new ones
        foreach my $interfaceObj ($self->macroList) {

            # The TRUE argument means 'don't show an error message if the interface doesn't exist'
            $self->session->deleteInterface($interfaceObj->name, TRUE);
        }

        return 1;
    }

    sub resetMacros {

        # Called by $self->doInit to set up macros that fire when the user presses one of the keys
        #   on their keypad
        #
        # Expected arguments
        #   (none)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $dictObj,
            @macroList,
            %dirHash, %cmdHash, %macroHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetMacros', @_);
        }

        # Import the hash of standard directions (for quick lookup)
        %dirHash = $self->keypadDirHash;
        # Import the hash of keyboard commands (for quick lookup)
        %cmdHash = $self->keypadCmdHash;
        # Import the current dictionary
        $dictObj = $self->session->currentDict;

        # Create one macro (initially disabled) for each standard primary direction
        OUTER: foreach my $standardKeycode (keys %dirHash) {

            my ($standardDir, $interfaceObj);

            $standardDir = $dirHash{$standardKeycode};

            # If the value in a key-value pair is 'undef', the corresponding key (a standard
            #   keycode) isn't available to this task
            if (defined $standardDir) {

                # Create the independent macro interface
                $interfaceObj = $self->session->createIndependentInterface(
                    'macro',
                    $standardKeycode,                                   # Stimulus
                    $dictObj->ivShow('primaryDirHash', $standardDir),   # Response
                    'enabled',
                    0,
                );

                if (! $interfaceObj) {

                    $self->writeWarning(
                        'Couldn\'t create macro for the standard keycode \'' . $standardKeycode
                        . '\'',
                        $self->_objClass . '->resetMacros',
                    );

                    last OUTER;

                } else {

                    push (@macroList, $interfaceObj);
                }
            }
        }

        # Create one macro (initially disabled) for each command mentioned in
        #   $self->keypadCmdHash
        OUTER: foreach my $standardKeycode (keys %cmdHash) {

            my ($cmdString, $cmd, $interfaceObj);

            $cmdString = $cmdHash{$standardKeycode};

            # If the value in a key-value pair is 'undef', the corresponding key (a standard
            #   keycode) isn't available to this task
            if (defined $cmdString) {

                # $cmdString is in the form 'sharpen axe' (for a command to be sent unaltered), or
                #   in the form 'kill,victim,axe' (for a command to be interpolated)
                # Convert the string into the actual command to be sent (an empty string is an
                #   acceptable return value, in which case we don't create a macro so that no
                #   command is sent when the user presses the corresponding key)
                if ($cmdString =~ m/,/) {

                    $cmd = $self->session->prepareCmd(split(m/,/, $cmdString));
                }

                if (! defined $cmd) {

                    # Couldn't interpolate the command, so use the original string unaltered
                    $cmd = $cmdString;
                }

                if ($cmd) {

                    # Create the independent macro interface
                    $interfaceObj = $self->session->createIndependentInterface(
                        'macro',
                        $standardKeycode,       # Stimulus
                        $cmd,                   # Response
                        'enabled',
                        0,
                    );

                    if (! $interfaceObj) {

                        $self->writeWarning(
                            'Couldn\'t create macro for the standard keycode \'' . $standardKeycode
                            . '\'',
                            $self->_objClass . '->resetMacros',
                        );

                        last OUTER;

                    } else {

                        push (@macroList, $interfaceObj);
                        $macroHash{$standardKeycode} = $interfaceObj;
                    }
                }
            }
        }

        # Update IVs
        $self->ivPoke('macroList', @macroList);
        $self->ivPoke('macroHash', %macroHash);

        return 1;
    }

    sub createWidgets {

        # Set up the widgets used in the task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $cmd, $radioTableObj, $radioTableObj2, $comboTableObj,
            @list, @comboList,
            %dirHash, %abbrevHash, %keypadHintHash, %keypadCmdHash, %compassHash, %cmdHash,
            %cmdKeycodeHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->createWidgets', @_);
        }

        # If the task window (a 'grid' window or a pseudo-window inside the session's 'main' window)
        #   isn't open, don't need to create widgets
        if (! $self->winObj) {

            return undef;
        }

        # Import the current dictionary's primary directions
        %dirHash = $self->session->currentDict->primaryDirHash;
        %abbrevHash = $self->session->currentDict->primaryAbbrevHash;
        # Import some local hashes
        %keypadHintHash = $self->keypadHintHash;
        %keypadCmdHash = $self->keypadCmdHash;

        # Reduce the standard spacing between table objects (5 pixels around each button is too
        #   much)
        $self->winObj->tableStripObj->changeSpacing(2);

        # Create buttons for each of the ten main primary directions (ignoring 'northnorthwest',
        #   etc), and store the buttons in a hash
        @list = (
            'northwest', 'kp_7', 0, 9, 0, 11,
            'north', 'kp_8', 10, 19, 0, 11,
            'northeast', 'kp_9', 20, 29, 0, 11,
            'west', 'kp_4', 0, 9, 12, 23,
            'east', 'kp_6', 20, 29, 12, 23,
            'southwest', 'kp_1', 0, 9, 24, 35,
            'south', 'kp_2', 10, 19, 24, 35,
            'southeast', 'kp_3', 20, 29, 24, 35,
            'up', 'kp_subtract', 0, 14, 36, 47,
            'down', 'kp_add', 15, 29, 36, 47,
        );

        do {

            my ($dir, $keycode, $left, $right, $top, $bottom, $hint, $tooltips, $thisTableObj);

            $dir = shift @list;
            $keycode = shift @list;
            $left = shift @list;
            $right = shift @list;
            $top = shift @list;
            $bottom = shift @list;

            $hint = $keypadHintHash{$keycode};

            if ($dirHash{$dir}) {
                $tooltips = 'Click to go ' . $dirHash{$dir} . ' (' . $hint . ')';
            } else {
                $tooltips = 'Direction not set (' . $hint . ')';
            }

            $thisTableObj = $self->winObj->tableStripObj->addTableObj(
                'Games::Axmud::Table::Button',
                $left, $right, $top, $bottom,
                undef,
                # Init settings
                'func'                      => $self->getMethodRef('compassCallback'),
                'id'                        => $keycode,
                'text'                      => $self->shortenText($abbrevHash{$dir}, 3),
                'tooltips'                  => $tooltips,
            );

            $compassHash{$thisTableObj} = $dir;

        } until (! @list);

        # Create buttons for other commands, using the remaining keypad keys
        @list = (
            'kp_divide', 30, 44, 36, 47,
            'kp_multiply', 45, 59, 36, 47,
            'kp_0', 0, 14, 48, 59,
            'kp_full_stop', 15, 29, 48, 59,
            'kp_enter', 30, 44, 48, 59,
            'kp_5', 45, 59, 48, 59,
        );

        do {

            my (
                $keycode, $left, $right, $top, $bottom, $original, $cmd, $text, $hint, $tooltips,
                $thisTableObj,
            );

            $keycode = shift @list;
            $left = shift @list;
            $right = shift @list;
            $top = shift @list;
            $bottom = shift @list;

            $original = $keypadCmdHash{$keycode};
            if ($original =~ m/,/) {

                $cmd = $self->session->prepareCmd(split(m/,/, $original));
            }

            if (! defined $cmd) {

                # Command can't be interpolated, so use the original
                $cmd = $original;
            }

            $hint = $keypadHintHash{$keycode};

            if ($cmd) {
                $tooltips = 'Click to send \'' . $cmd . '\' (' . $hint . ')';
            } else {
                $tooltips = 'Command not set (' . $hint . ')';
            }

            $thisTableObj = $self->winObj->tableStripObj->addTableObj(
                'Games::Axmud::Table::Button',
                $left, $right, $top, $bottom,
                undef,
                # Init settings
                'func'                      => $self->getMethodRef('cmdCallback'),
                'id'                        => $keycode,
                'text'                      => $self->shortenText($cmd, 12),
                'tooltips'                  => $tooltips,
            );

            $cmdHash{$thisTableObj} = $cmd;
            $cmdKeycodeHash{$keycode} = $thisTableObj;

        } until (! @list);

        # Store all the buttons created, so that $self->moveCallback and ->cmdCallback can work
        #   out which Gtk3::Button corresponds to which command
        $self->ivPoke('compassHash', %compassHash);
        $self->ivPoke('cmdHash', %cmdHash);
        $self->ivPoke('cmdKeycodeHash', %cmdKeycodeHash);

        # Create two radiobuttons to enable/disable the use of the keypad
        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Label',
            30, 39, 0, 11,
            undef,
            # Init settings
            'text'          => 'Keypad',
            'align_y'       => 0.5,
        );

        $radioTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::RadioButton',
            40, 49, 0, 11,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('radioButtonCallback'),
            'id'            => 'button_1',
            'text'          => 'Disable',
            'select_flag'   => FALSE,
        );

        $radioTableObj2 = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::RadioButton',
            50, 59, 0, 11,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('radioButtonCallback'),
            'id'            => 'button_2',
            'text'          => 'Enable',
            'select_flag'   => FALSE,
            'group'         => $radioTableObj->group,
        );

        # Create a combobox and an entry so that the user can set new values in $self->keypadCmdHash
        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Label',
            30, 39, 12, 23,
            undef,
            # Init settings
            'text'      => 'Keycode',
        );

        @comboList = (
            'kp_0',
            'kp_5',
            'kp_multiply',
            'kp_divide',
            'kp_full_stop',
            'kp_enter',
        );
        $comboTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::ComboBox',
            40, 59, 12, 23,
            undef,
            # Init settings
            'title'     => 'Select keycode',
            'list_ref'  => \@comboList,
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Label',
            30, 39, 24, 35,
            undef,
            # Init settings
            'text'      => 'Command',
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Entry',
            40, 59, 24, 35,
            undef,
            # Init settings
            'func'      => $self->getMethodRef('miniEntryCallback'),
        );

        # Store the table objects for the benefit of the callback functions
        $self->ivPoke('radioTableObj', $radioTableObj);
        $self->ivPoke('radioTableObj2', $radioTableObj2);
        $self->ivPoke('comboTableObj', $comboTableObj);

        # Display all the widgets
        $self->winObj->winShowAll($self->_objClass . '->createWidgets');

        return 1;
    }

    sub shortenText {

        # Called by $self->createWidgets and ->entryCallback
        # If the buttons are assigned very long labels, the task window gets stretched. Prevent this
        #   (hopefully) but limiting the size of the text assigned to button labels
        # Also converts the modified text to upper-case characters, before returning it
        #
        # Expected arguments
        #   $text   - Some text representing a command
        #   $max    - The maximum size of the text (minimum 3)
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, the shortened $text

        my ($self, $text, $max, $check) = @_;

        # Check for improper arguments
        if (! defined $text || ! defined $max || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->shortenText', @_);
        }

        if (length ($text) > $max) {

            $text = substr($text, 0, $max) . '...';
        }

        return uc($text);
    }

    sub enable {

        # Called by GA::Cmd::Compass->do and PermCompass->do, and also by $self->doInit
        # Enables the task window, meaning that keypad keys send a world command
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $callbackFlag   - TRUE if called by $self->radioButtonCallback, in which case there's no
        #                       need to modify the radio button widget itself
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $callbackFlag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->enable', @_);
        }

        $self->ivPoke('enabledFlag', TRUE);

        # If the task window is open, switch the radio buttons to 'enable'
        if ($self->winObj && $self->radioTableObj2 && ! $callbackFlag) {

            $self->radioTableObj2->button->set_active(TRUE);
        }

        # Modify the 'enabled' attribute for each independent macro interface
        foreach my $interfaceObj ($self->macroList) {

            $interfaceObj->modifyAttribs($self->session, 'enabled', TRUE);
        }

        return 1;
    }

    sub disable {

        # Called by GA::Cmd::Compass->do and PermCompass->do
        # Disables the task window, meaning that keypad keys behave normally
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $callbackFlag   - TRUE if called by $self->radioButtonCallback, in which case there's no
        #                       need to modify the radio button widget itself
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $callbackFlag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->disable', @_);
        }

        $self->ivPoke('enabledFlag', FALSE);

        # If the task window is open, switch the radio buttons to 'disable'
        if ($self->winObj && $self->radioTableObj && ! $callbackFlag) {

            $self->radioTableObj->button->set_active(TRUE);
        }

        # Modify the 'enabled' attribute for each independent macro interface
        foreach my $interfaceObj ($self->macroList) {

            $interfaceObj->modifyAttribs($self->session, 'enabled', FALSE);
        }

        return 1;
    }

    ##################
    # Response methods

    sub compassCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on one
        #   of the compass buttons
        # Sends the corresponding movement command to the world
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, a keycode
        #
        # Return values
        #   'undef' on improper arguments, if the button isn't one of the compass buttons or if no
        #       direction has been set for this button
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($standardDir, $customDir);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->compassCallback', @_);
        }

        # Work out which movement button was clicked
        if (! $self->ivExists('compassHash', $tableObj)) {

            # Unrecognised button
            return undef;

        } else {

            $standardDir = $self->ivShow('compassHash', $tableObj);
        }

        # Get the equivalent direction (using custom direction from the current dictionary)
        $customDir = $self->session->currentDict->ivShow('primaryDirHash', $standardDir);
        if (! $customDir) {

            # Unrecognised direction (or no direction set)
            return undef;

        } else {

            # Go in that direction
            $self->session->sendModCmd('go', 'direction', $customDir);

            return 1;
        }
    }

    sub cmdCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on one
        #   of the non-compass buttons
        # Sends the corresponding non-movement command to the world
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, a keycode
        #
        # Return values
        #   'undef' on improper arguments, if the button isn't one of the non-compass buttons or if
        #       no command has been set for this button
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my $cmd;

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->cmdCallback', @_);
        }

        # Work out which non-movement button was clicked
        if (! $self->ivExists('cmdHash', $tableObj)) {

            # Unrecognised button
            return undef;

        } else {

            $cmd = $self->ivShow('cmdHash', $tableObj);
            if (! $cmd) {

                # Unrecognised command (or no command set)
                return undef;

            } else {

                # Send the command (interpolation has already been done)
                $self->session->worldCmd($cmd);
                return 1;
            }
        }
    }

    sub radioButtonCallback {

        # Called by a ->signal_connect in a GA::Table::RadioButton object when the user clicks on
        #   one of the radio buttons
        # Enables or disable the keypad macros, as appropriate
        #
        # Expected arguments
        #   $tableObj       - The GA::Table::RadioButton object for the button
        #   $radioButton    - The actual Gtk3::RadioButton clicked
        #   $id             - The callback ID; in this case, the strings 'button_1' or 'button_2'
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $radioButton, $id, $check) = @_;

        # Check for improper arguments
        if (! defined $tableObj || ! defined $radioButton || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->radioButtonCallback', @_);
        }

        # Work out which button was clicked
        if ($id eq 'button_1') {

            # Disable the keypad macros. The TRUE argument means 'don't modify the widget'
            $self->disable(TRUE);

        } elsif ($id eq 'button_2') {

            # Otherwise, enable the keypad macros
            $self->enable(TRUE);
        }

        return 1;
    }

    sub miniEntryCallback {

        # Called by a ->signal_connect in a GA::Table::Entry object when the user types something
        #   in the entry box and presses ENTER
        # Finds the keycode currently displayed in the combobox and updates both the macro and the
        #   Gtk3::Button for that keycode
        #
        # Expected arguments
        #   $entryTableObj  - The GA::Table::Entry object for the entry
        #   $entry          - The actual Gtk3::Entry used
        #   $id             - The callback ID; in this case, an empty string
        #   $text           - The text entered by the user (may be an empty string)
        #
        # Return values
        #   'undef' on improper arguments, if no keycode has been selected in the combobox, if
        #       nothing has been typed in the entry box or for a general error
        #   1 otherwise

        my ($self, $entryTableObj, $entry, $id, $text, $check) = @_;

        # Local variables
        my ($cmd, $keycode, $buttonTableObj, $interfaceObj);

        # Check for improper arguments
        if (
            ! defined $entryTableObj || ! defined $entry || ! defined $id  || ! defined $text
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->miniEntryCallback', @_);
        }

        # Get the text entered in the Gtk3::Entry and trim leading/trailing whitespace
        $cmd = $axmud::CLIENT->trimWhitespace($text);

        # Retrieve the last value selected in the combobox, which will be a standard keycode
        $keycode = $self->comboTableObj->get_text();

        if (! defined $keycode || ! $self->ivExists('keypadCmdHash', $keycode) || ! $cmd) {

            # No selection made in the combobox. Reset both the entry and the combobox
            $entryTableObj->set_text();
            $self->comboTableObj->resetCombo();

            return undef;
        }

        # Update the main IV
        $self->ivAdd('keypadCmdHash', $keycode, $cmd);

        # Find the corresponding button
        $buttonTableObj = $self->ivShow('cmdKeycodeHash', $keycode);

        # Change the button's label, and the command sent when the button is clicked (max text
        #   length is 12)
        $buttonTableObj->set_text($self->shortenText($cmd, 12));
        $self->ivAdd('cmdHash', $buttonTableObj, $cmd);

        # Change the button's tooltips
        $buttonTableObj->set_tooltips(
            'Click to send \'' . $cmd . '\' (' . $self->ivShow('keypadHintHash', $keycode) . ')',
        );

        # Find the macro interface corresponding to the selected standard keycode
        $interfaceObj = $self->ivShow('macroHash', $keycode);
        if ($interfaceObj) {

            # Modify the macro to send the new command, instead of the old one
            $interfaceObj->modifyAttribs($self->session, 'response', $cmd);
        }

        # Clear the entry box
        $entryTableObj->set_text();

        # Update the task window to show the new button label
        $self->winObj->winShowAll($self->_objClass . '->miniEntryCallback');

        return 1;
    }

    ##################
    # Accessors - set

    sub set_enabledFlag {

        # Called by GA::Cmd::PermCompass->do

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_enabledFlag', @_);
        }

        if (! $flag) {
            $self->ivPoke('enabledFlag', FALSE);
        } else {
            $self->ivPoke('enabledFlag', TRUE);
        }

        return 1;
    }

    sub set_key {

        # Called by GA::Cmd::Compass->do and PermCompass->do

        my ($self, $keycode, $cmd, $check) = @_;

        # Local variables
        my ($buttonTableObj, $interfaceObj);

        # Check for improper arguments
        if (
            ! defined $keycode
            || ! $self->ivExists('keypadCmdHash', $keycode)
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_key', @_);
        }

        # (Code adapted from $self->entryCallback)

        # Trim leading/trailing whitespace ($cmd is an optional argument)
        if ($cmd) {

            $cmd = $axmud::CLIENT->trimWhitespace($cmd);
        }

        # Update the main IV
        $self->ivAdd('keypadCmdHash', $keycode, $cmd);

        # If the window is open, update it
        if ($self->winObj) {

            # Find the corresponding button table object
            $buttonTableObj = $self->ivShow('cmdKeycodeHash', $keycode);

            # Change the button's label, and the command sent when the button is clicked (max text
            #   length is 12)
            $buttonTableObj->set_text($self->shortenText($cmd, 12));
            $self->ivAdd('cmdHash', $buttonTableObj, $cmd);

            # Change the button's tooltips
            $buttonTableObj->set_tooltips(
                'Click to send \'' . $cmd . '\' (' . $self->ivShow('keypadHintHash', $keycode)
                . ')',
            );

            # Find the macro interface corresponding to the selected standard keycode
            $interfaceObj = $self->ivShow('macroHash', $keycode);
            if ($interfaceObj) {

                # Modify the macro to send the new command, instead of the old one
                $interfaceObj->modifyAttribs($self->session, 'response', $cmd);
            }

            # Update the task window to show the new button label
            $self->winObj->winShowAll($self->_objClass . '->set_key');
        }

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub enabledFlag
        { $_[0]->{enabledFlag} }

    sub compassHash
        { my $self = shift; return %{$self->{compassHash}}; }
    sub cmdHash
        { my $self = shift; return %{$self->{cmdHash}}; }
    sub cmdKeycodeHash
        { my $self = shift; return %{$self->{cmdKeycodeHash}}; }

    sub radioTableObj
        { $_[0]->{radioTableObj} }
    sub radioTableObj2
        { $_[0]->{radioTableObj2} }
    sub comboTableObj
        { $_[0]->{comboTableObj} }

    sub macroList
        { my $self = shift; return @{$self->{macroList}}; }
    sub macroHash
        { my $self = shift; return %{$self->{macroHash}}; }

    sub keypadDirHash
        { my $self = shift; return %{$self->{keypadDirHash}}; }
    sub keypadCmdHash
        { my $self = shift; return %{$self->{keypadCmdHash}}; }
    sub keypadHintHash
        { my $self = shift; return %{$self->{keypadHintHash}}; }
}

{ package Games::Axmud::Task::Condition;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Condition task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'condition_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'condition_task';
        $self->{prettyName}             = 'Condition';
        $self->{shortName}              = 'Cn';
        $self->{shortCutIV}             = 'conditionTask';  # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Tracks the condition of objects in the inventory';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = FALSE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = FALSE;
        $self->{winPreferList}          = [];
        $self->{winmap}                 = undef;
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
        # The task is activated when it starts, and can be disactivated (temporarily) to allow the
        #   user to see the normal output of commands like 'examine axe', etc
        $self->{activeFlag}             = FALSE;            # Task can be activated/disactivated

        # Task parameters
        #
        # The dependent trigger interfaces created; one for non-gagged patterns, the other for
        #   gagged patterns
        $self->{triggerList}            = [];
        $self->{ignoreTriggerList}      = [];

        # If this flag is set to TRUE, the check is only performed once (set to FALSE otherwise)
        $self->{checkOnceFlag}          = FALSE;
        # The inventory list, imported from the Inventory task at the beginning of each check.
        #   Objects are removed from this list, one by one, as they are checked
        $self->{inventoryList}          = [];
        # Every time the Inventory task updates its inventory, it copies the old list into its own
        #   ->previousList. That gets carved up by ->ivSplice operations, so the Inventory task
        #   supplies this task with a copy, because doing any splicing
        $self->{previousList}           = [];
        # Blessed reference of the current model object being checked ('undef' if none being
        #   checked right now)
        $self->{currentObj}             = undef;
        # When comparing two objects, how certain the task needs to be that it they match.
        #   0 = any objects match, 100 = only identical objects match, 70 - objects are fairly (70%)
        #   similar, 90 = objects are very (90%) similar), etc
        $self->{sensitivity}            = 80;

        # If $self->checkOnceFlag is FALSE, the following parameters are consulted:
        # How many seconds to wait before the first check (ignored if $self->checkOnceFlag is set to
        #   TRUE, in which case the check happens right away)
        $self->{firstCheckTime}         = 15;
        # How many seconds to wait before performing the next check
        $self->{waitTime}               = 90;
        # The time at which to perform the next check (matches GA::Session->sessionTime)
        $self->{nextCheckTime}          = undef;
        # If flag set to TRUE, no check cycles are done; set to FALSE otherwise.
        # Can be set to TRUE by any code (with a call to ->set_ignoreCheckFlag) when it wants to
        #   temporarily delay the inventory check; when it's time for an condition check, no check
        #   happens, and $self->condition is set as normal
        $self->{ignoreCheckFlag}        = FALSE;

        # Maximum no. seconds to wait while checking each object (a timeout)
        $self->{maxObjTime}             = 5;
        # The time at which to give up the check on the current object (matches
        #   GA::Session->sessionTime)
        $self->{nextObjTime}            = undef;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{triggerList}           = [$self->triggerList];
        $clone->{ignoreTriggerList}     = [$self->ignoreTriggerList];

        $clone->{checkOnceFlag}         = $self->checkOnceFlag;
        $clone->{inventoryList}         = [$self->inventoryList];
        $clone->{previousList}          = [$self->previousList];
        $clone->{currentObj}            = $self->currentObj;
        $clone->{sensitivity}           = $self->sensitivity;

        $clone->{firstCheckTime}        = $self->firstCheckTime;
        $clone->{waitTime}              = $self->waitTime;
        $clone->{nextCheckTime}         = $self->nextCheckTime;
        $clone->{ignoreCheckFlag}       = $self->ignoreCheckFlag;

        $clone->{maxObjTime}            = $self->maxObjTime;
        $clone->{nextObjTime}           = $self->nextObjTime;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        $newTask->ivPoke('checkOnceFlag', $self->checkOnceFlag);
        $newTask->ivPoke('sensitivity', $self->sensitivity);
        $newTask->ivPoke('firstCheckTime', $self->firstCheckTime);
        $newTask->ivPoke('waitTime', $self->waitTime);
        $newTask->ivPoke('ignoreCheckFlag', $self->ignoreCheckFlag);
        $newTask->ivPoke('maxObjTime', $self->maxObjTime);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

#   sub doFirstStage {          # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Can't do anything until the current character has been set, the user has logged
            #   in to the world and the Inventory task has started
            if (
                ! $self->session->currentChar
                || ! $self->session->loginFlag
                || ! $self->session->inventoryTask
                || ! $self->session->inventoryTask->activeFlag
            ) {
                # Repeat this stage indefinitely
                return $self->ivPoke('stage', 2);
            }

            # The task is now active
            $self->ivPoke('activeFlag', TRUE);

            # Set up triggers
            $self->resetTriggers();

            # Set the time at which the first (i.e. next) check occurs
            if ($self->checkOnceFlag) {

                # Do the first (and only) check right away
                $self->ivPoke('nextCheckTime', 0);

            } else {

                # Do the first check after some seconds
                $self->ivPoke(
                    'nextCheckTime',
                    ($self->session->sessionTime + $self->firstCheckTime),
                );
            }

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            # Stage 3: the start of a condition check cycle

            my (
                $invTaskObj, $charObj,
                @newList,
            );

            # Import the Inventory task and the current character profile
            $invTaskObj = $self->session->inventoryTask;
            $charObj = $self->session->currentChar;

            # If this task has been disactivated, or if the Inventory task doesn't exist, wait
            #   indefinitely
            # If it's not time for the next check, wait until it is
            if (
                ! $self->activeFlag
                || ! $invTaskObj
                || ! $invTaskObj->activeFlag
                || $self->session->sessionTime < $self->nextCheckTime
            ) {
                # Wait indefinitely
                return $self->ivPoke('stage', 3);

            } elsif ($self->ignoreCheckFlag) {

                # Don't do a check, but set $self->nextCheckTime as if a check cycle had just been
                #   completed
                return $self->endCheck();
            }

            # Otherwise, import the Inventory task's inventory list
            $self->ivPoke('inventoryList', $invTaskObj->inventoryList);
            $self->ivUndef('currentObj');

            # If the inventory list is empty, that's the end of the check
            if (! $self->inventoryList) {

                return $self->endCheck();
            }

            # If the character's monitored object list is empty, we monitor all objects; otherwise
            #   we must remove everything from $self->inventoryList that doesn't match a monitored
            #   object
            if ($charObj->monitorObjList) {

                foreach my $obj ($self->inventoryList) {

                    if (
                        $self->session->worldModelObj->objCompare(
                            $self->sensitivity,
                            $obj,
                            $charObj->monitorObjList,
                        )
                    ) {
                        # We need to monitor this object
                        push (@newList, $obj);
                    }
                }

                # If @newList is empty, that's the end of the check
                if (! @newList) {

                    return $self->endCheck();

                } else {

                    $self->ivPoke('inventoryList', @newList);
                }
            }

            # Check the condition of items in the inventory, one by one
            return $self->ivPoke('stage', 4);

        } elsif ($self->stage == 4) {

            # Stage 4: Check the next object in $self->inventoryList

            if (! $self->inventoryList) {

                # Every object has been checked. If only one check is allowed, end the task now
                if ($self->checkOnceFlag) {

                    # Task complete
                    $self->ivPoke('shutdownFlag', TRUE);
                    return undef;
                }

                # Otherwise, set the time at which the next check cycle occurs
                $self->ivPoke(
                    'nextCheckTime',
                    ($self->session->sessionTime + $self->waitTime),
                );

                # Stage 3 waits for the start of the next check cycle
                return $self->ivPoke('stage', 3);

            } else {

                # Retrieve the first unchecked object in the inventory list
                $self->ivPoke('currentObj', $self->ivShift('inventoryList'));
                # Send the 'examine' command to the world
                $self->session->sendModCmd('examine', 'object', $self->currentObj->noun);
                # Wait for a response
                $self->ivPoke(
                    'nextObjTime',
                    ($self->session->sessionTime + $self->maxObjTime),
                );

                return $self->ivPoke('stage', 5);
            }

        } elsif ($self->stage == 5) {

            # Stage 5: Having sent a command to the world, wait for a response

            # When the world responds with the selected item's condition, $self->triggerSeen is
            #   called. That method sets $self->currentObj to 'undef', so that we know its condition
            #   has been found
            # Wait until the object's condition has been found or until the time limit is reached
            if (
                ! defined ($self->currentObj)
                || $self->session->sessionTime < $self->nextCheckTime
            ) {
                # Move on to the next object in the list
                $self->ivUndef('currentObj', undef);

                return $self->ivPoke('stage', 4);

            } else {

                # Continue waiting for a response for this object
                return $self->ivPoke('stage', 5);
            }

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub activate {

        # Called by GA::Cmd::ActivateInventory->do (not called by $self->doStage, because the task
        #   is activated at stage 2, before interfaces are created)
        # Enables the interfaces created by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->activate', @_);
        }

        # Compile a list of triggers
        @list = ($self->triggerList, $self->ignoreTriggerList);

        # Enable each interface in turn
        foreach my $obj (@list) {

            $obj->modifyAttribs($self->session, 'enabled', 1);
        }

        # Update IVs
        $self->ivPoke('activeFlag', TRUE);
        $self->ivPoke(
            'nextCheckTime',
            ($self->session->sessionTime + $self->firstCheckTime),
        );

        return 1;
    }

    sub disactivate {

        # Called by GA::Cmd::DisactivateInventory->do
        # Disables the interfaces created by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->disactivate', @_);
        }

        # Compile a list of triggers
        @list = ($self->triggerList, $self->ignoreTriggerList);

        # Disable each interface in turn
        foreach my $obj (@list) {

            $obj->modifyAttribs($self->session, 'enabled', 0);
        }

        # Update IVs
        $self->ivPoke('activeFlag', FALSE);
        # Reset some IVs in case a condition check was in progress
        $self->ivEmpty('inventoryList');
        $self->ivUndef('currentObj');
        $self->ivUndef('nextCheckTime');
        $self->ivUndef('nextObjTime');

        # $self->main, stage 3, waits for the task to be activated again
        $self->ivPoke('stage', 3);

        return 1;
    }

    sub resetTriggers {

        # Called by $self->doStage at task stage 2, to create all the trigger interfaces used by
        #   this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetTriggers', @_);
        }

        # The world profile stores characteristic patterns which mark text as showing the condition
        #   of an object ('It is in excellent condition', etc)
        # It also stores patterns which should be ignored; we'll create triggers for those first,
        #   so that the ignorable patterns get checked before the usable patterns

        # Set up triggers for ignorable patterns
        OUTER: foreach my $pattern ($self->session->currentWorld->conditionIgnorePatternList) {

            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'ignorePatternSeen',
                'gag',
                1,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->conditionIgnorePatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;

            } else {

                # Store the trigger
                $self->ivPush('ignoreTriggerList', $interfaceObj);
            }
        }

        # Set up triggers for usable patterns
        @list = $self->session->currentWorld->conditionPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $gag, $condition, $interfaceObj, $flag);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger
            $gag = shift @list;
            # The corresponding condition in the range 0-100
            $condition = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (
                ! defined $condition
                || ($gag ne 'show' && $gag ne 'hide')
            ) {
                $self->writeWarning(
                    'Missing or invalid arguments in current world profile\'s'
                    . ' ->conditionPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'triggerSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->conditionPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;

            } else {

                # Store the trigger
                $self->ivPush('triggerList', $interfaceObj);

                # Give the trigger some properties that will help $self->triggerSeen to decide
                #   what to do when the trigger fires
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
                $interfaceObj->ivAdd('propertyHash', 'condition', $condition);
            }
        }

        # Reset complete
        return 1;
    }

    sub endCheck {

        # Can be called by anything, but mainly called by $self->doStage, stage 3
        # Ends the task's current check cycle, if one is in progress
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $ignoreCheckFlag     - If set to TRUE, no more inventory checks are run until the flag
        #                           is set back to FALSE
        #
        # Return values
        #   'undef' on improper arguments or if the task is shutting down now
        #   1 otherwise

        my ($self, $ignoreCheckFlag, $check) = @_;

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->endCheck', @_);
        }

        # If called by something other than $self->doStage, and we're in the middle of a check, stop
        #   checking
        if ($self->inventoryList) {

            $self->ivEmpty('inventoryList');
        }

        # Stop doing inventory checks, if the flag is set
        if ($ignoreCheckFlag) {

            $self->ivPoke('ignoreCheckFlag', TRUE);
        }

        # If only one check is allowed, end the task
        if ($self->checkOnceFlag) {

            # Task complete
            $self->ivPoke('shutdownFlag', TRUE);
            return undef;

        } else {

            # Set the time at which the next check occurs
            $self->ivPoke(
                'nextCheckTime',
                ($self->session->sessionTime + $self->waitTime),
            );

            # Wait until the time is up
            return $self->ivPoke('stage', 3);
        }
    }

    ##################
    # Response methods

    sub triggerSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture condition patterns
        #   e.g. 'The (.*) is in perfect condition'
        #
        # The world profile's condition pattern list occurs in groups of 4 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the object (set to 0 if no part of the string is
        #           guaranteed to contain the object)
        #   [2] - 'hide' to use a gag trigger, 'show' to use a non-gag trigger
        #   [3] - the corresponding condition in the range 0-100
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1])
        #   condition       - the corresponding condition (same as [3])
        #
        # This function analyses the matched string and updates IVs accordingly
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found or if the line seems to refer to a different object than the
        #       one we're checking (stored in $self->currentObj)
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my (
            $obj, $grpNum, $condition, $worldModelObj, $invTaskObj, $objString,
            @objList, @newList,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->triggerSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        $condition = $obj->ivShow('propertyHash', 'condition');

        # Import the model object and inventory task
        $worldModelObj = $self->session->worldModelObj;
        $invTaskObj = $self->session->inventoryTask;

        # Do nothing unless this task is in 'active' mode, there's a condition check in progress
        #   and the Inventory task is still in 'active' mode
        if ($self->activeFlag && $self->currentObject && $invTaskObj && $invTaskObj->activeFlag) {

            # If $grpNum is not 0, the line contains the object itself (e.g. 'The metal axe is in an
            #   excellent condition'), so we can check whether it matches $self->currentObj
            # If $grpNum is 0, then no part of the line contains the actual object (e.g. 'It is in
            #   an excellent condition'), so we must just assume that the condition refers to
            #   $self->currentObj
            if ($grpNum) {

                # $objectString is set to a string like 'a metal axe'
                $objString = $$grpStringListRef[$grpNum];
                # Remove unnecessary whitespace from $objectString, if it was defined (an object
                #   string might not be needed at this world)
                if ($objString) {

                    $objString = $axmud::CLIENT->trimWhitespace($objString);
                }

                # Convert the string into a list of blessed references to non-model objects
                #   (the list should contain one item, but if it contains none, assume that it's
                #   the right object anyway)
                @objList = $worldModelObj->parseObj($self->session, FALSE, $objString);
                if (@objList) {

                    # See whether the first object in the list corresponds to $self->currentObject
                    if (! $self->objCompare($self->sensitivity, $self->currentObj, $objList[0])) {

                        # The line that caused the trigger to fire describes an object that doesn't
                        #   match the one for which we're looking
                        return undef;
                    }
                }
            }

            # Now, we have a problem. If, halfway through checking the condition of objects in the
            #   inventory, the Inventory task updates its list, $self->currentObj will no longer
            #   be in that task's list, ->inventoryList
            # As a result, although this task will continue checking conditions, those conditions
            #   will be assigned to objects in the Inventory task's old list, now stored in
            #   ->previousList (which has been copied into this task's ->previousList)
            # If $self->currentObj exists in $self->previousList, we need to find the equivalent
            #   object in the Inventory task's current ->inventoryList
            if (
                $self->previousList
                && defined $self->ivFind('previousList', $self->currentObj)
            ) {
                # Find the equivalent object in the Inventory task's updated ->inventoryList
                @newList = $invTaskObj->inventoryList;
                OUTER: foreach my $newObj (@newList) {

                    if (
                        $worldModelObj->objCompare($self->sensitivity, $self->currentObj, $newObj)
                    ) {
                        # If $newObj->condition has already been set, we need to check whether it
                        #   was set by this function (in which case there are probably duplicate
                        #   objects in the character's inventory), or whether it was copied by the
                        #   Inventory task's ->triggerSeen (in which case we overwrite it with the
                        #   new value).
                        # If it was set by this function, $newObj->conditionChangeFlag is set to
                        #   TRUE
                        if (! $newObj->conditionChangeFlag) {

                            $newObj->ivPoke('condition', $condition);
                            $newObj->ivPoke('conditionChangeFlag', TRUE);
                        }

                        last OUTER;
                    }
                }

            } else {

                # The Inventory task's list hasn't been updated yet, so set the object's condition
                #   directly
                $self->currentObj->ivPoke('condition', $condition);
                $self->currentObj->ivPoke('conditionChangeFlag', TRUE);
            }

            # Mark the current object as 'undef', so that the next object in the inventory list can
            #   be checked (by $self->doStage)
            $self->ivPoke('currentObj', undef);

            # Tell the Inventory task to refresh its task window
            $invTaskObj->set_refreshWinFlag(TRUE);
        }

        return 1;
    }

    sub ignorePatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture condition patterns
        #   which contain an object's condition (e.g. 'It is in excellent condition') and a second
        #   set of patterns which contain no useful information (e.g. 'You consider the axe's
        #   condition') and which can be ignored
        #
        # The function does nothing (a commented-out call to ->writeDebug is provided, in case it
        #   will be useful)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->ignorePatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

#       $self->writeDebug('Condition task ignore pattern found: \'' . $$grpStringListRef[0] . '\'');

        return 1;
    }

    ##################
    # Accessors - set

    sub set_ignoreCheckFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_ignoreCheckFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('ignoreCheckFlag', TRUE);
        } else {
            $self->ivPoke('ignoreCheckFlag', FALSE);
        }

        return 1;
    }

    sub set_previousList {

        my ($self, @previousList) = @_;

        # (No improper arguments to check)

        $self->ivPoke('previousList', @previousList);   # @previousList can be empty

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub triggerList
        { my $self = shift; return @{$self->{triggerList}}; }
    sub ignoreTriggerList
        { my $self = shift; return @{$self->{ignoreTriggerList}}; }

    sub checkOnceFlag
        { $_[0]->{checkOnceFlag} }
    sub inventoryList
        { my $self = shift; return @{$self->{inventoryList}}; }
    sub previousList
        { my $self = shift; return @{$self->{previousList}}; }
    sub currentObj
        { $_[0]->{currentObj} }
    sub sensitivity
        { $_[0]->{sensitivity} }

    sub firstCheckTime
        { $_[0]->{firstCheckTime} }
    sub waitTime
        { $_[0]->{waitTime} }
    sub nextCheckTime
        { $_[0]->{nextCheckTime} }
    sub ignoreCheckFlag
        { $_[0]->{ignoreCheckFlag} }

    sub maxObjTime
        { $_[0]->{maxObjTime} }
    sub nextObjTime
        { $_[0]->{nextObjTime} }
}

{ package Games::Axmud::Task::Connections;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Connections task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'connections_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'connections_task';
        $self->{prettyName}             = 'Connections';
        $self->{shortName}              = 'Co';
        $self->{shortCutIV}             = 'connectionsTask'; # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Display a constantly-updated list of connections';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0.2;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = TRUE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        # Flag set to TRUE if the connection/idle times for each session should be shown, FALSE if
        #   not
        $self->{showInfoFlag}           = FALSE;
        # This task creates macros to intercept CTRL+1, CTRL+2 ... CTRL+9 (or an equivalent set of
        #   keypresses). If this flag is TRUE, the macros are created enabled; if FALSE, they are
        #   created disabled
        $self->{useMacrosFlag}          = TRUE;
        # Which set of keypresses to use:
        #   'default'   - CTRL+1, CTRL+2 ... CTRL+9
        #   'simple'    - F1, F2 ... F9
        $self->{macroMode}              = 'default';
        # A list of macros created
        $self->{macroList}              = [];

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{showInfoFlag}          = $self->showInfoFlag;
        $clone->{useMacrosFlag}         = {$self->useMacrosFlag};
        $clone->{macroMode}             = {$self->macroMode};

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the command lists
        $newTask->ivPoke('showInfoFlag', $self->showInfoFlag);
        $newTask->ivPoke('useMacrosFlag', $self->useMacrosFlag);
        $newTask->ivPoke('macroMode', $self->macroMode);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

#   sub doFirstStage {}         # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Create macros to intercept CTRL+1, CTRL+2 ... CTRL+9
            if (! $self->resetMacros()) {

                $self->writeError(
                    'Could not create macros for the ' . $self->prettyName . ' task, so halting',
                    $self->_objClass . '->doStage',
                );

                # Mark the task to be shutdown
                $self->ivPoke('shutdownFlag', TRUE);
                return undef;

            } else {

                return $self->ivPoke('stage', 3);
            }

        } elsif ($self->stage == 3) {

            my ($string, $count);

            # On every spin of the task loop, update the task window (if it is open)
            if ($self->taskWinFlag) {

                # Add the header
                if ($axmud::CLIENT->shareMainWinFlag) {
                    $string = ') (* current, > visible)';
                } else {
                    $string = ') (* current)';
                }

                $self->insertText(
                    'CONNECTIONS (Time ' . int($self->session->sessionTime) . $string,
                    'empty',
                );

                if (! $self->showInfoFlag) {

                    $self->insertText('   #  World            Character        Status');

                } else {

                    $self->insertText(
                        '   #  World            Character        Status       Info',
                    );
                }

                # Add the remaining lines, one for each session
                $count = 0;
                foreach my $otherSession (
                    sort {lc($a) cmp lc($b)} ($axmud::CLIENT->ivValues('sessionHash'))
                ) {
                    my ($column, $colour, $char);

                    $count++;


                    if ($otherSession eq $self->session) {
                        $column = '*';
                    } else {
                        $column = ' ';
                    }

                    if (
                        $axmud::CLIENT->shareMainWinFlag
                        && $otherSession->mainWin->visibleSession eq $otherSession
                    ) {
                        $column .= '> ';
                    } else {
                        $column .= '  ';
                    }

                    $self->insertText($column . sprintf('%-2.2s ', $count));

                    # Use the same colour as the 'main' window tab
                    if ($otherSession->status eq 'offline') {
                        $colour = 'magenta';
                    } elsif ($otherSession->status eq 'disconnected') {
                        $colour = 'blue';
                    } elsif ($otherSession->showNewTextFlag) {
                        $colour = 'red';
                    } else {
                        $colour = $self->defaultTabObj->textViewObj->textColour;
                    }

                    $self->insertText(
                        sprintf('%-16.16s ', $otherSession->currentWorld->name),
                        'echo',
                        $colour,
                    );

                    if ($otherSession->currentChar) {
                        $char = $otherSession->currentChar->name;
                    } else {
                        $char = '<none>';
                    }

                    $self->insertText(
                        sprintf(
                            '%-16.16s %-12.12s',
                            $char,
                            $otherSession->status,
                        ),
                        'echo',
                    );

                    if ($self->showInfoFlag) {


                        $self->insertText(
                            ' ' . $otherSession->getTimeLabelText(),
                            'echo',
                        );
                    }
                }
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub resetMacros {

        # Called by $self->doStage at stage 2 to set up macros to intercept CTRL+1, CTRL+2 ...
        #   CTRL+9 (or equivalents)
        #
        # Expected arguments
        #   (none)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @macroList;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetMacros', @_);
        }

        # Create one macro (initially disabled) for each keycode string between 1-9
        for (my $count = 1; $count <= 9; $count++) {

            my ($interfaceObj, $keycodeString);

            if ($self->macroMode eq 'simple') {
                $keycodeString = 'f' . $count;      # e.g. 'f1'
            } else {
                $keycodeString = 'ctrl ' . $count;  # e.g. 'ctrl 1';
            }

            # Create the dependent macro interface
            $interfaceObj = $self->session->createDependentInterface(
                'macro',
                $keycodeString,
                $self,
                'macroSeen',
                'enabled',
                $self->useMacrosFlag,
            );

            if (! $interfaceObj) {

                # If there's an error creating any macros, remove any macros already created
                $self->session->tidyInterfaces($self);
                return undef;

            } else {

                push (@macroList, $interfaceObj);

                # Give the trigger some properties that will give $self->macroSeen a number between
                #   1-9 (quicker than processing the interface object's IVs)
                $interfaceObj->ivAdd('propertyHash', 'number', $count);
            }
        }

        # Update IVs
        $self->ivPoke('macroList', @macroList);

        return 1;
    }

    ##################
    # Response methods

    sub macroSeen {

        # Called by GA::Session->checkMacros
        #
        # This task's ->resetMacros function creates some macros to capture CTRL+1, CTRL+2 ...
        #   CTRL+9 (or equivalents)
        #
        # If the number corresponds to a session, that session is made the visible one
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   number          - A number between 1 and 9
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $keycodeString  - The keycode string, e.g. 'ctrl 1'
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $keycodeString, $check) = @_;

        # Local variables
        my (
            $obj, $number, $newSession,
            @list,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $keycodeString
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->macroSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired macro

        $number = $obj->ivShow('propertyHash', 'number');
        # Find the equivalent GA::Session. $number is a value between 1-9, representing the first
        #   nine sessions which are still running, in the order they were created
        @list = sort {$a->number <=> $b->number} ($axmud::CLIENT->ivValues('sessionHash'));
        # If there aren't enough session, $newSession will remain as 'undef'
        $newSession = $list[$number - 1];
        if (defined $newSession) {

            $newSession->defaultTabObj->paneObj->setVisibleTab($newSession->defaultTabObj);
            $newSession->mainWin->restoreFocus();
        }

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub showInfoFlag
        { $_[0]->{showInfoFlag} }
    sub useMacrosFlag
        { $_[0]->{useMacrosFlag} }
    sub macroMode
        { $_[0]->{macroMode} }
    sub macroList
        { my $self = shift; return @{$self->{macroList}}; }
}

{ package Games::Axmud::Task::Countdown;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Countdown task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'countdown_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'countdown_task';
        $self->{prettyName}             = 'Countdown';
        $self->{shortName}              = 'Cd';
        $self->{shortCutIV}             = undef;            # Built-in task, but not jealous

        $self->{category}               = 'process';
        $self->{descrip}                = 'Creates a task window to show a countdown clock';
        $self->{jealousyFlag}           = FALSE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pseudo', 'grid'];
        $self->{winmap}                 = 'basic_empty';
        $self->{winUpdateFunc}          = 'createWidgets';
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters

        # Table objects created for various Gtk3 widgets, stored here because various functions
        #   need them
        $self->{labelTableObj}          = undef;        # GA::Table::Label
        $self->{entryTableObj}          = undef;        # GA::Table::Entry
        $self->{comboTableObj}          = undef;        # GA::Table::Combo
        $self->{comboTableObj2}         = undef;        # GA::Table::Combo
        $self->{buttonTableObj}         = undef;        # GA::Table::Button
        $self->{buttonTableObj2}        = undef;        # GA::Table::Button
        $self->{buttonTableObj3}        = undef;        # GA::Table::Button

        # Two IVs set by GA::Cmd::SetCountdown->do and GA::Cmd::SetCountup->do, so that a newly-
        #   opened task can start counting down/up as soon as it opens
        # IV with the values 'up' or 'down'
        $self->{initMode}               = undef;
        # The time, in seconds, to count
        $self->{initTime}               = undef;

        # The font and the text/underlay colours used to show the time; must be pango-recognisable
        #   colours
        $self->{textLen}                = 9;                # Label has 9 characters, by default
        $self->{textFont}               = 'monospace';      # Should be a monospace font
        $self->{textSize}               = '5';              # Relative size; value in range 1-10
        $self->{textColour}             = 'white';
        $self->{underlayColour}         = 'black';
        $self->{textWarning}            = 'white';
        $self->{underlayWarning}        = 'red';

        # The operating mode - 'default' when no time is visible, 'up' when counting up, 'down'
        #   when counting down
        $self->{countMode}              = 'default';
        # Flag set to TRUE when the 'Pause' button has been pressed
        $self->{pausedFlag}             = FALSE;
        # The time (matches GA::Session->sessionTime) at which the clock started
        $self->{clockStart}             = undef;
        # The time (matches GA::Session->sessionTime) at which the clock should stop
        $self->{clockStop}              = undef;
        # The time (matches GA::Session->sessionTime) at which the clock was paused; set back to
        #   'undef' when the clock resumes ticking
        $self->{clockFreeze}            = undef;
        # How long the clock runs. ->clockTarget is the time in seconds; ->clockShow is the number
        #   of seconds left
        $self->{clockTarget}            = undef;
        $self->{clockLeft}              = undef;
        # What time (matches ->clockLeft) at which to show a warning
        $self->{clockWarning}           = undef;

        # Sound effects. If 'undef', an empty string or an unrecognised sound effect, or if sound is
        #   turned off, nothing is played
        $self->{stopEffect}             = 'alert';
        $self->{warningEffect}          = 'bell';

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

#   sub clone {}                # Inherited from generic task

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

#   sub doFirstStage {}         # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Draw widgets for the task window
            $self->createWidgets();

            # If initialisation IVs were set, we can start the clock immediately
            if (defined $self->initMode) {

                $self->startClock($self->initMode, $self->initTime);
            }

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            my ($time, $oldTime);

            if ($self->countMode ne 'default' && ! $self->pausedFlag) {

                # Clock is running
                $time = $self->clockStop - $self->session->sessionTime;
                if ($time <= 0) {

                    # Stop the clock!
                    $self->ivUndef('clockWarning');         # Don't draw in red
                    if ($self->countMode eq 'down') {
                        $self->drawTime(0);
                    } else {
                        $self->drawTime($self->clockTarget);
                    }

                    # Update IVs
                    $self->ivPoke('countMode', 'default');
                    $self->ivPoke('pausedFlag', FALSE);
                    $self->ivUndef('clockStart');
                    $self->ivUndef('clockStop');
                    $self->ivUndef('clockFreeze');
                    $self->ivUndef('clockTarget');
                    $self->ivUndef('clockLeft');

                    # Update widgets
                    $self->buttonTableObj->set_normal(TRUE);
                    $self->buttonTableObj2->set_normal(FALSE);
                    $self->buttonTableObj3->set_normal(FALSE);

                    # Play sound effects
                    if (defined $self->stopEffect && $self->stopEffect ne '') {

                        $axmud::CLIENT->playSound($self->stopEffect);
                    }

                } elsif (int($time) < ($self->clockLeft - 1)) {

                    # Update the clock time
                    $oldTime = $self->clockLeft;
                    $self->ivPoke('clockLeft', int($time) + 1);

                    if ($self->countMode eq 'down') {
                        $self->drawTime($self->clockLeft);
                    } else {
                        $self->drawTime(int($self->clockStop - $self->clockStart - $time));
                    }

                    # Play sound effects
                    if (
                        defined $self->clockWarning
                        && $self->clockWarning >= $self->clockLeft
                        && $self->clockWarning < $oldTime
                        && defined $self->warningEffect
                        && $self->warningEffect ne ''
                    ) {
                        $axmud::CLIENT->playSound($self->warningEffect);
                    }
                }
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub createWidgets {

        # Set up the widgets used in the task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (@comboList, @comboList2);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->createWidgets', @_);
        }

        # If the task window (a 'grid' window or a pseudo-window inside the session's 'main' window)
        #   isn't open, don't need to create widgets
        if (! $self->winObj) {

            return undef;
        }

        my $labelTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Label',
            0, 59, 0, 39,
            undef,
            # Init settings
            'justify'       => 'centre',
            'align_x'       => 0.5,
            'align_y'       => 0.5,
        );

        # (This line forces the label to the middle of the window)
        $labelTableObj->label->set_hexpand(TRUE);

        my $entryTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Entry',
            0, 19, 40, 49,
            undef,
            # Init settings
            'width_chars'   => 8,
            'max_chars'     => 8,
        );

        @comboList = (
            'seconds',
            'minutes',
            'hours',
        );
        my $comboTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::ComboBox',
            20, 39, 40, 49,
            undef,
            # Init settings
            'list_ref'      => \@comboList,
        );

        @comboList2 = (
            'count down',
            'count up',
        );
        my $comboTableObj2 = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::ComboBox',
            40, 59, 40, 49,
            undef,
            # Init settings
            'list_ref'      => \@comboList2,
        );

        my $buttonTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            0, 19, 50, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('startCallback'),
            'id'            => 'start',
            'text'          => 'Start',
            'tooltips'      => 'Start the clock',
        );

        my $buttonTableObj2 = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            20, 39, 50, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('pauseCallback'),
            'id'            => 'pause',
            'text'          => 'Pause',
            'tooltips'      => 'Pause the clock',
            'normal_flag'   => FALSE,
        );

        my $buttonTableObj3 = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            40, 59, 50, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('stopCallback'),
            'id'            => 'stop',
            'text'          => 'Stop',
            'tooltips'      => 'Stop the clock',
            'normal_flag'   => FALSE,
        );

        # Store the table objects for the benefit of various functions
        $self->ivPoke('labelTableObj', $labelTableObj);
        $self->ivPoke('entryTableObj', $entryTableObj);
        $self->ivPoke('comboTableObj', $comboTableObj);
        $self->ivPoke('comboTableObj2', $comboTableObj2);
        $self->ivPoke('buttonTableObj', $buttonTableObj);
        $self->ivPoke('buttonTableObj2', $buttonTableObj2);
        $self->ivPoke('buttonTableObj3', $buttonTableObj3);

        # Display all the widgets, including a blank label
        $self->drawTime();
        $self->winObj->winShowAll($self->_objClass . '->createWidgets');

        return 1;
    }

    sub initClock {

        # Called by GA::Cmd::SetCountdown->do and GA::Cmd::SetCountup->do
        # Sets the task's initialisation IVs, so that the clock starts counting as soon as stage 3
        #   is reached for the first time
        #
        # Expected arguments
        #   $mode   - 'up' to count up, 'down' to count down
        #   $secs   - The time to count, in seconds
        #
        # Return values
        #   'undef' on improper arguments, if $self->stage is already at 3 or if $secs is invalid
        #   1 otherwise

        my ($self, $mode, $secs, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $mode
            || ($mode ne 'down' && $mode ne 'up')
            || ! defined $secs || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->initClock', @_);
        }

        # This function can't be called if the task is already running
        if ($self->stage >= 3) {

            return undef;

        # Check $secs is valid
        } elsif (! $axmud::CLIENT->floatCheck($secs, 0) || $secs == 0) {

            return undef;
        }

        # Update IVs
        $self->ivPoke('initMode', $mode);
        $self->ivPoke('initTime', $secs);

        return 1;
    }

    sub startClock {

        # Called by $self->doStage (at stage 2) and $self->startCallback
        # Also called by GA::Cmd::Countdown->do and Countup->do
        # Starts the clock, counting down towards zero or up from zero
        #
        # Expected arguments
        #   $mode   - 'up' to count up, 'down' to count down
        #   $secs   - The time to count, in seconds
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $mode, $secs, $check) = @_;

        # Local variables
        my $maxSecs;

        # Check for improper arguments
        if (
            ! defined $mode
            || ($mode ne 'down' && $mode ne 'up')
            || ! defined $secs || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->startClock', @_);
        }

        # The maximum time is 99, 59 minutes and 59 seconds. If $secs exceeds that, revert to the
        #   maximum
        $maxSecs = 100 * 60 * 60;
        if ($secs >= $maxSecs) {

            $secs = $maxSecs - 1;
        }

        # Update IVs
        $self->ivPoke('countMode', $mode);
        $self->ivPoke('pausedFlag', FALSE);
        $self->ivPoke('clockStart', $self->session->sessionTime);
        $self->ivPoke('clockStop', ($self->session->sessionTime + $secs));
        $self->ivUndef('clockFreeze');
        $self->ivPoke('clockTarget', $secs);
        $self->ivPoke('clockLeft', $secs);

        # The time at which a warning is shown depends on how long the clock is going to run.
        if ($secs <= 10) {

            # If it's less than 10 seconds, don't show a warning at all
            $self->ivPoke('clockWarning', undef);

        } elsif ($secs <= 60) {

            # Less than a minute
            $self->ivPoke('clockWarning', 5);

        } elsif ($secs <= 120) {

            # Less than two minutes
            $self->ivPoke('clockWarning', 10);

        } elsif ($secs <= 300) {

            # Less than five minutes
            $self->ivPoke('clockWarning', 30);

        } elsif ($secs <= 600) {

            # Less than ten minutes
            $self->ivPoke('clockWarning', 60);

        } elsif ($secs <= 1200) {

            # Less than twenty minutes
            $self->ivPoke('clockWarning', 120);

        } else {

            # Default - 5-minute warning
            $self->ivPoke('clockWarning', 300);
        }

        # Update table widgets
        $self->buttonTableObj->set_normal(FALSE);
        $self->buttonTableObj2->set_normal(TRUE);
        $self->buttonTableObj3->set_normal(TRUE);

        # Show the first clock time
        if ($self->countMode eq 'up') {
            $self->drawTime(0);
        } else {
            $self->drawTime($self->clockLeft);
        }

        return 1;
    }

    sub drawTime {

        # Called by various functions to set (or reset) the clock time displayed as a Gtk3::Label
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional values
        #   $secs   - The time to show, in seconds
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $secs, $check) = @_;

        # Local variables
        my ($origSecs, $string, $hours, $mins, $fontSize, $textColour, $underlayColour, $modString);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->drawTime', @_);
        }

        # Convert $secs, an integer, into a string like '49:12'
        $origSecs = $secs;
        if (! defined $secs) {

            # No time shown
            $string = $modString = '';

        } else {

            $hours = int($secs / 3600);
            $secs -= ($hours * 3600);

            $mins = int($secs / 60);
            $secs -= ($mins * 60);

            if ($hours) {
                if ($hours > 9) {
                    $string = sprintf('%02d:%02d:%02d', $hours, $mins, $secs);
                } else {
                    $string = sprintf('%01d:%02d:%02d', $hours, $mins, $secs);
                }
            } elsif ($mins) {
                if ($mins > 9) {
                    $string = sprintf('%02d:%02d', $mins, $secs);
                } else {
                    $string = sprintf('%01d:%02d', $mins, $secs);
                }
            } else {
                $string = sprintf('0:%02d', $secs);
            }

            $modString = ' ' . $string . ' ';
        }

        # Set the pango font size, making sure it's a reasonable value
        $fontSize = int($self->textSize * 10000);
        if ($fontSize < 10000) {
            $fontSize = 10000;
        } elsif ($fontSize > 100000) {
            $fontSize = 100000;
        }

        # Set the colours to use
        if (
            ! defined $self->clockWarning
            || ! $self->clockLeft
            || ! $origSecs
            || $self->clockWarning < $self->clockLeft
        ) {
            $textColour = $self->textColour;
            $underlayColour = $self->underlayColour;

        } else {

            $textColour = $self->textWarning;
            $underlayColour = $self->underlayWarning;
        }

        $self->labelTableObj->label->set_markup(
            '<span font_family =\'' . $self->textFont . '\' foreground=\'' . $textColour
            . '\' background=\'' . $underlayColour . '\' size=\'' . $fontSize . '\'>' . $modString
            . '</span>',
        );

        if ($string eq '') {
            $self->setTaskWinTitle();
        } else {
            $self->setTaskWinTitle('(' . $string . ')', TRUE);
        }

        return 1;
    }

    ##################
    # Response methods

    sub startCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on the
        #   'Start' button
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, 'start'
        #
        # Return values
        #   'undef' on improper arguments, if the user hasn't entered a valid time value (or if
        #       they've entered no value at all) or if $self->countMode is not 'default'
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($time, $unit, $method);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->startCallback', @_);
        }

        if ($self->countMode ne 'default') {

            # This shouldn't be possible
            return undef;
        }

        # Get the time value, and check it's valid
        $time = $self->entryTableObj->get_text();
        if ($time eq '') {

            # User hasn't entered a value
            return undef;

        } elsif (! $axmud::CLIENT->floatCheck($time, 0) || $time == 0) {

            # Non-numeric value, or less than 0
            return $self->winObj->showMsgDialogue(
                'Start clock',
                'Invalid number \'' . $time . '\'',
                'ok',
            );
        }

        # Convert the time to seconds
        $unit = $self->comboTableObj->get_text();
        if ($unit eq 'minutes') {
            $time *= 60;
        } elsif ($unit eq 'hours') {
            $time *= 3600;
        }

        $time = int($time);

        # Start the clock
        $method = $self->comboTableObj2->get_text();
        if ($method eq 'count up') {
            $self->startClock('up', $time);
        } else {
            $self->startClock('down', $time);
        }

        return 1;
    }

    sub pauseCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on the
        #   'Pause' button
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, 'pause'
        #
        # Return values
        #   'undef' on improper arguments or if $self->countMode is 'default'
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($time, $unit, $method);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->pauseCallback', @_);
        }

        if ($self->countMode eq 'default') {

            # This shouldn't be possible
            return undef;
        }

        if (! $self->pausedFlag) {

            $self->ivPoke('pausedFlag', TRUE);
            $self->ivPoke('clockFreeze', $self->session->sessionTime);

            # Update widgets
            $self->buttonTableObj->set_normal(FALSE);
            $self->buttonTableObj2->set_normal(TRUE);
            $self->buttonTableObj3->set_normal(FALSE);

        } else {

            $self->ivPoke('pausedFlag', FALSE);
            $self->ivPoke(
                'clockStop',
                $self->clockStop + $self->session->sessionTime - $self->clockFreeze,
            );

            $self->ivUndef('clockFreeze');

            # Update widgets
            $self->buttonTableObj->set_normal(FALSE);
            $self->buttonTableObj2->set_normal(TRUE);
            $self->buttonTableObj3->set_normal(FALSE);
        }

        return 1;
    }

    sub stopCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on the
        #   'Stop' button
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, 'stop'
        #
        # Return values
        #   'undef' on improper arguments, if the user hasn't entered a valid time value (or if
        #       they've entered no value at all) or if $self->countMode is still 'default'
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($time, $unit, $method);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->stopCallback', @_);
        }

        if ($self->countMode eq 'default') {

            # This shouldn't happen
            return undef;
        }

        # Reset the clock
        $self->drawTime();

        # Update IVs
        $self->ivPoke('countMode', 'default');
        $self->ivPoke('pausedFlag', FALSE);
        $self->ivUndef('clockStart');
        $self->ivUndef('clockStop');
        $self->ivUndef('clockFreeze');
        $self->ivUndef('clockTarget');
        $self->ivUndef('clockLeft');
        $self->ivUndef('clockWarning');

        # Update widgets
        $self->buttonTableObj->set_normal(TRUE);
        $self->buttonTableObj2->set_normal(FALSE);
        $self->buttonTableObj3->set_normal(FALSE);

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub labelTableObj
        { $_[0]->{labelTableObj} }
    sub entryTableObj
        { $_[0]->{entryTableObj} }
    sub comboTableObj
        { $_[0]->{comboTableObj} }
    sub comboTableObj2
        { $_[0]->{comboTableObj2} }
    sub buttonTableObj
        { $_[0]->{buttonTableObj} }
    sub buttonTableObj2
        { $_[0]->{buttonTableObj2} }
    sub buttonTableObj3
        { $_[0]->{buttonTableObj3} }

    sub initMode
        { $_[0]->{initMode} }
    sub initTime
        { $_[0]->{initTime} }

    sub textLen
        { $_[0]->{textLen} }
    sub textFont
        { $_[0]->{textFont} }
    sub textSize
        { $_[0]->{textSize} }
    sub textColour
        { $_[0]->{textColour} }
    sub underlayColour
        { $_[0]->{underlayColour} }
    sub textWarning
        { $_[0]->{textWarning} }
    sub underlayWarning
        { $_[0]->{underlayWarning} }

    sub countMode
        { $_[0]->{countMode} }
    sub pausedFlag
        { $_[0]->{pausedFlag} }
    sub clockStart
        { $_[0]->{clockStart} }
    sub clockStop
        { $_[0]->{clockStop} }
    sub clockFreeze
        { $_[0]->{clockFreeze} }
    sub clockTarget
        { $_[0]->{clockTarget} }
    sub clockLeft
        { $_[0]->{clockLeft} }
    sub clockWarning
        { $_[0]->{clockWarning} }

    sub stopEffect
        { $_[0]->{stopEffect} }
    sub warningEffect
        { $_[0]->{warningEffect} }
}

{ package Games::Axmud::Task::Divert;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Divert task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'divert_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'divert_task';
        $self->{prettyName}             = 'Divert';
        $self->{shortName}              = 'Dv';
        $self->{shortCutIV}             = 'divertTask';     # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Diverts text received from the world to a task window';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['entry', 'grid'];
        $self->{winmap}                 = 'entry_fill';
        $self->{winUpdateFunc}          = 'restoreWin';
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = TRUE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'divert';
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {
            'divert'                    => FALSE,
            'tell'                      => FALSE,
            'social'                    => FALSE,
            'custom'                    => FALSE,
            'warning'                   => FALSE,
        };
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # The normal background colour for the window (set when the window is enabled) - set to one
        #   of Axmud's standard colour tags or 'undef' to use the default colour
        $self->{defaultColour}          = undef;
        # When some text is diverted to the task window, the window's background colour changes.
        #   The colour depends on the channel. The channels 'tell', 'social', 'custom' and 'warning'
        #   have a colour assigned to them; all other channels share the same colour
        # The IVs are set to an Axmud colour tag. Any non-underlay tag can be used, but the task's
        #   edit window only shows standard colour tags like 'blue' and 'RED'
        # The colour to use for the 'tell' channel
        $self->{tellAlertColour}        = 'YELLOW';
        # The colour to use for the 'social' channel
        $self->{socialAlertColour}      = 'BLUE';
        # The colour to use for the 'custom' channel
        $self->{customAlertColour}      = 'cyan';
        # The colour to use for the 'warning' channel
        $self->{warningAlertColour}     = 'RED',
        # The colour to use for all other channels
        $self->{otherAlertColour}       = 'magenta',

        # When diverted text is received, how many seconds to use the alert colour
        $self->{tellAlertInterval}      = 10;
        $self->{socialAlertInterval}    = 3;
        $self->{customAlertInterval}    = 3;
        $self->{warningAlertInterval}   = 10;
        $self->{otherAlertInterval}     = 10;

        # Which sound effects are played when diverted text is received. The value should be
        #   one of the keys in GA::Client->customSoundHash; if the value is 'undef', no sound effect
        #   is played
        $self->{tellAlertSound}         = 'greeting';
        $self->{socialAlertSound}       = 'notify';
        $self->{customAlertSound}       = 'notify';
        $self->{warningAlertSound}      = 'alarm';
        $self->{otherAlertSound}        = 'notify';

        # Limits to the amount of text displayed in the task window. If set to 0, the whole matching
        #   line is displayed. Otherwise, the first n characters are displayed
        $self->{tellCharLimit}          = 0;
        $self->{socialCharLimit}        = 0;
        $self->{customCharLimit}        = 0;
        $self->{warningCharLimit}       = 0;
        $self->{otherCharLimit}         = 0;

        # Flags which, if set to TRUE, cause the automapper object's current room to be displayed
        #   when a tell, social or custom alert occurs. (If the automapper's current room isn't set,
        #   nothing extra is displayed)
        $self->{tellRoomFlag}           = FALSE;
        $self->{socialRoomFlag}         = FALSE;
        $self->{customRoomFlag}         = FALSE;
        $self->{warningRoomFlag}        = FALSE;
        $self->{otherRoomFlag}          = FALSE;

        # Flags which, if set to TRUE, cause the task window's urgency hint to be set when text is
        #   diverted (might not work in all desktop environments)
        $self->{tellUrgencyFlag}        = FALSE;
        $self->{socialUrgencyFlag}      = FALSE;
        $self->{customUrgencyFlag}      = FALSE;
        $self->{warningUrgencyFlag}     = FALSE;
        $self->{otherUrgencyFlag}       = FALSE;

        # When diverted text is received, the time (matching GA::Session->sessionTime) at which the
        #   alert background colour should be replaced by the default background colour . Usually
        #   set to 'undef', which means the default background colour is visible
        $self->{resetTime}              = undef;
        # Flag set to TRUE the first time diverted text is received (set to FALSE if no diverted
        #   text has been received)
        $self->{firstTextFlag}          = FALSE;
        # Multiple triggers can match a single line, but this task only displays a single line in
        #   its task window once. The display buffer line number of the last line that matched a
        #   tell, social or custom pattern
        $self->{lastLine}               = 0;
        # A short string written to the task window, in front of any text typed by the user (to make
        #   clear what was typed by whom)
        $self->{responseString}         = '=> ';
        # A list of all lines displayed in the window so that, if the window is closed when text is
        #   visible in it, when the window is opened they'll be ready for the user to see
        $self->{lineList}               = [];

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{defaultColour}         = $self->defaultColour;
        $clone->{tellAlertColour}       = $self->tellAlertColour;
        $clone->{socialAlertColour}     = $self->socialAlertColour;
        $clone->{customAlertColour}     = $self->customAlertColour;
        $clone->{warningAlertColour}    = $self->warningAlertColour;
        $clone->{otherAlertColour}      = $self->otherAlertColour;

        $clone->{tellAlertInterval}     = $self->tellAlertInterval;
        $clone->{socialAlertInterval}   = $self->socialAlertInterval;
        $clone->{customAlertInterval}   = $self->customAlertInterval;
        $clone->{warningAlertInterval}  = $self->warningAlertInterval;
        $clone->{otherAlertInterval}    = $self->otherAlertInterval;

        $clone->{tellAlertSound}        = $self->tellAlertSound;
        $clone->{socialAlertSound}      = $self->socialAlertSound;
        $clone->{customAlertSound}      = $self->customAlertSound;
        $clone->{warningAlertSound}     = $self->warningAlertSound;
        $clone->{otherAlertSound}       = $self->otherAlertSound;

        $clone->{tellCharLimit}         = $self->tellCharLimit;
        $clone->{socialCharLimit}       = $self->socialCharLimit;
        $clone->{customCharLimit}       = $self->customCharLimit;
        $clone->{warningCharLimit}      = $self->warningCharLimit;
        $clone->{otherCharLimit}        = $self->otherCharLimit;

        $clone->{tellRoomFlag}          = $self->tellRoomFlag;
        $clone->{socialRoomFlag}        = $self->socialRoomFlag;
        $clone->{customRoomFlag}        = $self->customRoomFlag;
        $clone->{warningRoomFlag}       = $self->warningRoomFlag;
        $clone->{otherRoomFlag}         = $self->otherRoomFlag;

        $clone->{tellUrgencyFlag}       = $self->tellUrgencyFlag;
        $clone->{socialUrgencyFlag}     = $self->socialUrgencyFlag;
        $clone->{customUrgencyFlag}     = $self->customUrgencyFlag;
        $clone->{warningUrgencyFlag}    = $self->warningUrgencyFlag;
        $clone->{otherUrgencyFlag}      = $self->otherUrgencyFlag;

        $clone->{resetTime}             = $self->resetTime;
        $clone->{firstTextFlag}         = $self->firstTextFlag;
        $clone->{lastLine}              = $self->lastLine;
        $clone->{responseString}        = $self->responseString;

        $clone->{lineList}              = [$self->lineList];

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the default colour
        $newTask->ivPoke('defaultColour', $self->defaultColour);
        # Preserve the background colours
        $newTask->ivPoke('tellAlertColour', $self->tellAlertColour);
        $newTask->ivPoke('socialAlertColour', $self->socialAlertColour);
        $newTask->ivPoke('customAlertColour', $self->customAlertColour);
        $newTask->ivPoke('warningAlertColour', $self->warningAlertColour);
        $newTask->ivPoke('otherAlertColour', $self->otherAlertColour);
        # Preserve intervals before resetting the background colour
        $newTask->ivPoke('tellAlertInterval', $self->tellAlertInterval);
        $newTask->ivPoke('socialAlertInterval', $self->socialAlertInterval);
        $newTask->ivPoke('customAlertInterval', $self->customAlertInterval);
        $newTask->ivPoke('warningAlertInterval', $self->warningAlertInterval);
        $newTask->ivPoke('otherAlertInterval', $self->otherAlertInterval);
        # Preserve sound effects
        $newTask->ivPoke('tellAlertSound', $self->tellAlertSound);
        $newTask->ivPoke('socialAlertSound', $self->socialAlertSound);
        $newTask->ivPoke('customAlertSound', $self->customAlertSound);
        $newTask->ivPoke('warningAlertSound', $self->warningAlertSound);
        $newTask->ivPoke('otherAlertSound', $self->otherAlertSound);
        # Preserve character limits
        $newTask->ivPoke('tellCharLimit', $self->tellCharLimit);
        $newTask->ivPoke('socialCharLimit', $self->socialCharLimit);
        $newTask->ivPoke('customCharLimit', $self->customCharLimit);
        $newTask->ivPoke('warningCharLimit', $self->warningCharLimit);
        $newTask->ivPoke('otherCharLimit', $self->otherCharLimit);
        # Preserve room flags
        $newTask->ivPoke('tellRoomFlag', $self->tellRoomFlag);
        $newTask->ivPoke('socialRoomFlag', $self->socialRoomFlag);
        $newTask->ivPoke('customRoomFlag', $self->customRoomFlag);
        $newTask->ivPoke('warningRoomFlag', $self->warningRoomFlag);
        $newTask->ivPoke('otherRoomFlag', $self->otherRoomFlag);
        # Preserve urgency hint flags
        $newTask->ivPoke('tellUrgencyFlag', $self->tellUrgencyFlag);
        $newTask->ivPoke('socialUrgencyFlag', $self->socialUrgencyFlag);
        $newTask->ivPoke('customUrgencyFlag', $self->customUrgencyFlag);
        $newTask->ivPoke('warningUrgencyFlag', $self->warningUrgencyFlag);
        $newTask->ivPoke('otherUrgencyFlag', $self->otherUrgencyFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

    sub ttsSwitchFlagAttrib {

        # Called by GA::Cmd::Switch->do and PermSwitch->do
        # Users can use the client command ';switch' to interact with individual tasks, typically
        #   telling them to turn on/off the automatic reading out of information (e.g. the Locator
        #   task can be told to start or stop reading out room titles as they are received from
        #   the world)
        # The ';switch' command is in the form ';switch <flag_attribute>'. The ';switch' command
        #   looks up the <flag_attribute> (which is a string, not a TRUE/FALSE value) in
        #   GA::Client->ttsFlagAttribHash, which tells it which task to call
        #
        # Expected arguments
        #   $flagAttrib - The TTS flag attribute specified by the calling function. Must be one of
        #                   the keys in $self->ttsFlagAttribHash
        #
        # Optional arguments
        #   $noSpecialFlag
        #               - Set to TRUE when called by GA::Cmd::PermSwitch->do, in which case only
        #                   this task's hash of flag attributes is updated. Otherwise set to FALSE
        #                   (or 'undef'), in which case other things can happen when a flag
        #                   attribute is switched. For all built-in tasks, there is no difference
        #                   in behaviour
        #
        # Return values
        #   'undef' on improper arguments or if the $flagAttrib doesn't exist in this task's
        #       ->ttsFlagAttribHash
        #   Otherwise returns a confirmation message for the calling function to display

        my ($self, $flagAttrib, $noSpecialFlag, $check) = @_;

        # Local variables
        my $msg;

        # Check for improper arguments
        if (! defined $flagAttrib || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->ttsSwitchFlagAttrib', @_);
        }

        # TTS flag attributes are case-insensitive
        $flagAttrib = lc($flagAttrib);

        # Check that the specified attribute flag is actually used by this task (';switch' should
        #   carry out this check, but better safe than sorry)
        if (! $self->ivExists('ttsFlagAttribHash', $flagAttrib)) {

            return undef;

        } else {

            # If a current task performs some kind of action, when a flag attribute is switched,
            #   the code for the action should be placed here. (Tasks in the global initial
            #   tasklist can't perform an action, of course.)
            if (! $noSpecialFlag) {

                # (no actions to perform)
            }

            $msg = '\'' . $self->prettyName . '\' flag attribute \'' . $flagAttrib
                            . '\' switched to ';

            # One change from the generic ->ttsSwitchFlagAttrib function: if the 'divert' attribute
            #   is switched on/off, switch the other four attributes on/off, too
            if ($flagAttrib eq 'divert') {

                if ($self->ivShow('ttsFlagAttribHash', 'divert')) {

                    $self->ivAdd('ttsFlagAttribHash', 'divert', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'tell', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'social', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'custom', FALSE);
                    $self->ivAdd('ttsFlagAttribHash', 'warning', FALSE);
                    $msg .= 'OFF';

                } else {

                    $self->ivAdd('ttsFlagAttribHash', 'divert', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'tell', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'social', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'custom', TRUE);
                    $self->ivAdd('ttsFlagAttribHash', 'warning', TRUE);
                    $msg .= 'ON';
                }

            } else {

                if ($self->ivShow('ttsFlagAttribHash', $flagAttrib)) {

                    $self->ivAdd('ttsFlagAttribHash', $flagAttrib, FALSE);
                    $msg .= 'OFF';

                } else {

                    $self->ivAdd('ttsFlagAttribHash', $flagAttrib, TRUE);
                    $msg .= 'ON';
                }
            }

            return $msg;
        }
    }

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

    sub doReset {

        # Called just before the task completes a reset
        # For process tasks, called by $self->main. For activity tasks, called by $self->reset
        #
        # Makes sure the task window is using the default background colour
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # In case the task window is currently a different colour when the task is reset, set it
        #   back to the default colour
        if ($self->defaultTabObj) {

            $self->defaultTabObj->paneObj->applyMonochrome(
                $self->defaultTabObj,
                $self->defaultColour,
            );
        }

        return 1;
    }

    sub doFirstStage {

        # Called by $self->main, just before the task completes the first stage ($self->stage)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doFirstStage', @_);
        }

        if ($self->session->channelsTask) {

            $self->writeError(
                'The Channels and Divert tasks cannot run at the same time, so halting the'
                . ' Divert task',
                $self->_objClass . '->doStage',
            );

            # Mark the task to be shutdown
            $self->ivPoke('shutdownFlag', TRUE);
            return undef;
        }
    }

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Create dependent triggers for patterns defined by the current world profile
            if (! $self->resetTriggers()) {

                $self->writeError(
                    'Could not create ' . $self->prettyName . ' task triggers, so halting the task',
                    $self->_objClass . '->doStage',
                );

                # Mark the task to be shutdown
                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            if (defined $self->resetTime && $self->resetTime <= $self->session->sessionTime) {

                if ($self->defaultTabObj) {

                    # The delay, started the last time some diverted text was received, is over.
                    #   Revert the task window to its original background colour
                    $self->defaultTabObj->paneObj->applyMonochrome(
                        $self->defaultTabObj,
                        $self->defaultColour,
                    );
                }

                $self->ivPoke('resetTime', undef);
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub resetTriggers {

        # Called by $self->doStage, GA::Cmd::AddChannelPattern->do and
        #   GA::Cmd::DeleteChannelPattern->do
        # Removes all of this task's dependent triggers and replaces them with new ones
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, or if there's an error replacing or creating the triggers
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @channelList;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetTriggers', @_);
        }

        # First remove any existing triggers. ->tidyInterfaces returns the number of triggers
        #   removed, or 'undef' it there's an error
        if (! defined $self->session->tidyInterfaces($self)) {

            return undef;
        }

        # Import the list of patterns (groups of 3)
        @channelList = $self->session->currentWorld->channelList;
        if (@channelList) {

            do {

                my ($pattern, $channel, $flag, $interfaceObj);

                $pattern = shift @channelList;
                $channel = shift @channelList;
                $flag = shift @channelList;

                # Create dependent trigger
                $interfaceObj = $self->session->createDependentInterface(
                    'trigger',
                    $pattern,
                    $self,
                    'divertPatternSeen',
                    'gag',
                    $flag,
                );

                if (! $interfaceObj) {

                    # If there's an error creating any triggers, remove any triggers already created
                    $self->session->tidyInterfaces($self);
                    return undef;

                } else {

                    # Give the trigger some properties that will tell $self->divertPatternSeen which
                    #   channel to use when the trigger fires
                    $interfaceObj->ivAdd('propertyHash', 'channel', $channel);
                }

            } until (! @channelList);
        }

        return 1;
    }

    sub displayText {

        # Called by $self->divertPatternSeen and ->displayWarning (only) to display text in the task
        #   window
        #
        # Expected arguments
        #   $channel    - The channel name, e.g. 'tell', 'social', 'custom', 'warning'
        #
        # Optional arguments
        #   $text       - The text to display. If 'undef', no text is displayed in the task window,
        #                   but the background colour is still changed
        #   $stripLine  - The original line of text received from the world (after escape sequences
        #                   are removed); used for writing logs ('undef' for 'warning' messages)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $channel, $text, $stripLine, $check) = @_;

        # Local variables
        my ($time, $origText, $otherFlag);

        # Check for improper arguments
        if (! defined $channel || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayText', @_);
        }

        # Change the task window's background colour, and set the interval at which the colour
        #   will revert back to the default colour
        # Also, play a sound (if allowed) and apply a size limit to the text (if a limit is set)
        if ($channel eq 'tell') {

            if ($self->defaultTabObj) {

                $time = $self->session->sessionTime + $self->tellAlertInterval;
                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->tellAlertColour,
                );
            }

            if ($self->tellAlertSound) {

                $axmud::CLIENT->playSound($self->tellAlertSound);
            }

            if ($self->tellCharLimit) {

                $text = substr($text, 0, $self->tellCharLimit);
            }

        } elsif ($channel eq 'social') {

            if ($self->defaultTabObj) {

                $time = $self->session->sessionTime + $self->socialAlertInterval;
                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->socialAlertColour,
                );
            }

            if ($self->socialAlertSound) {

                $axmud::CLIENT->playSound($self->socialAlertSound);
            }

            if ($self->socialCharLimit) {

                $text = substr($text, 0, $self->socialCharLimit);
            }

        } elsif ($channel eq 'custom') {

            if ($self->defaultTabObj) {

                $time = $self->session->sessionTime + $self->customAlertInterval;
                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->customAlertColour,
                );
            }

            if ($self->customAlertSound) {

                $axmud::CLIENT->playSound($self->customAlertSound);
            }

            if ($self->customCharLimit) {

                $text = substr($text, 0, $self->customCharLimit);
            }

        } elsif ($channel eq 'warning') {

            if ($self->defaultTabObj) {

                $time = $self->session->sessionTime + $self->warningAlertInterval;
                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->warningAlertColour,
                );
            }

            if ($self->warningAlertSound) {

                $axmud::CLIENT->playSound($self->warningAlertSound);
            }

            if ($self->warningCharLimit) {

                $text = substr($text, 0, $self->warningCharLimit);
            }

        } else {

            $otherFlag = TRUE;

            if ($self->defaultTabObj) {

                $time = $self->session->sessionTime + $self->otherAlertInterval;
                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->otherAlertColour,
                );
            }

            if ($self->otherAlertSound) {

                $axmud::CLIENT->playSound($self->otherAlertSound);
            }

            if ($self->otherCharLimit) {

                $text = substr($text, 0, $self->otherCharLimit);
            }
        }

        if ($self->taskWinFlag) {

            # (Different types of messages have different intervals; don't change the time at which
            #   the window reverts if the new interval finishes before the existing one)
            if (! $self->resetTime || $self->resetTime < $time) {

                $self->ivPoke('resetTime', $time);
            }
        }

        # Preserve the original text, in case we convert the text to speech below
        $origText = $text;

        # Add the automapper's current room, if allowed
        if (
            $self->session->mapObj->currentRoom
            && (
                ($channel eq 'tell' && $self->tellRoomFlag)
                || ($channel eq 'social' && $self->socialRoomFlag)
                || ($channel eq 'custom' && $self->customRoomFlag)
                || ($channel eq 'warning' && $self->warningRoomFlag)
                || ($otherFlag && $self->otherRoomFlag)
            )
        ) {
            $text .= ' [Room #' . $self->session->mapObj->currentRoom->number . ']';
        }

        # Display the text (if there is any)
        if ($self->taskWinFlag && $text ne '') {

            if (! $self->firstTextFlag) {

                # This is the first text to be displayed in the task window; need to remove any
                #   holding messages
                $self->insertWithLinks($text, 'empty');
                $self->ivPoke('firstTextFlag', TRUE);

            } else {

                $self->insertWithLinks($text);
            }
        }

        # If there are any Watch tasks running - in any session - update them
        foreach my $otherSession ($axmud::CLIENT->listSessions()) {

            my ($world, $char);

            if ($otherSession->watchTask) {

                $world = $self->session->currentWorld->name;
                if ($self->session->currentChar) {

                    $char = $self->session->currentChar->name;  # Otherwise 'undef'
                }

                $otherSession->watchTask->displayText('divert', $world, $char, $text);
            }
        }

        # Set the task window's urgency hint, if the appropriate flag is set
        if ($self->winObj) {

            if (
                ($channel eq 'tell' && $self->tellUrgencyFlag)
                || ($channel eq 'social' && $self->socialUrgencyFlag)
                || ($channel eq 'custom' && $self->customUrgencyFlag)
                || ($channel eq 'warning' && $self->warningUrgencyFlag)
                || ($otherFlag && $self->otherUrgencyFlag)
            ) {
                $self->winObj->setUrgent();
            }
        }

        # Also write the text to the logs, as if it had appeared in the 'main' window (if allowed)
        if (defined $stripLine && defined $origText) {

            $self->session->writeIncomingDataLogs($stripLine, $origText);
        }

        # Store the text in this list IV so that, if the window is closed and then re-opened, all
        #   the lines of text (since the last use of ';emptydivertwindow') can be re-displayed
        $self->ivPush('lineList', $text);

        # Read out a TTS message, if required
        if (
            # Read out all messages, if set
            $self->ivShow('ttsFlagAttribHash', 'divert')
            # Read out only this text for this $channel, if set
            || $self->ivShow('ttsFlagAttribHash', $channel)
        ) {
            $self->ttsQuick($origText);
        }

        return 1;
    }

    sub displayWarning {

        # Can be called by anything that wants to display some text in the task window as if it were
        #   in the 'warning' channel (a handy alternative to an audible alarm)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   $text    - The warning text to display. If 'undef', nothing is displayed, but the
        #               window's background colour is still changed
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $text, $check) = @_;

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayWarning', @_);
        }

        # Write the text to the window
        $self->displayText('warning', $text);

        return 1;
    }

    sub resetWin {

        # Called by GA::Cmd::EmptyDivertWindow->do
        # Resets the task window - removes any text, and sets the background colour back to the
        #   default
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetWin', @_);
        }

        if ($self->taskWinFlag) {

            # Empty the window of text
            $self->clearBuffer();

            # Use the default background colour
            if ($self->defaultTabObj) {

                $self->defaultTabObj->paneObj->applyMonochrome($self->defaultColour);
            }

            # If the window's urgency hint is set, then reset it
            if ($self->winObj) {

                $self->winObj->resetUrgent();
            }
        }

        $self->ivPoke('resetTime', undef);
        $self->ivEmpty('lineList');

        return 1;
    }

    sub restoreWin {

        # Called by $self->toggleWin, when the window is re-opened after being closed
        # Restores all the lines of text that would have been displayed, had the window been open
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->restoreWin', @_);
        }

        if ($self->taskWinFlag) {

            foreach my $line ($self->lineList) {

                $self->insertText($line);
            }
        }

        return 1;
    }

    ##################
    # Response methods

    sub divertPatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   patterns in the world profile's ->patternList
        #   e.g. Gandalf tells you, Give me the ring!
        #
        # The function diverts the line to the task window and modifies its background colour
        #   (temporarily). Group substrings are ignored
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   channel         - Which channel to use
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the corresponding IV can't be found or if the received
        #       line of text matches one of the exception patterns for this type of message
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->divertPatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Ignore the trigger, if this line has already been diverted
        if ($self->lastLine && $self->lastLine == $self->session->displayBufferLast) {

            return undef;

        } else {

            # Don't act on any more triggers from this line
            $self->ivPoke('lastLine', $self->session->displayBufferLast);
        }

        # Check this line of text doesn't match one of the patterns in the exception list,
        #   GA::Profile::World->noChannelList
        # Any line of text which matches a pattern in that list is diverted back to the 'main'
        #   window, and is not displayed in the task window
        # Check that $modLine doesn't contain one of those exception patterns
        foreach my $pattern ($self->session->currentWorld->noChannelList) {

            if ($modLine =~ m/$pattern/i) {

                # Divert the text back into the 'main' window, where it belongs (but only if a gag
                #   trigger was used)
                if ($obj->ivShow('attribHash', 'gag')) {

                    $self->session->defaultTabObj->textViewObj->insertText(
                        $modLine,
                        'after',           # Assume that the line would have ended in a newline char
                    );

                    # Write to logs, if allowed
                    $self->session->writeIncomingDataLogs($stripLine, $modLine);
                }

                # (Don't bother checking the other exception patterns)
                return undef;
            }
        }

        # Display the text in the task window
        $self->displayText(
            $obj->ivShow('propertyHash', 'channel'),
            $modLine,
            $stripLine,
        );

        # Write the message to a logfile (if possible)
        $axmud::CLIENT->writeLog(
            $self->session,
            FALSE,      # Not a 'standard' logfile
            $modLine,
            FALSE,      # Don't precede with a newline character
            TRUE,       # Use final newline character
            'divert',   # Write to this logfile
        );

        return 1;
    }

    sub entryCallback {

        # Usually called by ->signal_connect in GA::Strip::Entry->setEntrySignals or in
        #   GA::Table::Entry->setActivateEvent, when the user types something in the strip/table
        #   object's Gtk3::Entry and presses RETURN
        # The text is treated as an ordinary instruction (but Perl commands are not allowed); a
        #   copy is also displayed in the task window
        #
        # Expected arguments
        #   $obj        - The strip or table object whose Gtk3::Entry was used
        #   $entry      - The Gtk3::Entry itself
        #
        # Optional arguments
        #   $id         - A value passed to the table object that identifies the particular
        #                   Gtk3::Entry used (in case the table object uses multiple entries). By
        #                   default, $self->openWin sets $id to the same as $self->uniqueName;
        #                   could be an 'undef' value otherwise
        #   $text       - The text typed in the entry by the user (should not be 'undef')
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $obj, $entry, $id, $text, $check) = @_;

        # Check for improper arguments
        if (! defined $obj || ! defined $entry || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->entryCallback', @_);
        }

        if ($self->taskWinFlag) {

            # Execute the instruction (but don't allow Perl commands)
            $self->session->doInstruct($text, TRUE);
            $self->insertText($self->responseString . $text);
        }

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub defaultColour
        { $_[0]->{defaultColour} }
    sub tellAlertColour
        { $_[0]->{tellAlertColour} }
    sub socialAlertColour
        { $_[0]->{socialAlertColour} }
    sub customAlertColour
        { $_[0]->{customAlertColour} }
    sub warningAlertColour
        { $_[0]->{warningAlertColour} }
    sub otherAlertColour
        { $_[0]->{otherAlertColour} }

    sub tellAlertInterval
        { $_[0]->{tellAlertInterval} }
    sub socialAlertInterval
        { $_[0]->{socialAlertInterval} }
    sub customAlertInterval
        { $_[0]->{customAlertInterval} }
    sub warningAlertInterval
        { $_[0]->{warningAlertInterval} }
    sub otherAlertInterval
        { $_[0]->{otherAlertInterval} }

    sub tellAlertSound
        { $_[0]->{tellAlertSound} }
    sub socialAlertSound
        { $_[0]->{socialAlertSound} }
    sub customAlertSound
        { $_[0]->{customAlertSound} }
    sub warningAlertSound
        { $_[0]->{warningAlertSound} }
    sub otherAlertSound
        { $_[0]->{otherAlertSound} }

    sub tellCharLimit
        { $_[0]->{tellCharLimit} }
    sub socialCharLimit
        { $_[0]->{socialCharLimit} }
    sub customCharLimit
        { $_[0]->{customCharLimit} }
    sub warningCharLimit
        { $_[0]->{warningCharLimit} }
    sub otherCharLimit
        { $_[0]->{otherCharLimit} }

    sub tellRoomFlag
        { $_[0]->{tellRoomFlag} }
    sub socialRoomFlag
        { $_[0]->{socialRoomFlag} }
    sub customRoomFlag
        { $_[0]->{customRoomFlag} }
    sub warningRoomFlag
        { $_[0]->{warningRoomFlag} }
    sub otherRoomFlag
        { $_[0]->{otherRoomFlag} }

    sub tellUrgencyFlag
        { $_[0]->{tellUrgencyFlag} }
    sub socialUrgencyFlag
        { $_[0]->{socialUrgencyFlag} }
    sub customUrgencyFlag
        { $_[0]->{customUrgencyFlag} }
    sub warningUrgencyFlag
        { $_[0]->{warningUrgencyFlag} }
    sub otherUrgencyFlag
        { $_[0]->{otherUrgencyFlag} }

    sub resetTime
        { $_[0]->{resetTime} }
    sub firstTextFlag
        { $_[0]->{firstTextFlag} }
    sub lastLine
        { $_[0]->{lastLine} }
    sub responseString
        { $_[0]->{responseString} }
    sub lineList
        { my $self = shift; return @{$self->{lineList}}; }
}

{ package Games::Axmud::Task::Frame;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Frame task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'frame_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'frame_task';
        $self->{prettyName}             = 'Frame';
        $self->{shortName}              = 'Fr';
        $self->{shortCutIV}             = undef;            # Built-in task, but not jealous

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Implements MXP floating frames using a task window';
        $self->{jealousyFlag}           = FALSE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = FALSE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;            # No func to call after ;opentaskwindow
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters

        # The GA::Mxp::Frame object that uses this task's window
        $self->{frameObj}               = undef;
        # The preferred size and position of the task window on the workspace, in pixels
        $self->{leftPixels}             = undef;
        $self->{topPixels}              = undef;
        $self->{widthPixels}            = undef;
        $self->{heightPixels}           = undef;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
#        $clone->{frameObj}              = $self->frameObj;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the GA::Mxp::Frame object and the window's preferred size/position
        $newTask->ivPoke('frameObj', $self->frameObj);
        $newTask->ivPoke('leftPixels', $self->leftPixels);
        $newTask->ivPoke('topPixels', $self->topPixels);
        $newTask->ivPoke('widthPixels', $self->widthPixels);
        $newTask->ivPoke('heightPixels', $self->heightPixels);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

    sub openWin {

        # Called by the task's ->main method or by its ->toggleWin method
        # Tries to open each type of task window in $self->winPreferList, halting at the first
        #   successful attempt (or when all attempts fail)
        #
        # Expected arguments
        #   (none besides self)
        #
        # Optional arguments
        #   $winmap     - Ignored if specified
        #   @preferList - Ignored if specified
        #
        # Return values
        #   'undef' on improper arguments or if a task window is not opened
        #   1 if a window is opened

        my ($self, $winmap, @preferList) = @_;

        # Local variables
        my ($winObj, $workspaceObj, $gridObj, $layer);

        # (No improper arguments to check)

        # We want to open the window at a preferred size and position, so we use our own code,
        #   rather than code in the generic task
        # Also, if $winmap / @preferList are specified, they is ignored; this function only opens a
        #   'grid' window using a 'basic_fill' winmap
        # Only the workspace used by the session's 'main' window is used; if the task window can't
        #   be opened there, it's not opened at all

        # If $self->frameObj isn't set, there's no point in opening a task window
        if (! $self->frameObj) {

            return undef;
        }

        # Set the task IV to enable/disable scrolling
        if (! $self->frameObj->scrollingFlag) {

            $self->ivPoke('noScrollFlag', TRUE);
        }

        # Prepare arguments
        $workspaceObj = $self->session->mainWin->workspaceObj;
        $gridObj = $workspaceObj->findWorkspaceGrid($self->session);

        # Use the layer one higher than the highest window (ignoring other Frame task windows, of
        #   course), so that the task window's size won't be adjusted to fit around existing windows
        # Exception: if Pueblo specified a floating frame, implement that by putting the task window
        #   in the highest layer (rather than calling Gtk3::Window->set_keep_above, or something)
        if ($self->frameObj->floatingFlag) {

            $layer = $gridObj->maxLayers - 1;

        } else {

            # Start on layer 1, on the assumption that the session's 'main' window must exist, so
            #   there is probably a window in layer 0
            $layer = 1;
            foreach my $otherWinObj ($gridObj->ivValues('gridWinHash')) {

                if (
                    (
                        ! $otherWinObj->owner
                        || ! $otherWinObj->owner->isa('Games::Axmud::Task::Frame')
                    )
                    && $otherWinObj->areaObj->layer >= $layer
                ) {
                    $layer = $otherWinObj->areaObj->layer + 1;
                }
            }
        }

        # Open the window
        $winObj = $workspaceObj->createGridWin(
            'custom',                       # All task windows are 'custom' windows
            $self->name,                    # Window name is the same as the task name
            $self->frameObj->title,         # Window title
            'basic_fill',                   # Winmap
            'Games::Axmud::Win::Internal',  # Package name
            undef,                          # No windows exists yet
            undef,                          # Ditto
            $self,                          # Owner
            $self->session,                 # Session
            $gridObj,                       # Session's workspace grid object
            undef,                          # Use any zone
            $layer,
            $self->leftPixels,              # Preferred window size/position (integer or 'undef')
            $self->topPixels,
            $self->widthPixels,
            $self->heightPixels,
        );

        if (! $winObj) {

            # Window not opened
            return undef;
        }

        # Window created and enabled
        $self->ivPoke('winObj', $winObj);
        $self->ivPoke('taskWinFlag', TRUE);
        # Set its title (using the frame title, not the task name)
        $self->setTaskWinTitle($self->frameObj->name);

        # Add a tab, if required. The TRUE argument indicates window setup
        $self->addTab(undef, TRUE);
        # (No entry box to setup)

        # Update the frame object's IVs
        $self->frameObj->ivPoke('tabObj', $self->defaultTabObj);
        $self->frameObj->ivPoke('paneObj', $self->defaultTabObj->paneObj);
        $self->frameObj->ivPoke('textViewObj', $self->defaultTabObj->textViewObj);

        return 1;
    }

    sub closeWin {

        # Called by the task's ->main method or by its ->toggleWin method, or by any other code
        # Close the task window for this task, if it is open
        #
        # Expected arguments
        #   (none besides self)
        #
        # Return values
        #   'undef' on improper arguments or if the window was not closed
        #   1 if the window was closed

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->closeWin', @_);
        }

        # Most of the code is in the generic function
        if (! $self->Games::Axmud::Generic::Task::closeWin()) {

            # Task window not closed
            return undef;

        } else {

            # Also inform the session, so it can stop using frames altogether
            $self->session->removeMxpFrames();

            return 1;
        }
    }

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

#   sub doInit {}               # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    ##################
    # Response methods

    ##################
    # Accessors - set

    sub set_frameObj {

        my ($self, $frameObj, $check) = @_;

        # Check for improper arguments
        if (! defined $frameObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_frameObj', @_);
        }

        # Update IVs
        $self->ivPoke('frameObj', $frameObj);

        return 1;
    }

    sub del_winObj {

        # Called by GA::Win::Generic->winDestroy

        my ($self, $winObj, $check) = @_;

        # Local variables
        my $stripObj;

        # Check for improper arguments
        if (! defined $winObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->del_winObj', @_);
        }

        # Most of the code is in the generic function
        if (! $self->Games::Axmud::Generic::Task::del_winObj($winObj)) {

            return undef;

        } else {

            # Inform the session, so it can stop using frames altogether
            $self->session->removeMxpFrames();

            return 1;
        }
    }

    sub set_winPosn {

        my ($self, $left, $top, $width, $height, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_winPosn', @_);
        }

        # Update IVs
        $self->ivPoke('leftPixels', $left);
        $self->ivPoke('topPixels', $top);
        $self->ivPoke('widthPixels', $width);
        $self->ivPoke('heightPixels', $height);

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub frameObj
        { $_[0]->{frameObj} }
    sub leftPixels
        { $_[0]->{leftPixels} }
    sub topPixels
        { $_[0]->{topPixels} }
    sub widthPixels
        { $_[0]->{widthPixels} }
    sub heightPixels
        { $_[0]->{heightPixels} }
}

{ package Games::Axmud::Task::Inventory;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Inventory task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'inventory_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'inventory_task';
        $self->{prettyName}             = 'Inventory';
        $self->{shortName}              = 'Iv';
        $self->{shortCutIV}             = 'inventoryTask';  # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Keeps track of the character\'s inventory';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = TRUE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
        # The task is activated when it starts, and can be disactivated (temporarily) to allow the
        #   user to see the normal output of commands like 'inventory', etc
        $self->{activeFlag}             = FALSE;            # Task can be activated/disactivated
        # The task should be called only once per second (to give a better chance of capturing the
        #   whole of the character's inventory)
        $self->{delayTime}              = 1;

        # Task parameters
        #
        # If ->activeFlag = TRUE, a list of commands that should be sent to the world, and how
        #   often. The list is imported from GA::Profile::Char->inventoryCmdHash. Hash in the form
        #       $cmdHash{'i'} = 60 >> the task creates a timer to send the command 'i' every 60 secs
        $self->{cmdHash}                = {};
        # The independent timer interface objects created; enable when the task is active, disabled
        #   when it is disactivated. Hash in the form
        #       $timerNameHash{timer_name} = timer_interface_object
        $self->{timerHash}              = {};
        # The dependent trigger interfaces created; one for non-gagged patterns, the other for
        #   gagged patterns
        $self->{triggerList}            = [];
        $self->{ignoreTriggerList}      = [];

        # The world profile has a ->inventoryMode IV, which tells this task how to gather the
        #   character's inventory:
        #       Mode 'match_all' - Use lines matching patterns in ->inventoryPatternList
        #       Mode 'start_stop' - Use all lines between a 'start' and 'stop' line
        #       Mode 'start_empty' - Use lines between a 'start' line and the first empty line
        #
        # This IV is used in mode 'match_all', whenever a 'stop' line is received. The next received
        #   line which matches a pattern in ->inventoryPatternList causes the inventory list to be
        #   reset (when a 'start' line is received, the inventory list is reset before the line is
        #   processed)
        $self->{resetInventoryFlag}     = FALSE;
        #
        # The following IVs are used in modes 'start_stop' and 'start_empty'
        # A list of lines matching 'start' and 'stop' patterns, in the order in which they were
        #   found. Every time the task loop calls this task (default - once a second), the list
        #   is processed, and then emptied.
        # In GA::Profile::World->inventoryMode 'start_stop', a list in the form
        #   ('start', start_line_num, start_line_text, 'stop', stop_line_num, stop_line_text...)
        # In ->inventoryMode 'start_empty', a list in the form
        #   ('start', start_line_num, start_line_text...)
        # If the world sends two inventory lists, one after the other (e.g. if we send the 'i' and
        #   'money' commands together), the list will contain more than one start line.
        # If the list contains anything at all, when this task is called, the inventory saved in
        #   $self->inventoryList is reset and then refilled. If, when the list is processed, the
        #   list contains more than one 'start' line, $self->inventoryList is reset for the first
        #   one, but only reset for subsequent 'start' lines if those lines are the same as an
        #   earlier 'start' line (so, it's safe to send 'inventory;inventory', as well as safe to
        #   send 'inventory;money' - the former will only update $self->inventoryList once)
        # The list described above
        $self->{startStopList}          = [];
        # As ->startStopList is being written, this flag is set to TRUE when a 'start' line is
        #   found, and set back to FALSE when a 'stop' line is found
        $self->{startFlag}              = FALSE;
        # As the contents of ->startStopList are processed, the text of any 'start' line is added
        #   to this list, such that identical 'start' lines are not processed
        $self->{startLineTextList}      = [];
        # In modes 'start_stop' & 'start_empty', a list of lines from the display buffer (matches
        #   GA::Buffer::Display->modLine) which have already been processed, because they match one
        #   of the patterns iN ->inventoryPatternList (and don't match one of the patterns in
        #   ->inventoryIgnorePatternList). Always empty in mode 'match_all'
        $self->{useLineList}            = [];
        # The display buffer number of the last line that caused one of this task's triggers to
        #   fire. If a second trigger fires on the same line, it's ignored completely; only the
        #   first trigger on each line is used
        $self->{lastLine}               = 0;

        # The list of things currently in the character's inventory (each element in the list is a
        #   non-model object)
        $self->{inventoryList}          = [];
        # The contents of ->inventoryList, the previous time the character's inventory was
        #   analysed. If the Condition task was running, this previous list of things contains
        #   information about their condition, which we need to preserve (because it can take
        #   several minutes to test the condition of everything in the inventory, but the inventory
        #   itself is usually gathered once every 60 seconds)
        $self->{previousList}           = [];
        # When comparing two objects, how certain the task needs to be that it they match.
        #   0 = any objects match, 100 = only identical objects match, 70 - objects are fairly (70%)
        #   similar, 90 = objects are very (90%) similar, etc
        $self->{sensitivity}            = 80;

        # Flag set to TRUE any time one of the timers fires, meaning that the task window has to
        #   be refreshed (set to FALSE otherwise)
        $self->{refreshWinFlag}         = FALSE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{cmdHash}               = {$self->cmdHash};
        $clone->{timerHash}             = {$self->timerHash};
        $clone->{triggerList}           = [$self->triggerList];
        $clone->{ignoreTriggerList}     = [$self->ignoreTriggerList];

        $clone->{resetInventoryFlag}    = $self->resetInventoryFlag;

        $clone->{startStopList}         = [$self->startStopList];
        $clone->{startFlag}             = $self->startFlag;
        $clone->{startLineTextList}     = [$self->startLineTextList];
        $clone->{useLineList}           = [$self->useLineList];
        $clone->{lastLine}              = $self->lastLine;

        $clone->{inventoryList}         = [$self->inventoryList];
        $clone->{previousList}          = [$self->previousList];
        $clone->{sensitivity}           = $self->sensitivity;

        $clone->{refreshWinFlag}        = $self->refreshWinFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the inventory lists so we don't lose track of what we have in containers (like
        #   sacks). Don't preserve the previous inventory
        $newTask->ivPoke('inventoryList', $self->inventoryList);
        # Preserve the sensitivity at which objects are compared
        $newTask->ivPoke('sensitivity', $self->sensitivity);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

    sub doShutdown {

        # Called by $self->main, just before the task completes a shutdown
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doShutdown', @_);
        }

        # Destroy the independent timer interfaces created by this task (which aren't destroyed
        #   automatically when the task finishes)
        foreach my $obj ($self->ivValues('timerHash')) {

            # Delete the timer. The TRUE argument means 'don't show an error message if the
            #   interface has already been deleted (probably during a disconnection)'
            $self->session->deleteInterface($obj->name, TRUE);
        }

        return 1;
    }

#   sub doReset {}              # Inherited from generic task

    sub doFirstStage {

        # Called by $self->main, just before the task completes the first stage ($self->stage)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doFirstStage', @_);
        }

        # Display a holding message in the task window, if necessary
        if (! $self->session->currentChar || ! $self->session->loginFlag) {

            # Empty the window, and show the message
            $self->insertText('<waiting for current character and login>', 'empty');
        }

        return 1;
    }

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Can't do anything until the current character has been set and the user has logged
            #   in to the world
            if (! $self->session->currentChar || ! $self->session->loginFlag) {

                # Repeat this stage indefinitely
                return $self->ivPoke('stage', 2);
            }

            # From the current character, import the list of commands to send periodically
            $self->ivPoke('cmdHash', $self->session->currentChar->inventoryCmdHash);

            # The task is now active
            $self->ivPoke('activeFlag', TRUE);

            # Set up timers and triggers
            $self->resetInterfaces();

            # Mark the task window as needing to be refreshed
            $self->ivPoke('refreshWinFlag', TRUE);

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            my $worldObj;

            # Import the current world profile
            $worldObj = $self->session->currentWorld;

            # If the beginning and/or end of an inventory list has been detected,
            #   $self->startStopList won't be empty
            if ($self->startStopList) {

                # Update $self->inventoryList
                $self->updateInventory($worldObj);

                # Every time this function is called (default - once a second), reset the start/
                #   stop lines and everything in between
                $self->resetLineData();

                # Tell this task's window to refresh, if open
                $self->ivPoke('refreshWinFlag', TRUE);
            }

            # Refresh the task window, if any new inventory-related text has been received
            if ($self->refreshWinFlag) {

                $self->refreshWin();
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub activate {

        # Called by GA::Cmd::ActivateInventory->do (not called by $self->doStage, because the task
        #   is activated at stage 2, before interfaces are created)
        # Enables the interfaces created by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->activate', @_);
        }

        # Enable each timer in turn
        foreach my $obj ($self->ivValues('timerHash')) {

            $obj->modifyAttribs($self->session, 'enabled', 1);
        }

        # Update IVs
        $self->ivPoke('activeFlag', TRUE);
        $self->ivPoke('refreshWinFlag', TRUE);
        $self->resetLineData();

        return 1;
    }

    sub disactivate {

        # Called by GA::Cmd::DisactivateInventory->do
        # Disables the interfaces created by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->disactivate', @_);
        }

        # Disable each timer in turn
        foreach my $obj ($self->ivValues('timerHash')) {

            $obj->modifyAttribs($self->session, 'enabled', 0);
        }

        # Update IVs
        $self->ivPoke('activeFlag', FALSE);
        $self->ivPoke('refreshWinFlag', TRUE);
        $self->resetLineData();

        return 1;
    }

    sub resetInterfaces {

        # Called by $self->doStage at task stage 2, to create all the timer and trigger interfaces
        #   used by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $worldObj,
            @list,
            %typeHash, %cmdHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetInterfaces', @_);
        }

        # Import the current world profile
        $worldObj = $self->session->currentWorld;

        # Create a hash of valid possession types (for quick lookup)
        %typeHash = (
            'wield'             => undef,
            'hold'              => undef,
            'wear'              => undef,
            'carry'             => undef,
            'sack'              => undef,
            'purse'             => undef,
            'deposit'           => undef,
            'deposit_only'      => undef,
            'withdraw'          => undef,
            'withdraw_only'     => undef,
            'balance'           => undef,
            'empty_purse'       => undef,
            'empty_bank'        => undef,
            'misc'              => undef,
            'ignore'            => undef,
        );

        # The world profile stores characteristic patterns which mark text as being part of the
        #   inventory list (e.g. 'You are wearing: ', 'You have .* in your purse' etc)
        # It also stores patterns which mean the whole line should be ignored; we'll create triggers
        #   for those first, so that the ignorable patterns get checked before the usable patterns

        # Set up triggers for ignorable patterns
        @list = $worldObj->inventoryIgnorePatternList;
        OUTER: while (@list) {

            my ($pattern, $gag, $flag, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger
            $gag = shift @list;

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'ignorePatternSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->inventoryIgnorePatternList IV',
                    $self->_objClass . '->resetInterfaces',
                );

                last OUTER;

            } else {

                # Store the trigger
                $self->ivPush('ignoreTriggerList', $interfaceObj);
            }
        }

        # Set up triggers for usable patterns
        @list = $worldObj->inventoryPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $type, $gag, $posn, $flag, $interfaceObj);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;
            # The type of possession: a key in %typeHash
            $type = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger
            $gag = shift @list;
            # 'start' if this line is always at the beginning of an inventory list; 'stop' if it is
            #   always the end of an inventory list; 'optional' for any other line
            $posn = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (
                ! defined $posn
                || ! exists $typeHash{$type}
                || ($gag ne 'show' && $gag ne 'hide')
                || ($posn ne 'start' && $posn ne 'stop' && $posn ne 'optional')
            ) {
                $self->writeWarning(
                    'Missing or invalid arguments in current world profile\'s'
                    . ' ->inventoryPatternList IV',
                    $self->_objClass . '->resetInterfaces',
                );

                last OUTER;
            }

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'triggerSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->inventoryPatternList IV',
                    $self->_objClass . '->resetInterfaces',
                );

                last OUTER;

            } else {

                # Store the trigger
                $self->ivPush('triggerList', $interfaceObj);

                # Give the trigger some properties that will help $self->triggerSeen to decide
                #   what to do when the trigger fires
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
                $interfaceObj->ivAdd('propertyHash', 'inv_type', $type);
                $interfaceObj->ivAdd('propertyHash', 'position', $posn);
            }
        }

        # Import the hash of commands to send periodically
        %cmdHash = $self->cmdHash;
        OUTER: foreach my $cmd (keys %cmdHash) {

            my ($interval, $interfaceObj);

            # The interval is how many seconds to wait before re-sending the same command
            $interval = $cmdHash{$cmd};

            # Each command should be sent for the first time right now
            $self->session->worldCmd($cmd);

            # Create independent timers to send the command to the world every few seconds
            $interfaceObj = $self->session->createIndependentInterface(
                'timer',
                $interval,          # Stimulus
                $cmd,               # Response
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create timer for current character profile\'s'
                    . ' ->inventoryCmdHash IV',
                    $self->_objClass . '->resetInterfaces',
                );

                last OUTER;

            } else {

                # Store the timer
                $self->ivAdd('timerHash', $cmd, $interfaceObj);
            }
        }

        # Reset complete
        return 1;
    }

    sub updateInventory {

        # Called by $self->doStage at task stage 3 to update the character's inventory based on
        #   the information stored in $self->startStopList
        #
        # Expected arguments
        #   $worldObj   - The current world profile object
        #
        # Return values
        #   'undef' on improper arguments or if there is an error
        #   1 otherwise

        my ($self, $worldObj, $check) = @_;

        # Local variables
        my @startStopList;

        # Check for improper arguments
        if (! defined $worldObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->updateInventory', @_);
        }

        # In GA::Profile::World->inventoryMode 'start_stop', $self->startStopList is in the form
        #   ('start', start_line_num, start_line_text, 'stop', stop_line_num, stop_line_text...)
        # In ->inventoryMode 'start_empty', $self->startStopList is in the form
        #   ('start', start_line_num, start_line_text...)
        # Import $self->startStopList
        @startStopList = $self->startStopList;

        do {

            my (
                $startType, $startLineNum, $startLineText, $stopType, $stopLineNum, $stopLineText,
                $matchFlag, $lineNum, $exitFlag,
                @lineTextList,
            );

            # Remove the 'start' line data
            $startType = shift @startStopList;
            $startLineNum = shift @startStopList;
            $startLineText = shift @startStopList;

            if ($startType ne 'start') {

                # List is corrupted
                return undef;
            }

            # Remove the 'stop' line data, if it exists
            if (@startStopList && $startStopList[0] eq 'stop') {

                $stopType = shift @startStopList;
                $stopLineNum = shift @startStopList;
                $stopLineText = shift @startStopList;
            }

            # This function may have to cope with several inventory lists, one after the other. For
            #   example, the user might type 'money;i' which sends us two inventory lists, which
            #   must be combined. The user might also type 'i;i;i' which sends us three identical
            #   inventory lists; each successive 'i' overwrites the ->inventoryList compiled by the
            #   previous one
            # Check $startLineText against all other $startLineTexts received recently
            if ($self->startLineTextList) {

                OUTER: foreach my $otherText ($self->startLineTextList) {

                    if ($otherText eq $startLineText) {

                        # This is the second time a line matching a particular 'start' pattern has
                        #   been found; to avoid duplication of an object, we have to empty the
                        #   whole list
                        $self->ivEmpty('inventoryList');
                        $matchFlag = TRUE;

                        last OUTER;
                    }
                }
            }

            # Record this start line text for the next spin of this DO loop (unless it's already
            #   been added to $self->startLineTextList)
            if (! $matchFlag) {

                $self->ivPush('startLineTextList', $startLineText);
            }

            # When the triggers that matched the 'start' line (and/or 'stop' line) fire, they record
            #   the size of the GA::Session's display buffer at that point - BEFORE the line is
            #   added to the display buffer. As a result, the number of the actual display buffer
            #   line that caused the trigger to fire is currently unknown (the best we can say is
            #   that the value isn't lower than $startLineNum / $stopLineNum, respectively)
            # Find the actual line numbers
            $startLineNum = $self->findMatchingLine($startLineNum, $startLineText);
            if ($stopLineNum) {

                $stopLineNum = $self->findMatchingLine($stopLineNum, $stopLineText);
            }

            # Depending on the value of GA::Profile::World->inventoryMode, we may have to process
            #   lines from the display buffer, adding them to the inventory list
            if (
                $worldObj->inventoryMode eq 'start_stop'
                && $startLineNum && $stopLineNum
                # Must be at least one line between the start/stop lines
                && $startLineNum < ($stopLineNum - 1)
            ) {
                # Compile a list of display buffer lines between the 'start' and 'stop' lines
                #   (inclusive), but ignore lines that have already been processed
                OUTER: for (my $count = $startLineNum; $count < $stopLineNum; $count++) {

                    my $bufferObj = $self->session->ivShow('displayBufferHash', $count);
                    if ($bufferObj) {

                        # Has this line already been processed?
                        INNER: foreach my $string ($self->useLineList) {

                            if ($string eq $bufferObj->modLine) {

                                # This line already processed
                                next OUTER;
                            }
                        }

                        # The line hasn't been processed yet
                        push (@lineTextList, $bufferObj->modLine);
                    }
                }

            } elsif (
                $worldObj->inventoryMode eq 'start_empty'
                && $startLineNum
                # The start line isn't the last line in the buffer
                && $startLineNum < $self->session->displayBufferLast
            ) {
                # Compile a list of display buffer lines between the 'start' and either the first
                #   empty line, or the last line in the display buffer
                $lineNum = $startLineNum - 1;    # First line to check is $startLine
                do {

                    my ($bufferObj, $lineText, $matchFlag);

                    $lineNum++;

                    $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
                    if (! $bufferObj) {

                        # End of the buffer reached, or missing line
                        $exitFlag = TRUE;

                    } else {

                        $lineText = $bufferObj->modLine;
                        if (! ($lineText =~ m/\w/)) {

                            # First empty line found; stop looking here
                            $exitFlag = TRUE;
                        }
                    }

                    if (! $exitFlag) {

                        # Has this line already been processed?
                        INNER: foreach my $string ($self->useLineList) {

                            if ($string eq $lineText) {

                                # This line already processed
                                $matchFlag = TRUE;
                                last INNER;
                            }
                        }

                        if (! $matchFlag) {

                            # The line hasn't been processed yet
                            push (@lineTextList, $bufferObj->modLine);
                        }
                    }

                } until ($exitFlag);
            }

            if (@lineTextList) {

                # Process every unprocessed line
                foreach my $lineText (@lineTextList) {

                    $self->processString($lineText);
                }
            }

        } until (! @startStopList);

        return 1;
    }

    sub findMatchingLine {

        # Called by $self->updateInventory
        # When $self->triggerSeen is called, one of this task's triggers has fired; but the
        #   matching line is not stored in the GA::Session's display buffer until later on, when all
        #   triggers have been tested. As a result, we don't know the display buffer line number of
        #   the line that caused the trigger to fire
        # This function finds the number of the display buffer line matching a specified string
        #
        # Expected arguments
        #   $initLineNum    - The size of the display buffer some time before the trigger fired; we
        #                       know that the matching line number must be equal or greater than
        #                       this value
        #   $lineText       - The text of the line that caused the trigger to fire
        #
        # Return values
        #   'undef' on improper arguments or if the line number can't be found
        #   Otherwise returns the display buffer line number of the matching line

        my ($self, $initLineNum, $lineText, $check) = @_;

        # Local variables
        my $exitFlag;

        # Check for improper arguments
        if (! defined $initLineNum || ! defined $lineText || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findMatchingLine', @_);
        }

        # Subtract one, so that the first iteration checks $initLineNum
        $initLineNum--;

        do {

            my ($bufferObj);

            $initLineNum++;

            $bufferObj = $self->session->ivShow('displayBufferHash', $initLineNum);
            if (! $bufferObj) {

                # We've reached the end of the buffer, without finding a matching line. Report an
                #   error to the calling function
                return undef;

            } elsif ($bufferObj->modLine eq $lineText) {

                # This is the line we're looking for
                return $initLineNum;
            }

        } until (0);
    }

    sub processString {

        # Called by $self->updateInventory or $self->triggerSeen
        # Given a string which contains (exclusively) objects in the character's inventory, process
        #   the string and store the objects
        #
        # Expected arguments
        #   $string     - The string to process
        #
        # Optional arguments
        #   $type       - When called by $self->triggerSeen, the inventory type ('wield', 'hold',
        #                   'wear', 'carry', 'sack', purse', 'deposit', 'deposit_only', 'withdraw',
        #                   'withdraw_only', 'balance', 'empty_purse', 'empty_bank', 'misc',
        #                   'ignore'). If 'undef', 'carry' is used as a default inventory type
        #
        # Return values
        #   'undef' on improper arguments or if the string is empty
        #   1 otherwise

        my ($self, $string, $type, $check) = @_;

        # Local variables
        my (
            $statusObj, $modelObj, $worldObj, $cashValue,
            @patternList, @stringList, @modList,
        );

        # Check for improper arguments
        if (! defined $string || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processString', @_);
        }

        # Don't process an empty string
        if (! $string) {

            return undef;
        }

        # Import Perl objects (for speed)
        $statusObj = $self->session->statusTask;
        $modelObj = $self->session->worldModelObj;
        $worldObj = $self->session->currentWorld;

        # The default inventory type is 'carry'
        if (! defined $type) {

            $type = 'carry';
        }

        # The world profile provides patterns which specify portions of $string that can be
        #   discarded
        # This time, all patterns are matched against $string, and all matching portions are
        #   discarded
        @patternList = $worldObj->inventoryDiscardPatternList;
        if (@patternList) {

            do {

                my $pattern = shift @patternList;
                $string =~ s/$pattern//;

            } until (! @patternList);
        }

        # The world profile provides patterns which should be applied in a Perl split operation,
        #   discarding the matching portions and using everything between them as separate objects
        push (@stringList, $string);
        foreach my $pattern ($worldObj->inventorySplitPatternList) {

            foreach my $item (@stringList) {

                push (@modList, split(/$pattern/, $item));
            }

            @stringList = @modList;
            @modList = ();
        }

        # If $string has been split into multiple strings, apply the same $type to all of them
        foreach my $item (@stringList) {

            my @objList;

            # For carried objects in an inventory list...
            if (
                $type eq 'wield' || $type eq 'hold' || $type eq 'wear' || $type eq 'carry'
                || $type eq 'sack' || $type eq 'misc'
            ) {
                # Convert the matched string into a list of non-model objects
                push (@objList, $modelObj->parseObj($self->session, FALSE, $item));

                OUTER: foreach my $obj (@objList) {

                    my (
                        $count, $index,
                        @previousList,
                    );

                    # Add the object to this task's inventory list
                    $self->ivPush('inventoryList', $obj);
                    # Set the object's inventory type
                    $obj->ivPoke('inventoryType', $type);

                    # Compare $obj against everything in the previous inventory list. If a match is
                    #   found, remove the equivalent object from the previous inventory list, but
                    #   copy its condition (if set) to $obj
                    @previousList = $self->previousList;    # We're going to splice the IV
                    $count = -1;

                    INNER: foreach my $oldObj (@previousList) {

                        $count++;
                        if ($modelObj->objCompare($self->sensitivity, $obj, $oldObj)) {

                            # $obj was also present in the previous list. Remove the entry from that
                            #   list

                            # Remove the entry in the previous list
                            $index = $self->ivFind('previousList', $oldObj);
                            if (defined $index) {

                                $self->ivSplice('previousList', $index, 1);
                            }

                            # If $oldObj had a condition set, copy it to the current object
                            if (defined $oldObj->condition) {

                                $obj->ivPoke('condition', $oldObj->condition);
                            }

                            # Only need to copy one condition setting per object
                            next OUTER;
                        }
                    }
                }

            # For all lines representing money..
            } elsif ($type ne 'ignore') {

                if ($type eq 'empty') {

                    $cashValue = 0;

                } else {

                    # Convert the value into the standard currency unit (defined by the current
                    #   world profile)
                    $cashValue = $self->convertCash($item);
                }

                if (defined $cashValue) {

                    # Update the Status task, if it is running
                    if ($statusObj) {

                        $statusObj->set_cashValues($type, $cashValue);
                    }
                }
            }
        }

        return 1;
    }

    sub resetLineData {

        # Called by several of this task's functions
        # Resets IVs that store information about display buffer lines which contain the character's
        #   inventory
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetLineData', @_);
        }

        $self->ivPoke('resetInventoryFlag', FALSE);

        $self->ivEmpty('startStopList');
        $self->ivPoke('startFlag', FALSE);
        $self->ivEmpty('startLineTextList');
        $self->ivEmpty('useLineList');

        return 1;
    }

    sub refreshWin {

        # Called by $self->doStage (at stage 3)
        # Refreshes the task window (if it is open)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $worldModelObj,
            @inventoryList, @modList, @protectList, @monitorList,
            %inventoryHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refreshWin', @_);
        }

        # Don't do anything if the task window isn't open
        if (! $self->taskWinFlag) {

            return undef;
        }

        # Empty the task window, and write the first line of text
        if ($self->activeFlag) {

            $self->insertText(
                'Inv task activated (* protected @ monitored)',
                'empty',
                'white',
            );

        } else {

            $self->insertText('Inv task disactivated', 'empty', 'white');
        }

        # Import the protected and monitored object lists
        @protectList = $self->session->currentChar->protectObjList;
        @monitorList = $self->session->currentChar->monitorObjList;
        # Import the world model
        $worldModelObj = $self->session->worldModelObj;

        # Import the inventory, and eliminate duplicates; instead, compile a hash showing the
        #   number of identical copies of each type of object
        @inventoryList = $self->inventoryList;
        foreach my $obj (@inventoryList) {

            if (exists $inventoryHash{$obj->baseString}) {

                # This is a duplicate
                $inventoryHash{$obj->baseString} = $inventoryHash{$obj->baseString} + 1;

            } else {

                # First occurence of this object
                push (@modList, $obj);
                $inventoryHash{$obj->baseString} = 1;
            }
        }

        # Write a line of text for each unique item in the inventory
        OUTER: foreach my $obj (@modList) {

            my (
                $line, $nounString, $otherNounString, $adjString, $unknownWordString, $multiple,
                $condition,
            );

            # Check whether the object is on the protected object list
            if (
                @protectList
                && $worldModelObj->objCompare($self->sensitivity, $obj, @protectList)
            ) {
                $line = '*';    # $obj matches a protected object
            } else {
                $line = ' ';    # $obj doesn't match a protected object
            }

            # Check whether the object is on the monitored object list
            if (! $self->session->conditionTask) {

                # When the Condition task is not running, no objects are monitored
                $line .= ' ';

            } elsif (! @monitorList) {

                # If the monitored object list is empty, all objects are monitored
                $line .= '@';

            } elsif ($worldModelObj->objCompare($self->sensitivity, $obj, @monitorList)) {

                $line .= '@';   # $obj matches a monitored object

            } else {

                $line .= ' ';    # $obj doesn't match a monitored object
            }

            # Add a letter to show whether the object is wielded, worn, etc
            if ($obj->inventoryType eq 'wield') {
                $line .= 'W ';
            } elsif ($obj->inventoryType eq 'hold') {
                $line .= 'H ';
            } elsif ($obj->inventoryType eq 'wear') {
                $line .= 'R ';
            } elsif ($obj->inventoryType eq 'carry') {
                $line .= 'C ';
            } elsif ($obj->inventoryType eq 'sack') {
                $line .= 'S ';
            } elsif ($obj->inventoryType eq 'misc') {
                $line .= '- ';
            } else {

                # Invalid value for ->inventoryType
                $line .= '  ';
            }

            # Convert the object's word lists into strings, with each word separated by a space
            $nounString = $obj->noun;
            $otherNounString = join(' ', $obj->otherNounList);
            $adjString = join(' ', $obj->adjList, $obj->pseudoAdjList);
            $unknownWordString = join(' ', $obj->unknownWordList);
            $multiple = $obj->multiple;
            $condition = $obj->condition;

            # Display the information, in a single line, in different colours
            $self->insertText($line . $nounString, 'white');

            if ($otherNounString) {

                $self->insertText(' ' . $otherNounString, 'echo', 'yellow');
            }

            if ($adjString) {

                $self->insertText(' '  .$adjString, 'echo', 'green');
            }

            if ($unknownWordString) {

                $self->insertText(' ' . $unknownWordString, 'echo', 'magenta');
            }

            if ($multiple != 1 && $multiple > 0) {
                $self->insertText(' [' . $multiple . ']', 'echo', 'white');
            } elsif ($multiple == -1) {
                $self->insertText(' [m]', 'echo', 'white');
            }

            if (defined $condition) {

                $self->insertText(' <' . $condition . '>', 'echo', 'white');
            }

            if ($inventoryHash{$obj->baseString} > 1) {

                $self->insertText(
                    ' [' . $inventoryHash{$obj->baseString} . ']', 'echo', 'white',
                );
            }
        }

        # Refresh finished
        $self->ivPoke('refreshWinFlag', FALSE);

        return 1;
    }

    sub convertCash {

        # Called by $self->triggerSeen
        # Converts a list of cash values (e.g. '135 gold, 5 silver and 15 bronze coins') into a
        #   single value in the unit specified by the current world profile's
        #   ->standardCurrencyUnit
        #
        # e.g. If the world's ->currencyHash defines the following exchange values:
        #   Gold = 1, Silver = 0.1, Bronze = 0.01
        # ...then the sum above would be 135.65 gold coins
        #
        # Expected arguments
        #   @lines  - A list of lines received from the world (most of the time it's only one line)
        #
        # Return values
        #   'undef' on improper arguments, if the list of @lines is empty or if no cash value can be
        #       calculated
        #   Otherwise, returns the equivalent cash value in the standard unit (which might be 0)

        my ($self, @lines) = @_;

        # Local variables
        my (
            $returnValue, $matchFlag,
            @objList,
            %currencyHash,
        );

        # Check for improper arguments
        if (! @lines) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->convertCash', @_);
        }

        # Import the world profile's currency hash. Hash in the form
        #   $currencyHash{'gold'} = 1;
        %currencyHash = $self->session->currentWorld->currencyHash;

        # Convert the lines into a list of non-model objects, one object for each denomination
        #   (i.e. one object for the 135 gold coins, one for the 5 silver, one for the 15 bronze)
        # (The TRUE flag tells the function to convert '5 coins' into a single object, with its
        #   ->multiple flag set to 5)
        @objList = $self->session->worldModelObj->parseObj($self->session, TRUE, @lines);
        if (! @objList) {

            # No objects were found in any of the lines
            return undef;
        }

        # For each object, check the nouns, adjectives and unknown words against a known
        #   denomination (a key in %currencyHash)
        $returnValue = 0;
        OUTER: foreach my $obj (@objList) {

            # Compile a list of all the words that describe this object
            my @wordList = (
                $obj->noun, $obj->otherNounList, $obj->adjList,
                $obj->pseudoAdjList, $obj->rootAdjList, $obj->unknownWordList,
            );

            # Ignore objects whose ->multiple isn't above zero (-1 means 'some')
            if ($obj->multiple <= 0) {

                next OUTER;
            }

            # Check each describing word in turn
            INNER: foreach my $word (@wordList) {

                if (exists $currencyHash{$word}) {

                    # Convert the coin (or coins) into the equivalent value in the standard
                    #   denomination (e.g. convert 5 silver coins into 0.5 gold coins), and add it
                    #   to the running total
                    $returnValue += ($currencyHash{$word} * $obj->multiple);
                    # At least one match found
                    $matchFlag = TRUE;
                    # Use the first matching word found - don't keep looking
                    next OUTER;
                }
            }
        }

        if (! $matchFlag) {

            # Nothing in @lines contains a recognisable cash value
            return undef;

        } else {

            # Return the equivalent value in the standard denomination
            return $returnValue;
        }
    }

    ##################
    # Response methods

    sub triggerSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetInterfaces function creates some triggers to capture inventory lists
        #   e.g. 'You are carrying (.*)'
        #
        # The world profile's inventory pattern list occurs in groups of 5 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need (might be 0 if the line doesn't
        #           contain any objects)
        #   [2] - what kind of possession this is. Standard strings are:
        #           'wield', 'hold', 'wear', 'carry' for objects;
        #           'sack' for anything being carried in something else (which usually doesn't
        #               appear in the inventory list);
        #           'purse', 'deposit', 'deposit_only', 'withdraw', 'withdraw_only', 'balance'
        #               which update the character's purse and bank balances;
        #           'empty_purse' for an empty purse, 'empty_bank' for an empty bank account
        #           'misc' for any other type of possession
        #           'ignore' for a line in the inventory which should be ignored
        #   [3] - 'hide' to use a gag trigger, 'show' to use a non-gag trigger
        #   [4] - 'start' if this line is always at the beginning of an inventory list; 'stop'
        #           if it is always the end of an inventory list; 'optional' for any other line
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #   inv_type        - 'wield', 'hold', 'wear', 'carry', 'sack', 'purse', 'deposit',
        #                       'deposit_only', 'withdraw', 'withdraw_only', 'balance',
        #                       'empty_purse', 'empty_bank', 'misc', 'ignore' (same as [2] )
        #   position        - 'start', 'stop', 'optional' (same as [4])
        #
        # This function analyses the matched string and updates IVs accordingly
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $invType, $posn, $grpString, $worldObj, $nowFlag);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->triggerSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        $invType = $obj->ivShow('propertyHash', 'inv_type');
        $posn = $obj->ivShow('propertyHash', 'position');

        if ($grpNum) {

            # Get the correct group substring
            $grpString = $$grpStringListRef[$grpNum];
            # Trim any unnecessary whitespace from it
            $grpString = $axmud::CLIENT->trimWhitespace($grpString);
        }

        # Shortcut to the current world
        $worldObj = $self->session->currentWorld;

        # We respond only to the first trigger that fires on each line (unless it's a 'start' or
        #   'stop' line), to prevent the task interpreting a single line as multiple objects of
        #   inventory
        if ($posn eq 'optional' && $self->lastLine == $self->session->displayBufferLast) {

            # This line has already been used
            return undef;

        } else {

            $self->ivPoke('lastLine', $self->session->displayBufferLast);
        }

        # Empty this task's inventory list in certain circumstances
        if ($posn eq 'start' || $self->resetInventoryFlag) {

            $self->ivPoke('resetInventoryFlag', FALSE);

            # Modes 'start_stop', 'start_empty'
            if (
                $worldObj->inventoryMode eq 'start_stop'
                || $worldObj->inventoryMode eq 'start_empty'
            ) {
                # Store the beginning of the inventory list
                $self->ivPoke('startFlag', TRUE);
                $self->ivPush('startStopList',
                    'start',
                    $self->session->displayBufferLast,
                    $modLine,
                );
            }

            # Empty the inventory list, but retain a copy so we can preserve an object's condition
            if ($self->inventoryList) {

                $self->ivPoke('previousList', $self->inventoryList);
                $self->ivEmpty('inventoryList');

            } else {

                $self->ivEmpty('previousList');
            }

            # If the Condition task is running, it receives its own copy of ->previousList
            #   (because this task's ->previousList gets emptied by successive ->ivSplice
            #   operations)
            if ($self->session->conditionTask) {

                $self->session->conditionTask->set_previousList($self->previousList);
            }

            # The objects in $grpString must be processed
            $nowFlag = TRUE;

        } elsif ($posn eq 'stop') {

            # Mode 'match_all'
            if ($worldObj->inventoryMode eq 'match_all') {

                # Next matching line, no matter what type it is, causes the current inventory list
                #   to be emptied
                $self->ivPoke('resetInventoryFlag', TRUE);

            # Modes 'start_stop', 'start_empty'
            } else {

                # Store the end of the inventory list
                $self->ivPoke('startFlag', FALSE);
                $self->ivPush('startStopList',
                    'stop',
                    $self->session->displayBufferLast,
                    $modLine,
                );
            }

        } elsif (! $self->startFlag && $worldObj->inventoryMode ne 'match_all') {

            # This line can be ignored - we are not currently at or between a start/stop line (and
            #   not in mode 'match_all', in which case all matching lines are processed)
            return undef;
        }

        # Add items to the character's inventory, as long as this line is a 'start' or 'stop' line,
        #   or is between a 'start' and 'stop' line (or if we're in mode 'match_all', in which case
        #   all lines are processed immediately)
        if (
            $grpString          # No group substring means the line doesn't contain object(s)
            && (
                $nowFlag
                || $posn eq 'stop'
                || $worldObj->inventoryMode eq 'match_all'
                || $self->startFlag
            )
        ) {
            $self->processString($grpString, $invType);
        }

        # In modes 'start_stop', 'start_empty', store an inventory line which has already been used
        #   (even if it wasn't sent to ->processString to be parsed)
        if ($worldObj->inventoryMode ne 'match_all') {

            $self->ivPush('useLineList', $modLine);
        }

        # Tell this task's window to refresh, if open
        $self->ivPoke('refreshWinFlag', TRUE);

        return 1;
    }

    sub ignorePatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetInterfaces function creates some triggers to capture lines from
        #   inventory lists which don't contain objects, but which should still be gagged
        #   e.g. 'In your pockets you find these currencies:'
        #
        # The function does nothing (a commented-out call to ->writeDebug is provided, in case it
        #   will be useful)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->ignorePatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # We respond only to the first trigger that fires on each line, to prevent the task
        #   interpreting a single line as multiple objects of inventory
        if ($self->lastLine == $self->session->displayBufferLast) {

            # This line has already been used
            return undef;

        } else {

            $self->ivPoke('lastLine', $self->session->displayBufferLast);
        }

#       $self->writeDebug('Inventory task ignore pattern found: \'' . $$grpStringListRef[0] . '\'');

        return 1;
    }

    ##################
    # Accessors - set

    sub set_refreshWinFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_refreshWinFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('refreshWinFlag', TRUE);
        } else {
            $self->ivPoke('refreshWinFlag', FALSE);
        }

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub cmdHash
        { my $self = shift; return %{$self->{cmdHash}}; }
    sub timerHash
        { my $self = shift; return %{$self->{timerHash}}; }
    sub triggerList
        { my $self = shift; return @{$self->{triggerList}}; }
    sub ignoreTriggerList
        { my $self = shift; return @{$self->{ignoreTriggerList}}; }

    sub resetInventoryFlag
        { $_[0]->{resetInventoryFlag} }

    sub startStopList
        { my $self = shift; return @{$self->{startStopList}}; }
    sub startFlag
        { $_[0]->{startFlag} }
    sub startLineTextList
        { my $self = shift; return @{$self->{startLineTextList}}; }
    sub useLineList
        { my $self = shift; return @{$self->{useLineList}}; }
    sub lastLine
        { $_[0]->{lastLine} }

    sub inventoryList
        { my $self = shift; return @{$self->{inventoryList}}; }
    sub previousList
        { my $self = shift; return @{$self->{previousList}}; }
    sub sensitivity
        { $_[0]->{sensitivity} }

    sub refreshWinFlag
        { $_[0]->{refreshWinFlag} }
}

{ package Games::Axmud::Task::Launch;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Launch task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'launch_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'launch_task';
        $self->{prettyName}             = 'Launch';
        $self->{shortName}              = 'La';
        $self->{shortCutIV}             = 'launchTask';     # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Opens a task window from which scripts can be launched';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivistyFlag}   = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pseudo', 'grid'];
        $self->{winmap}                 = 'basic_empty';
        $self->{winUpdateFunc}          = 'createWidgets';
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # Table object handling the GA::Obj::SimpleList
        $self->{slTableObj}             = undef;
        # A hash of scripts displayed in the window, in the form
        #   $fileHash{script_name} = full_file_path
        # ...where 'script_name' is the file name, e.g. 'wumpus.bas'
        $self->{fileHash}               = {};

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

#   sub clone {}                # Inherited from generic task

#   sub preserve {}             # Inherited from generic task

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # If the task has recently been updated, quickest way to update the window is to remove all
        #   the current table objects, before creating new ones
        if ($self->hasResetFlag) {

            if (! defined $self->winObj->tableStripObj->removeAllTableObjs()) {

                # Operation failed; task must close
                $self->ivPoke('shutdownFlag', TRUE);
                return 1;
            }
        }

        # Create widgets for the task window
        $self->createWidgets();

        # Display the list of available scripts in the task window's simple list
        $self->populateList();

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    sub createWidgets {

        # Set up the widgets used in the task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->createWidgets', @_);
        }

        # Add a simple list on the left
        my $tableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::SimpleList',
            0, 44, 0, 59,
            undef,
            # Init settings
            'column_ref'    => [
                'Script', 'text',
                'Path', 'text',
            ],
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            45, 59, 0, 9,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('newCallback'),
            'text'          => 'New script',
            'tooltips'      => 'Add a new script to this list',
            'align_x'       => 0.5,
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            45, 59, 10, 19,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('editCallback'),
            'text'          => 'Edit script',
            'tooltips'      => 'Edits the selected script',
            'align_x'       => 0.5,
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            45, 59, 20, 29,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('deleteCallback'),
            'text'          => 'Delete script',
            'tooltips'      => 'Deletes the selected script',
            'align_x'       => 0.5,
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            45, 59, 30, 39,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('runCallback'),
            'text'          => 'Run script',
            'tooltips'      => 'Runs the selected script',
            'align_x'       => 0.5,
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            45, 59, 40, 49,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('runTaskCallback'),
            'text'          => 'Run as task',
            'tooltips'      => 'Runs the selected script as a task',
            'align_x'       => 0.5,
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            45, 59, 50, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('refreshCallback'),
            'text'          => 'Refresh list',
            'tooltips'      => 'Refreshes the list of scripts',
            'align_x'       => 0.5,
        );

        # Store the simple list's table object, so that callbacks can access it
        $self->ivPoke('slTableObj', $tableObj);

        # Display all the widgets
        $self->winObj->winShowAll($self->_objClass . '->createWidgets');

        return 1;
    }

    sub populateList {

        # Displays the list of available scripts in the task window's simple list
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $newSelectFile  - If set, the name of the file (e.g. 'wumpus.bas') that should be
        #                       selected after the list is populated. If 'undef', the previous
        #                       selected file (if any) remains selected
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newSelectFile, $check) = @_;

        # Local variables
        my (
            $index, $rowRef, $oldSelectFile, $foundFlag, $count,
            @dirList, @fileList, @dataList,
            %fileHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->populateList', @_);
        }

        # The simple list is updated once a second, so we need to preserve whichever script is
        #   selected (if any)
        # Get the currently selected row's number (if any)
        $index = $self->slTableObj->get_select();
        if (defined $index) {

            # Get the selected row itself
            $rowRef = $self->slTableObj->get_row($index);
            $oldSelectFile = $$rowRef[0];
        }

        # Get a list of directories in which scripts are stored
        @dirList = (
            $axmud::DATA_DIR . '/scripts',
            $axmud::CLIENT->scriptDirList,
        );

        # Get a list of scripts in each directory of @dirList. If we encounter a duplicate, ignore
        #   it and use the earlier one (which would be the script loaded by, for example,
        #   ';runscript')
        foreach my $dir (@dirList) {

            my @pathList;

            if ($^O eq 'MSWin32') {
                @pathList = glob($dir . '\\*.bas');
            } else {
                @pathList = glob($dir . '/*.bas');
            }

            foreach my $path (@pathList) {

                my $file;

                # $path is the whole path, $file is just the filename (e.g. test.bas)
                $file = $path;
                # Repair MSWin paths, so $scriptDir can be used in a substitution
                $file =~ s/\\/\\\\/g;
                # Do the substitution
                $file =~ s/$dir\///;

                # If a script of this name has not already been found, use it
                if (! exists $fileHash{$file}) {

                    $fileHash{$file} = $path;
                    push (@fileList, $file);
                }

                # If the file that was selected (when this function was called) still exists, we
                #   can re-select it below
                if ($oldSelectFile && $oldSelectFile eq $path) {

                    $foundFlag = TRUE;
                }
            }
        }

        @fileList = sort {$a cmp $b} (@fileList);
        foreach my $file (@fileList) {

            push (@dataList, [$file, $fileHash{$file}]);
        }

        # Update the task window's simple list
        $self->slTableObj->set_list(@dataList);

        # If $newSelectFile was specified, work out which row to select
        if (defined $newSelectFile) {

            $count = -1;
            OUTER: foreach my $file (@fileList) {

                $count++;
                if ($file eq $newSelectFile) {

                    $index = $count;
                    last OUTER;
                }
            }

        } elsif (! $foundFlag) {

            # The old selected file no longer exists, so don't try to re-select it
            $index = undef;
        }

        # Select one of the rows (if required)
        if (defined $index) {

            $self->slTableObj->set_select($index);
        }

        # Save details about each file, and its full file path, so that we don't have to work them
        #   out a second time
        $self->ivPoke('fileHash', %fileHash);

        return 1;
    }

    ##################
    # Response methods

    sub newCallback {

        # Called by an anonymous function in the task window object when the user clicks the
        #   'New script' button
        # Prompts the user for a script name and, if it doesn't already exist, creates a new file
        #   in the default scripts directory
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, an empty string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my (
            $scriptName, $path, $fileHandle,
            @list,
        );

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->newCallback', @_);
        }

        # Prompt the user for a file name
        $scriptName = $self->winObj->showEntryDialogue(
            'New script',
            'Enter a name for the script (e.g. \'wumpus\')',
            $self->winObj->winWidget,
        );

        if (defined $scriptName) {

            # Does a file of this name already exist in the default script directory?
            $path = $axmud::DATA_DIR . '/scripts/' . $scriptName . '.bas';
            if (-e $path) {

                return $self->winObj->showMsgDialogue(
                    'New script',
                    'error',
                    'The file \'' . $path . '\' already exists',
                    'ok',
                );
            }

            # Open the file for writing, overwriting previous contents
            if (! open ($fileHandle, ">$path")) {

                return $self->winObj->showMsgDialogue(
                    'New script',
                    'error',
                    'Unable to create the file \'' . $path . '\'',
                    'ok',
                );
            }

            # The contents of the file are the same as that used in the default 'test.bas' script
            @list = (
                "REM Write your own " . $axmud::BASIC_NAME . " code here\n",
                "\n",
                "END\n",
            );

            # Write the file
            print $fileHandle @list;
            close $fileHandle;

            # Update the task window's simple list, selecting the file we just created
            $self->populateList($scriptName . '.bas');
        }

        return 1;
    }

    sub editCallback {

        # Called by an anonymous function in the task window object when the user clicks the
        #   'Edit script' button
        # Opens the selected file (if any) in Axmud's default text editor
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, an empty string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($index, $rowRef, $selectFile, $path, $cmd);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->editCallback', @_);
        }

        $index = $self->slTableObj->get_select();
        if (defined $index) {

            # Get the selected row itself
            $rowRef = $self->slTableObj->get_row($index);
            $selectFile = $$rowRef[0];

            # Open the selected file in the external text editor
            $cmd = $axmud::CLIENT->textEditCmd;
            if (! $cmd || ! ($cmd =~ m/%s/)) {

                return $self->winObj->showMsgDialogue(
                    'New script',
                    'error',
                    'Can\'t edit the ' . $axmud::BASIC_NAME . ' script - invalid external'
                    . ' application command',
                    'ok',
                );
            }

            $path = $self->ivShow('fileHash', $selectFile);
            $cmd =~ s/%s/$path/;
            if ($cmd) {

                system $cmd;
            }
        }

        # In all cases, update the task window's simple list (leave the same script selected)
        $self->populateList($selectFile);

        return 1;
    }

    sub deleteCallback {

        # Called by an anonymous function in the task window object when the user clicks the
        #   'Delete script' button
        # Deletes the selected file (if any)
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, an empty string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($index, $rowRef, $selectFile, $path, $choice);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->deleteCallback', @_);
        }

        $index = $self->slTableObj->get_select();
        if (defined $index) {

            # Get the selected row itself
            $rowRef = $self->slTableObj->get_row($index);
            $selectFile = $$rowRef[0];
            $path = $self->ivShow('fileHash', $selectFile);

            # Prompt the user for a confirmation...
            $choice = $self->winObj->showMsgDialogue(
                'Delete script',
                'question',
                'Are you sure you want to delete \'' . $path . '\'?',
                'yes-no',
                'no',
            );

            if (defined $choice && $choice eq 'yes' && -e $path) {

                unlink $path;
            }
        }

        # In all cases, update the task window's simple list
        $self->populateList();

        return 1;
    }

    sub runCallback {

        # Called by an anonymous function in the task window object when the user clicks the
        #   'Run script' button
        # Runs the selected script (if any)
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, an empty string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($index, $rowRef, $selectFile, $shortFile);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->runCallback', @_);
        }

        $index = $self->slTableObj->get_select();
        if (defined $index) {

            # Get the selected row itself
            $rowRef = $self->slTableObj->get_row($index);
            $selectFile = $$rowRef[0];

            # The ';runscript' command needs us to remove the '.bas' ending
            $shortFile = $selectFile;
            $shortFile =~ s/\.bas$//;
            $self->session->pseudoCmd('runscript ' . $shortFile);
        }

        # In all cases, update the task window's simple list (leave the same script selected)
        $self->populateList($selectFile);

        return 1;
    }

    sub runTaskCallback {

        # Called by an anonymous function in the task window object when the user clicks the
        #   'Run as task' button
        # Runs the selected script (if any) as a task
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, an empty string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($index, $rowRef, $selectFile, $shortFile);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->runTaskCallback', @_);
        }

        $index = $self->slTableObj->get_select();
        if (defined $index) {

            # Get the selected row itself
            $rowRef = $self->slTableObj->get_row($index);
            $selectFile = $$rowRef[0];

            # The ';runscripttask' command needs us to remove the '.bas' ending
            $shortFile = $selectFile;
            $shortFile =~ s/\.bas$//;
            $self->session->pseudoCmd('runscripttask ' . $shortFile);
        }

        # In all cases, update the task window's simple list (leave the same script selected)
        $self->populateList($selectFile);

        return 1;
    }

    sub refreshCallback {

        # Called by an anonymous function in the task window object when the user clicks the
        #   'Refresh list' button
        # Refreshes the simple list, in case any script files have been added/removed by something
        #   (or someone) other than this task
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, an empty string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refreshCallback', @_);
        }

        # Update the task window's simple list
        $self->populateList();

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub slTableObj
        { $_[0]->{slTableObj} }
    sub fileHash
        { my $self = shift; return %{$self->{fileHash}}; }
}

{ package Games::Axmud::Task::Locator;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Locator task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'locator_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'locator_task';
        $self->{prettyName}             = 'Locator';
        $self->{shortName}              = 'Lc';
        $self->{shortCutIV}             = 'locatorTask';    # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Keeps track of character\'s location in the world';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;            # Locator doesn't require itself
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = 'refreshWin';
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = TRUE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'locator';
        $self->{ttsAttribHash}          = {
            'title'                     => undef,
            'descrip'                   => undef,
            'description'               => undef,
            'exit'                      => undef,
            'exits'                     => undef,
            'content'                   => undef,
            'contents'                  => undef,
        };
        $self->{ttsFlagAttribHash}      = {
            'title'                     => FALSE,
            'descrip'                   => FALSE,
            'description'               => FALSE,
            'exit'                      => FALSE,
            'exits'                     => FALSE,
            'content'                   => FALSE,
            'contents'                  => FALSE,
        };
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        # Total number of room statements seen since the task started (or since the last reset)
        $self->{roomCount}              = 0;
        # The last line in the display buffer searched by the task (matches a key in
        #   GA::Session->displayBufferHash); 'undef' if no lines searched yet
        $self->{lastBufferLine}         = undef;
        # The last line in the display buffer where the task found an anchor for a room statement
        #   (usually a line containing the list of exits); 'undef' if no anchors found yet
        $self->{lastAnchorLine}         = undef;
        # The last line in the room statement whose anchor is stored in ->lastAnchorLine (if the
        #   anchor was the last line of the statement, this IV's value will be the same as the value
        #   stored in ->lastAnchorLine)
        $self->{lastStatementEndLine}   = undef;
        # The first line in the room statement whose anchor is stored in ->lastAnchorLine (not used
        #   by anything at the moment, but available if required)
        $self->{lastStatementStartLine} = undef;
        # Lines are usually analysed only ones. In certain cases (where a world profile's
        #   ->verboseFinalPattern, ->shortFinalPattern or ->briefFinalPattern is set), when the task
        #   receives some but not all of the room statement, it can wait until the rest of the
        #   statement is received, before processing it (in fact, it waits until a line matching
        #   ->verboseFinalPattern etc is received)
        # In that situation, this IV is set to the first line that should be re-analysed; its value
        #   is copied to $self->lastBufferLine once per task loop until the rest of the room
        #   statement is received
        $self->{restartBufferLine}      = undef;
        # Blessed reference to the current room object (a GA::ModelObj::Room), which contains
        #   information about the current room, as it is now: the same information is not
        #   necessarily stored by the automapper's current room. 'undef' if no current room
        $self->{roomObj}                = undef;
        # Blessed reference to the previous room object (a GA::ModelObj::Room), which will be the
        #   most recent one created by this task. 'undef' if no previous room
        $self->{prevRoomObj}            = undef;
        # If the automapper has a current room, the world model number of that room (which will
        #   correspond to the non-model room, $self->roomObj). 'undef' otherwise
        $self->{modelNumber}            = undef;

        # The 'weather' component captures lines of text that should not be stored in the model, but
        #   should be displayed in this task's window. (This is mostly used for weather and the time
        #   of day, but can be used for anything)
        # The contents of this hash is replaced whenever $self->processAnchor successfully extracts
        #   a room statement
        # The keys are the name of the 'weather' component. The corresponding values are the line(s)
        #   captured by that component, with any text portions removed as normal, and with multiple
        #   lines joined together in a single string. Both key and value are displayed in the window
        $self->{weatherHash}            = {};
        # Flag which sets how objects in the room contents are displayed in the Locator task window.
        #   FALSE to display the (original) base string, TRUE to nouns first, and so on
        $self->{showParsedFlag}         = FALSE;

        # Hash used to store the received lines of text that comprise certain components of the
        #   room statement, so that the text-to-speech routines can read them aloud (if required).
        # Hash in the form
        #   $ttsToReadHash{attribute} = string
        # ...where 'attribute' is one of the TTS attributes 'title', 'descrip', 'exit', 'content'
        #   or 'command', and string is a single string comprising one or more received lines of
        #   text, joined together. The hash is emptied at the start of the call to
        #   $self->processAnchor, filled up as required, and then the strings are read out in the
        #   correct order, if required
        $self->{ttsToReadHash}          = {};

        # If MXP is turned on and is returning the standard tag properties 'RoomName', 'RoomDesc',
        #   'RoomExit' and 'RoomNum', this flag is set to TRUE (and the task searches for these
        #   tag properties rather than searching for anchor lines as usual)
        $self->{useMxpFlag}             = FALSE;
        # A hash of MXP standard tag properties. The keys are the four properties recognised by the
        #   Locator Task
        # The Locator task expects the tags to be used in a group, representing a single room
        #   statement. The way the group is handled, by default is unsophisticated: for example, if
        #   the task sees two successive 'RoomName' tags, that must represent two room statements.
        #   Likewise, if the task has analysed all of the received text, that must represent the end
        #   of the room statement, even if all four types of tag were not found
        # The world profile can optionally specify the tags marking the beginning and/or end of the
        #   group. If either (or both) are defined, the handling is a little more robust
        $self->{mxpPropHash}            = {
            'RoomName'                  => undef,
            'RoomDesc'                  => undef,
            'RoomExit'                  => undef,
            'RoomNum'                   => undef,
        };
        # The tagged text, when received, is stored temporarily in this hash. The keys are the same
        #   as those in $self->mxpPropHash; the corresponding values are the tagged text. When a
        #   room statement is processed, the hash is emptied
        $self->{mxpPropCurrentHash}     = {};
        # The line number at which tagged text was added to an empty $self->currentMxpPropHash
        #   (undef if the hash is empty)
        $self->{mxpPropStartLine}       = undef;

        # On some worlds, the part of the room statement containing the verbose description can
        #   contain lines that are actually part of the room's contents, for example the third line
        #   in:
        #
        # You are in a vast forest. The trees and thick vegetation press in from...
        # canopy overhead. The forest is impassably thick southward.
        # There is a sign here you can read.
        # Obvious exits: south, north, east, northeast, northwest
        #
        # When those lines are found, their replacement strings (defined in the world profile,
        #   e.g. 'A sign is here') are temporarily stored in this IV until the same room's contents
        #   can be processed
        $self->{tempContentsList}       = [];

        # Whenever a world command is sent, GA::Session->updateCmdBuffer calls this task, and the
        #   following IVs are set
        # A list of all GA::Buffer::Cmd objects which haven't yet been checked, some of which might
        #   be movement commands
        $self->{cmdObjList}             = [];
        # A subset of $self_>cmdObjList, containing all GA::Buffer::Cmd objects which the session
        #   has already decided are look/glance or movement commands. The number of room
        #   descriptions expected by the Locator is the number of objects in this list
        $self->{moveList}               = [];
        # The GA::Buffer::Cmd that is thought to have caused the most recent move from one room to
        #   another (actually, the last one removed from $self->moveList); set to 'undef' if the
        #   last move was a result of unknown command(s)
        $self->{prevMoveObj}            = undef;
        # The direction of the most recent move, taken from $self->prevMoveObj; set to 'undef' if
        #   unknown
        $self->{prevMove}               = undef;
        # When $self->add_cmdObj is called, this IV is set (or reset). If $self->moveList is empty
        #   at that time, this IV is set to GA::Session->displayBufferCount, which represents the
        #   buffer number of the first line of text received from the world in response to the
        #   command. If $self->moveList is not empty at that time, it's reset to 'undef'
        # (The IV is used for auto-processing new failed exit messages)
        $self->{prevCmdBufferNum}       = undef;
        # Flag set to TRUE whenever a failed exit is detected by this task (any code can set this
        #   flag to FALSE using $self->reset_failExitFlag, and then check the flag regularly,
        #   waiting to see when a failed exit is next detected)
        $self->{failExitFlag}           = FALSE;
        # Flag set to TRUE whenever an involuntary exit is detected by this task, meaning the
        #   character's current location is no longer known (any code can set this flag to FALSE,
        #   using $self->reset_involuntaryExitFlag, and then check the flag regularly, waiting to
        #   see when an involuntary exit is next detected)
        $self->{involuntaryExitFlag}    = FALSE;
        # Arrival tag: for the ';drive' (etc) commands, when the character moves along a pre-defined
        #   route and the room at the end of the route has a tag specified by the ';drive' (etc)
        #   command, that tag is stored here. (When this task's ->moveList becomes empty, that
        #   current room object is given this tag if it doesn't already have a tag). If set to
        #   'undef', no tag setting necessary
        $self->{arrivalTag}             = undef;

        # When the task is reset because a current profile has changed, the task searches backwards,
        #   from the most recent line received, looking for a room statement. However, if the task
        #   is reset using the ';resetlocator' command, we don't want this behaviour - the task must
        #   wait for the user to move, or type a look/glance command
        # ';resetlocator' sets this flag to TRUE to prevent the task, when it is reset, from
        #   searching already-received lines for room descriptions
        $self->{manualResetFlag}        = FALSE;
        # After a manual reset, it's a bit of effort for the user to set the automapper's current
        #   room. This IV can be used to reduce the effort
        #       'do_nothing' - do nothing after a manual reset; user must type 'look', 'north' etc
        #           to get a room statement, then the automapper's current room can be set
        #       'search_back' - (behaviour before v1.0.568) - search backwards, from the most recent
        #           line received, looking for a room statement - as if the task had just started
        #       'send_look' - send a standard 'look' command to the world, which the task can
        #           interpret
        $self->{autoLookMode}           = 'search_back';

        # Task window customisation
        # Maximum number of characters of the verbose room description to display in the task
        #   window ('undef' or 0 to display the whole description)
        $self->{winDescripLimit}        = 200;
        # Flag set to TRUE if multiple corpses should be shown on a single line; set to FALSE if
        #   each corpse should be shown on a separate line
        $self->{combineCorpseFlag}      = TRUE;
        # Flag set to TRUE if multiple body parts (detached arms, legs, etc) are shown on a single
        #   line (the same line as corpses); set to FALSE if each body part shown on a separate line
        $self->{combineBodyPartFlag}    = TRUE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{roomCount}             = $self->roomCount;
        $clone->{lastBufferLine}        = $self->lastBufferLine;
        $clone->{lastAnchorLine}        = $self->lastAnchorLine;
        $clone->{lastStatementEndLine}  = $self->lastStatementEndLine;
        $clone->{lastStatementStartLine}
                                        = $self->lastStatementStartLine;
        $clone->{restartBufferLine}     = $self->restartBufferLine;
        $clone->{roomObj}               = $self->roomObj;
        $clone->{prevRoomObj}           = $self->prevRoomObj;
        $clone->{modelNumber}           = $self->modelNumber;
        $clone->{weatherHash}           = {$self->weatherHash};
        $clone->{showParsedFlag}        = $self->showParsedFlag;

        $clone->{ttsToReadHash}         = {$self->ttsToReadHash};

        $clone->{useMxpFlag}            = $self->useMxpFlag;
        $clone->{mxpPropHash}           = {$self->mxpPropHash};
        $clone->{mxpPropCurrentHash}    = {$self->mxpPropCurrentHash};
        $clone->{mxpPropStartLine}      = $self->mxpPropStartLine;

        $clone->{tempContentsList}      = [$self->tempContentsList];

        $clone->{cmdObjList}            = [$self->cmdObjList];
        $clone->{moveList}              = [$self->moveList];
        $clone->{prevMoveObj}           = $self->prevMoveObj;
        $clone->{prevMove}              = $self->prevMove;
        $clone->{prevCmdBufferNum}      = $self->prevCmdBufferNum;
        $clone->{failExitFlag}          = $self->failExitFlag;
        $clone->{involuntaryExitFlag}   = $self->involuntaryExitFlag;
        $clone->{arrivalTag}            = $self->arrivalTag;

        $clone->{manualResetFlag}       = $self->manualResetFlag;
        $clone->{autoLookMode}          = $self->autoLookMode;

        $clone->{winDescripLimit}       = $self->winDescripLimit;
        $clone->{combineCorpseFlag}     = $self->combineCorpseFlag;
        $clone->{combineBodyPartFlag}   = $self->combineBodyPartFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the flag that indicates a manual reset
        $newTask->ivPoke('manualResetFlag', $self->manualResetFlag);
        $newTask->ivPoke('autoLookMode', $self->autoLookMode);
        # Preserve the description size limit
        $newTask->ivPoke('winDescripLimit', $self->winDescripLimit);
        # Preserve the corpse/body part flags
        $newTask->ivPoke('combineCorpseFlag', $self->combineCorpseFlag);
        $newTask->ivPoke('combineBodyPartFlag', $self->combineBodyPartFlag);
        $newTask->ivPoke('showParsedFlag', $self->showParsedFlag);
        $newTask->ivPoke('autoLookMode', $self->autoLookMode);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

    sub ttsReadAttrib {

        # Called by GA::Cmd::Read->do and PermRead->do
        # Users can use the client command ';read' to interact with individual tasks, typically
        #   getting them to read out information (e.g. the Status task can read out current health
        #   points)
        # The ';read' command is in the form ';read <attribute>' or ';read <attribute> <value>'.
        #   The ';read' command looks up the <attribute> in GA::Client->ttsAttribHash, which tells
        #   it which task to call
        #
        # Expected arguments
        #   $attrib     - The TTS attribute specified by the calling function. Must be one of the
        #                   keys in $self->ttsAttribHash
        #
        # Optional arguments
        #   $value      - The value specified by the calling function (or 'undef' if none was
        #                   specified)
        #   $noReadFlag - Set to TRUE when called by GA::Cmd::PermRead->do, in which case only this
        #                   task's hash of attributes is updated. If set to FALSE (or 'undef'),
        #                   something is usually read aloud, too
        #
        # Return values
        #   'undef' on improper arguments or if the $attrib doesn't exist in this task's
        #       ->ttsConfig
        #   1 otherwise

        my ($self, $attrib, $value, $noReadFlag, $check) = @_;

        # Local variables
        my ($string, $number);

        # Check for improper arguments
        if (! defined $attrib || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->ttsReadAttrib', @_);
        }

        # TTS attributes are case-insensitive
        $attrib = lc($attrib);

        # Check that the specified attribute is actually used by this task (';read' or ';permread'
        #   should carry out this check, but better safe than sorry)
        if (! $self->ivExists('ttsAttribHash', $attrib)) {

            return undef;

        } elsif ($noReadFlag) {

            # When called by GA::Cmd::PermRead->do, don't read out anything, just update the hash of
            #   attributes (when appropriate)

            # (no attributes require an update)

            return 1;

        } else {

            # Custom behaviour for this task

            # Can't do anything if the current room isn't known
            if (! $self->roomObj) {

                $self->ttsQuick('The Locator task doesn\'t know anything about the current room');

            # Locator task attributes
            } elsif ($attrib eq 'title') {

                # Read the current room's title, if known
                if (! $self->roomObj->titleList) {

                    $self->ttsQuick('The current room\'s title is not known');

                } else {

                    # There should only be one title in ->$roomObj
                    $self->ttsQuick('Room title: ' . $self->roomObj->ivFirst('titleList'));
                }

            } elsif ($attrib eq 'descrip' || $attrib eq 'description') {

                # Read the room description, if known
                if (! $self->roomObj->descripHash) {

                    $self->ttsQuick('The current room\'s description is not known');

                } else {

                    # Decide which description to use. There should only be one in $self->roomObj,
                    #   so use the first one found
                    OUTER: foreach my $key ($self->roomObj->descripHash) {

                        $string = $self->roomObj->ivShow('descripHash', $key);
                        last OUTER;
                    }

                    # If the user specified a maximum number of characters as $value, use it
                    if (
                        defined $value
                        && $axmud::CLIENT->intCheck($value, 1, ((length $string) + 1))
                    ) {
                        $number = index($string, ' ', $value);
                        if ($number != -1) {

                            $string = substr($string, 0, ($number));
                        }
                    }

                    $self->ttsQuick('Room description: ' . $string);
                }

            } elsif ($attrib eq 'exit' || $attrib eq 'exits') {

                # Read the exit list, if known
                if (! $self->roomObj->sortedExitList) {

                    $self->ttsQuick('The current room has no exits');

                } else {

                    $self->ttsQuick('Exits: ' . join(', ', $self->roomObj->sortedExitList));
                }

            } elsif ($attrib eq 'content' || $attrib eq 'contents') {

                # Read the contents list, if known
                if (! $self->roomObj->tempObjList) {

                    $self->ttsQuick('The current room is empty');

                } else {

                    $string = '';
                    foreach my $obj ($self->roomObj->tempObjList) {

                        $string .= ', ' . $obj->baseString;
                    }

                    $self->ttsQuick('Contents: ' . $string);
                }
            }

            # Operation complete
            return 1;
        }
    }

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

    sub doShutdown {

        # Called by $self->main, just before the task completes a shutdown
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doShutdown', @_);
        }

        # Go through all tasks in the current tasklist, sending shutdown messages to any of them
        #   that require the Locator task
        foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

            # (Locator task doesn't have its ->requireLocatorFlag set, so we don't need to check
            #   whether $obj is the same task as $self)
            if ($taskObj->requireLocatorFlag) {

                # Tell the other task to shut down
                $taskObj->set_shutdownFlag(TRUE);
            }
        }

        # If the automapper is running, it must be informed when the Locator task shuts down
        if ($self->session->mapWin) {

            $self->session->mapWin->setMode('wait');
            $self->session->mapObj->setCurrentRoom();
        }

        return 1;
    }

    sub doReset {

        # Called just before the task completes a reset
        # For process tasks, called by $self->main. For activity tasks, called by $self->reset
        #
        # Resets the automapper's current room, so the user see a 'Lost after a move in an unknown
        #   direction' message after typing ';resettask locator'
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # Update the automapper
        if ($self->session->mapObj->currentRoom) {

            $self->session->mapObj->setCurrentRoom();
        }

        return 1;
    }

    sub doFirstStage {

        # Called by $self->main, just before the task completes the first stage ($self->stage)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doFirstStage', @_);
        }

        # Display a holding message in the task window, if necessary
        $self->insertText('<waiting for first room statement>', 'empty');

        return 1;
    }

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # When the task is reset because a current profile has changed (or because of the
            #   ';resettask' command), the task searches backwards, from the most recent line
            #   received, looking for a room statement.
            # However, if the task is reset using the ';resetlocator' command, the behaviour
            #   depends on the setting of $self->autoLookMode
            #   'do_nothing' - do nothing after a manual reset; user must type 'look', 'north' etc
            #       to get a room statement, then the automapper's current room can be set (the task
            #       only checks lines received after this moment)
            #   'search_back' - (behaviour before v1.0.568) - search backwards, from the most recent
            #       line received, looking for a room statement - as if the task had just started
            #   'send_look' - send a standard 'look' command to the world, which the task can
            #       interpret (the task only checks lines received after this moment)
            if ($self->manualResetFlag && $self->autoLookMode ne 'search_back') {

                # Only search text in the display buffer, which appears after this task is started
                #   (deduct one, because the Locator task starts searching AFTER the line
                #   described by $self->lastBufferLine)
                $self->ivPoke('lastBufferLine', $self->session->displayBufferLast - 1);
                $self->ivPoke('manualResetFlag', FALSE);

                if ($self->autoLookMode eq 'send_look') {

                    $self->session->sendModCmd('look');
                }

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 101: Manual reset, will start searching at line '
                        . $self->lastBufferLine,
                    );
                }

            } else {

                # Search the whole display buffer
                $self->ivPoke('lastBufferLine', 0);

                # (Don't show debug message if no text has been received from the world yet)
                if ($axmud::CLIENT->debugLocatorFlag && $self->session->displayBufferCount) {

                    $self->writeDebug(
                        'LOCATOR 102: Auto reset, will start searching at line '
                        . $self->lastBufferLine,
                    );
                }
            }

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            my (
                $worldObj, $modelObj, $bufferLast, $bufferObj, $restartLine, $startLineNum,
                $stopLineNum, $step, $lastRoomObj, $lastDestRoomObj,
                @sortedList,
            );

            # Stage 3 of the task analyses each line of text received from the world, looking for
            #   anchor lines and lines which match the patterns for failed exits, involuntary exits,
            #   dark rooms and unspecified rooms. These patterns are defined by the current world
            #   profile
            #
            # A room 'statement' is all the lines of text that comprise a description of a room,
            #   including the list of exits and the lists of things currently in the room, including
            #   portables, decorations, others characters and minions
            # An 'anchor line' is one that marks the surrounding lines as being part of a room
            #   statement. It's usually the room's list of exits, but almost any kind of line can be
            #   set as the anchor line
            # The world profile normally defines two kinds of anchor lines - one for verbose (long)
            #   room statements, another for brief room statements (which might be only one or two
            #   lines long)
            #
            # Failed exit patterns are seen when a movement is attempted, but fails for some reason
            #   (often because a door is locked or closed)
            # Involuntary exit patterns are seen when the character is moved from one room to
            #   another involuntarily
            # Dark room patterns are a special kind of anchor line that tell us we're in a dark
            #   room; they usually contain no information about the room itself - but at least we
            #   know that a movement command was successful
            # Unspecified room patterns are another special kind of anchor line, used in the same
            #   circumstances (but when the room is not dark)
            #
            # MXP offers custom elements which can define standard tag properties, among them
            #   'RoomName', 'RoomDesc', 'RoomExit', 'RoomNum'. As soon as one of these tag
            #   properties is found, the task stops looking for anchor lines, and uses these tag
            #   properties instead. (It still checks for failed exit patterns, etc)
            # When any of the four tag properties is found, the task keeps searching for more tag
            #   properties until (1) an identical tag property is found (which means the start of
            #   another room statement), or (2) all four tag properties are found (which means the
            #   end of the current room statement) or (3) the task runs out of lines (which means
            #   the end of the current room statement)
            #
            # The GA::Session's display buffer is analysed in these situations:
            #   (1) This task has just started (or has just been reset), and needs to find its first
            #           room statement
            #   (2) The display buffer has been updated with at least one new line since the last
            #       anchor line was found, and that line ends with a newline character

            # Import IVs
            $worldObj = $self->session->currentWorld;
            $modelObj = $self->session->worldModelObj;
            $bufferLast = $self->session->displayBufferLast;

            # Check the last display buffer line. If it's not marked as ending with a newline
            #   character, this task should not process it (yet)
            if (defined $bufferLast) {

                $bufferObj = $self->session->ivShow('displayBufferHash', $bufferLast);
                if ($bufferObj && ! $bufferObj->newLineFlag) {

                    $bufferLast--;
                }
            }

            if ($bufferLast && $self->lastBufferLine < $bufferLast) {

                # Decide which portion of display buffer to analayse
                #   Situation 1     - move backwards, from the end of the display buffer (we only
                #                       care about the most recent anchor line)
                #   Situation 2     - move forwards, starting with the line immediately after the
                #                       last line analysed, the previous time the display buffer was
                #                       checked (recorded in $self->lastBufferLine)
                $restartLine = $startLineNum = $self->lastBufferLine + 1;
                $stopLineNum = $bufferLast;
                # If the display buffer has just been reduced in size by the user, $startLineNum may
                #   be outside the buffer
                if ($startLineNum < $self->session->displayBufferFirst) {

                    $startLineNum = $self->session->displayBufferFirst;
                }

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 111: Start #' . $startLineNum . ', stop #' . $stopLineNum,
                    );
                }

                if (
                    ! $self->roomCount
                    # (In basic mapping mode, always move forwards)
                    && ! $worldObj->basicMappingFlag
                ) {
                    # Situation 1 - move backwards from the end of the display buffer
                    ($stopLineNum, $startLineNum) = ($startLineNum, $stopLineNum);
                    $step = -1;

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug('LOCATOR 112: Reversing search');
                    }

                } else {

                    # Situation 2 - move forwards from after the most recently analysed line
                    $step = 1;
                }

                # Compose a list of buffer line numbers which we have to process
                OUTER: for (
                    my $lineNum = $startLineNum;
                    $lineNum != ($stopLineNum + $step);
                    $lineNum+= $step
                ) {
                    push (@sortedList, $lineNum);
                }

                # Now analyse each line in the display buffer between $startLineNum and
                #   $stopLineNum, looking for anchor lines and failed exit, involuntary exit, dark
                #   room and unspecified room patterns
                do {

                    my ($lineNum, $bufferObj, $cmdObj, $roomObj, $destRoomObj, $result);

                    $lineNum = shift @sortedList;
                    $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);

                    # Get the first command GA::Buffer::Cmd in $self->moveList, if any ($cmdObj
                    #   remains set to 'undef', if there are none)
                    $cmdObj = $self->ivIndex('moveList', 0);

                    # Get the automapper's current room again (in case it has changed since the
                    #   last call to ->processLine
                    if (! $self->modelNumber) {

                        # Automapper object doesn't have a current room set
                        $lastRoomObj = undef;
                        $lastDestRoomObj = undef;

                    } elsif (! $lastRoomObj || $self->modelNumber != $lastRoomObj->number) {

                        # This is the first iteration of the loop, or the current room has changed
                        #   since the previous iteration of this loop
                        $roomObj = $modelObj->ivShow('modelHash', $self->modelNumber);

                        # If $cmdObj is set, work out the likely destination room for a move in that
                        #   direction
                        if ($cmdObj && $cmdObj->moveFlag) {

                            $destRoomObj = $self->session->mapObj->identifyDestination($cmdObj);
                        }

                        # ($destRoomObj may be 'undef')
                        $lastDestRoomObj = $destRoomObj;

                    } else {

                        # The current room hasn't changed since the previous iteration of this loop
                        $roomObj = $lastRoomObj;
                        $destRoomObj = $lastDestRoomObj;
                    }

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug('LOCATOR 121: Processing line ' . $lineNum);
                    }

                    $result = $self->processLine(
                        $worldObj,
                        $modelObj,
                        $lineNum,
                        $bufferObj,
                        $stopLineNum,
                        $restartLine,
                        $roomObj,               # May be 'undef'
                        $cmdObj,                # May be 'undef'
                        $destRoomObj,           # May be 'undef'
                    );

                    if (! defined $result) {

                        # No more lines should be analysed until some more text is received from
                        #   the world (could also be an error)
                        @sortedList = ();

                    } elsif ($result == -1) {

                        # Repeat this line ($self->processLine can only process one room statement
                        #   at once, and two have been detected)
                        unshift(@sortedList, $lineNum);

                    } else {

                        # After extracting a room statement, start analysing lines after the end
                        #   (but, in situation 1, stop analysing after the first statement found)
                        if ($step == -1 && $self->roomCount) {

                            # Situation 1
                            @sortedList = ();

                        } elsif ($step == 1 && @sortedList && defined $self->lastStatementEndLine) {

                            # Situation 2. The next line to be analysed is the first line after the
                            #   room statement we've just analysed
                            # Walk the list, eliminating any lines up to and including the end of
                            #   the statement
                            # NB If the world profile's ->basicMappingFlag is TRUE, $step will be 1
                            #   but $self->lastStatementEndLine will be 'undef', so we had to check
                            #   for that
                            do {

                                my $item = $sortedList[0];

                                if ($item <= $self->lastStatementEndLine) {

                                    shift @sortedList;
                                }

                            } until (! @sortedList || $sortedList[0] > $self->lastStatementEndLine);
                        }
                    }

                } until (! @sortedList);

                # Analysis complete. Remember the last line that was due to be searched; the next
                #   time $self->main is called, the analysis will begin on the following line
                # Exception: if $self->restartBufferLine is set, we're still waiting for the second
                #   part of a room statement, in which case, the next analysis will begin on the
                #   same line as before
                if ($self->restartBufferLine) {

                    $self->ivPoke('lastBufferLine', $self->restartBufferLine - 1);
                    $self->ivUndef('restartBufferLine');

                } elsif ($step == -1) {

                    $self->ivPoke('lastBufferLine', $startLineNum);

                } else {

                    $self->ivPoke('lastBufferLine', $stopLineNum);
                }

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 131: On next task loop, will examine line '
                        . ($self->lastBufferLine + 1),
                    );
                }
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    # Component methods

    sub convertLines {

        # Called by $self->doStage (at stage 3) to convert a list of display buffer line numbers,
        #   e.g. (92, 93, 94, 95) or (5, 4, 3, 2, 1) into a list of strings, each one containing
        #   a line of text for $self->processLine to analyse
        #
        # Expected arguments
        #   @lineList   - A list of numbers of display buffer lines
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list in the form
        #       (line_number, line_text, line_number, line_text)
        #   For combined lines, the line number of the first line is used

        my ($self, @lineList) = @_;

        # Local variables
        my (@emptyList, @returnArray);

        # Check for improper arguments
        if (! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->convertLines', @_);
        }

        do {

            my ($lineNum, $bufferObj, $lineText, $exitFlag);

            $lineNum = shift @lineList;
            # Get the corresponding line object and the line of text that it displayed in
            #   the 'main' window (after being modified by any triggers)
            $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
            if ($bufferObj) {

                $lineText = $bufferObj->modLine;
                if ($lineText && $lineText =~ m/\w/ && @lineList) {

                    do {

                        my ($nextLine, $nextLineObj);

                        # Check any following lines of text
                        $nextLine = $lineList[0];
                        if ($nextLine =~ m/^\s*[[:lower:]]/) {

                            $nextLine = shift @lineList;
                            $nextLineObj = $self->session->ivShow('displayBufferHash', $nextLine);

                        } else {

                            # Don't check any more following lines for lines that begin a-z
                            $exitFlag = TRUE;
                        }

                    } until ($exitFlag || ! @lineList);
                }

                push (@returnArray, $lineNum, $lineText);
            }

        } until (! @lineList);

        return @returnArray;
    }

    sub processLine {

        # Called by $self->doStage (at stage 3) to analyse a single line from the display buffer
        #
        # Expected arguments
        #   $worldObj       - Shortcut to $self->session->currentWorld
        #   $modelObj       - Shortcut to $self->session->worldModelObj
        #   $lineNum        - The display buffer number of the line being analysed
        #   $bufferObj      - $lineNum's corresponding display buffer object
        #   $stopLineNum    - The display buffer number of last line that can be analysed during
        #                       this task loop (might be the same as $lineNum. If we're checking
        #                       lines from beginning to end, it might be higher than $lineNum; if
        #                       we're checking lines from end to beginning, it might be lower than
        #                       $lineNum)
        #   $restartLineNum - The display buffer number of the first line being analysed during this
        #                       task loop (might be the same as $lineNum)
        #
        # Optional arguments
        #   $mapRoomObj     - The automapper's current room object, if known (otherwise 'undef')
        #   $cmdObj         - The first GA::Buffer::Cmd object in $self->moveList that stores a
        #                       movement command ('undef' if there are none, or if $mapRoomObj is
        #                       not defined)
        #   $destRoomObj    - If $cmdObj is set, the likely destination room for that movement, if
        #                       known ('undef' otherwise)
        #
        # Return values
        #   'undef' on improper arguments, if there's an error or if no more lines should be
        #       analysed until some more text is received from the world
        #   -1 if the current line should be processed again, in a new call to this function (see
        #       PART 12 code for the reasons why)
        #   1 otherwise

        my (
            $self, $worldObj, $modelObj, $lineNum, $bufferObj, $stopLineNum, $restartLineNum,
            $mapRoomObj, $cmdObj, $destRoomObj, $check,
        ) = @_;

        # Local variables
        my (
            $lineText, $existsFlag, $failExitFlag, $foundFailPattern, $moveDir,
            $involuntaryExitFlag, $involuntaryValue, $specialFlag, $followCmd, $followFlag,
            $followAnchorFlag, $newCmdObj, $ghostRoomObj, $exitNum, $exitObj, $updateFlag,
            $tempRoomObj, $anchorFlag, $promptFlag, $missionObj,
            @followPatternList, @followAnchorPatternList, @unspecifiedList, @promptList,
            %tempMxpPropHash,
        );

        # Check for improper arguments
        if (
            ! defined $worldObj || ! defined $modelObj || ! defined $lineNum || ! defined $bufferObj
            || ! defined $stopLineNum || ! defined $restartLineNum || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->processLine', @_);
        }

        # We check the received line, after it has been modified by triggers, etc
        $lineText = $bufferObj->modLine;
        # Check the current world's override hash once per call
        $existsFlag = $worldObj->ivExists('mxpOverrideHash', 'room');

        # PART 1
        # Check this line against those which are definitely not anchor lines (these patterns mostly
        #   used in basic mapping mode, when worlds echo back the movement command, as in MUD1 /
        #   British Legends)
        OUTER: foreach my $pattern ($worldObj->notAnchorPatternList) {

            if ($lineText =~ m/$pattern/) {

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 201: Found a no-anchor line matching: ' . $pattern,
                    );
                }

                # Ask ->doStage the continue analysis with the following line
                return 1;
            }
        }

        # PART 2
        # If the current location is known, look for that room's own failed exit pattern list
        #   (assuming that we've already found the first room statement)
        if ($mapRoomObj && $self->roomCount) {

            OUTER: foreach my $pattern ($mapRoomObj->failExitPatternList) {

                if ($lineText =~ m/$pattern/) {

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 211: Found room\'s own failed exit pattern: ' . $pattern,
                        );
                    }

                    # $lineText contains one of the room's own failed exit patterns
                    $failExitFlag = TRUE;
                    # If one failed exit found on a line, don't need to check for others
                    $foundFailPattern = $pattern;

                    last OUTER;
                }
            }
        }

        # PART 3
        # If none of the current room's own failed exit patterns are found, look for failed exit
        #   patterns defined by the current world profile
        if (! $failExitFlag) {

            OUTER: foreach my $pattern (
                $worldObj->doorPatternList,
                $worldObj->lockedPatternList,
                $worldObj->failExitPatternList,
            ) {
                if ($lineText =~ m/$pattern/) {

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 221: Found failed exit pattern: ' . $pattern,
                        );
                    }

                    # $lineText contains one of the failed exit patterns
                    $failExitFlag = TRUE;

                    # If we're still looking for the first room statement, we have to give up now
                    if (! $self->roomCount) {

                        return $self->haltAnalysis('fail_exit');       # Returns 'undef'

                    } else {

                        # If one failed exit found on a line, don't need to check for
                        #   others
                        $foundFailPattern = $pattern;

                        last OUTER;
                    }
                }
            }
        }

        # PART 4
        # React to a failed exit pattern from parts 2-3
        if ($failExitFlag) {

            if ($cmdObj) {

                # Remove the command object that represents the first movement command in
                #   $self->moveList
                $self->removeFirstMove($cmdObj);

                # Find the direction of the failed move, if known (otherwise, $moveDir remains set
                #   to 'undef')
                if ($cmdObj->moveDir) {
                    $moveDir = $cmdObj->moveDir;
                } elsif ($cmdObj->assistedFlag && $cmdObj->assistedExitObj) {
                    $moveDir = $cmdObj->assistedExitObj->dir;
                }
            }

            # Set flag that other parts of the Axmud code can consult frequently, to see if there's
            #   been a failed exit or not
            $self->ivPoke('failExitFlag', TRUE);

            # Refresh the task window (if it's open and enabled) - if the direction of the failed
            #   exit is one of the current room's known exits, the exit is marked with an asterisk
            if ($self->taskWinFlag) {

                $self->refreshWin($moveDir);
            }

            # Inform the automapper of the failed exit so it can decide what to do
            $self->session->mapObj->failedExitSeen($foundFailPattern, $moveDir);
        }

        # PART 5
        # If the current location is known, look for that room's own involuntary / repulse exit
        #   patterns (assuming that we've already found the first room statement, but don't look if
        #   a failed exit pattern already found)
        # NB Although room model objects have separate involuntary / repulse pattern lists,
        #   the Locator task treats them in the same way
        if (! $failExitFlag && $mapRoomObj && $self->roomCount) {

            OUTER: foreach my $pattern ($mapRoomObj->ivKeys('involuntaryExitPatternHash')) {

                if ($lineText =~ m/$pattern/) {

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 231: Found room\'s own involuntary exit pattern: ' . $pattern,
                        );
                    }

                    # $lineText contains one of the room's own involuntary exit patterns
                    $involuntaryExitFlag = TRUE;
                    $involuntaryValue = $mapRoomObj->ivShow('involuntaryExitPatternHash', $pattern);
                    # If one involuntary exit found on a line, don't need to check for others
                    last OUTER;
                }
            }

            if (! $involuntaryExitFlag) {

                OUTER: foreach my $pattern ($mapRoomObj->ivKeys('repulseExitPatternHash')) {

                    if ($lineText =~ m/$pattern/) {

                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 232: Found room\'s own repulse exit pattern: ' . $pattern,
                            );
                        }

                        # $lineText contains one of the room's own repulse exit patterns, which the
                        #   task treats as just another involuntary exit
                        $involuntaryExitFlag = TRUE;
                        $involuntaryValue
                            = $mapRoomObj->ivShow('repulseExitPatternHash', $pattern);

                        # The movement command described by $cmdObj was rejected by the world, so
                        #   we need to discard it
                        if ($cmdObj) {

                            $self->removeFirstMove($cmdObj);
                            $cmdObj = undef;
                        }

                        # If one involuntary exit found on a line, don't need to check for others
                        last OUTER;
                    }
                }
            }
        }

        # PART 6
        # Look out for the world profile's involuntary exit patterns (but don't look if a failed or
        #   involuntary exit pattern already found)
        if (! $failExitFlag && ! $involuntaryExitFlag) {

            OUTER: foreach my $pattern ($worldObj->involuntaryExitPatternList) {

                if ($lineText =~ m/$pattern/) {

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 241: Found involuntary exit pattern: ' . $pattern,
                        );
                    }

                    # $lineText contains one of the involuntary exit patterns, meaning the character
                    #   has moved (usually after fleeing a fight at the point of death), and that
                    #   the current location is now unknown
                    $involuntaryExitFlag = TRUE;

                    # If we're still looking for the first room statement, we have to give up now
                    if (! $self->roomCount) {

                        return $self->haltAnalysis('involuntary');       # Returns 'undef'

                    } else {

                        # If one failed involuntary exit found on a line, don't need to check for
                        #   others
                        last OUTER;
                    }
                }
            }
        }

        # PART 7
        # React to an involuntary/repulse exit pattern from parts 5-6
        if (defined $involuntaryValue) {

            # If defined, $involuntaryValue is the involuntary/repulse exit pattern's corresponding
            #   direction or destination room
            if ($self->session->worldModelObj->ivExists('modelHash', $involuntaryValue)) {

                # Create a temporary command buffer object, treating the move as if the user had
                #   typed a teleport command to the destination room whose model number is
                #   $involuntaryValue
                $cmdObj = Games::Axmud::Buffer::Cmd->new(
                    $self->session,
                    'session',
                    # Not a real world command, but an involuntary movement
                    -1,
                    'teleport',                 # A fake world command
                    $self->session->sessionTime,
                );

                $cmdObj->addTeleport($involuntaryValue);

            } else {

                # $involuntaryValue can be a custom or standard direction. If standard, convert it
                #   to custom
                if ($self->session->currentDict->ivExists('primaryDirHash', $involuntaryValue)) {

                    $involuntaryValue = $self->session->currentDict->ivShow(
                        'primaryDirHash',
                        $involuntaryValue,
                    );
                }

                # Create a temporary command buffer object, as if the user had a typed a command in
                #   the direction $involuntaryValue, which is assumed to lead to a destination room
                $cmdObj = Games::Axmud::Buffer::Cmd->new(
                    $self->session,
                    'session',
                    # Not a real world command, but an involuntary move
                    -1,
                    $involuntaryValue,
                    $self->session->sessionTime,
                );

                $cmdObj->addMove();
            }

            # Update $self->cmdObjList and ->moveList
            $self->add_cmdObj($cmdObj);

            # Treat this as an anchor line
            $updateFlag = TRUE;

            # Remember the position in the display buffer of the most recent anchor line found
            $self->ivPoke('lastAnchorLine', $lineNum);
            $self->ivPoke('lastStatementEndLine', $lineNum);
            $self->ivPoke('lastStatementStartLine', $lineNum);

            # Archive the previous current room object (if there was one)
            if ($self->roomObj) {

                $self->ivPoke('prevRoomObj', $self->roomObj);
            }

            # Create a non-model object for this room
            $tempRoomObj = Games::Axmud::ModelObj::Room->new(
                $self->session,
                'temporary',
                'non_model',
            );

            $self->ivPoke('roomObj', $tempRoomObj);     # Even if $tempRoomObj is 'undef'

            # Mark this non-model room as (currently) unspecified
            $self->roomObj->ivPoke('unspecifiedFlag', TRUE);

        } elsif ($involuntaryExitFlag) {

            if ($cmdObj) {

                # Remove the command object that represents the first movement command in
                #   $self->moveList
                $self->removeFirstMove($cmdObj);
            }

            # Direction of move is unknown
            $self->ivUndef('prevMoveObj');
            $self->ivUndef('prevMove');
            # Current location is unknown
            $self->ivUndef('roomObj');

            # Set flag that other parts of the Axmud code can consult frequently, to see if there's
            #   been an involuntary exit or not
            $self->ivPoke('involuntaryExitFlag', TRUE);

            # Update the task window (if it's open and enabled) - it displays a 'location unknown'
            #   message
            if ($self->taskWinFlag) {

                $self->refreshWin();
            }

            # Inform the automapper of the involuntary exit so it can decide what to do
            $self->session->mapObj->involuntaryExitSeen();
        }

        # PARTS 8-10
        # Look for a special departure/follow/follow anchor pattern
        if (! $failExitFlag && ! $involuntaryExitFlag) {

            # PART 8
            # Look for special departure patterns
            if ($mapRoomObj && $self->roomCount) {

                OUTER: foreach my $pattern ($mapRoomObj->specialDepartPatternList) {

                    if ($lineText =~ m/$pattern/) {

                        # $line contains one of the current room's special departure patterns,
                        #   meaning the character has moved, but that the world is not going to send
                        #   a room statement for the new room
                        # Treat it as an unspecified room (and therefore an anchor line)

                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 251: Found special departure pattern (anchor): '
                                . $pattern,
                            );
                        }

                        $specialFlag = TRUE;
                        last OUTER;
                    }
                }
            }

            # PART 9
            # Look for follow patterns
            @followPatternList = $worldObj->followPatternList;
            if (! $specialFlag && @followPatternList) {

                # Check this line against follow patterns, in which a new room statement is
                #   expected after a line like 'You follow the orc to the north'
                do {

                    my (
                        $pattern, $grpNum, $result,
                        @grpStringList,
                    );

                    $pattern = shift @followPatternList;
                    $grpNum = shift @followPatternList;

                    $result = @grpStringList = ($lineText =~ m/$pattern/);

                    # The direction of travel (e.g. 'north') should be in @grpStringList, at index
                    #   number $grpNum
                    if ($result && scalar (@grpStringList) >= $grpNum) {

                        # $line contains one of the current room's follow patterns, meaning
                        #   the character has moved and that the world is going to send a room
                        #   statement for the new room
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 252: Found follow pattern: ' . $pattern,
                            );
                        }

                        # NB The GA::Session converts all world commands to lower case, so we'll do
                        #   the same here
                        $grpNum--;
                        $followCmd = lc($grpStringList[$grpNum]);

                        # Leave the loop early
                        @followPatternList = ();
                        $followFlag = TRUE;
                    }

                } until (! @followPatternList);
            }

            # PART 10
            # Look for follow anchor patterns
            @followAnchorPatternList = $worldObj->followAnchorPatternList;
            if (! $specialFlag && ! $followFlag && @followAnchorPatternList) {

                # Check this line against follow anchor patterns, in which no new room statement is
                #   expected after a line like 'You follow the orc to the north', so this line must
                #   be treated as the anchor
                do {

                    my (
                        $pattern, $grpNum, $result,
                        @grpStringList,
                    );

                    $pattern = shift @followAnchorPatternList;
                    $grpNum = shift @followAnchorPatternList;

                    $result = @grpStringList = ($lineText =~ m/$pattern/);

                    # The direction of travel (e.g. 'north') should be in @grpStringList, at index
                    #   number $grpNum
                    if ($result && scalar (@grpStringList) >= $grpNum) {

                        # $line contains one of the current room's follow anchor patterns, meaning
                        #   the character has moved, but that the world is not going to send a room
                        #   statement for the new room
                        # Treat it as an unspecified room (and therefore an anchor line)

                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 253: Found follow pattern (anchor): ' . $pattern,
                            );
                        }

                        # NB The GA::Session converts all world commands to lower case, so we'll do
                        #   the same here
                        $grpNum--;
                        $followCmd = lc($grpStringList[$grpNum]);

                        # Leave the loop early
                        @followPatternList = ();
                        $followAnchorFlag = TRUE;
                    }

                } until (! @followAnchorPatternList);
            }

            # PARTS 8-10
            # React to special departure/follow/follow anchor patterns
            if ($specialFlag || $followFlag || $followAnchorFlag) {

                if ($followCmd) {

                    # Code borrowed from GA::Session->updateCmdBuffer. The Locator task is rather
                    #   unsophisticated in the way it handles follows; it attempts to use the raw
                    #   direction as a movement command, without checking assisted moves, redirect
                    #   mode commands, or anything like that

                    # Create a new object for this world command
                    $newCmdObj = Games::Axmud::Buffer::Cmd->new(
                        $self->session,
                        'session',
                        # Not a real world command, but an involuntary follow
                        -1,
                        $followCmd,
                        $self->session->sessionTime,
                    );

                    $newCmdObj->addFollow($followAnchorFlag);

                    # Update the automapper's ghost room, if it is set
                    $ghostRoomObj = $self->session->mapObj->ghostRoom;
                    # If there is a ghost room, and it has an exit in this direction...
                    if ($ghostRoomObj && $ghostRoomObj->ivExists('exitNumHash', $followCmd)) {

                        $exitNum = $ghostRoomObj->ivShow('exitNumHash', $followCmd);
                        $exitObj = $modelObj->ivShow('exitModelHash', $exitNum);
                        # The new ghost room is the exit's destination room (may be set to
                        #   'undef')
                        if ($exitObj->destRoom) {

                            # Set the automapper's ghost room
                            $self->session->mapObj->setGhostRoom(
                                $modelObj->ivShow('modelHash', $exitObj->destRoom),
                            );
                        }
                    }

                    # Update $self->cmdObjList and ->moveList
                    $self->add_cmdObj($newCmdObj);
                }

                if ($specialFlag || $followAnchorFlag) {

                    # Treat this as an anchor line
                    $updateFlag = TRUE;

                    # Remember the position in the display buffer of the most recent anchor line
                    #   found
                    $self->ivPoke('lastAnchorLine', $lineNum);
                    $self->ivPoke('lastStatementEndLine', $lineNum);
                    $self->ivPoke('lastStatementStartLine', $lineNum);

                    # Archive the previous current room object (if there was one)
                    if ($self->roomObj) {

                        $self->ivPoke('prevRoomObj', $self->roomObj);
                    }

                    # Create a non-model object for this room
                    $tempRoomObj = Games::Axmud::ModelObj::Room->new(
                        $self->session,
                        'temporary',
                        'non_model',
                    );

                    $self->ivPoke('roomObj', $tempRoomObj);     # Even if $tempRoomObj is 'undef'

                    # Mark this non-model room as (currently) unspecified
                    $self->roomObj->ivPoke('unspecifiedFlag', TRUE);
                }

                # In the remainder of this function, it's easier to just check for $specialFlag,
                #   rather than checking for all of $specialFlag, $followFlag and $followAnchorFlag
                $specialFlag = TRUE;
            }
        }

        # PART 11
        # Look for dark/unspecified room patterns that take the place of an anchor (unless a failed
        #   exit, involuntary exit, special departure pattern or a verbose/short verbose/brief
        #   anchor has already been found)
        if (! $failExitFlag && ! $involuntaryExitFlag && ! $specialFlag) {

            # Look for dark room patterns
            OUTER: foreach my $pattern ($worldObj->darkRoomPatternList) {

                if ($lineText =~ m/$pattern/) {

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 261: Found dark room pattern (anchor): ' . $pattern,
                        );
                    }

                    # A dark room pattern (anchor) was found on this line
                    $updateFlag = TRUE;

                    # Remember the position in the display buffer of the most recent anchor line
                    #   found
                    $self->ivPoke('lastAnchorLine', $lineNum);
                    $self->ivPoke('lastStatementEndLine', $lineNum);
                    $self->ivPoke('lastStatementStartLine', $lineNum);

                    # Archive the previous current room object (if there was one)
                    if ($self->roomObj) {

                        $self->ivPoke('prevRoomObj', $self->roomObj);
                    }

                    # Create a non-model object for this room
                    $tempRoomObj = Games::Axmud::ModelObj::Room->new(
                        $self->session,
                        'temporary',
                        'non_model',
                    );

                    $self->ivPoke('roomObj', $tempRoomObj);     # Even if $tempRoomObj is 'undef'

                    # Mark this non-model room as (currently) dark
                    $self->roomObj->ivPoke('currentlyDarkFlag', TRUE);
                    $specialFlag = TRUE;
                }
            }

            # Look for unspecified room patterns
            if (! $specialFlag) {

                # Check the room's unspecified patterns first, then the world's unspecified patterns
                if ($destRoomObj) {

                    push (@unspecifiedList, $destRoomObj->unspecifiedPatternList);
                }

                push (@unspecifiedList, $worldObj->unspecifiedRoomPatternList);
                INNER: foreach my $pattern (@unspecifiedList) {

                    if ($lineText =~ m/$pattern/) {

                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 262: Unspecified room pattern (anchor): ' . $pattern,
                            );
                        }

                        # An unspecified room pattern (anchor) was found on this line
                        $updateFlag = TRUE;

                        # Remember the position in the display buffer of the most recent anchor line
                        #   found
                        $self->ivPoke('lastAnchorLine', $lineNum);
                        $self->ivPoke('lastStatementEndLine', $lineNum);
                        $self->ivPoke('lastStatementStartLine', $lineNum);

                        # Archive the previous current room object (if there was one)
                        if ($self->roomObj) {

                            $self->ivPoke('prevRoomObj', $self->roomObj);
                        }

                        # Create a non-model object for this room
                        $tempRoomObj = Games::Axmud::ModelObj::Room->new(
                            $self->session,
                            'temporary',
                            'non_model',
                        );

                        $self->ivPoke('roomObj', $tempRoomObj);   # Even if $tempRoomObj is 'undef'

                        # Mark this non-model room as (currently) unspecified
                        $self->roomObj->ivPoke('unspecifiedFlag', TRUE);
                        $specialFlag = TRUE;
                    }
                }
            }

            if ($specialFlag && ! $self->roomCount) {

                # First room statement found (even though it's a dark or unspecified room) - in
                #   future, search forwards, not backwards
                $self->ivPoke('roomCount', 1);
                # Make sure the move lists are empty
                $self->ivEmpty('cmdObjList');
                $self->ivEmpty('moveList');
                $self->ivUndef('prevCmdBufferNum');
                # Display information about the room statement in the task window (if it's open and
                #   enabled)
                if ($self->taskWinFlag) {

                    $self->refreshWin();
                }

                # Don't look for any more room statements until some more text is received from the
                #   world
                return undef;
            }
        }

        # PART 12
        # If allowed, look for an MXP tag property (one of the keys in $self->mxpPropHash, namely
        #   'RoomName', 'RoomDesc', 'RoomExit', 'RoomNum')
        if (
            ! $failExitFlag
            && ! $involuntaryExitFlag
            && ! $specialFlag
            && (
                (! $existsFlag && $axmud::CLIENT->allowMxpRoomFlag)
                || ($existsFlag && $worldObj->ivShow('mxpOverrideHash', 'room'))
            )
        ) {
            OUTER: foreach my $prop ($bufferObj->ivKeys('mxpFlagTextHash')) {

                my $text = $bufferObj->ivShow('mxpFlagTextHash', $prop);

                if ($self->ivExists('mxpPropHash', $prop)) {

                    # When the first tag property is found, stop searching for anchor lines
                    #   altogether
                    if (! $self->useMxpFlag) {

                        $self->ivPoke('useMxpFlag', TRUE);
                    }

                    # Temporarily store the tagged text. An <ELEMENT>...</ELEMENT> construction
                    #   could appear more than once on the same line, so watch out for that
                    if (! defined $tempMxpPropHash{$prop}) {

                        $tempMxpPropHash{$prop} = $text;

                    } elsif ($prop eq 'RoomExit') {

                        # Tell $self->setRoomFromMxp that these are separate exits by using a
                        #   newline character
                        $tempMxpPropHash{$prop} = $tempMxpPropHash{$prop} . "\n" . $text;

                    } else {

                        $tempMxpPropHash{$prop} = $tempMxpPropHash{$prop} . " " . $text;
                    }
                }
            }

            # Special case: if no rooms have been processed yet and we're moving through the buffer
            #   from bottom to top, and if start/stop tags are defined, keep searching for a line
            #   containing those tags (and in the meantime, store any other tags we find)
            if (
                ! $self->roomCount
                && %tempMxpPropHash
                && ($worldObj->mxpRoomTagStart || $worldObj->mxpRoomTagStop)
            ) {
                foreach my $key (keys %tempMxpPropHash) {

                    $self->ivAdd('mxpPropCurrentHash', $key, $tempMxpPropHash{$key});
                    if ($worldObj->mxpRoomTagStart && $key eq $worldObj->mxpRoomTagStart) {

                        $self->ivPoke('mxpPropStartLine', $lineNum);
                    }
                }

                if (
                    (
                        ! $worldObj->mxpRoomTagStart
                        || $self->ivExists('mxpPropCurrentHash', $worldObj->mxpRoomTagStart)
                    ) && (
                        ! $worldObj->mxpRoomTagStop
                        || $self->ivExists('mxpPropCurrentHash', $worldObj->mxpRoomTagStop)
                    )
                ) {
                    # The start and/or stop tag has just been found, so process the first room
                    #   statement
                    $self->prepareMxpProperties($lineNum, $worldObj);
                    $anchorFlag = TRUE;
                }

                # (Take no further action in this PART)
                %tempMxpPropHash = ();
            }

            # If the current world profile has specified tags marking the beginning and/or end of a
            #   room statement, the Locator Task is more likely to be able to catch the whole room
            #   statement (so check for that first)
            # If not, then proceed with dumb handling of MXP tag properties (we'll do the best we
            #   can with what we've got)
            if (
                %tempMxpPropHash
                && $worldObj->mxpRoomTagStart
                && exists $tempMxpPropHash{$worldObj->mxpRoomTagStart}
            ) {
                # A room statement starts on this line
                # If $self->mxpPropCurrentHash is already storing tagged text, then that represents
                #   a previous room statement, which can now be processed
                if ($self->mxpPropCurrentHash) {

                    # Process the previous room statement
                    $self->prepareMxpProperties($lineNum, $worldObj);
                    # Return -1 to force $self->doStage to call this function for the same line
                    #   again, this time processing the room statement that starts on this line
                    return -1;
                }

                if (! $worldObj->mxpRoomTagStop || ! $self->roomCount) {

                    # Dumb handling of MXP room tags, because the world profile doesn't specify
                    #   ->mxpRoomTagStart or ->mxpRoomTagStop
                    $anchorFlag = TRUE;

                    # Search more lines, looking for more MXP tag properties until (1) an identical
                    #   tag property is found (which means the start of another room statement), or
                    #   (2) all four tag properties are found (which means the end of the current
                    #   room statement) or (3) the task runs out of lines (which means the end of
                    #   the current room statement), or (4) more than 32 lines without a tag
                    #   property are checked
                    if (! $self->roomCount) {

                        # (Still looking for first statement, but just for now, don't search lines
                        #   in reverse order)
                        $self->processMxpProperties(
                            $stopLineNum,
                            $lineNum,
                            $worldObj,
                            %tempMxpPropHash,
                        );

                    } else {

                        $self->processMxpProperties(
                            $lineNum,
                            $stopLineNum,
                            $worldObj,
                            %tempMxpPropHash,
                        );
                    }

                } else {

                    # Store the new tagged text, and wait for the end of the statement
                    foreach my $key (keys %tempMxpPropHash) {

                        $self->ivAdd('mxpPropCurrentHash', $key, $tempMxpPropHash{$key});
                    }

                    $self->ivPoke('mxpPropStartLine', $lineNum);
                }

                # (Take no further action in this PART)
                %tempMxpPropHash = ();
            }

            if (
                %tempMxpPropHash
                && $worldObj->mxpRoomTagStop
                && exists $tempMxpPropHash{$worldObj->mxpRoomTagStop}
            ) {
                # A room statement stops on this line
                # If this line contains the same type of tagged text as seen on previous lines,
                #   then that represents a previous room statement, which can now be processed
                foreach my $key (keys %tempMxpPropHash) {

                    if ($self->ivExists('mxpPropCurrentHash', $key)) {

                        # Process the previous room statement
                        $self->prepareMxpProperties($lineNum, $worldObj);
                        # Return -1 to force $self->doStage to call this function for the same line
                        #   again, this time processing the room statement that stops on this line
                        return -1;
                    }
                }

                # (This condition will be true, if the room statement starts and stops on the same
                #   line)
                if (! $self->mxpPropCurrentHash) {

                    $self->ivPoke('mxpPropStartLine', $lineNum);
                }

                # Store the new tagged text
                foreach my $key (keys %tempMxpPropHash) {

                    $self->ivAdd('mxpPropCurrentHash', $key, $tempMxpPropHash{$key});
                }

                # Process the new room statement
                $self->prepareMxpProperties($lineNum, $worldObj);
                $anchorFlag = TRUE;

                # (Take no further action in this PART)
                %tempMxpPropHash = ();
            }

            if (
                %tempMxpPropHash
                && ($worldObj->mxpRoomTagStart || $worldObj->mxpRoomTagStop)
            ) {
                # The world profile specifies the tags which mark the beginning and/or end of a room
                #   statement, but only some of the other MXP tag properties were found on this line
                OUTER: foreach my $key (keys %tempMxpPropHash) {

                    if ($self->ivExists('mxpPropCurrentHash', $key)) {

                        # Process the previous room statement
                        $self->prepareMxpProperties($lineNum, $worldObj);
                        $anchorFlag = TRUE;

                        last OUTER;
                    }
                }

                # Store the new tagged text
                foreach my $key (keys %tempMxpPropHash) {

                    $self->ivAdd('mxpPropCurrentHash', $key, $tempMxpPropHash{$key});
                }

                # (Take no further action in this PART)
                %tempMxpPropHash = ();
            }

            if (%tempMxpPropHash) {

                # Dumb handling of MXP room tags, because the world profile doesn't specify
                #   ->mxpRoomTagStart or ->mxpRoomTagStop
                $anchorFlag = TRUE;

                # Search more lines, looking for more MXP tag properties until (1) an identical tag
                #   property is found (which means the start of another room statement), or (2) all
                #   four tag properties are found (which means the end of the current room
                #   statement) or (3) the task runs out of lines (which means the end of the
                #   current room statement), or (4) more than 32 lines without a tag property are
                #   checked
                $self->processMxpProperties(
                    $lineNum,
                    $stopLineNum,
                    $worldObj,
                    %tempMxpPropHash,
                );
            }
        }

        # PARTS 13-16
        # Look for an anchor line (unless a failed exit, involuntary exit, special departure pattern
        #   or an MXP room tag has already been found)
        if (! $failExitFlag && ! $involuntaryExitFlag && ! $specialFlag && ! $anchorFlag) {

            # PART 13
            # In Basic mapping mode, if this task is expecting room statements, then accept any line
            #   that's not a failed exit, involuntary exit, special departure, command prompt or
            #   empty line
            if (
                ! $anchorFlag
                && ! $self->useMxpFlag
                && $worldObj->basicMappingFlag
                && $self->moveList
            ) {
                push (@promptList,
                    '^\s*$',                            # Empty line
                    $worldObj->cmdPromptPatternList,    # Recognisable command prompts
                );

                OUTER: foreach my $pattern (@promptList) {

                    if ($lineText =~ m/$pattern/) {

                        $promptFlag = TRUE;             # Treat empty lines and prompts the same way
                        last OUTER;
                    }
                }

                # Basic mapping anchor found
                if (! $promptFlag && $axmud::CLIENT->debugLocatorFlag) {

                    $self->session->writeDebug(
                        'LOCATOR 281: Found basic mapping anchor line',
                    );
                }

                # If room statement components have been specified in either of the three lists,
                #   try to extract those components (stopping after the first successful
                #   extraction)
                if (! $promptFlag && $worldObj->verboseComponentList) {

                    # (Check termination pattern - see comments in PART 13)
                    if (
                        $worldObj->verboseFinalPattern
                        && (
                            ! $self->findTerminationPattern(
                                $lineNum,
                                $worldObj,
                                $worldObj->verboseFinalPattern,
                            )
                        )
                    ) {
                        # (None found)
                        $self->ivPoke('restartBufferLine', $restartLineNum);
                        return undef;

                    } elsif ($self->processAnchor($lineNum, $worldObj, 'verbose')) {

                        $anchorFlag = TRUE;
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 282: Processed verbose component list',
                            );
                        }
                    }
                }

                if (! $promptFlag && ! $anchorFlag && $worldObj->shortComponentList) {

                    # (Check termination pattern - see comments in PART 13)
                    if (
                        $worldObj->shortFinalPattern
                        && (
                            ! $self->findTerminationPattern(
                                $lineNum,
                                $worldObj,
                                $worldObj->shortFinalPattern,
                            )
                        )
                    ) {
                        # (None found)
                        $self->ivPoke('restartBufferLine', $restartLineNum);
                        return undef;

                    } elsif ($self->processAnchor($lineNum, $worldObj, 'short')) {

                        $anchorFlag = TRUE;
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 283: Processed short verbose component list',
                            );
                        }
                    }
                }

                if (! $promptFlag && ! $anchorFlag && $worldObj->briefComponentList) {

                    # (Check termination pattern - see comments in PART 13)
                    if (
                        $worldObj->briefFinalPattern
                        && (
                            ! $self->findTerminationPattern(
                                $lineNum,
                                $worldObj,
                                $worldObj->briefFinalPattern,
                            )
                        )
                    ) {
                        # (None found)
                        $self->ivPoke('restartBufferLine', $restartLineNum);
                        return undef;

                    } elsif ($self->processAnchor($lineNum, $worldObj, 'brief')) {

                        $anchorFlag = TRUE;
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 284: Processed brief component list',
                            );
                        }
                    }
                }

                if (! $promptFlag && ! $anchorFlag) {

                    if (! $self->processAnchor($lineNum, $worldObj, 'basic')) {

                        $self->writeDebug('LOCATOR 285: Unable to process basic mapping anchor');

                    } else {

                        # (Call to ->processAnchor didn't end in failure - an unlikely situation)
                        $anchorFlag = TRUE;
                    }
                }
            }

            # PART 14
            # Look for a verbose anchor line
            if (! $promptFlag && ! $anchorFlag && ! $self->useMxpFlag) {

                OUTER: foreach my $pattern ($worldObj->verboseAnchorPatternList) {

                    if ($lineText =~ m/$pattern/) {

                        # Verbose anchor found
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->session->writeDebug(
                                'LOCATOR 291: Found verbose anchor pattern: ' . $pattern,
                            );
                        }

                        # If a termination pattern has been specified, check remaining lines in the
                        #   display buffer until either that line is found, or another anchor is
                        #   found
                        if (
                            $worldObj->verboseFinalPattern
                            && (
                                ! $self->findTerminationPattern(
                                    $lineNum,
                                    $worldObj,
                                    $worldObj->verboseFinalPattern,
                                )
                            )
                        ) {
                            # No termination pattern found, probably because it's a long room
                            #   statement and some part of it hasn't been received yet. Wait until
                            #   the next task before trying again
                            $self->ivPoke('restartBufferLine', $restartLineNum);
                            return undef;
                        }

                        # If any anchor check patterns have been specified, check that nearby lines
                        #   match those patterns
                        if (! $self->checkAnchor($lineNum, $worldObj, 'verbose')) {

                            if ($axmud::CLIENT->debugLocatorFlag) {

                                $self->writeDebug(
                                    'LOCATOR 292: Anchor check patterns do not match nearby lines',
                                );
                            }

                            last OUTER;
                        }

                        # Can we extract the rest of the components? If not, ignore the anchor
                        if ($self->processAnchor($lineNum, $worldObj, 'verbose')) {

                            $anchorFlag = TRUE;

                        } elsif ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug('LOCATOR 293: Unable to process verbose anchor');
                        }

                        last OUTER;
                    }
                }
            }

            # PART 15
            # Look for a short verbose anchor line
            if (! $promptFlag && ! $anchorFlag && ! $self->useMxpFlag) {

                OUTER: foreach my $pattern ($worldObj->shortAnchorPatternList) {

                    if ($lineText =~ m/$pattern/) {

                        # Short verbose anchor found
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 301: Found short verbose anchor pattern: ' . $pattern,
                            );
                        }

                        # If a termination pattern has been specified, check remaining lines in the
                        #   display buffer until either that line is found, or another anchor is
                        #   found
                        if (
                            $worldObj->shortFinalPattern
                            && (
                                ! $self->findTerminationPattern(
                                    $lineNum,
                                    $worldObj,
                                    $worldObj->shortFinalPattern,
                                )
                            )
                        ) {
                            # No termination pattern found, probably because it's a long room
                            #   statement and some part of it hasn't been received yet. Wait until
                            #   the next task loop before trying again
                            $self->ivPoke('restartBufferLine', $restartLineNum);
                            return undef;
                        }

                        # If any anchor check patterns have been specified, check that nearby lines
                        #   match those patterns
                        if (! $self->checkAnchor($lineNum, $worldObj, 'short')) {

                            if ($axmud::CLIENT->debugLocatorFlag) {

                                $self->writeDebug(
                                    'LOCATOR 302: Anchor check patterns do not match nearby lines',
                                );
                            }

                            last OUTER;
                        }

                        # Can we extract the rest of the components? If not, ignore the anchor
                        if ($self->processAnchor($lineNum, $worldObj, 'short')) {

                            $anchorFlag = TRUE;

                        } elsif ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 303: Unable to process short verbose anchor',
                            );
                        }

                        last OUTER;
                    }
                }
            }

            # PART 16
            # Look for a brief anchor line
            if (! $promptFlag && ! $anchorFlag && ! $self->useMxpFlag) {

                OUTER: foreach my $pattern ($worldObj->briefAnchorPatternList) {

                    if ($lineText =~ m/$pattern/) {

                        # Brief anchor found
                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 311: Found brief anchor pattern: ' . $pattern,
                            );
                        }

                        # If a termination pattern has been specified, check remaining lines in the
                        #   display buffer until either that line is found, or another anchor is
                        #   found
                        if (
                            $worldObj->briefFinalPattern
                            && (
                                ! $self->findTerminationPattern(
                                    $lineNum,
                                    $worldObj,
                                    $worldObj->briefFinalPattern,
                                )
                            )
                        ) {
                            # No termination pattern found, probably because it's a long room
                            #   statement and some part of it hasn't been received yet. Wait until
                            #   the next task loop before trying again
                            $self->ivPoke('restartBufferLine', $restartLineNum);
                            return undef;
                        }

                        # If any anchor check patterns have been specified, check that nearby lines
                        #   match those patterns
                        if (! $self->checkAnchor($lineNum, $worldObj, 'brief')) {

                            if ($axmud::CLIENT->debugLocatorFlag) {

                                $self->writeDebug(
                                    'LOCATOR 312: Anchor check patterns do not match nearby lines',
                                );
                            }

                            last OUTER;
                        }

                        # Can we extract the rest of the components? If not, ignore the anchor
                        if ($self->processAnchor($lineNum, $worldObj, 'brief')) {

                            $anchorFlag = TRUE;

                        } elsif ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 313: Unable to process brief anchor',
                            );
                        }

                        last OUTER;
                    }
                }
            }
        }

        # (Parts 12-16)
        if ($anchorFlag) {

            $updateFlag = TRUE;
            # Remember the position in the display buffer of the most recent anchor line found
            $self->ivPoke('lastAnchorLine', $lineNum);

            if (! $self->roomCount) {

                # First room statement found - in future, search forwards, not backwards
                $self->ivPoke('roomCount', 1);

                # Special case: if the automapper object's ->currentRoom has already been set
                #   (usually because the user opens the Automapper window and sets the current
                #   room there), $self->modelNumber will already be set
                # In that case, see if the room has a room tag and, if so, display it
                if ($self->modelNumber) {

                    if (
                        ! $self->session->worldModelObj->ivExists(
                            'modelHash',
                            $self->modelNumber,
                        )
                    ) {
                        # Room doesn't exist in the model (rather unlikely)
                        $self->ivUndef('modelNumber');

                    } else {

                        $mapRoomObj = $self->session->worldModelObj->ivShow(
                            'modelHash',
                            $self->modelNumber,
                        );

                        # Make sure it's a room
                        if ($mapRoomObj->category ne 'room') {

                            $self->ivUndef('modelNumber');

                        } elsif ($mapRoomObj->roomTag) {

                            # Set the room tag of this task's non-model room to match
                            $self->roomObj->ivPoke('roomTag', $mapRoomObj->roomTag);
                        }
                    }

                } else {

                    # Otherwise, make sure the move lists are empty
                    $self->ivEmpty('cmdObjList');
                    $self->ivEmpty('moveList');
                    $self->ivUndef('prevCmdBufferNum');
                }

                # Display information about the room statement in the task window (if it's open
                #   and enabled)
                if ($self->taskWinFlag) {

                    $self->refreshWin();
                }

                # Don't look for any more room statements until some more text is received from
                #   the world
                # Exception: if the automapper has a current room set and we're updating the
                #   world model (the Automapper window is in 'update' mode, or the automapper
                #   object's ->trackAloneFlag is set), then we need to process PART 16, in order
                #   to update the current room
                if (
                    ! $self->session->mapObj->currentRoom
                    || ! (
                        ($self->session->mapWin && $self->session->mapWin->mode eq 'update')
                        || $self->session->mapObj->trackAloneFlag
                    )
                ) {
                    return undef;
                }
            }
        }

        # PART 17
        # If an anchor line has been found, need to decide which command object in $self->cmdObjList
        #   was responsible for it
        if ($updateFlag) {

            $self->ivIncrement('roomCount');
            $self->ivUndef('modelNumber');

            # $cmdObj represents a look/glance command or a movement command (including redirect
            #   mode commands, assisted moves and teleport commands). We'll assume that it was
            #   responsible for the room statement whose anchor line appears on this line
            if ($cmdObj) {

                $self->removeFirstMove($cmdObj);
            }

            # Are the command lists empty of look/glance/movement commands?
            if (! $cmdObj) {

                # The move list is empty. Inform the automapper of a move in an unknown direction
                #   and let it work out where the character is now
                $self->session->mapObj->moveUnknownDirSeen();

            # Is it a look/glance command?
            } elsif ($cmdObj->lookFlag || $cmdObj->glanceFlag) {

                # If the previous room object had a room tag and the new room object doesn't have
                #   one (yet), it should be given the same tag (so that the look/glance command
                #   doesn't cancel a tag assigned to the room by the user)
                if (
                    $self->prevRoomObj
                    && $self->prevRoomObj->roomTag
                    && ! $self->roomObj->roomTag
                ) {
                    $self->roomObj->ivPoke('roomTag', $self->prevRoomObj->roomTag);
                }

                # Inform the automapper object in case the room statement contains new information
                $self->session->mapObj->lookGlanceSeen($cmdObj);

            # Is it a teleport?
            } elsif ($cmdObj->teleportFlag) {

                if ($cmdObj->teleportDestRoom) {

                    # Inform the automapper of a move to a known destination room
                    $self->session->mapObj->teleportSeen($cmdObj);

                } else {

                    # Treat this as a normal move in an unknown direction
                    $self->session->mapObj->moveUnknownDirSeen();
                }

            # Is it a movement direction?
            } else {

                # Inform the automapper object
                $self->session->mapObj->moveKnownDirSeen($cmdObj);

                # Store the direction of movement so it's available to anything that wants to know
                if ($cmdObj->redirectFlag || $cmdObj->assistedFlag) {
                    $self->ivPoke('prevMove', 'cmd');
                } else {
                    $self->ivPoke('prevMove', $cmdObj->moveDir);
                }

                # Also store the buffer object itself, in case anything wants more details about
                #   the move
                $self->ivPoke('prevMoveObj', $cmdObj);
            }

            # Display information about the room statement in the task window (if it's open and
            #   enabled)
            if ($self->taskWinFlag) {

                $self->refreshWin();
            }

            # Also, if there's a current mission running that's on a Locator break, we need to
            #   inform the mission whenever $self->moveList becomes empty (i.e. no more room
            #   statements are expected)
            if (! $self->moveList && ! $self->session->excessCmdList) {

                $missionObj = $self->session->currentMission;
                if ($missionObj && $missionObj->breakType && $missionObj->breakType eq 'locator') {

                    $missionObj->taskReady($self->session);
                }
            }
        }

        # Analysis complete
        return 1;
    }

    sub findTerminationPattern {

        # Called by $self->processLine (at stage 3) when an anchor line is found and when the world
        #   profile defines a termination pattern (which means that the whole room statement has
        #   been received - it is normally not necessary to check, except at MUDs with long room
        #   statements, where the verbose description appears after the anchor line)
        # Checks the remaining lines in the display buffer, looking for either another anchor line,
        #   or a line matching the specified termination pattern.
        #
        # Expected arguments
        #   $lineNum        - The display buffer number of the anchor line
        #   $worldObj       - Shortcut to the current world profile object
        #   $termPattern    - The specified termination pattern, matching either
        #                       $worldObj->verboseFinalPattern, ->shortFinalPattern or
        #                       ->briefFinalPattern
        #
        # Return values
        #   'undef' on improper arguments or if the expected termination pattern (or another anchor
        #       line) isn't found in the remaining display buffer
        #   1 if either the expected termination pattern or another anchor line is found

        my ($self, $lineNum, $worldObj, $termPattern, $check) = @_;

        # Local variables
        my @patternList;

        # Check for improper arguments
        if (! defined $lineNum || ! defined $worldObj || ! defined $termPattern || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findTerminationPattern', @_);
        }

        # If $lineNum is the last line in the display buffer, then of course there's no need to
        #   search beyond it
        if ($lineNum >= $self->session->displayBufferLast) {

            return undef;
        }

        # Compile a list of patterns matching anchor lines (verbose, short verbose and brief). We
        #   can add the termination pattern, too
        @patternList = (
            $termPattern,
            $worldObj->verboseAnchorPatternList,
            $worldObj->shortAnchorPatternList,
            $worldObj->briefAnchorPatternList,
        );

        for (my $line = ($lineNum + 1); $line <= $self->session->displayBufferLast; $line++) {

            my ($bufferObj, $lineText);

            $bufferObj = $self->session->ivShow('displayBufferHash', $line);
            $lineText = $bufferObj->modLine;

            foreach my $pattern (@patternList) {

                if ($lineText =~ m/$pattern/) {

                    # Success! The room statement is complete
                    if ($axmud::CLIENT->debugMaxLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 401: Found anchor line matching termination pattern \''
                            . $pattern . '\'',
                        );
                    }

                    return 1;
                }
            }
        }

        # The room statement is not complete
        return undef;
    }

    sub processMxpProperties {

        # Called by $self->processLine (at stage 3) when an MXP tag property is found
        # Searches successive lines for more tag properties until (1) an identical tag property is
        #   found (which means the start of another room statement), or (2) all four tag properties
        #   are found (which means the end of the current room statement), or (3) the task runs out
        #   of lines (which means the end of the current room statement), or (4) more than 8
        #   lines without a tag property are checked
        #
        # Expected arguments
        #   $lineNum        - The display buffer number of the line containing an MXP tag property,
        #                       the first matching line found for the current room statement
        #   $stopLineNum    - The display buffer number of last line that can be analysed during
        #                       this task loop (might be the same as $lineNum. If we're checking
        #                       lines from beginning to end, it might be higher than $lineNum; if
        #                       we're checking lines from end to beginning, it might be lower than
        #                       $lineNum)
        #   $worldObj       - Shortcut to the current world profile object
        #   %mxpPropHash    - A hash of MXP tag properties (should not be empty), in the form
        #                       $mxpPropHash{tag} = string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $lineNum, $stopLineNum, $worldObj, %mxpPropHash) = @_;

        # Local variables
        my ($roomObj, $step, $maxLines, $origLineNum, $matchCount, $loopCount, $useLine, $prevProp);

        # Check for improper arguments
        if (! defined $lineNum || ! defined $stopLineNum || ! defined $worldObj || ! %mxpPropHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processMxpProperties', @_);
        }

        # Reset the list of lines to be converted to text-to-speech; this hash should only contain
        #   the lines that we might want to read out
        $self->ivEmpty('ttsToReadHash');

        # Create a non-model object for this room
        $roomObj = Games::Axmud::ModelObj::Room->new(
            $self->session,
            'temporary',
            'non_model',
        );

        # Move backwards or forwards (default) in the display buffer
        if ($stopLineNum < $lineNum) {
            $step = -1;
        } else {
            $step = 1;
        }

        # Don't carry on forever. (Before v2.1.062, the limit was 8 lines)
        $maxLines = 32;

        # Check each line in turn
        $origLineNum = $lineNum;
        $matchCount = 0;
        $loopCount = 0;

        do {

            my (
                $bufferObj,
                %newHash,
            );

            $lineNum += $step;
            $loopCount++;
            $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
            # Sanity check
            if (! $bufferObj) {

                # Apply the room statement text we've gathered so far
                $self->setRoomFromMxp($worldObj, $roomObj, $origLineNum, $lineNum, %mxpPropHash);
                # Store the lines at which the room statement starts/ends
                if ($step < 0) {

                    $self->ivPoke('lastStatementStartLine', $lineNum - 1);
                    $self->ivPoke('lastStatementEndLine', $origLineNum);

                } else {

                    $self->ivPoke('lastStatementStartLine', $origLineNum);
                    $self->ivPoke('lastStatementEndLine', $lineNum - 1);
                }

                # Do logfiles, TTS, etc
                return $self->confirmMxpProperties($worldObj, $roomObj, %mxpPropHash);
            }

            # Compile a list of MXP tag properties on this line, only using those properties
            #   that the task wants to use
            foreach my $prop ($bufferObj->ivKeys('mxpFlagTextHash')) {

                my $text = $bufferObj->ivShow('mxpFlagTextHash', $prop);

                if ($self->ivExists('mxpPropHash', $prop)) {

                    # The task wants to use this property

                    # If this property was found on a previous line, then this line is the start of
                    #   a new room statement
                    # However, if this property was found on the previous line, then it's not the
                    #   start of a new room statement (e.g. <RoomExit>...</RoomExit> might appear on
                    #   several consecutive lines)
                    # NB The first line of a room statement was analysed by the calling function,
                    #   and it may possibly have contained two or more relevant tags. The calling
                    #   function doesn't tell us which tag appeared last in that first line, so we
                    #   don't know what the value of $prevProp would have been, if that line had
                    #   been analysed by this function. To keep things simple, we assume that first
                    #   line did not contain a complete room statement in a single line, and we do
                    #   that by checking that $loopCount > 1
                    if (
                        exists $mxpPropHash{$prop}
                        && $loopCount > 1
                        && $prevProp
                        && $prevProp ne $prop
                    ) {
                        # Apply the room statement text we've gathered so far
                        $self->setRoomFromMxp($worldObj, $roomObj, %mxpPropHash);
                        # Store the lines at which the room statement starts/ends
                        if ($step < 0) {

                            $self->ivPoke('lastStatementStartLine', $lineNum - 1);
                            $self->ivPoke('lastStatementEndLine', $origLineNum);

                        } else {

                            $self->ivPoke('lastStatementStartLine', $origLineNum);
                            $self->ivPoke('lastStatementEndLine', $lineNum - 1);
                        }

                        # Do logfiles, TTS, etc
                        return $self->confirmMxpProperties($worldObj, $roomObj, %mxpPropHash);

                    } else {

                        $prevProp = $prop;

                        # Guard against the possibility that, for example, a
                        #   <RoomName>...</RoomName> construction might appear more than once on a
                        #   line (which we don't treat as a new room statement, but a continuation
                        #   of the existing one)
                        if (! exists $newHash{$prop}) {

                            $newHash{$prop} = $text;

                        } elsif ($prop eq 'RoomExit') {

                            # Tell $self->setRoomFromMxp that these are separate exits by using a
                            #   newline character
                            $newHash{$prop} = $newHash{$prop} . "\n" . $text;

                        } else {

                            $newHash{$prop} = $newHash{$prop} . " " . $text;
                        }
                    }
                }
            }

            if (! %newHash) {

                $prevProp = undef;

                # No MXP tag properties found on this line
                $matchCount++;

            } else {

                $useLine = $lineNum;

                # Add the tag properties found on this line to those found on previous lines
                foreach my $prop (keys %newHash) {

                    if (! exists $mxpPropHash{$prop}) {

                        $mxpPropHash{$prop} = $newHash{$prop};

                    } else {

                        $mxpPropHash{$prop} = $mxpPropHash{$prop} . " " . $newHash{$prop};
                    }
                }
            }

        # Give up after many lines without a tag property
        } until ($matchCount >= $maxLines);

        # Apply the room statement text we've gathered so far
        $self->setRoomFromMxp($worldObj, $roomObj, %mxpPropHash);

        # Store the lines at which the room statement starts/ends
        if (! defined $useLine) {

            # No additional lines found, besides $origLineNum
            $useLine = $origLineNum;
        }

        if ($step < 0) {

            $self->ivPoke('lastStatementStartLine', $useLine);
            $self->ivPoke('lastStatementEndLine', $origLineNum);

        } else {

            $self->ivPoke('lastStatementStartLine', $origLineNum);
            $self->ivPoke('lastStatementEndLine', $useLine);
        }

        # Do logfiles, TTS, etc
        return $self->confirmMxpProperties($worldObj, $roomObj, %mxpPropHash);
    }

    sub prepareMxpProperties {

        # Simplified version of $self->processMxpProperties()
        # In some situations, the MXP tag properties defining a whole room statement have been
        #   captured, so there's no need to check other lines. In that case, this function is
        #   called instead to process the tagged text we already have
        #
        # Expected arguments
        #   $lineNum        - The display buffer number of the line containing the last MXP tag
        #                       property for a room statement (matching the line being processed by
        #                       the calling function, $self->processLine)
        #   $worldObj       - Shortcut to the current world profile object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $lineNum, $worldObj, $check) = @_;

        # Local variables
        my $roomObj;

        # Check for improper arguments
        if (! defined $lineNum || ! defined $worldObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareMxpProperties', @_);
        }

        # Reset the list of lines to be converted to text-to-speech; this hash should only
        #   contain the lines that we might want to read out
        $self->ivEmpty('ttsToReadHash');

        # Create a non-model object for this room
        $roomObj = Games::Axmud::ModelObj::Room->new(
            $self->session,
            'temporary',
            'non_model',
        );

        # Apply the room statement text we've gathered so far
        $self->setRoomFromMxp($worldObj, $roomObj, $self->mxpPropCurrentHash);

        # Store the lines at which the room statement starts/ends
        # (If the start line isn't known, for some reason, use $lineNum as both a start/stop line,
        #   as an emergency fallback)
        if (! defined $self->mxpPropStartLine) {
            $self->ivPoke('lastStatementStartLine', $lineNum);
        } else {
            $self->ivPoke('lastStatementStartLine', $self->mxpPropStartLine);
        }

        $self->ivPoke('lastStatementEndLine', $lineNum);

        # Do logfiles, TTS, etc
        return $self->confirmMxpProperties($worldObj, $roomObj, $self->mxpPropCurrentHash);
    }

    sub confirmMxpProperties {

        # Called by $self->processMxpProperties and ->prepareMxpProperties, after a room statement
        #   is extracted
        # Handles logfiles, TTS etc, duplicating code also found in ->processAnchor
        #
        # Expected arguments
        #   $worldObj       - Shortcut to the current world profile object
        #   $roomObj        - The non-model room object created by the calling function
        #   %mxpPropHash    - A hash of MXP tag properties (should not be empty), in the form
        #                       $mxpPropHash{tag} = string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $worldObj, $roomObj, %mxpPropHash) = @_;

        # Check for improper arguments
        if (! defined $worldObj || ! defined $roomObj || ! %mxpPropHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->confirmMxpProperties', @_);
        }

        if ($axmud::CLIENT->debugLocatorFlag) {

            $self->writeDebug(
                'LOCATOR 451: Start of statement set to line #' . $self->lastStatementStartLine,
            );

            $self->writeDebug(
                'LOCATOR 452: End of statement set to line #' . $self->lastStatementEndLine,
            );
        }

        #  Archive the previous current room object (if there was one)...
        if ($self->roomObj) {

            $self->ivPoke('prevRoomObj', $self->roomObj);
        }

        # ...and store the new one
        $self->ivPoke('roomObj', $roomObj);

        # Write details of the whole room statement to logfiles. (Unusual step - to save time,
        #   check that this logfile is actually being written, before doing the messy business of
        #   consulting the display buffer)
        # NB Obviously, don't bother in basic mapping mode, if no room statement components have
        #   been specified ($type = 'basic')
        if ($worldObj->ivShow('logPrefHash', 'rooms')) {

            for (
                my $thisLineNum = $self->lastStatementStartLine;
                $thisLineNum <= $self->lastStatementEndLine;
                $thisLineNum++
            ) {
                my $bufferObj = $self->session->ivShow('displayBufferHash', $thisLineNum);
                if ($bufferObj) {

                    $axmud::CLIENT->writeLog(
                        $self->session,
                        FALSE,          # Not a 'standard' logfile
                        $bufferObj->modLine,
                        FALSE,          # Don't precede with a newline character
                        TRUE,           # Use final newline character
                        'rooms',        # Write to this logfile
                    );
                }
            }

            # Add a blank line, so that the logfile isn't a wall of text
            $axmud::CLIENT->writeLog(
                $self->session,
                FALSE,          # Not a 'standard' logfile
                '',
                FALSE,          # Don't precede with a newline character
                TRUE,           # Use final newline character
                'rooms',        # Write to this logfile
            );
        }

        # Write details of the verbose description to logfiles (if allowed, and if the verbose
        #   statement was captured)
        if (defined $mxpPropHash{'RoomDesc'}) {

            $axmud::CLIENT->writeLog(
                $self->session,
                FALSE,          # Not a 'standard' logfile
                $mxpPropHash{'RoomDesc'},
                FALSE,      # Don't precede with a newline character
                TRUE,       # Use final newline character
                'descrips',  # Write to this logfile
            );

            # Add a blank line, so that the logfile isn't a wall of text
            $axmud::CLIENT->writeLog(
                $self->session,
                FALSE,          # Not a 'standard' logfile
                '',
                FALSE,          # Don't precede with a newline character
                TRUE,           # Use final newline character
                'descrips',     # Write to this logfile
            );
        }

        # Do text-to-speech, if required
        if (
            $self->ivShow('ttsFlagAttribHash', 'title')
            && $self->ivShow('ttsToReadHash', 'title')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'title'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'descrip')
                || $self->ivShow('ttsFlagAttribHash', 'description')
            ) && $self->ivShow('ttsToReadHash', 'descrip')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'descrip'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'exit')
                || $self->ivShow('ttsFlagAttribHash', 'exits')
            ) && $self->ivShow('ttsToReadHash', 'exit')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'exit'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'content')
                || $self->ivShow('ttsFlagAttribHash', 'contents')
            ) && $self->ivShow('ttsToReadHash', 'content')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'content'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'command')
                || $self->ivShow('ttsFlagAttribHash', 'cmd')
            ) && $self->ivShow('ttsToReadHash', 'command')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'command'));
        }

        # Show debug messages, if required
        if ($axmud::CLIENT->debugLocatorFlag) {

            $self->session->writeDebug(
                'LOCATOR 271: Found MXP tag properties '
                . join('/', sort {$a cmp $b} (keys %mxpPropHash)),
            );
        }

        # Reset IVs, ready for the next room statement
        $self->ivEmpty('mxpPropCurrentHash');
        $self->ivUndef('mxpPropStartLine');

        # Anchor line processing complete
        return 1;
    }

    sub checkAnchor {

        # Called by $self->processLine (at stage 3) when an anchor line is found
        # The world profile can specify some patterns used to check the anchor line really is an
        #   anchor line, by checking that other nearby lines match specified patterns
        # If any check patterns have been specified, checks the received text against them. If any
        #   of the lines at specified offsets don't match the pattern, the test fails (and the
        #   anchor line is not used as an anchor line). If no check patterns have been specified,
        #   the test succeeds (and the anchor line is used as normal)
        #
        # Expected arguments
        #   $lineNum    - The display buffer number of the anchor line
        #   $worldObj   - Shortcut to the current world profile object
        #   $type       - The anchor type 'verbose', 'short' for short verbose anchors, 'brief' or
        #                   'basic' in basic mapping mode
        #
        # Return values
        #   'undef' on improper arguments or if the test fails
        #   1 if the test succeeds

        my ($self, $lineNum, $worldObj, $type, $check) = @_;

        # Local variables
        my (
            $iv,
            @list,
        );

        # Check for improper arguments
        if (! defined $lineNum || ! defined $worldObj || ! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkAnchor', @_);
        }

        # Import the IV
        $iv = $type . 'AnchorCheckList';        # e.g. ->verboseAnchorCheckList
        @list = $worldObj->$iv;
        if (! @list) {

            # If no check patterns were specified, the test succeeds (the anchor line can be used)
            return 1;
        }

        # Check each specified pattern on the specified line (an offset of -1 means 'check the line
        #   before the anchor line', 2 means 'check the line 2 lines after the anchor line' and 0,
        #   though discouraged, means 'check the anchor line itself)
        do {

            my ($offset, $pattern, $bufferObj);

            $offset = shift @list;
            $pattern = shift @list;

            $bufferObj = $self->session->ivShow('displayBufferHash', ($lineNum + $offset));
            if (
                # Specified line not received yet (or never existed, if $thisLineNum is negative)
                ! $bufferObj
                # Specified line doesn't match the pattern
                || (! ($bufferObj->modLine =~ m/$pattern/))
            ) {
                # Test fails, and the anchor line can't be used
                if ($axmud::CLIENT->debugMaxLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 501: Anchor check pattern \'' . $pattern . '\' doesn\'t match line'
                        . $bufferObj->number,
                    );
                }

                return undef;
            }

        } until (! @list);

        # All specified patterns match their specified lines, so the test succeeds and the anchor
        #   line can be used
        return 1;
    }

    sub processAnchor {

        # Called by $self->processLine (at stage 3) when an anchor line is found
        # The world profile contains a list of room statement components; this function splits the
        #   list in two (either side of the 'anchor' component), and calls $self->extractComponents
        #   to process each component in turn
        #
        # Expected arguments
        #   $lineNum    - The display buffer number of the anchor line
        #   $worldObj   - Shortcut to the current world profile object
        #   $type       - The anchor type 'verbose', 'short' for short verbose anchors, 'brief' or
        #                   'basic' in basic mapping mode
        #               - NB In 'basic' mapping mode, this function is still called with $type set
        #                   to 'verbose', 'short' or 'brief', if those room statement components
        #                   have been specified. If none are specified, or if some components are
        #                   specified but could not be extracted in earlier calls to this function,
        #                   only then is this function called with $type set to 'basic')
        #
        # Return values
        #   'undef' on improper arguments, or if the components of the room statement can't be
        #       extracted
        #   1 otherwise

        my ($self, $lineNum, $worldObj, $type, $check) = @_;

        # Local variables
        my (
            $anchorOffset, $anchorFlag, $roomObj, $result, $successFlag, $firstLineNum,
            $lastLineNum, $otherLineNum, $hashRef1, $hashRef2,
            @componentList, @beforeList, @afterList,
            %posnHash, %noExtractHash, %oldWeatherHash,
        );

        # Check for improper arguments
        if (! defined $lineNum || ! defined $worldObj || ! defined $type || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processAnchor', @_);
        }

        # Reset the list of lines to be converted to text-to-speech; this hash should only
        #   contain the lines that we might want to read out
        $self->ivEmpty('ttsToReadHash');

        # Import the list of room statement components
        if ($type eq 'basic') {

            # (@componentList is purposely empty, so this function will not attempt to extract any
            #   components - see the comments at the top of this function)
            $anchorOffset = 0;      # Anchor line doesn't share its line with any component

        } else {

            if ($type eq 'verbose') {

                @componentList = $worldObj->verboseComponentList;
                $anchorOffset = $worldObj->verboseAnchorOffset;

            } elsif ($type eq 'short') {

                @componentList = $worldObj->shortComponentList;
                $anchorOffset = $worldObj->shortAnchorOffset;

            } elsif ($type eq 'brief') {

                @componentList = $worldObj->briefComponentList;
                $anchorOffset = $worldObj->briefAnchorOffset;
            }

            # In basic mapping mode, while trying to extract room statement components, the offset
            #   must be +1 (anchor line shares a line with the first line of the statement)
            if ($worldObj->basicMappingFlag) {

                $anchorOffset = 1;
            }

            if (! @componentList) {

                # No components to extract (or invalid $type); so ignore this form of room statement
                return undef;
            }

            # The component list consists of component names. Most of them are keys in
            #   GA::Profile::World->componentHash, but the name 'anchor' is not
            # Divide the component list into two - all the components before the 'anchor' line (in
            #   reverse order), and all the components after it
            foreach my $component (@componentList) {

                if ($component eq 'anchor') {
                    $anchorFlag = TRUE;
                } elsif ($anchorFlag) {
                    push (@afterList, $component);
                } else {
                    unshift (@beforeList, $component);
                }
            }

            if (! $anchorFlag || (! @beforeList && ! @afterList)) {

                # The one compulsory component, 'anchor', is missing from @componentList; or there
                #   are no components beside the 'anchor' component. Can't continue
                return undef;
            }
        }

        # In anticipation of a successful extraction, create a non-model object for this room
        $roomObj = Games::Axmud::ModelObj::Room->new(
            $self->session,
            'temporary',
            'non_model',
        );

        # $self->roomObj hasn't been set yet, but we still need to reset $self->weatherHash, as it's
        #   about to be re-filled
        # Keep a copy of the existing contents, in case extraction fails (in which case we can
        #   restore the existing contents)
        %oldWeatherHash = $self->weatherHash;
        $self->ivEmpty('weatherHash');

        # Extract the components that come before the anchor line (if any)
        if (@beforeList) {

            # $self->extractComponents returns the first and last line of all the extracted
            #   components, as well as the line numbers which comprise each component, stores as a
            #   hash reference in the form
            #       $posnHash{component_name} = reference_to_list_of_line_numbers
            ($successFlag, $firstLineNum, $otherLineNum, $hashRef1) = $self->extractComponents(
                    $lineNum,
                    $worldObj,
                    $roomObj,
                    $anchorOffset,
                    -1,
                    \%noExtractHash,
                    @beforeList,
            );

            # Attempted to extract a non-optional component, but failed
            if (! $successFlag) {

                $self->ivPoke('weatherHash', %oldWeatherHash);

                return undef;

            # If no components were extracted, because none were specified or because all
            #   specified components were optional, and were not found, then $firstLineNum
            #   won't be set
            } elsif (! defined $firstLineNum) {

                # First line is the anchor line
                $firstLineNum = $lineNum;

            # All components extracted successfully
            } else {

                %posnHash = %$hashRef1;
            }

        } else {

            # First line is the anchor line
            $firstLineNum = $lineNum;
        }

        # Extract the components that come after the anchor line (if any)
        if (@afterList) {

            # The return values need to be combined with the values returned in the earlier call
            #   to ->extractComponents
            ($successFlag, $otherLineNum, $lastLineNum, $hashRef2) = $self->extractComponents(
                    $lineNum,
                    $worldObj,
                    $roomObj,
                    $anchorOffset,
                    1,
                    \%noExtractHash,
                    @afterList,
            );

            # Attempted to extract a non-optional component, but failed
            if (! $successFlag) {

                $self->ivPoke('weatherHash', %oldWeatherHash);

                return undef;

            # If no components were extracted, because none were specified or because all specified
            #   components were optional, and were not found, then $otherLineNum won't be set
            } elsif (! defined $lastLineNum) {

                # Last line is the anchor line
                $lastLineNum = $lineNum;

            # All components extracted successfully
            } else {

                # Combine this hash with the previous one, if there was one
                %posnHash = (%posnHash, %$hashRef2);
            }

        } else {

            # Last line is the anchor line
            $lastLineNum = $lineNum;
        }

        # Extraction successful. Store the line at which this room statement ends (required by
        #   $self->checkLineValid and others).
        # NB In basic mapping mode, if no room statement components have been specified (and
        #   therefore $type = 'basic'), we'll assume that the first line processed after a movement
        #   command is the first and last line of the room statement. Since we're not extracting
        #   any components, we don't actually care where the statement starts or ends
        $self->ivPoke('lastStatementEndLine', $lastLineNum);
        # Nothing uses $firstLineNum at the moment, but we'll store it anyway
        $self->ivPoke('lastStatementStartLine', $firstLineNum);
        if ($axmud::CLIENT->debugLocatorFlag) {

            $self->writeDebug(
                'LOCATOR 511: Start of statement set to line #' . $self->lastStatementStartLine,
            );

            $self->writeDebug(
                'LOCATOR 512: End of statement set to line #' . $self->lastStatementEndLine,
            );
        }

        #  Archive the previous current room object (if there was one)...
        if ($self->roomObj) {

            $self->ivPoke('prevRoomObj', $self->roomObj);
        }

        # ...and store the new one
        $self->ivPoke('roomObj', $roomObj);

        # Write details of the whole room statement to logfiles. (Unusual step - to save time,
        #   check that this logfile is actually being written, before doing the messy business of
        #   consulting the display buffer)
        # NB Obviously, don't bother in basic mapping mode, if no room statement components have
        #   been specified ($type = 'basic')
        if ($type ne 'basic' && $worldObj->ivShow('logPrefHash', 'rooms')) {

            for (my $thisLineNum = $firstLineNum; $thisLineNum <= $lastLineNum; $thisLineNum++) {

                my $bufferObj = $self->session->ivShow('displayBufferHash', $thisLineNum);
                if ($bufferObj) {

                    $axmud::CLIENT->writeLog(
                        $self->session,
                        FALSE,          # Not a 'standard' logfile
                        $bufferObj->modLine,
                        FALSE,          # Don't precede with a newline character
                        TRUE,           # Use final newline character
                        'rooms',        # Write to this logfile
                    );
                }
            }

            # Add a blank line, so that the logfile isn't a wall of text
            $axmud::CLIENT->writeLog(
                $self->session,
                FALSE,          # Not a 'standard' logfile
                '',
                FALSE,          # Don't precede with a newline character
                TRUE,           # Use final newline character
                'rooms',        # Write to this logfile
            );
        }

        # Write details of the verbose description to logfiles (if allowed, and if the verbose
        #   statement was captured)
        foreach my $compName (keys %posnHash) {

            my ($compObj, $listRef);

            $compObj = $worldObj->ivShow('componentHash', $compName);
            $listRef = $posnHash{$compName};

            if ($compObj->type eq 'verb_descrip' && $worldObj->ivShow('logPrefHash', 'descrips')) {

                foreach my $lineNum (@$listRef) {

                    my $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
                    if ($bufferObj) {

                        $axmud::CLIENT->writeLog(
                            $self->session,
                            FALSE,          # Not a 'standard' logfile
                            $bufferObj->modLine,
                            FALSE,      # Don't precede with a newline character
                            TRUE,       # Use final newline character
                            'descrips',     # Write to this logfile
                        );
                    }
                }

                # Add a blank line, so that the logfile isn't a wall of text
                $axmud::CLIENT->writeLog(
                    $self->session,
                    FALSE,          # Not a 'standard' logfile
                    '',
                    FALSE,          # Don't precede with a newline character
                    TRUE,           # Use final newline character
                    'descrips',     # Write to this logfile
                );

            } elsif (
                ($compObj->type eq 'verb_content' || $compObj->type eq 'brief_content')
                && $worldObj->ivShow('logPrefHash', 'contents')
            ) {
                foreach my $lineNum (@$listRef) {

                    my $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
                    if ($bufferObj) {

                        $axmud::CLIENT->writeLog(
                            $self->session,
                            FALSE,          # Not a 'standard' logfile
                            $bufferObj->modLine,
                            FALSE,          # Don't precede with a newline character
                            TRUE,           # Use final newline character
                            'contents',     # Write to this logfile
                        );
                    }
                }

                # Add a blank line, so that the logfile isn't a wall of text
                $axmud::CLIENT->writeLog(
                    $self->session,
                    FALSE,          # Not a 'standard' logfile
                    '',
                    FALSE,          # Don't precede with a newline character
                    TRUE,           # Use final newline character
                    'contents',     # Write to this logfile
                );
            }
        }

        # Do text-to-speech, if required
        if (
            $self->ivShow('ttsFlagAttribHash', 'title')
            && $self->ivShow('ttsToReadHash', 'title')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'title'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'descrip')
                || $self->ivShow('ttsFlagAttribHash', 'description')
            ) && $self->ivShow('ttsToReadHash', 'descrip')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'descrip'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'exit')
                || $self->ivShow('ttsFlagAttribHash', 'exits')
            ) && $self->ivShow('ttsToReadHash', 'exit')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'exit'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'content')
                || $self->ivShow('ttsFlagAttribHash', 'contents')
            ) && $self->ivShow('ttsToReadHash', 'content')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'content'));
        }

        if (
            (
                $self->ivShow('ttsFlagAttribHash', 'command')
                || $self->ivShow('ttsFlagAttribHash', 'cmd')
            ) && $self->ivShow('ttsToReadHash', 'command')
        ) {
            $self->ttsQuick($self->ivShow('ttsToReadHash', 'command'));
        }

        # Anchor line processing complete
        return 1;
    }

    sub extractComponents {

        # Called by $self->processAnchor (at stage 3) with a list of room statement components
        #   that occur either before, or after, the anchor line
        # Processes each component in turn
        #
        # Expected arguments
        #   $lineNum        - The display buffer number of the anchor line
        #   $worldObj       - Shortcut to the current world profile object
        #   $roomObj        - A non-model room object in which we store information gathered from
        #                       each successfully-extracted component
        #   $anchorOffset   - The anchor line's relationship with other components in the component
        #                       list: -1 if the anchor line shares a line with the component before
        #                       it; +1 if the anchor line shares a line with the component after it;
        #                       0 if the anchor line does not share its line with any component
        #   $step           - The direction in which to move through the display buffer: 1 to move
        #                       forwards (for components that appear after the anchor line), -1 to
        #                       move backwards (for components that appear before the anchor line)
        #   $noExtractHashRef
        #                   - Hash of room components which should NOT be extracted, because a
        #                       previous component has already been successfully extracted. Hash
        #                       reference in the form
        #                       $$noExtractHashRef{component_name} = previous_component_name;
        #
        # Optional arguments
        #   @componentList  - A list of component names (keys in GA::Profile::World->componentHash)
        #                       in the order in which they appear in the room statement. If there
        #                       are no components before (or after) the anchor line, will be an
        #                       empty list
        #
        # Return values
        #   An empty list on improper arguments
        #   An empty list if any of the non-optional components in @componentList can't be extracted
        #       (including when a component with exits contains a recognised non-delimiter)
        #   If no components are extracted (because none were specified, or because only optional
        #       components are  specified, and none of them are found), returns a list in the form
        #           (success_flag)
        #   If at least one component is extracted, returns a list in the form
        #           (
        #               success_flag, first_line_of_first_component, last_line_of_last_component,
        #               hash_reference,
        #           )
        #   ...where 'hash_reference' is a reference to a hash in the form
        #       $posnHash{component_name} = reference_to_list_of_line_numbers

        my (
            $self, $lineNum, $worldObj, $roomObj, $anchorOffset, $step, $noExtractHashRef,
            @componentList,
        ) = @_;

        # Local variables
        my (
            $nextLineNum, $firstLineNum, $lastLineNum,
            @emptyList,
            %componentHash, %posnHash, %noExtractHash,
        );

        # Check for improper arguments
        if (
            ! defined $lineNum || ! defined $worldObj || ! defined $roomObj
            || ! defined $anchorOffset || ! defined $step || ! defined $noExtractHashRef
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->extractComponents', @_);
            return @emptyList;
        }

        if (! @componentList) {

            # No components extracted, because none were specified. The TRUE value means that we
            #   didn't encounter an error
            return TRUE;
        }

        # Import the world profile's component hash (for quick lookup)
        %componentHash = $worldObj->componentHash;

        # Cycle through components until we have processed all components in @componentList, or
        #   until we run out of lines, or until a non-optional component can't be extracted
        do {

            my (
                $component, $componentObj, $newLineNum, $type, $result,
                @lineNumList, @bufferObjList, @lineTextList, @lineOffsetList,
                %emptyHash,
            );

            # Next component to extract
            $component = shift @componentList;
            # Get the component object, which tells us how to extract all the display buffer lines
            #   for this component
            $componentObj = $componentHash{$component};

            if (! $componentObj) {

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 551: Component object \'' . $component . '\' not found',
                    );
                }

            } elsif (
                exists $$noExtractHashRef{$component}
                || exists $$noExtractHashRef{$componentObj->type}
            ) {
                # A previously-extracted component specifies that this component must not be
                #   extracted

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 552: Component object \'' . $component . '\' not extracted, as a'
                        . ' previously-extracted component (\'' . $$noExtractHashRef{$component}
                        . '\') forbids it',
                    );
                }

            } else {

                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 553: Processing component \'' . $component . '\' (type \''
                        . $componentObj->type . '\')',
                    );
                }

                # If this is the first component to be extracted, decide whether the component's
                #   first line is on the anchor line, or whether it is immediately before/after it
                if (! defined $nextLineNum) {

                    if (
                        ($anchorOffset == 1 && $step == 1)
                        || ($anchorOffset == -1 && $step == -1)
                    ) {
                         # Anchor line shares a line with the first component in @componentList
                        $nextLineNum = $lineNum;

                    } else {

                        # Anchor line is before/after the first component
                        $nextLineNum = $lineNum + $step;
                    }
                }

                # Extract the lines for this component
                ($newLineNum, @lineNumList) = $self->extractLines(
                    $worldObj,
                    $componentObj,
                    $nextLineNum,
                    $step,
                );

                if ($axmud::CLIENT->debugLocatorFlag) {

                    if (! @lineNumList) {

                        $self->writeDebug('LOCATOR 554: Extracted lines: 0');

                    } else {

                        $self->writeDebug(
                            'LOCATOR 555: Extracted lines: ' . scalar @lineNumList . ': '
                            . join(' ', @lineNumList),
                        );
                    }
                }

                if (! @lineNumList && $componentObj->minSize) {

                    # Failed to extract a component that is not optional
                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 561: Failed to extract a non-optional component',
                        );
                    }

                    return @emptyList;

                } elsif (! defined $newLineNum && @componentList) {

                    # Run out of lines before extracting all components. If any of them are not
                    #   optional, we have to give up
                    foreach my $name (@componentList) {

                        my $obj = $componentHash{$name};
                        if ($obj && $obj->minSize) {

                            if ($axmud::CLIENT->debugLocatorFlag) {

                                $self->writeDebug(
                                    'LOCATOR 562: Ran out of lines before extracting all non-'
                                    . 'OPTIONAL components',
                                );
                            }

                            return @emptyList;
                        }
                    }

                } else {

                    # Start looking for the next component on $newLineNum. If the call to
                    #   $self->extractLines failed, $newLineNum will be 'undef'
                    $nextLineNum = $newLineNum;

                    if ($axmud::CLIENT->debugLocatorFlag) {

                        if ($nextLineNum) {

                            $self->writeDebug(
                                'LOCATOR 563: Next line to check: #' . $nextLineNum,
                            );

                        } elsif (! defined $nextLineNum) {

                            $self->writeDebug(
                                'LOCATOR 564: Next line to check: (not received yet)',
                            );

                        } else {

                            $self->writeDebug(
                                'LOCATOR 565: Next line to check: (reached top of buffer)',
                            );
                        }
                    }
                }

                # Process the lines in @lineList (if any), updating the room model object, $roomObj
                if (@lineNumList) {

                    # Temporarily store the first line of the first component, and the last line of
                    #   the last component (for log-writing purposes), and the position of the
                    #   component
                    if (! defined $firstLineNum || $lineNumList[0] < $firstLineNum) {

                        $firstLineNum = $lineNumList[0];
                    }

                    if (! defined $lastLineNum || $lineNumList[-1] > $lastLineNum) {

                        $lastLineNum = $lineNumList[-1];
                    }

                    $posnHash{$componentObj->name} = \@lineNumList;

                    # If we're searching backwards, reverse the order of the line numbers in
                    #   @lineNumList, so that they appear in the same order they were sent
                    if ($step == -1) {

                        @lineNumList = reverse (@lineNumList);
                    }

                    # Convert the list of display buffer line numbers into a list of display buffer
                    #   objects (@bufferObjList, a list of Axmud colour/style tags and the offsets
                    #   at which they occur (@lineOffsetList), and a list of display buffer objects
                    #   (@bufferObjList);
                    foreach my $lineNum (@lineNumList) {

                        my (
                            $bufferObj, $modText,
                            %modOffsetHash, %offsetHash,
                        );

                        $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
                        if ($bufferObj) {

                            if ($componentObj->useTextColour) {

                                ($modText, %modOffsetHash)
                                    = $self->trimColouredText($bufferObj, $componentObj);

                                if (defined $modText) {

                                    push (@bufferObjList, $bufferObj);
                                    push (@lineTextList, $modText);
                                    push (@lineOffsetList, \%modOffsetHash);
                                }

                            } else {

                                push (@bufferObjList, $bufferObj);
                                push (@lineTextList, $bufferObj->modLine);

                                # (Don't want anything to modify the buffer object's offset hash, so
                                #   dereference then reference the hash)
                                %offsetHash = $bufferObj->offsetHash;
                                push (@lineOffsetList, \%offsetHash);
                            }
                        }
                    }

                    # If none of the lines contained text of the right colour (where applicable),
                    #   treat the component as if it contained no text
                    if (! @lineTextList) {

                        push (@bufferObjList, undef);
                        push (@lineTextList, '');
                        push (@lineOffsetList, \%emptyHash);

                    # Treat the lines as a single line, if the component's flag is set
                    } elsif (@lineTextList > 1 && $componentObj->combineLinesFlag) {

                        $self->combineLines(\@lineTextList, \@lineOffsetList);
                        # The buffer object is required for its ->previousTagList IV, so we can
                        #   simply return the first buffer object
                        @bufferObjList = ($bufferObjList[0]);
                    }

                    # Ignore the first n characters of the line, if the component's IV is set (but
                    #   not if ->useTextColour is set)
                    if (! $componentObj->useTextColour && $componentObj->ignoreFirstChars) {

                        $self->reduceLineFromStart(
                            \@lineTextList,
                            \@lineOffsetList,
                            $componentObj->ignoreFirstChars,
                        );
                    }

                    # Use only the first n characters of the line, if the component's IV is set (but
                    #   not if ->useTextColour or ->ignoreFirstChars is set)
                    if (
                        ! $componentObj->useTextColour
                        && ! $componentObj->ignoreFirstChars
                        && $componentObj->useFirstChars
                    ) {
                        $self->reduceLineFromEnd(
                            \@lineTextList,
                            \@lineOffsetList,
                            $componentObj->useFirstChars,
                        );
                    }

                    # Use only the group substrings from a matching pattern, if the component's IV
                    #   is set and its regex matches the line (but not if ->useTextColour,
                    #   ->ignoreFirstChars or ->useFirstChars are set)
                    if (
                        ! $componentObj->useTextColour
                        && ! $componentObj->ignoreFirstChars
                        && ! $componentObj->useFirstChars
                        && $componentObj->usePatternGroups
                    ) {
                        $self->reduceLineUsingRegex(
                            \@lineTextList,
                            \@lineOffsetList,
                            $componentObj->usePatternGroups,
                        );
                    }

                    # Process the lines. The functions which process exit lines will return 'undef'
                    #   if the line(s) contain non-delimiters; all the functions will return 'undef'
                    #   on improper arguments
                    $type = $componentObj->type;
                    if ($type eq 'verb_title' || $type eq 'brief_title') {

                        $result = $self->setRoomTitle($roomObj, @lineTextList);

                    } elsif ($type eq 'verb_descrip') {

                        $result = $self->setRoomDescrip($roomObj, @lineTextList);

                    } elsif ($type eq 'verb_exit') {

                        $result = $self->setRoomExit(
                            $worldObj,
                            $roomObj,
                            'verbose',
                            \@bufferObjList,
                            \@lineTextList,
                            \@lineOffsetList,
                        );

                    } elsif ($type eq 'verb_content' || $type eq 'brief_content') {

                        $result = $self->setRoomContent($roomObj, @lineTextList);

                    } elsif ($type eq 'verb_special') {

                        $result = $self->setRoomVerbSpecial($worldObj, $roomObj, @lineTextList);

                    } elsif ($type eq 'brief_exit') {

                        $result = $self->setRoomExit(
                            $worldObj,
                            $roomObj,
                            'brief',
                            \@bufferObjList,
                            \@lineTextList,
                            \@lineOffsetList,
                        );

                    } elsif ($type eq 'brief_title_exit') {

                        $result = $self->setRoomTitleExit(
                            $worldObj,
                            $roomObj,
                            TRUE,
                            \@bufferObjList,
                            \@lineTextList,
                            \@lineOffsetList,
                        );

                    } elsif ($type eq 'brief_exit_title') {

                        $result = $self->setRoomTitleExit(
                            $worldObj,
                            $roomObj,
                            FALSE,
                            \@bufferObjList,
                            \@lineTextList,
                            \@lineOffsetList,
                        );

                    } elsif ($type eq 'room_cmd') {

                        $result = $self->setRoomCmd($worldObj, $roomObj, @lineTextList);

                    } elsif ($type eq 'mudlib_path') {

                        $result = $self->setRoomMudlibPath($roomObj, @lineTextList);

                    } elsif ($type eq 'weather') {

                        $result = $self->setRoomWeather($componentObj, @lineTextList);

                    } elsif ($type eq 'anchor' || $type eq 'ignore_line' || $type eq 'custom') {

                        # These components don't have a function, so there is no error flag to set
                        $result = TRUE;
                    }

                    if (! $result) {

                        if ($axmud::CLIENT->debugLocatorFlag) {

                            $self->writeDebug(
                                'LOCATOR 571: Unable to set up the \'' . $componentObj->name
                                . '\' component',
                            );
                        }

                        return @emptyList;
                    }

                    # Component extracted. If it specifies any other components which shouldn't now
                    #   be extracted, add them to our hash
                    foreach my $otherComponent ($componentObj->noExtractList) {

                        $$noExtractHashRef{$otherComponent} = $component;
                    }
                }
            }

        # (@componentList is empty if we have processed all components; $nextLineNum is undefined
        #   if we have run out of lines)
        } until (! @componentList || ! defined $nextLineNum);

        if ($firstLineNum) {

            # All components extracted. For log-writing purposes, return the boundaries of the
            #   room statement and the positions of each extracted component
            return (TRUE, $firstLineNum, $lastLineNum, \%posnHash);

        } else {

            # No components extracted, but all specified components were optional. The TRUE value
            #   means we didn't encounter an error
            return TRUE;
        }
    }

    sub extractLines {

        # Called by $self->extractComponents (at stage 3) to extract a single room statement
        #   component, consisting of 1 or more lines from the display buffer
        # The specified GA::Obj::Component tells us how many lines to extract
        #
        # Expected arguments
        #   $worldObj       - The current world profile
        #   $componentObj   - The GA::Obj::Component that tells us how many lines to extract
        #   $firstLineNum   - The number of the first display buffer line to check
        #   $step           - The direction in which to check lines: -1 to move backwards through
        #                       the display buffer, 1 to move forwards
        #
        # Return values
        #   An empty list on improper arguments or if we run out lines while extracting valid lines
        #   Otherwise, a list in the form
        #       (next_line, line_list)
        #   ...where:
        #       'next_line' - The next line in the display buffer after (or before) this component,
        #                       which should be checked by the next call to ->extractLines (set to
        #                       'undef' if we run out of lines)
        #       'line_list' - A list of display buffer line numbers in the order in which they were
        #                       received from the world, regardless of $step (earliest line first).
        #                       If the component can't be extracted, an empty list

        my ($self, $worldObj, $componentObj, $firstLineNum, $step, $check) = @_;

        # Local variables
        my (
            $firstLineText, $firstLineObj, $thisLineNum, $thisLineText,
            @emptyList, @lineList, @resultList,
        );

        # Check for improper arguments
        if (
            ! defined $worldObj || ! defined $componentObj || ! defined $firstLineNum
            || ! defined $step || defined $check
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->extractLines', @_);
            return @emptyList;
        }

        # Check that the buffer line $firstLineNum actually exists, and that it hasn't already been
        #   checked by this task
        ($firstLineText, $firstLineObj) = $self->checkLineValid($firstLineNum);
        if (! defined $firstLineText) {

            if ($axmud::CLIENT->debugLocatorFlag) {

                $self->writeDebug(
                    'LOCATOR 601: Component \'' . $componentObj->name . '\' not found: first line'
                    . ' not valid',
                );
            }

            return @emptyList;
        }

        # Part 1 - fixed size
        # -------------------

        # If the ->size IV is a positive integer, our task is easy - we just return a fixed
        #   number of lines
        if ($componentObj->size) {

            # (Set a variable containing a hypothetical previous line, so that we can use simple
            #   'for' loops)
            $thisLineNum = $firstLineNum - $step;

            OUTER: for (my $count = 1; $count <= $componentObj->size; $count++) {

                $thisLineNum += $step;
                if (! $self->checkLineValid($thisLineNum)) {

                    # We have run out of lines, so the component is not found (return $firstLineNum
                    #   so that the next component to be extracted, if any, starts on the same line
                    #   as this one)
                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 611: Component \'' . $componentObj->name . '\' not found: ran'
                            . ' out of lines (->extractLines part 1)',
                        );
                    }

                    return $firstLineNum;

                } else {

                    push (@lineList, $thisLineNum);
                }
            }

            # The next line to check is the line before/after this one
            $thisLineNum += $step;
            # Return the list of lines comprising this component.
            return ($thisLineNum, @lineList);
        }

        # Part 2 - special contents
        # -------------------------

        # For 'verb_special' components, our task is easy - check every line until we find one which
        #   doesn't contain one of the special patterns
        if ($componentObj->type eq 'verb_special') {

            # (Set a variable containing a hypothetical previous line, so that we can use simple
            #   'for' loops)
            $thisLineNum = $firstLineNum - $step;

            OUTER: for (my $count = 1; $count >= $componentObj->size; $count++) {

                $thisLineNum += $step;
                ($thisLineText) = $self->checkLineValid($thisLineNum);
                if (! defined $thisLineText) {

                    # We have run out of lines, so returning all matching lines so far (if any)
                    push (@lineList, $thisLineNum);

                } else {

                    INNER: foreach my $pattern ($worldObj->ivKeys('specialPatternHash')) {

                        if ($thisLineText =~ m/$pattern/) {

                            # Use this line
                            push (@lineList, $thisLineNum);
                            next OUTER;
                        }
                    }

                    # No special patterns matched; end the search here. The next component starts
                    #   searching on $thisLineNum
                    if ($axmud::CLIENT->debugLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 621: Component \'' . $componentObj->name . '\' not found: no'
                            . ' special patterns matched (->extractLines part 2)',
                        );
                    }

                    return ($thisLineNum, @lineList);
                }
            }

            # The next line to check is the line before/after this one
            $thisLineNum += $step;
            # Return the list of lines comprising this component.
            return ($thisLineNum, @lineList);
        }

        # Part 3 - start patterns/tags
        # ----------------------------

        # ->startPatternList contains patterns matching the first buffer line
        # ->startTagList contains tags found in the first buffer line
        # ->startTagMode can specify that the line should contain no colour/style tags
        if (
            $componentObj->startPatternList
            || $componentObj->startTagList
            || $componentObj->startTagMode ne 'default'
        ) {
            if (
                ! $self->testLine(
                    $firstLineText,
                    $firstLineObj,
                    $componentObj,
                    'startPatternList',
                    'startTagList',
                    'startAllFlag',
                    'startTagMode',
                )
            ) {
                # Can't extract the component. Return $firstLineNum so that the next component to be
                #   extracted, if any, starts on the same line as this one)
                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 631: Component \'' . $componentObj->name . '\' not found: first'
                        . ' start patterns/tags test failed (->extractLines part 3)',
                    );
                }

                return $firstLineNum;
            }
        }

        # ->startNoPatternList contains patterns NOT matching the first buffer line
        # ->startTagList contains tags NOT found in the first buffer line
        # ->startTagMode can specify that the line should NOT contain no colour/style tags
        if (
            $componentObj->startNoPatternList
            || $componentObj->startNoTagList
            || $componentObj->startNoTagMode ne 'default'
        ) {
            if (
                $self->testLine(
                    $firstLineText,
                    $firstLineObj,
                    $componentObj,
                    'startNoPatternList',
                    'startNoTagList',
                    'startNoAllFlag',
                    'startNoTagMode',
                )
            ) {
                # Can't extract the component. Return $firstLineNum so that the next component to be
                #   extracted, if any, starts on the same line as this one)
                if ($axmud::CLIENT->debugLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 641: Component \'' . $componentObj->name . '\' not found: second'
                        . ' start patterns/tags test failed (->extractLines part 3)',
                    );
                }

                return $firstLineNum;
            }
        }

        # Parts 4-5
        # ---------
        # The rest of the analysis depends on the value of $componentObj->extractLines
        if ($componentObj->analyseMode eq 'check_line') {

            # Mode 0 - Check each line, one at a time, against all patterns/tags
            @resultList = $self->extractByLine(
                $componentObj,
                $firstLineNum,
                $step,
            );

        } else {

            # Mode 1 - Check each pattern/tag, one at a time, against all lines
            @resultList = $self->extractByPatternTag(
                $componentObj,
                $firstLineNum,
                $step,
            );
        }

        # @resultList is in the form ($thisLineNum, @lineList), but $thisLineNum might be 'undef'
        if (defined $resultList[0] || scalar @resultList > 1) {

            $thisLineNum = shift @resultList;
            push (@lineList, @resultList);
        }

        # Stop here
        return ($thisLineNum, @lineList);
    }

    sub extractByLine {

        # Called by $self->extractLines for a component whose ->analyseMode is set to 'check_line'
        #   (Check each line, one at a time, against all patterns/tags)
        #
        # Expected arguments
        #   $componentObj   - The GA::Obj::Component object
        #   $firstLineNum   - The number of the first display buffer line to check
        #   $step           - The direction in which to check lines: -1 to move backwards through
        #                       the display buffer, 1 to move forwards
        #
        # Return values
        #   An empty list on improper arguments or if we run out lines while extracting valid lines
        #   Otherwise, a list in the form
        #       (next_line, line_list)
        #   ...where:
        #       'next_line' - The next line in the display buffer after (or before) this component,
        #                       which should be checked by the next call to ->extractLines (set to
        #                       'undef' if we run out of lines)
        #       'line_list' - A list of display buffer line numbers in the order in which they were
        #                       received from the world, regardless of $step (earliest line first).
        #                       If the component can't be extracted, an empty list

        my ($self, $componentObj, $firstLineNum, $step, $check) = @_;

        # Local variables
        my (
            $thisLineNum, $thisLineText, $thisLineObj, $upperCount, $otherCount, $nextLineNum,
            $nextLineText, $nextLineObj,
            @lineList,
        );

        # (Set a variable containing a hypothetical previous line, so that we can use simple 'for'
        #   loops)
        $thisLineNum = $firstLineNum - $step;

        # Check each line, one at a time, against all patterns/tags
        OUTER: for (my $count = 1; $count <= $componentObj->maxSize; $count++) {

            $thisLineNum += $step;
            ($thisLineText, $thisLineObj) = $self->checkLineValid($thisLineNum);
            if (! defined $thisLineText) {

                return (undef, @lineList);
            }

            # Part 4 (removed in v1.0.317)
            # ----------------------------

            # Part 5
            # ------

            $upperCount = 0;
            $otherCount = 0;

            if (
                $componentObj->skipPatternList
                || $componentObj->skipTagList
                || $componentObj->skipTagMode  ne 'default'
            ) {
                if (
                    $self->testLine(
                        $thisLineText,
                        $thisLineObj,
                        $componentObj,
                        'skipPatternList',
                        'skipTagList',
                        'skipAllFlag',
                        'skipTagMode',
                    )
                ) {
                    if ($axmud::CLIENT->debugMaxLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 651: Skipping line ' . $thisLineNum . ' after ->testLine call'
                            . ' succeeded',
                        );
                    }

                    next OUTER;
                }
            }

            # Some component object IVs require us to check the next line after (before) this one
            $nextLineNum = $thisLineNum + $step;
            ($nextLineText, $nextLineObj) = $self->checkLineValid($nextLineNum);

            if (defined $nextLineText) {

                if (
                    $componentObj->stopBeforePatternList
                    || $componentObj->stopBeforeTagList
                    || $componentObj->stopBeforeTagMode  ne 'default'
                ) {
                    if (
                        $self->testLine(
                            $nextLineText,
                            $nextLineObj,
                            $componentObj,
                            'stopBeforePatternList',
                            'stopBeforeTagList',
                            'stopBeforeAllFlag',
                            'stopBeforeTagMode',
                        )
                    ) {
                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                        return ($nextLineNum, @lineList);
                    }
                }

                if (
                    $componentObj->stopBeforeNoPatternList
                    || $componentObj->stopBeforeNoTagList
                    || $componentObj->stopBeforeNoTagMode  ne 'default'
                ) {
                    if (
                        ! $self->testLine(
                            $nextLineText,
                            $nextLineObj,
                            $componentObj,
                            'stopBeforeNoPatternList',
                            'stopBeforeNoTagList',
                            'stopBeforeNoAllFlag',
                            'stopBeforeNoTagMode',
                        )
                    ) {
                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                        return ($nextLineNum, @lineList);
                    }
                }
            }

            if (
                $componentObj->stopAtPatternList
                || $componentObj->stopAtTagList
                || $componentObj->stopAtTagMode ne 'default'
            ) {
                if (
                    $self->testLine(
                        $thisLineText,
                        $thisLineObj,
                        $componentObj,
                        'stopAtPatternList',
                        'stopAtTagList',
                        'stopAtAllFlag',
                        'stopAtTagMode',
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list
                    #   of lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            if (
                $componentObj->stopAtNoPatternList
                || $componentObj->stopAtNoTagList
                || $componentObj->stopAtNoTagMode ne 'default'
            ) {
                if (
                    ! $self->testLine(
                        $thisLineText,
                        $thisLineObj,
                        $componentObj,
                        'stopAtNoPatternList',
                        'stopAtNoTagList',
                        'stopAtNoAllFlag',
                        'stopAtNoTagMode',
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list
                    #   of lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            # Count lines which start with an upper-case letter (and those which don't start with
            #   an alphanumeric character that's not a capital). If we've reached the limit, stop
            #   at this line
            if ($componentObj->upperCount) {

                if ($thisLineText =~ m/^[[:upper:]]/) {

                    $upperCount++;

                    if ($upperCount >= $componentObj->upperCount) {

                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                       return ($nextLineNum, @lineList);
                    }
                }
            }

            if ($componentObj->otherCount) {

                if ($thisLineText =~ m/^[[:lower:]0-9\_]/) {

                    $otherCount++;

                    if ($otherCount >= $componentObj->otherCount) {

                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                       return ($nextLineNum, @lineList);
                    }
                }
            }

            # The ->stopBeforeMode and ->stopAtMode IVs detail other instances in which we should
            #   stop
            if (defined $nextLineText && $componentObj->stopBeforeMode ne 'default') {

                if (
                    # 'no_char' - Stop one line before the first line containing no characters at
                    #   all
                    ($componentObj->stopBeforeMode eq 'no_char' && ! ($nextLineText =~ m/\S/))
                    # 'no_letter_num' - Stop one line before the first line containing no
                    #   alphanumeric characters
                    || (
                        $componentObj->stopBeforeMode eq 'no_letter_num'
                        && ! ($nextLineText =~ m/\w/)
                    )
                    # 'no_start_letter_num' - Stop one line before the first line which doesn't
                    #   start with an alpha-numeric character
                    || (
                        $componentObj->stopBeforeMode eq 'no_start_letter_num'
                        && ! ($nextLineText =~ m/^\w/)
                    )
                    # 'no_tag' - Stop one line before the first line containing no Axmud colour/
                    #   style tags at all (not including the dummy style tags like 'bold',
                    #   'reverse_off' and 'attribs_off')
                    || (
                        $componentObj->stopBeforeMode eq 'no_tag'
                        && ! $self->checkLineTags($nextLineObj)
                    )
                    # 'has_letter_num' - Stop one line before the first line which DOES contain
                    #   alphanumeric characters
                    || (
                        $componentObj->stopBeforeMode eq 'has_letter_num'
                        && ($nextLineText =~ m/\w/)
                    )
                    # 'has_start_letter_num' - Stop one line before the first line which DOES start
                    #   with an alphanumeric characters
                    || (
                        $componentObj->stopBeforeMode eq 'has_start_letter_num'
                        && ($nextLineText =~ m/^\w/)
                    )
                    # 'has_tag' - Stop one line before the first line which DOES contain an Axmud
                    #   colour/style tag (not including the dummy style tags like 'bold',
                    #   'reverse_off' and 'attribs_off')
                    || (
                        $componentObj->stopBeforeMode eq 'has_tag'
                        && $self->checkLineTags($nextLineObj)
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list of
                    #   lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            if ($componentObj->stopAtMode ne 'default') {

                if (
                    # 'no_char' - Stop at the first line containing no characters at all
                    ($componentObj->stopAtMode eq 'no_char' && ! ($thisLineText =~ m/\S/))
                    # 'no_letter_num' - Stop at the first line containing no alphanumeric characters
                    || ($componentObj->stopAtMode eq 'no_letter_num' && ! ($thisLineText =~ m/\w/))
                    # 'no_start_letter_num' - Stop at the first line which doesn't start with an
                    #   alphanumeric character
                    || (
                        $componentObj->stopAtMode eq 'no_start_letter_num'
                        && ! ($thisLineText =~ m/^\w/)
                    )
                    # 'no_tag' - Stop at the first line containing no Axmud colour/style tags at all
                    #   (not including the dummy style tags like 'bold', 'reverse_off' and
                    #   'attribs_off')
                    || (
                        $componentObj->stopAtMode eq 'no_tag'
                        && ! $self->checkLineTags($thisLineObj)
                    )
                    # 'has_letter_num' - Stop at the first line which DOES contain alphanumeric
                    #   characters
                    || (
                        $componentObj->stopAtMode eq 'has_letter_num'
                        && ($thisLineText =~ m/\w/)
                    )
                    # 'has_start_letter_num' - Stop at the first line which DOES start with an
                    #   alphanumeric characters
                    || (
                        $componentObj->stopAtMode eq 'has_start_letter_num'
                        && ($thisLineText =~ m/^\w/)
                    )
                    # 'has_tag' - Stop at the first line which DOES contain an Axmud colour/style
                    #   tag (not including the dummy style tags like 'bold', 'reverse_off' and
                    #   'attribs_off')
                    || (
                        $componentObj->stopAtMode eq 'has_tag'
                        && $self->checkLineTags($thisLineObj)
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list of
                    #   lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            # Otherwise, use this line
            push (@lineList, $thisLineNum);
        }

        # Stop here. We have reached this point because we have reached the line limit specified by
        #   $componentObj->maxSize; the next line to check should be the line before/after this one
        $thisLineNum += $step;

        return ($thisLineNum, @lineList);
    }

    sub extractByPatternTag {

        # Called by $self->extractLines for a component whose ->analyseMode is set to
        #   'check_pattern_tag' (check each pattern/tag, one at a time, against all lines)
        #
        # Expected arguments
        #   $componentObj   - The GA::Obj::Component object
        #   $firstLineNum   - The number of the first display buffer line to check
        #   $step           - The direction in which to check lines: -1 to move backwards through
        #                       the display buffer, 1 to move forwards
        #
        # Return values
        #   An empty list on improper arguments or if we run out lines while extracting valid lines
        #   Otherwise, a list in the form
        #       (next_line, line_list)
        #   ...where:
        #       'next_line' - The next line in the display buffer after (or before) this component,
        #                       which should be checked by the next call to ->extractLines (set to
        #                       'undef' if we run out of lines)
        #       'line_list' - A list of display buffer line numbers in the order in which they were
        #                       received from the world, regardless of $step (earliest line first).
        #                       If the component can't be extracted, an empty list

        my ($self, $componentObj, $firstLineNum, $step, $check) = @_;

        # Local variables
        my (
            $thisLineNum, $thisLineText, $thisLineObj, $upperCount, $otherCount, $nextLineNum,
            $nextLineText, $nextLineObj,
            @lineList,
        );

        # Part 4 (removed in v1.0.317)
        # ----------------------------

        # Part 5
        # ------

        # (Set a variable containing a hypothetical previous line, so that we can use simple 'for'
        #   loops)
        $thisLineNum = $firstLineNum - $step;
        $upperCount = 0;
        $otherCount = 0;

        OUTER: for (my $count = 1; $count <= $componentObj->maxSize; $count++) {

            $thisLineNum += $step;
            ($thisLineText, $thisLineObj) = $self->checkLineValid($thisLineNum);
            if (! defined $thisLineText) {

                # We have run out of lines. Return the lines already extracted; it's up to the
                #   calling function to check whether we have too few lines
                return (undef, @lineList);
            }

            if (
                $componentObj->skipPatternList
                || $componentObj->skipTagList
                || $componentObj->skipTagMode  ne 'default'
            ) {
                if (
                    $self->testLine(
                        $thisLineText,
                        $thisLineObj,
                        $componentObj,
                        'skipPatternList',
                        'skipTagList',
                        'skipAllFlag',
                        'skipTagMode',
                    )
                ) {
                    # Skip this line
                    next OUTER;
                }
            }

            # Some component object IVs require us to check the next line after (before) this one
            $nextLineNum = $thisLineNum + $step;
            ($nextLineText, $nextLineObj) = $self->checkLineValid($nextLineNum);

            if (defined $nextLineText) {

                if (
                    $componentObj->stopBeforePatternList
                    || $componentObj->stopBeforeTagList
                    || $componentObj->stopBeforeTagMode ne 'default'
                ) {
                    if (
                        $self->testLine(
                            $nextLineText,
                            $nextLineObj,
                            $componentObj,
                            'stopBeforePatternList',
                            'stopBeforeTagList',
                            'stopBeforeAllFlag',
                            'stopBeforeTagMode',
                        )
                    ) {
                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                        return ($nextLineNum, @lineList);
                    }
                }

                if (
                    $componentObj->stopBeforeNoPatternList
                    || $componentObj->stopBeforeNoTagList
                    || $componentObj->stopBeforeNoTagMode ne 'default'
                ) {
                    if (
                        ! $self->testLine(
                            $nextLineText,
                            $nextLineObj,
                            $componentObj,
                            'stopBeforeNoPatternList',
                            'stopBeforeNoTagList',
                            'stopBeforeNoAllFlag',
                            'stopBeforeNoTagMode',
                        )
                    ) {
                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                        return ($nextLineNum, @lineList);
                    }
                }
            }

            if (
                $componentObj->stopAtPatternList
                || $componentObj->stopAtTagList
                || $componentObj->stopAtTagMode ne 'default'
            ) {
                if (
                    $self->testLine(
                        $thisLineText,
                        $thisLineObj,
                        $componentObj,
                        'stopAtPatternList',
                        'stopAtTagList',
                        'stopAtAllFlag',
                        'stopAtTagMode',
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list
                    #   of lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            if (
                $componentObj->stopAtNoPatternList
                || $componentObj->stopAtNoTagList
                || $componentObj->stopAtNoTagMode ne 'default'
            ) {
                if (
                    ! $self->testLine(
                        $thisLineText,
                        $thisLineObj,
                        $componentObj,
                        'stopAtNoPatternList',
                        'stopAtNoTagList',
                        'stopAtNoAllFlag',
                        'stopAtNoTagMode',
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list
                    #   of lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            # Count lines which start with an upper-case letter (and those which don't start with
            #   an alphanumeric character that's not a capital). If we've reached the limit, stop
            #   at this line
            if ($componentObj->upperCount) {

                if ($thisLineText =~ m/^[[:upper:]]/) {

                    $upperCount++;

                    if ($upperCount >= $componentObj->upperCount) {

                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                        return ($nextLineNum, @lineList);
                    }
                }
            }

            if ($componentObj->otherCount) {

                if ($thisLineText =~ m/^[[:lower:]0-9\_]/) {

                    $otherCount++;

                    if ($otherCount >= $componentObj->otherCount) {

                        # The component stops at this line
                        push (@lineList, $thisLineNum);
                        # The next line to check is the line before/after this one. Return the list
                        #   of lines comprising this component
                        return ($nextLineNum, @lineList);
                    }
                }
            }

            # The ->stopBeforeMode and ->stopAtMode IVs detail other instances in which we should
            #   stop
            if (defined $nextLineText && $componentObj->stopBeforeMode) {

                if (
                    # 'no_char' - Stop one line before the first line containing no characters at
                    #   all
                    ($componentObj->stopBeforeMode eq 'no_char' && ! ($nextLineText =~ m/\S/))
                    # 'no_letter_num' - Stop one line before the first line containing no
                    #   alphanumeric characters
                    || (
                        $componentObj->stopBeforeMode eq 'no_letter_num'
                        && ! ($nextLineText =~ m/\w/)
                    )
                    # 'no_start_letter_num' - Stop one line before the first line which doesn't
                    #   start with an alpha-numeric character
                    || (
                        $componentObj->stopBeforeMode eq 'no_start_letter_num'
                        && ! ($nextLineText =~ m/^\w/)
                    )
                    # 'no_tag' - Stop one line before the first line containing no Axmud colour/
                    #   style tags at all (not including the dummy style tags like 'bold',
                    #   'reverse_off' and 'attribs_off')
                    || (
                        $componentObj->stopBeforeMode eq 'no_tag'
                        && ! $self->checkLineTags($nextLineObj)
                    )
                    # 'has_letter_num' - Stop one line before the first line which DOES contain
                    #   alphanumeric characters
                    || (
                        $componentObj->stopBeforeMode eq 'has_letter_num'
                        && ($nextLineText =~ m/\w/)
                    )
                    # 'has_start_letter_num' - Stop one line before the first line which DOES start
                    #   with an alphanumeric characters
                    || (
                        $componentObj->stopBeforeMode eq 'has_start_letter_num'
                        && ($nextLineText =~ m/^\w/)
                    )
                    # 'has_tag' - Stop one line before the first line which DOES contain an Axmud
                    #   colour/style tag (not including the dummy style tags like 'bold',
                    #   'reverse_off' and 'attribs_off')
                    || (
                        $componentObj->stopBeforeMode eq 'has_tag'
                        && $self->checkLineTags($nextLineObj)
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list of
                    #   lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            if ($componentObj->stopAtMode) {

                if (
                    # 'no_char' - Stop at the first line containing no characters at all
                    ($componentObj->stopAtMode eq 'no_char' && ! ($thisLineText =~ m/\S/))
                    # 'no_letter_num' - Stop at the first line containing no alphanumeric characters
                    || ($componentObj->stopAtMode eq 'no_letter_num' && ! ($thisLineText =~ m/\w/))
                    # 'no_start_letter_num' - Stop at the first line which doesn't start with an
                    #   alphanumeric character
                    || (
                        $componentObj->stopAtMode eq 'no_start_letter_num'
                        && ! ($thisLineText =~ m/^\w/)
                    )
                    # 'no_tag' - Stop at the first line containing no Axmud colour/style tags at all
                    #   (not including the dummy style tags like 'bold', 'reverse_off' and
                    #   'attribs_off')
                    || (
                        $componentObj->stopAtMode eq 'no_tag'
                        && ! $self->checkLineTags($thisLineObj)
                    )
                    # 'has_letter_num' - Stop at the first line which DOES contain alphanumeric
                    #   characters
                    || (
                        $componentObj->stopAtMode eq 'has_letter_num'
                        && ($thisLineText =~ m/\w/)
                    )
                    # 'has_start_letter_num' - Stop at the first line which DOES start with an
                    #   alphanumeric characters
                    || (
                        $componentObj->stopAtMode eq 'has_start_letter_num'
                        && ($thisLineText =~ m/^\w/)
                    )
                    # 'has_tag' - Stop at the first line which DOES contain an Axmud colour/style
                    #   tag (not including the dummy style tags like 'bold', 'reverse_off' and
                    #   'attribs_off')
                    || (
                        $componentObj->stopAtMode eq 'has_tag'
                        && $self->checkLineTags($thisLineObj)
                    )
                ) {
                    # The component stops at this line
                    push (@lineList, $thisLineNum);
                    # The next line to check is the line before/after this one. Return the list of
                    #   lines comprising this component
                    return ($nextLineNum, @lineList);
                }
            }

            # Otherwise, use this line
            push (@lineList, $thisLineNum);
        }

        # Stop here. We have reached this point because we have reached the line limit specified by
        #   $componentObj->maxSize; the next line to check should be the line before/after this one
        $thisLineNum += $step;
        return ($thisLineNum, @lineList);
    }

    sub testLine {

        # Called by $self->extractLines, ->extractByLine and ->extractByPatternTag to test how
        #   many of a single type of pattern/tag are found in a single line
        # Some room statement components demand that all specified patterns must be matched and all
        #   specified tags must be found in the line. Other components specify that only one
        #   specified pattern or tag need be found in the line
        # NB If the component's ->useInitialTagsFlag, this function behaves as if the colours/
        #   styles that were applied (i.e. displayed) at the beginning of the line are the only
        #   tags on the line
        # Returns the result of the test in both situations
        #
        # Expected arguments
        #   $lineText       - The text of the line to check
        #   $bufferObj      - The corresponding GA::Buffer::Display object
        #   $componentObj   - The GA::Obj::Component object to which this line might be assigned,
        #                       if all tests are successful
        #   $patternListIV  - One of the component object's IVs, e.g. 'startPatternList'
        #   $tagListIV      - One of the component object's IVs, e.g. 'startTagList'
        #   $allFlagIV      - One of the component object's IVs, e.g. 'startAllFlag'
        #   $tagModeIV      - One of the component object's IVs, e.g. 'startTagMode'
        #
        # Return values
        #   'undef' on improper arguments or if the test fails
        #   1 if the test succeeds

        my (
            $self, $lineText, $bufferObj, $componentObj, $patternListIV, $tagListIV, $allFlagIV,
            $tagModeIV, $check
        ) = @_;

        # Local variables
        my (
            $count, $mode, $colourCount, $styleCount, $specialFlag,
            %tagHash, %otherTagHash,
        );

        # Check for improper arguments
        if (
            ! defined $lineText || ! defined $bufferObj || ! defined $componentObj
            || ! defined $patternListIV || ! defined $tagListIV || ! defined $allFlagIV
            || ! defined $tagModeIV || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->testLine', @_);
        }

        $count = 0;
        $mode = $componentObj->$tagModeIV;

        # Check for patterns, if they are specified
        OUTER: foreach my $pattern ($componentObj->$patternListIV) {

            if ($lineText =~ m/$pattern/) {

                if ($axmud::CLIENT->debugMaxLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 701: Line ' . $bufferObj->number . ' matches pattern \'' . $pattern
                        . '\', IVs: \'' . $patternListIV . '\', etc (->testLine)',
                    );
                }

                $count++;
                if (! $componentObj->$allFlagIV) {

                    last OUTER;
                }
            }
        }

        # If there were no matching patterns (or if the component specifies that we must match
        #   both patterns and Axmud colour/style tags), then check for Axmud colour/style tags, if
        #   they are specified
        if (! $count || $componentObj->$allFlagIV) {

            # Compile a hash of relevant colour/style tags

            # Use only colours/styles that applied at the beginning of the line, including those
            #   which are still in effect from previous lines
            if ($componentObj->useInitialTagsFlag && ! $componentObj->useExplicitTagsFlag) {

                # Save a bit of time, by only processing the condition just above, once
                $specialFlag = TRUE;

                foreach my $tag ($bufferObj->initialTagList) {

                    my $type;

                    $tagHash{$tag} = undef;

                    # If this is a standard colour tag and the component is bold-insensitive, we
                    #   add two entries to a parallel hash, so we can check 'RED' against this
                    #   parallel hash, and get a match against either 'red' or 'RED'
                    ($type) = $axmud::CLIENT->checkColourTags($tag, 'standard');
                    if ($type && ! $componentObj->boldSensitiveFlag) {

                        $otherTagHash{lc($tag)} = undef;
                        $otherTagHash{uc($tag)} = undef;

                    } else {

                        $otherTagHash{$tag} = undef;
                    }
                }

            # For other combinations of ->useInitialTagsFlag and ->useExplicitTagsFlag, we have to
            #   let $self->findTag do the hard work
            } else {

                %tagHash = $bufferObj->tagHash;
            }

            # Count the number of relevant colour/style tags
            OUTER: foreach my $tag (keys %tagHash) {

                my $type;

                if ($axmud::CLIENT->debugMaxLocatorFlag) {

                    $self->writeDebug(
                        'LOCATOR 711: Line ' . $bufferObj->number . ' uses colour/style tag \''
                        . $tag . '\' (->testLine)',
                    );
                }

                ($type) = $axmud::CLIENT->checkColourTags($tag);
                if ($type) {
                    $colourCount++;     # It's a colour tag
                } else {
                    $styleCount++;      # It's a style tag
                }
            }

            # Here, what is relevant depends on the setting of $mode, set from
            #   $componentObj->$tagModeIV
            if (
                # Mode 'no_colour' - line contains no colour tags
                ($mode eq 'no_colour' && ! $colourCount)
                # Mode 'no_style' - line contains no style tags (not including dummy tags)
                || ($mode eq 'no_style' && ! $styleCount)
                # Mode 'no_colour_style' - line contains no colour or style tags
                || ($mode eq 'no_colour_style' && (! $colourCount) && (! $styleCount))
            ) {
                if ($count == scalar $componentObj->$patternListIV) {

                    # We have the right number of patterns, and the right number of tags, so the
                    #   test has succeeded
                    if ($axmud::CLIENT->debugMaxLocatorFlag) {

                        $self->writeDebug(
                            'LOCATOR 721: Line ' . $bufferObj->number
                            . ' matches required patterns/tags (->testLine)',
                        );
                    }

                    return TRUE;

                } else {

                    # Otherwise, all tags are now accounted for (the test might have succeeded or
                    #   failed; we'll apply the usual test at the end of the function)
                    $count += scalar $componentObj->$tagListIV;
                }

            } elsif ($mode eq 'default') {

                # Mode 'default' - line may contain colour and/or style tags, depending on the
                #   contents of $componentObj->$tagListIV

                # For mode 'default', we have to check each tag individually
                OUTER: foreach my $tag ($componentObj->$tagListIV) {

                    if (
                        # Use only colours/styles that applied at the beginning of the line,
                        #   including those which are still in effect from previous lines
                        ($specialFlag && exists $otherTagHash{$tag})
                        # For other combinations of ->useInitialTagsFlag and ->useExplicitTagsFlag,
                        #   we have to let $self->findTag do the hard work
                        || (! $specialFlag) && $self->findTag($componentObj, $bufferObj, $tag)
                    ) {
                        $count++;
                        if (! $componentObj->$allFlagIV) {

                            last OUTER;
                        }
                    }
                }
            }
        }

        if (
            # No matching patterns/tags found
            ! $count
            # Not enough matching patterns/tags found
            || (
                $componentObj->$allFlagIV
                && $count < (
                    scalar $componentObj->$patternListIV
                    + scalar $componentObj->$tagListIV
                )
            )
        ) {
            # Test failed
            if ($axmud::CLIENT->debugMaxLocatorFlag) {

                $self->writeDebug(
                    'LOCATOR 731: Line ' . $bufferObj->number . ', no matching patterns/tags found'
                    . ' for IVs \'' . $patternListIV . '\', \'' . $tagListIV . '\', etc',
                );
            }

            return undef;

        } else {

            # Test successful
            return 1;
        }
    }

    sub findTag {

        # Called by $self->testLine
        # Given a display buffer line and an Axmud colour/style tag, see if the tag is present in
        #   the object's tag hash
        #
        # Expected arguments
        #   $componentObj   - The GA::Obj::Component object which is currently being extracted
        #   $bufferObj      - The display buffer object corresponding to the line of received text
        #                       which is being checked, to see if it belongs to this component
        #   $tag            - The tag to find
        #
        # Return values
        #   'undef' on improper arguments or if the tag is not present
        #   1 if the tag is present

        my ($self, $componentObj, $bufferObj, $tag, $check) = @_;

        # Local variables
        my (
            $boldFlag, $otherBoldFlag, $currentText, $currentUnderlay, $colourType,
            @offsetList,
            %offsetHash, %tagHash,
        );

        # Check for improper arguments
        if (! defined $componentObj || ! defined $bufferObj || ! defined $tag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->findTag', @_);
        }

        # Import the buffer object's hashes of tags
        %offsetHash = $bufferObj->offsetHash;
        %tagHash = $bufferObj->tagHash;

        # Work out if $tag is a standard bold colour tag (not an RGB/xterm colour tag)
        ($colourType) = $axmud::CLIENT->checkColourTags($tag);
        if ($axmud::CLIENT->checkBoldTags($tag)) {

            $boldFlag = TRUE;
        }

        # For style tags, we can check the buffer object's tag hash directly
        if (! $colourType) {

            if (exists $tagHash{$tag}) {
                return 1;
            } else {
                return undef;
            }
        }

        # If the component specifies that standard normal/bold colour tags are interchangeable,
        #   convert bold colour tags like 'RED'/'UL_RED' to normal colour tags like 'red'/'ul_red'
        if ($colourType && ! $componentObj->boldSensitiveFlag) {

            $tag = lc($tag);
        }

        # Get the colours that apply at the start of the line
        if (! $componentObj->useInitialTagsFlag && ! $componentObj->useExplicitTagsFlag) {

            OUTER: foreach my $startTag ($bufferObj->previousTagList) {

                if ($axmud::CLIENT->checkTextTags($startTag)) {
                    $currentText = $startTag;
                } elsif ($axmud::CLIENT->checkUnderlayTags($startTag)) {
                    $currentUnderlay = $startTag;
                }

                if ($currentText && $currentUnderlay) {

                    # Only one of each can exist in ->previousTagList, so we can stop searching now
                    last OUTER;
                }
            }

            if (! defined $currentText && ! defined $currentUnderlay) {

                # No colour tags were applied at the end of the last line, so the colours actually
                #   displayed are the 'main' window's default colours (usually white)
                $currentText = $self->session->currentTabObj->textViewObj->textColour;
                $currentUnderlay = $self->session->currentTabObj->textViewObj->underlayColour;
            }

        } else {

            # Using an empty string, rather than 'undef', lets us apply lc() and uc() to the string,
            #   without checking that it's defined
            $currentText = '';
            $currentUnderlay = '';
        }

        # Now, go through the offsets at which tags occur, in order, looking for the colour $tag
        $otherBoldFlag = FALSE;
        if ($componentObj->useInitialTagsFlag) {

            # Eliminate everything but the entry for offset 0 (if it exists)
            if (exists $offsetHash{0}) {
                %offsetHash = (0, $offsetHash{0});
            } else {
                %offsetHash = ();
            }
        }

        OUTER: foreach my $offset (sort {$a <=> $b} (keys %offsetHash)) {

            my $listRef = $offsetHash{$offset};

            foreach my $otherTag (@$listRef) {

                my ($otherType, $underlayFlag);

                if ($otherTag eq 'bold') {

                    # Convert 'blue' to 'BLUE', etc. Don't worry whether it's an xterm or RGB colour
                    #   tag, as they are both case-insensitive
                    # 'bold' tags don't apply to underlay colours, of course
                    $currentText = uc($currentText);
                    $otherBoldFlag = TRUE;

                } elsif ($otherTag eq 'bold_off' || $otherTag eq 'attribs_off') {

                    $currentText = lc($currentText);
                    $otherBoldFlag = FALSE;

                } else {

                    ($otherType, $underlayFlag) = $axmud::CLIENT->checkColourTags($otherTag);
                    if ($otherType) {

                        if (! $underlayFlag) {

                            $currentText = $otherTag;
                            if ($otherBoldFlag && $otherType eq 'standard' && ! $underlayFlag) {

                                $currentText = uc($currentText);
                            }

                        } else {

                            $currentUnderlay = $otherTag;
                        }
                    }
                }
            }

            if (
                (
                    $currentText     # A colour tag was either initially applied, or actually found
                    && (
                        (! $componentObj->boldSensitiveFlag && lc($currentText) eq lc($tag))
                        || ($boldFlag && $currentText eq uc($tag))
                        || (! $boldFlag && $currentText eq $tag)
                    )
                )
                || ($currentUnderlay && lc ($currentUnderlay) eq lc($tag))
            ) {
                # Success!
                return 1;
            }
        }

        # The bold colour tag wasn't found on this line
        return undef;
    }

    sub getColoursAtStart {

        # Called by $self->setRoomExit
        # Gets the text and underlay colours that apply at the start of a line
        #
        # Expected arguments
        #   $bufferObj      - The display buffer object which stores the line
        #
        # Expected arguments
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list in the form (text_colour_tag, underlay_colour_tag)

        my ($self, $bufferObj, $check) = @_;

        # Local variables
        my (
            $text, $underlay,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $bufferObj || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->getColoursAtStart', @_);
            return @emptyList;
        }

        # Get the colours that apply at the start of the line
        OUTER: foreach my $startTag ($bufferObj->previousTagList) {

            if ($axmud::CLIENT->checkTextTags($startTag)) {
                $text = $startTag;
            } elsif ($axmud::CLIENT->checkUnderlayTags($startTag)) {
                $underlay = $startTag;
            }

            if ($text && $underlay) {

                # Only one of each can exist in ->previousTagList, so we can stop searching now
                last OUTER;
            }
        }

        if (! defined $text) {

            # No colour tags were applied at the end of the last line, so the colours actually
            #   displayed are the 'main' window's default colours (usually white)
            $text = $self->session->currentTabObj->textViewObj->textColour;
        }

        if (! defined $underlay) {

            $underlay = $self->session->currentTabObj->textViewObj->underlayColour;
        }

        return ($text, $underlay);
    }

    sub getColoursAtPosn {

        # Called by $self->setRoomExit
        # The calling function is analysing a line of received text, but that line might have been
        #   modified
        # For any modifications made, the hash of Axmud colour/style tags at each offset has also
        #   been updated
        # This function works out the text and underlay tags that apply to a specified offset, and
        #   returns them
        #
        # Expected arguments
        #   $currentText    - The Axmud text colour tag which applies either at the beginning of
        #                       the line, or before the first offset remaining in %offsetHash
        #   $currentUnderlay
        #                   - The Axmud underlay colour tag which applies either at the beginning of
        #                       the line, or before the first offset remaining in %offsetHash
        #   $currentBoldFlag
        #                   - Set to TRUE and FALSE by the presence/absence of 'bold' tags (default
        #                       value is FALSE)
        #   $offset         - Update the text and underlay colours using those that apply at this
        #                       offset
        #   %offsetHash     - Hash of Axmud colour/style tags, equivalent to $bufferObj->offsetHash,
        #                       but whose offsets have been updated as the line has been modified by
        #                       the calling function
        #
        # Return values
        #   An empty list on improper arguments
        #   Otherwise, returns a list in the form
        #       (
        #           modified_text_colour_tag, modified_underlay_colour_tag, modified_bold_flag,
        #           modified_offset_hash,
        #       )

        my ($self, $currentText, $currentUnderlay, $currentBoldFlag, $offset, %offsetHash) = @_;

        # Local variables
        my @emptyList;

        # Check for improper arguments
        if (
            ! defined $currentText && ! defined $currentUnderlay && ! defined $currentBoldFlag
            && ! defined $offset
        ) {
            $axmud::CLIENT->writeImproper($self->_objClass . '->getColoursAtPosn', @_);
            return @emptyList;
        }

        # Go through each offset in %offsetHash until we reach the specified one, updating
        #   $currentText and $currentUnderlay as we go
        foreach my $thisOffset (sort {$a <=> $b} (keys %offsetHash)) {

            my $listRef;

            if ($thisOffset > $offset) {

                # We already have the colours that apply at the specified offset
                return ($currentText, $currentUnderlay, $currentBoldFlag, %offsetHash);
            }

            $listRef = $offsetHash{$thisOffset};
            foreach my $otherTag (@$listRef) {

                my ($otherType, $underlayFlag);

                if ($otherTag eq 'bold') {

                    # Convert 'blue' to 'BLUE', etc. Don't worry whether it's an xterm or RGB colour
                    #   tag, as they are both case-insensitive
                    # 'bold' tags don't apply to underlay colours, of course
                    $currentText = uc($currentText);
                    $currentBoldFlag = TRUE;

                } elsif ($otherTag eq 'bold_off' || $otherTag eq 'attribs_off') {

                    $currentText = lc($currentText);
                    $currentBoldFlag = FALSE;

                } else {

                    ($otherType, $underlayFlag) = $axmud::CLIENT->checkColourTags($otherTag);
                    if ($otherType) {

                        if (! $underlayFlag) {

                            $currentText = $otherTag;
                            if ($currentBoldFlag && $otherType eq 'standard' && ! $underlayFlag) {

                                $currentText = uc($currentText);
                            }

                        } else {

                            $currentUnderlay = $otherTag;
                        }
                    }
                }
            }

            # Once we've got the colour tags for this offset, we don't need to check it again
            delete $offsetHash{$thisOffset};
        }

        # Operation complete
        return ($currentText, $currentUnderlay, $currentBoldFlag, %offsetHash);
    }

    sub trimColouredText {

        # Called by $self->extractComponents when processing a component which has its
        #   ->useTextColour IV set to any Axmud colour or underlay tag
        # Checks the text of a single display buffer line, and removes any text whose colour doesn't
        #   match the colour specified by ->useTextColour. Updates the buffer object's hash of
        #   offsets where Axmud colour/style tags are found to match the modified text
        #
        # NB If the component's ->boldSensitiveFlag is not set, normal and bold colours are treated
        #   the same way
        #
        # Expected arguments
        #   $bufferObj      - A GA::Buffer::Display object. Normally the task would use the text in
        #                       $bufferObj->modLine; this function returns an alternative modified
        #                       line with all the escape sequences stripped away, and with any text
        #                       of the wrong colour also stripped away
        #   $componentObj   - The GA::Obj::Component object for this line
        #
        # Return values
        #   An empty list on improper arguments or if there is no text on the line that uses the
        #       component's specified colour tag
        #   Otherwise, returns a list in the form
        #       (modified_text, modified_offset_hash)

        my ($self, $bufferObj, $componentObj, $check) = @_;

        # Local variables
        my (
            $tag, $lineLen, $boldFlag, $currentText, $currentUnderlay, $modText,
            @emptyList, @useOffsetList, @disposeOffsetList,
            %offsetHash, %modOffsetHash,
        );

        # Check for improper arguments
        if (! defined $bufferObj || ! defined $componentObj || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->trimColouredText', @_);
            return @emptyList;
        }

        # We only want to keep text using this Axmud colour tag
        $tag = $componentObj->useTextColour;
        # Set the line length once, for quick lookup
        $lineLen = length($bufferObj->modLine);

        # Import the buffer object's hash of Axmud colour/style tags, which is in the form
        #   $offsetHash{offset} = reference_to_list_of_tags_that_occur_there
        %offsetHash = $bufferObj->offsetHash;

        # Get the colours that apply at the start of the line
        OUTER: foreach my $startTag ($bufferObj->previousTagList) {

            if ($axmud::CLIENT->checkTextTags($startTag)) {
                $currentText = $startTag;
            } elsif ($axmud::CLIENT->checkUnderlayTags($startTag)) {
                $currentUnderlay = $startTag;
            }

            if ($currentText && $currentUnderlay) {

                # Only one of each can exist in ->previousTagList, so we can stop searching now
                last OUTER;
            }
        }

        if (! defined $currentText) {

            # No text colour tags were applied at the end of the last line, so the colours actually
            #   displayed are the textview object's default colours (usually white)
            $currentText = $self->session->currentTabObj->textViewObj->textColour;
        }

        if (! defined $currentUnderlay) {

            # Ditto for underlays
            $currentUnderlay = $self->session->currentTabObj->textViewObj->underlayColour;
        }

        if ($currentText eq $tag || $currentUnderlay eq $tag) {

            # Items in @useOffsetList are list references. Each list reference contain two items (a
            #   start offset and a stop offset), marking a portion of the text in the colour we're
            #   searching for
            # The following line assumes the previous line's colour applies to the whole of this
            #   line. If any more colour tags are found, we'll replace the 'stop' offset
            push (@useOffsetList, [0, $lineLen]);
        }

        # Now, go through the offsets at which tags occur, looking for occurences of $tag
        @disposeOffsetList = sort {$a <=> $b} (keys %offsetHash);
        foreach my $offset (@disposeOffsetList) {

            my ($tagListRef, $otherType, $underlayFlag, $miniListRef);

            $tagListRef = $offsetHash{$offset};

            foreach my $otherTag (@$tagListRef) {

                if ($otherTag eq 'bold') {

                    $boldFlag = TRUE;
                    $currentText = uc($currentText);

                } elsif ($otherTag eq 'bold_off') {

                    $boldFlag = FALSE;
                    $currentText = lc($currentText);

                } elsif ($otherTag eq 'attribs_off') {

                    $boldFlag = FALSE;
                    $currentText = $self->session->currentTabObj->textViewObj->textColour;

                } else {

                    ($otherType, $underlayFlag) = $axmud::CLIENT->checkColourTags($otherTag);
                    if ($otherType) {

                        if (! $underlayFlag) {
                            $currentText = $otherTag;
                        } else {
                            $currentUnderlay = $otherTag;
                        }

                        if ($boldFlag && $otherType eq 'standard' && ! $underlayFlag) {

                            $currentText = uc($otherTag);
                        }
                    }
                }
            }

            # Items in @useOffsetList are list references. Each list reference contain two items (a
            #   start offset and a stop offset), marking a portion of the text in the colour we're
            #   searching for
            if (
                (
                    $currentText
                    && (
                        $currentText eq $tag
                        || (! $componentObj->boldSensitiveFlag && lc($currentText) eq lc($tag))
                    )
                ) || (
                    $currentUnderlay
                    && (
                        $currentUnderlay eq $tag
                        || (! $componentObj->boldSensitiveFlag && lc($currentUnderlay) eq lc($tag))
                    )
                )
            ) {
                $miniListRef = $useOffsetList[-1];
                # v1.2.043 - the line below appears to be an error, but I'm not sure yet
#                if (! defined $miniListRef || ! defined $$miniListRef[1]) {
                if (! defined $miniListRef || defined $$miniListRef[1]) {

                    # This is a 'start'
                    $miniListRef = [$offset, $lineLen];
                    push (@useOffsetList, $miniListRef);
                }

            } elsif (
                (
                    $currentText
                    && (
                        ($componentObj->boldSensitiveFlag && $currentText ne $tag)
                        || (! $componentObj->boldSensitiveFlag && lc($currentText) ne lc($tag))
                    )
                ) || (
                    $currentUnderlay
                    && (
                        ($componentObj->boldSensitiveFlag && $currentUnderlay ne $tag)
                        || (! $componentObj->boldSensitiveFlag && lc($currentUnderlay) ne lc($tag))
                    )
                )
            ) {
                $miniListRef = $useOffsetList[-1];
                if (defined $miniListRef) {

                    # A 'start' and 'stop' could occur at the same offset, in which case they cancel
                    #   each other at
                    if (defined $$miniListRef[0] && $$miniListRef[0] == $offset) {

                        pop @useOffsetList;

                    } elsif (! defined $$miniListRef[1] || $$miniListRef[1] > $offset) {
                        # This is a 'stop', which replaces any 'stop' from later in the line (such
                        #   as one we added to @useOffsetList near the beginning of this function)
                        $$miniListRef[1] = $offset;
                    }
                }
            }
        }

        if (! @useOffsetList) {

            # Line contained no text of the right colour
            return @emptyList;
        }

        # Compile a string consisting of all the line portions in the right colour. Also update
        #   %offsetHash so that it matches the modified string
        $modText = '';
        do {

            my ($miniListRef, $start, $stop);

            $miniListRef = shift @useOffsetList;
            $start = shift @$miniListRef;
            $stop = shift @$miniListRef;

            OUTER: foreach my $offset (@disposeOffsetList) {

                if ($offset < $start) {

                    # This offset occurs before $start, so ignore it
                    shift @disposeOffsetList;
                    next OUTER;

                } elsif ($offset <= $stop) {

                    # This offset occurs within the text of the right colour, so preserve its
                    #   Axmud colour/style tags at their new position
                    shift @disposeOffsetList;
                    $modOffsetHash{$offset - length($modText)} = $offsetHash{$offset};

                } else {

                    # This offset occurs after $stop; process it on the next do... loop
                    last OUTER;
                }
            }

            if (! defined $stop) {
                $modText .= substr($bufferObj->modLine, $start);
            } elsif ($stop > $start) {
                $modText .= substr($bufferObj->modLine, $start, ($stop - $start));
            }

        } until (! @useOffsetList);

        return ($modText, %modOffsetHash);
    }

    sub combineLines {

        # Called by $self->extractComponents when processing a component which has its
        #   ->combineLinesFlag IV set
        # Combines the text of the lines into a single string. Also combines the hashes of offsets
        #   where Axmud colour/style tags occur into a single hash matching the single string
        #
        # Expected arguments
        #   $lineTextListRef    - Reference to a list of strings, each representing text on a single
        #                           line
        #   $lineOffsetListRef  - Reference to a corresponding list of hash references, containing
        #                           offsets on each line where Axmud colour/style tags occur. Those
        #                           hashes are in the form
        #                           $hash{offset} = reference_to_list_of_Axmud_colour_&_style_tags
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise (for which the contents of the two list references have been modifed)

        my ($self, $lineTextListRef, $lineOffsetListRef, $check) = @_;

        # Local variables
        my (
            $modText,
            %modOffsetHash,
        );

        # Check for improper arguments
        if (! defined $lineTextListRef || ! defined $lineOffsetListRef || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->combineLines', @_);
        }

        if (@$lineTextListRef) {

            $modText = '';

            do {

                my ($text, $hashRef);

                $text = shift @$lineTextListRef;
                $hashRef = shift @$lineOffsetListRef;

                foreach my $offset (keys %$hashRef) {

                    $modOffsetHash{$offset + length($modText)} = $$hashRef{$offset};
                }

                if ($modText eq '') {
                    $modText = $text;
                } else {
                    $modText .= ' ' . $text;
                }

            } until (! @$lineTextListRef);
        }

        # Update the list references supplied as arguments, so we don't need to return them as
        #   values
        @$lineTextListRef = ($modText);
        @$lineOffsetListRef = (\%modOffsetHash);

        return 1;
    }

    sub reduceLineFromStart {

        # Called by $self->extractComponents when processing a component which has its
        #   ->ignoreFirstChars IV set
        # Removes the first n characters from each line (leaving an empty string, if necessary).
        #   Also updates the hashes of offsets where Axmud colour/style tags occur to match the
        #   modified strings
        #
        # Expected arguments
        #   $lineTextListRef    - Reference to a list of strings, each representing text on a single
        #                           line
        #   $lineOffsetListRef  - Reference to a corresponding list of hash references, containing
        #                           offsets on each line where Axmud colour/style tags occur. Those
        #                           hashes are in the form
        #                           $hash{offset} = reference_to_list_of_Axmud_colour_&_style_tags
        #   $number             - The number of characters to remove from the beginning of each
        #                           line
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise (for which the contents of the two list references have been modifed)

        my ($self, $lineTextListRef, $lineOffsetListRef, $number, $check) = @_;

        # Local variables
        my (@modLineTextList, @modLineOffsetList);

        # Check for improper arguments
        if (
            ! defined $lineTextListRef || ! defined $lineOffsetListRef || ! defined $number
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->reduceLineFromStart', @_);
        }

        if (@$lineTextListRef) {

            do {

                my (
                    $text, $hashRef,
                    %modHash,
                );

                $text = shift @$lineTextListRef;
                $hashRef = shift @$lineOffsetListRef;

                if (length ($text) >= $number) {
                    $text = substr($text, $number);
                } else {
                    $text = '';
                }

                foreach my $offset (keys %$hashRef) {

                    if ($offset >= $number) {

                        $modHash{$offset - $number} = $$hashRef{$offset};
                    }
                }

                push (@modLineTextList, $text);
                push (@modLineOffsetList, \%modHash);

            } until (! @$lineTextListRef);
        }

        # Update the list references supplied as arguments, so we don't need to return them as
        #   values
        @$lineTextListRef = @modLineTextList;
        @$lineOffsetListRef = @modLineOffsetList;

        return 1;
    }

    sub reduceLineFromEnd {

        # Called by $self->extractComponents when processing a component which has its
        #   ->useFirstChars IV set
        # Removes everything after the first n characters from each line. Also updates the hashes of
        #   offsets where Axmud colour/style tags occur to match the modified strings
        #
        # Expected arguments
        #   $lineTextListRef    - Reference to a list of strings, each representing text on a single
        #                           line
        #   $lineOffsetListRef  - Reference to a corresponding list of hash references, containing
        #                           offsets on each line where Axmud colour/style tags occur. Those
        #                           hashes are in the form
        #                           $hash{offset} = reference_to_list_of_Axmud_colour_&_style_tags
        #   $number             - The number of characters to preserve at the beginning of each line
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise (for which the contents of the two list references have been modifed)

        my ($self, $lineTextListRef, $lineOffsetListRef, $number, $check) = @_;

        # Local variables
        my (@modLineTextList, @modLineOffsetList);

        # Check for improper arguments
        if (
            ! defined $lineTextListRef || ! defined $lineOffsetListRef || ! defined $number
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->reduceLineFromEnd', @_);
        }

        if (@$lineTextListRef) {

            do {

                my (
                    $text, $hashRef,
                    %modHash,
                );

                $text = shift @$lineTextListRef;
                $hashRef = shift @$lineOffsetListRef;

                if (length ($text) >= $number) {

                    $text = substr($text, 0, $number);
                }

                foreach my $offset (keys %$hashRef) {

                    if ($offset < $number) {

                        $modHash{$offset} = $$hashRef{$offset};
                    }
                }

                push (@modLineTextList, $text);
                push (@modLineOffsetList, \%modHash);

            } until (! @$lineTextListRef);
        }

        # Update the list references supplied as arguments, so we don't need to return them as
        #   values
        @$lineTextListRef = @modLineTextList;
        @$lineOffsetListRef = @modLineOffsetList;

        return 1;
    }

    sub reduceLineUsingRegex {

        # Called by $self->extractComponents when processing a component which has its
        #   ->usePatternGroups IV set
        # Tests a regex against each line. If the lines matches, this function removes everything
        #   except the group substrings. Also updates the hashes of offsets where Axmud colour/style
        #   tags occur to match the modified strings
        #
        # Expected arguments
        #   $lineTextListRef    - Reference to a list of strings, each representing text on a single
        #                           line
        #   $lineOffsetListRef  - Reference to a corresponding list of hash references, containing
        #                           offsets on each line where Axmud colour/style tags occur. Those
        #                           hashes are in the form
        #                           $hash{offset} = reference_to_list_of_Axmud_colour_&_style_tags
        #   $regex              - The regex to use (which is assumed to contain at least one
        #                           substring)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise (for which the contents of the two list references have been modifed)

        my ($self, $lineTextListRef, $lineOffsetListRef, $regex, $check) = @_;

        # Local variables
        my (@modLineTextList, @modLineOffsetList);

        # Check for improper arguments
        if (
            ! defined $lineTextListRef || ! defined $lineOffsetListRef || ! defined $regex
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->reduceLineUsingRegex', @_);
        }

        if (@$lineTextListRef) {

            do {

                my (
                    $text, $modText, $hashRef,
                    @grpStringList, @matchMinusList, @matchPlusList, @discardList,
                    %modOffsetHash,
                );

                $text = shift @$lineTextListRef;
                $hashRef = shift @$lineOffsetListRef;

                @grpStringList = ($text =~ m/$regex/);
                if (@grpStringList) {

                    @matchMinusList = @-;
                    @matchPlusList = @+;

                    $modText = join('', @grpStringList);
                    # In case the combined group substrings contain no text, restore the
                    #   original line
                    if ($modText eq '') {

                        push (@modLineTextList, $text);
                        push (@modLineOffsetList, $hashRef);

                    } else {

                        # Now update the hashes that contain the offsets on each line where Axmud
                        #   colour/style tags occur
                        # @matchMinusList contains the offsets where each group substring in
                        #   @grpStringList occurs, and @matchPlusList contains the offsets
                        #   immediately after the group substring
                        # e.g. You are in a twisty maze of passages
                        #                   ^^^^^^^^^^^
                        #      0123456789012345678901234567890123456789
                        #      Group substring 'twisty maze', offsets are 13 and 24

                        # The first element in each list are the offets of the whole matching
                        #   portion of $text, so we can discard them immediately
                        shift @matchMinusList;
                        shift @matchPlusList;
                        # The offsets at which Axmud colour/style tags occur can be discarded from
                        #   this list as they're processed
                        @discardList = sort {$a <=> $b} (keys %$hashRef);
                        $modText = '';

                        do {

                            my ($start, $stop, $string);

                            $start = shift @matchMinusList;
                            $stop = shift @matchPlusList;
                            $string = shift @grpStringList;

                            OUTER: foreach my $offset (@discardList) {

                                if ($offset < $start) {

                                    # This offset occurs before $start, so ignore it
                                    shift @discardList;
                                    next OUTER;

                                } elsif ($offset <= $stop) {

                                    # This offset occurs within group substring, so preserve its
                                    #   Axmud colour/style tags at their new position
                                    shift @discardList;
                                    $modOffsetHash{$offset - length($modText)} = $$hashRef{$offset};

                                } else {

                                    # This offset occurs after $stop; process it on the next do...
                                    #   loop
                                    last OUTER;
                                }
                            }

                            # Combined the group substrings into a single string as we go, so we
                            #   know how to adjust the offsets in %hashRef
                            $modText .= $string;

                        } until (! @matchMinusList);

                        push (@modLineTextList, $modText);
                        push (@modLineOffsetList, \%modOffsetHash);
                    }

                } else {

                    # Line doesn't match, so preserve the whole line
                    push (@modLineTextList, $text);
                    push (@modLineOffsetList, $hashRef);
                }

            } until (! @$lineTextListRef);
        }

        # Update the list references supplied as arguments, so we don't need to return them as
        #   values
        @$lineTextListRef = @modLineTextList;
        @$lineOffsetListRef = @modLineOffsetList;

        return 1;
    }

    sub checkLineValid {

        # Called by $self->extractLines
        # Given a display buffer line number, check that it isn't outside the display buffer (i.e.
        #   that the line actually exists), and is not within the bounds of a previously-found room
        #   statement
        #
        # Expected arguments
        #   $lineNum    - The display buffer line number to check
        #
        # Return values
        #   An empty list on improper arguments, if the buffer line is within a previously-found
        #       room statement or if the buffer line doesn't exist
        #   Otherwise returns a list in the form
        #       (text_buffer_line, corresponding_text_buffer_object)

        my ($self, $lineNum, $check) = @_;

        # Local variables
        my (
            $bufferObj,
            @emptyList,
        );

        # Check for improper arguments
        if (! defined $lineNum || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->checkLineValid', @_);
            return @emptyList;
        }

        if (
            $self->roomCount
            && $self->lastStatementEndLine
            && $lineNum <= $self->lastStatementEndLine
        ) {
            # Line already searched
            return @emptyList;
        }

        # Get the corresponding GA::Buffer::Display object
        $bufferObj = $self->session->ivShow('displayBufferHash', $lineNum);
        if (! $bufferObj) {

            # Line doesn't exist
            return @emptyList;

        } else {

            # Return the text of the line
            return ($bufferObj->modLine, $bufferObj);
        }
    }

    sub checkLineTags {

        # Called by $self->extractLines
        # Given a display buffer line object, checks the contents of the object's ->tagHash. If the
        #   hash contains any Axmud colour/style tags (not including the dummy tags like 'bold',
        #   'reverse_off' and 'attribs_off'), returns 1
        #
        # Expected arguments
        #   $lineObj    - The GA::Buffer::Display to check
        #
        # Return values
        #   'undef' on improper arguments or if the object contains no Axmud colour/style tags
        #       (besides the dummy tags like 'bold', 'reverse_off' and 'attribs_off')
        #   1 otherwise

        my ($self, $lineObj, $check) = @_;

        # Check for improper arguments
        if (! defined $lineObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkLineTags', @_);
        }

        foreach my $tag ($lineObj->ivKeys('tagHash')) {

            if (! $axmud::CLIENT->ivExists('constDummyTagHash', $tag)) {

                # The object contains at least one Axmud colour/style tag
                return 1;
            }
        }

        # The object contains no Axmud colour/style tags (apart from dummy tags, if any)
        return undef;
    }

    sub haltAnalysis {

        # Called by $self->processLine (at stage 3) when the task is looking for its first room
        #   statement, but finds a failed exit pattern or an involuntary exit pattern
        #
        # Expected arguments
        #   $type   - 'fail_exit' or 'involuntary'
        #
        # Return values
        #   'undef'

        my ($self, $type, $check) = @_;

        # Check for improper arguments
        if (! defined $type || ($type ne 'fail_exit' && $type ne 'involuntary') || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->haltAnalysis', @_);
        }

        # Empty the command lists, since none of them matter now
        $self->ivEmpty('moveList');
        $self->ivEmpty('cmdObjList');
        $self->ivUndef('prevCmdBufferNum');

        # Set flags that other parts of the Axmud code can consult frequently, to see if there's
        #   been a failed exit (etc) or not
        if ($type eq 'fail_exit') {
            $self->ivPoke('failExitFlag', TRUE);
        } elsif ($type eq 'involuntary') {
            $self->ivPoke('involuntaryExitFlag', TRUE);
        }

        # Update the task window
        if ($self->taskWinFlag) {

            $self->refreshWin();
        }

        # Don't search any more lines for anchor lines until the next task loop
        return undef;
    }

    sub removeFirstMove {

        # Called by $self->processLine (at stage 3)
        # Whenever a world command is sent, a GA::Buffer::Cmd object is created, and that object
        #   is stored in $self->cmdObjList. In most cases, each world command gets its own buffer
        #   object, but for assisted moves, a sequence of one or more world commands (comprising a
        #   single assisted move) is assigned to a single buffer object
        # If it's a look, glance or movement command, the object is also stored in $self->moveList
        #   (movement commands include redirect mode commands and assisted moves)
        #
        # The calling function can specify a GA::Buffer::Cmd object, which should exist in
        #   $self->cmdObjList, and be the first item in $self->moveList. If so, remove everything in
        #   both lists up to (and including) that object
        # If the calling function doesn't specify a GA::Buffer::Cmd object, empty both lists (as we
        #   have no further use for their contents)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $cmdObj     - The GA::Buffer::Cmd object to remove (or 'undef' to empty both lists)
        #
        # Return values
        #   'undef' on improper arguments or if there's an error
        #   1 otherwise

        my ($self, $cmdObj, $check) = @_;

        # Local variables
        my (
            $matchFlag,
            @cmdObjList, @moveList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->removeFirstMove', @_);
        }

        if (! $cmdObj) {

            # Just empty both lists
            $self->ivEmpty('cmdObjList');
            $self->ivEmpty('moveList');
            $self->ivUndef('prevCmdBufferNum');

            # Update the task window's title bar (if open)
            $self->prepareTitleBar();

            return 1;
        }

        # Import the move lists
        @cmdObjList = $self->cmdObjList;
        @moveList = $self->moveList;

        # First check @cmdObjList
        OUTER: for (my $count = 0; $count < scalar @cmdObjList; $count++) {

            my $thisObj = $cmdObjList[$count];

            if ($thisObj eq $cmdObj) {

                # Object representing a movement command found
                $matchFlag = TRUE;
                # Remove it from the list
                splice (@cmdObjList, 0, ($count + 1));

                last OUTER;
            }
        }

        if (! $matchFlag || $moveList[0] ne $cmdObj) {

            $self->writeError(
                'Command object lists do not match',
                $self->_objClass . '->removeFirstMove',
            );

            # Empty both lists, to prevent a whole stream of errors
            $self->ivEmpty('cmdObjList');
            $self->ivEmpty('moveList');
            $self->ivUndef('prevCmdBufferNum');

            # Update the task window's title bar (if open)
            $self->prepareTitleBar();

            return undef;

        } else {

            # Object representing a movement command found (in both lists). Restore IVs
            $self->ivPoke('cmdObjList', @cmdObjList);
            $self->ivPoke('moveList', splice(@moveList, 1));
            $self->ivUndef('prevCmdBufferNum');

            # Update the task window's title bar (if open)
            $self->prepareTitleBar();

            return 1;
        }
    }

    # Room methods

    sub setRoomTitle {

        # Called by $self->extractComponents (at stage 3)
        # Adds titles to the non-model room
        #
        # Expected arguments
        #   $roomObj    - A GA::ModelObj::Room object
        #   @lineList   - A list of lines comprising the room statement components 'verb_title' or
        #                   'brief_title'
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $roomObj, @lineList) = @_;

        # Local variables
        my @modList;

        # Check for improper arguments
        if (! defined $roomObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomTitle', @_);
        }

        # Preserve the original @lineList (required for TTS);
        $self->ivAdd('ttsToReadHash', 'title', join(' ', @lineList));

        # Eliminate any empty lines (containing no non-whitespace characters)
        foreach my $line (@lineList) {

            if ($line =~ m/\S/) {

                # Also eliminate extraneous whitespace, including in the middle of lines
                $line = $axmud::CLIENT->trimWhitespace($line, TRUE);
                push (@modList, $line);
            }
        }

        if (@modList) {

            # Update the room's title list
            $roomObj->ivPush('titleList', @modList);
        }

        return 1;
    }

    sub setRoomDescrip {

        # Called by $self->extractComponents (at stage 3)
        # Adds (verbose) descriptions to the non-model room
        #
        # Expected arguments
        #   $roomObj    - A GA::ModelObj::Room object
        #   @lineList   - A list of lines comprising the room statement component 'verb_descrip'
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $roomObj, @lineList) = @_;

        # Local variables
        my @modList;

        # Check for improper arguments
        if (! defined $roomObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomDescrip', @_);
        }

        # Preserve the original @lineList (required for TTS);
        $self->ivAdd('ttsToReadHash', 'descrip', join(' ', @lineList));

        # Eliminate any empty lines (containing no non-whitespace characters)
        foreach my $line (@lineList) {

            if ($line =~ m/\S/) {

                # Also eliminate extraneous whitespace, including in the middle of lines
                $line = $axmud::CLIENT->trimWhitespace($line, TRUE);
                push (@modList, $line);
            }
        }

        if (@modList) {

            # Update the room's verbose description hash
            $roomObj->ivAdd(
                'descripHash',
                $self->session->worldModelObj->lightStatus,
                join(' ', @modList),
            );
        }

        return 1;
    }

    sub setRoomExit {

        # Called by $self->extractComponents (at stage 3); also called by $self->setRoomTitleExit
        # Adds a list of verbose or brief exits to the non-model room
        #
        # Expected arguments
        #   $worldObj           - The current world profile
        #   $roomObj            - A GA::ModelObj::Room object
        #   $mode               - 'verbose' or 'brief'
        #   $bufferObjListRef   - Reference to a list of display buffer objects, one for each line
        #                           in the extracted component
        #   $lineTextListRef    - Reference to a corresponding list of strings, each representing
        #                           text on a single line
        #   $lineOffsetListRef  - Reference to a corresponding list of hash references, containing
        #                           offsets on each line where Axmud colour/style tags occur. Those
        #                           hashes are in the form
        #                           $hash{offset} = reference_to_list_of_Axmud_colour_&_style_tags
        #
        # Return values
        #   'undef' on improper arguments, if any line in $lineTextListRef is invalid or if
        #       $lineTextListRef is empty
        #   1 otherwise

        my (
            $self, $worldObj, $roomObj, $mode, $bufferObjListRef, $lineTextListRef,
            $lineOffsetListRef, $check,
        ) = @_;

        # Local variables
        my (
            $delimIV, $nonDelimIV, $leftMarkerIV, $rightMarkerIV, $splitCharIV,
            @bufferObjList, @lineTextList, @lineOffsetList, @markerList, @exitList, @finalList,
            %checkHash, %duplicateHash,
        );

        # Check for improper arguments
        if (
            ! defined $worldObj || ! defined $roomObj || ! defined $mode
            || ! defined $bufferObjListRef || ! defined $lineTextListRef
            || ! defined $lineOffsetListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomExit', @_);
        }

        # Set which IVs to use, depending on $mode
        if ($mode eq 'verbose') {

            $delimIV = 'verboseExitDelimiterList';
            $nonDelimIV = 'verboseExitNonDelimiterList';
            $leftMarkerIV = 'verboseExitLeftMarkerList';
            $rightMarkerIV = 'verboseExitRightMarkerList';
            $splitCharIV = 'verboseExitSplitCharFlag';

        } else {

            $delimIV = 'briefExitDelimiterList';
            $nonDelimIV = 'briefExitNonDelimiterList';
            $leftMarkerIV = 'briefExitLeftMarkerList';
            $rightMarkerIV = 'briefExitRightMarkerList';
            $splitCharIV = 'briefExitSplitCharFlag';
        }

        # For convenience, dereference the arguments
        @bufferObjList = @$bufferObjListRef;
        @lineTextList = @$lineTextListRef;
        @lineOffsetList = @$lineOffsetListRef;
        if (! @lineTextList) {

            # Nothing to process
            return undef;
        }

        # Preserve the original @lineTextList (required for TTS);
        $self->ivAdd('ttsToReadHash', 'exit', join(' ', @lineTextList));

        # Remove left/right markers, if any, from each line in @lineTextList. (It's up to the user
        #   to create left-marker regexes that start with '^' and right-marker regexes that end with
        #   '$')
        @markerList = (
            $worldObj->$leftMarkerIV,
            $worldObj->$rightMarkerIV,
        );

        if (@markerList) {

            for (my $count = 0; $count < (scalar @lineTextList); $count++) {

                my (
                    $text, $modText, $hashRef, $modFlag,
                    %modHash,
                );

                $modText = $text = $lineTextList[$count];
                $hashRef = $lineOffsetList[$count];
                %modHash = %$hashRef;

                foreach my $marker (@markerList) {

                    my (
                        $newText, $start, $stop, $len,
                        %newHash,
                    );

                    $newText = $modText;

                    if ($newText =~ s/$marker//) {

                        $start = $-[0];
                        $stop = $+[0];
                        $len = $stop - $start;

                        # Update the hash of offsets where Axmud colour/style tags occur to match
                        #   the modified text
                        foreach my $offset (keys %modHash) {

                            if ($offset < $start) {
                                $newHash{$offset} = $modHash{$offset};
                            } elsif ($offset >= $stop) {
                                $newHash{$offset - $len} = $modHash{$offset};
                            }
                        }

                        $modText = $newText;
                        %modHash = %newHash;
                        $modFlag = TRUE;
                    }
                }

                if ($modFlag) {

                    # At least one substitution was made
                    $lineTextList[$count] = $modText;
                    $lineOffsetList[$count] = \%modHash;
                }
            }
        }

        # Check that no lines contain any non-delimiters (forbidden substrings)
        foreach my $line (@lineTextList) {

            foreach my $delim ($worldObj->$nonDelimIV) {

                if (index ($line, $delim) > -1) {

                    # Invalid line; don't process any exits
                    return undef;
                }
            }
        }

        # Replace any exit aliases with their substitutions (e.g. substitute 'compass' for
        #   'n s e w nw ne sw se'
        if ($worldObj->exitAliasHash) {

            for (my $count = 0; $count < (scalar @lineTextList); $count++) {

                my (
                    $text, $modText, $hashRef, $modFlag,
                    %modHash,
                );

                $modText = $text = $lineTextList[$count];
                $hashRef = $lineOffsetList[$count];
                %modHash = %$hashRef;

                foreach my $pattern ($worldObj->ivKeys('exitAliasHash')) {

                    my (
                        $substitution, $newText, $start, $stop, $diff,
                        %newHash,
                    );

                    $substitution = $worldObj->ivShow('exitAliasHash', $pattern);
                    $newText = $modText;

                    if ($newText =~ s/$pattern/$substitution/i) {

                        $start = $-[0];
                        $stop = $+[0];
                        $diff = length($newText) - length($modText);

                        # Update the hash of offsets where Axmud colour/style tags occur to match
                        #   the modified text. Any colour/style tags which occured inside the
                        #   substituted text are lost
                        foreach my $offset (keys %modHash) {

                            if ($offset < $start) {
                                $newHash{$offset} = $modHash{$offset};
                            } elsif ($offset >= $stop) {
                                $newHash{$offset + $diff} = $modHash{$offset};
                            }
                        }

                        $modText = $newText;
                        %modHash = %newHash;
                        $modFlag = TRUE;
                    }
                }

                if ($modFlag) {

                    # At least one substitution was made
                    $lineTextList[$count] = $modText;
                    $lineOffsetList[$count] = \%modHash;
                }
            }
        }

        # In case the delimiter interferes with exit state strings, remove anything matching the
        #   pattern(s), and treat that portion as an exit
        # Compose a list, @exitList, in groups of 3, in the form
        #   (exit_string, text_colour, underlay_colour...)
        if ($worldObj->exitStatePatternList) {

            for (my $count = 0; $count < (scalar @lineTextList); $count++) {

                my (
                    $bufferObj, $text, $modText, $hashRef, $modFlag,
                    %modHash,
                );

                $bufferObj = $bufferObjList[$count];
                $modText = $text = $lineTextList[$count];
                $hashRef = $lineOffsetList[$count];
                %modHash = %$hashRef;

                foreach my $pattern ($worldObj->exitStatePatternList) {

                    my (
                        $substitution, $newText, $exitFlag,
                        %newHash,
                    );

                    $substitution = $worldObj->ivShow('exitAliasHash', $pattern);
                    $newText = $modText;

                    # Use a do... loop, as we might need to remove several exits from the same line
                    do {

                        my ($start, $stop, $len);

                        $exitFlag = FALSE;

                        if ($newText =~ s/($pattern)//) {

                            $start = $-[0];
                            $stop = $+[0];
                            $len = $stop - $start;

                            # Get the text/underlay colours which apply to the offset $start, so we
                            #   can add them to @exitList alongside $1, the exit itself
                            push (@exitList,
                                $1,
                                $self->getColoursAtPosn(
                                    $self->getColoursAtStart($bufferObj),
                                    FALSE,              # No 'bold' tag detected yet
                                    $start,
                                    %modHash,
                                ),
                            );

                            # Update the hash of offsets where Axmud colour/style tags occur to
                            #   match the modified text. Any colour/style tags which occured inside
                            #   the substituted text are lost
                            foreach my $offset (keys %modHash) {

                                if ($offset < $start) {
                                    $newHash{$offset} = $modHash{$offset};
                                } elsif ($offset >= $stop) {
                                    $newHash{$offset - $len} = $modHash{$offset};
                                }
                            }

                            $modText = $newText;
                            %modHash = %newHash;
                            $modFlag = TRUE;
                        }

                    } until (! $exitFlag);
                }

                if ($modFlag) {

                    # At least one substitution was made
                    $lineTextList[$count] = $modText;
                    $lineOffsetList[$count] = \%modHash;
                }
            }
        }

        # Now extract exits from every line in turn
        OUTER: for (my $count = 0; $count < (scalar @lineTextList); $count++) {

            my (
                $bufferObj, $text, $hashRef, $offset, $textColour, $underlayColour, $boldFlag,
                $stopFlag,
                %newHash,
            );

            $bufferObj = $bufferObjList[$count];
            $text = $lineTextList[$count];
            $hashRef = $lineOffsetList[$count];
            $offset = 0;

            ($textColour, $underlayColour) = $self->getColoursAtStart($bufferObj);
            $boldFlag = FALSE;

            # $text should now be in the general form
            #   <exit><delimiter><exit><delimiter><exit>...
            do {

                my ($posn, $delim, $exitString);

                # Find the position of the first delimiter at $offset or after
                foreach my $thisDelim ($worldObj->$delimIV) {

                    my $thisPosn = index(substr($text, $offset), $thisDelim);
                    if ($thisPosn >= 0) {

                        if (! defined $posn || $posn > $thisPosn) {

                            $posn = $thisPosn;
                            $delim = $thisDelim;
                        }
                    }
                }

                if (! defined $posn) {

                    # $text, from $offset onwards, contains no delimiters
                    push (@exitList, substr($text, $offset));

                    ($textColour, $underlayColour, $boldFlag, %newHash) = $self->getColoursAtPosn(
                        $textColour,
                        $underlayColour,
                        $boldFlag,
                        $offset,
                        %$hashRef,
                    ),

                    push (@exitList, $textColour, $underlayColour);
                    %$hashRef = %newHash;
                    $stopFlag = TRUE;

                } else {

                    # $text contains an offset an exit between $offset and ($offset + $posn)
                    push (@exitList, substr($text, $offset, $posn));

                    ($textColour, $underlayColour, $boldFlag, %newHash) = $self->getColoursAtPosn(
                        $textColour,
                        $underlayColour,
                        $boldFlag,
                        $offset,
                        %$hashRef,
                    ),

                    push (@exitList, $textColour, $underlayColour);
                    %$hashRef = %newHash;

                    # On the next iteration of this loop, check from the start of the next exit
                    #   string
                    $offset += $posn + length($delim);
                }

            } until ($stopFlag);
        }

        # Improve the exits in @exitList
        if (@exitList) {

            do {

                my ($exitString, $currentText, $currentUnderlay, $modString, $replace);

                $exitString = shift @exitList;
                $currentText = shift @exitList;
                $currentUnderlay = shift @exitList;

                # Remove any leading/trailing whitespace from strings in @exitList, convert
                #   multiple-character whitespace into single-character whitespace
                $modString = lc($axmud::CLIENT->trimWhitespace($exitString, TRUE));

                # Ignore any strings that are now empty strings
                if (defined $modString && $modString ne '') {

                    # Deal with duplicate exits. In some situations, we can replace it with
                    #   something else
                    if (
                        exists $checkHash{$modString}
                        && defined $worldObj->duplicateReplaceString
                    ) {
                        # For worlds like Two Towers, a duplicate 'east' exit is normally one which
                        #   can be substituted for something like 'swim east', in which case
                        #   $replace will be something like 'swim @@@'
                        $replace = $worldObj->duplicateReplaceString;
                        $replace =~ s/@@@/$modString/g;
                        $modString = $replace;
                        # After the non-model exit object is created, we'll mark it as a hidden exit
                        $duplicateHash{$modString} = undef;
                    }

                    # If $modString is still a duplicate, just ignore it
                    if (! exists $checkHash{$modString}) {

                        # Split the exit into single letters, if required
                        if ($worldObj->$splitCharIV) {

                            foreach my $char (split('', $modString)) {

                                # Still eliminate duplicate exits
                                if (! exists $checkHash{$char}) {

                                    # The code assumes that any colour tag applying to the beginning
                                    #   of the exit applies to all of it; in this case, it applies
                                    #   to every character in the split string
                                    push (@finalList, $char, $currentText, $currentUnderlay);
                                }
                            }

                        } else {

                            push (@finalList, $modString, $currentText, $currentUnderlay);
                        }

                        $checkHash{$modString} = undef;
                    }
                }

            } until (! @exitList);
        }

        # Now, for each exit in @finalList, create a non-exit model object, and add it to the room
        #   model object
        if (! $self->processExits($worldObj, $roomObj, @finalList)) {

            return undef;
        }

        # Any duplicate exits that were converted (e.g. east > swim east) must be marked as hidden
        #   exits, so that the imaginary 'swim east' direction doesn't conflict with the
        #   corresponding room's list of exits in a map
        foreach my $string (keys %duplicateHash) {

            my $exitObj = $roomObj->ivShow('exitNumHash', $string);
            $exitObj->ivPoke('hiddenFlag', TRUE);
        }

        return 1;
    }

    sub setRoomContent {

        # Called by $self->extractComponents (at stage 3). Also called by $self->setRoomVerbSpecial
        # Extracts the non-model room's (temporary) list of contents
        #
        # Expected arguments
        #   $roomObj    - A GA::ModelObj::Room object
        #   @lineList   - A list of lines comprising the room statement components 'verb_content'
        #                   or 'brief_content'
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $roomObj, @lineList) = @_;

        # Local variables
        my (
            $dictObj, $string,
            @objList,
        );

        # Check for improper arguments
        if (! defined $roomObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomVerbContent', @_);
        }

        # Preserve the original @lineList (required for TTS);
        $self->ivAdd('ttsToReadHash', 'content', join(' ', @lineList));

        # Import the current dictionary (for convenience)
        $dictObj = $self->session->currentDict;

        # Extract a list of model objects by parsing each line
        @objList = $self->session->worldModelObj->parseObj(
            $self->session,
            FALSE,                  # Treat '5 coins' as 5 separate objects
            @lineList,
        );

        if (@objList) {

            # Update the non-model room object
            $roomObj->ivPush('tempObjList', @objList);
        }

        # Store the contents lines themselves, if the flag is set
        if ($self->session->currentWorld->collectContentsFlag) {

            foreach my $line (@lineList) {

                my $thisLine;

                # Don't store lines containing no alphanumeric characters
                if ($line =~ m/\w/) {

                    # Remove all leading/trailing whitespace, and reduce any whitespace in the
                    #   middle to a single character (and reduce everything to lower case,
                    #   because that's what $self->session->worldModelObj->parseObj uses)
                    $thisLine = lc($axmud::CLIENT->trimWhitespace($line, TRUE));
                    # If the line starts or ends with non-alphanumeric characters (especially
                    #   punctuation at the end of the line), remove them
                    $thisLine =~ s/^\W+//;
                    $thisLine =~ s/\W+$//;

                    if ($thisLine && ! $dictObj->ivExists('contentsLinesHash', $thisLine)) {

                        $dictObj->ivAdd('contentsLinesHash', $thisLine, undef);
                    }
                }
            }
        }

        return 1;
    }

    sub setRoomVerbSpecial {

        # Called by $self->extractComponents (at stage 3)
        # Extracts lines in the 'verb_special' component and converts them to parseable lines
        #   (e.g. converts '^There is a sign here you can read\.' to 'A sign is here')
        #
        # Expected arguments
        #   $worldObj   - The current world profile
        #   $roomObj    - A GA::ModelObj::Room object
        #   @lineList   - A list of lines comprising the room statement component 'verb_special'
        #
        # Return values
        #   'undef' on improper arguments or if no matching lines are converted
        #   1 otherwise

        my ($self, $worldObj, $roomObj, @lineList) = @_;

        # Local variables
        my (@specialList, @modList);

        # Check for improper arguments
        if (! defined $worldObj || ! defined $roomObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomVerbSpecial', @_);
        }

        # Import the hash of special patterns (for quick lookup)
        @specialList = $worldObj->ivKeys('specialPatternHash');

        # Check each line in turn, looking for lines that match a pattern in %specialHash. Ignore
        #   any non-matching lines
        OUTER: foreach my $line (@lineList) {

            INNER: foreach my $pattern (@specialList) {

                if ($line =~ $pattern) {

                    # Replace the whole line
                    push (@modList, $worldObj->ivShow('specialPatternHash', $pattern));
                    next OUTER;
                }
            }
        }

        # If any lines were replaced, treat them as ordinary contents lines
        if (@modList) {

            return $self->setRoomContent($roomObj, @modList);

        } else {

            # No lines converted
            return undef;
        }
    }

    sub setRoomTitleExit {

        # Called by $self->extractComponents (at stage 3)
        # Processes a line that contains both a room's title and brief list of exits
        #
        # Expected arguments
        #   $worldObj           - The current world profile
        #   $roomObj            - A GA::ModelObj::Room object
        #   $flag               - If set to TRUE, the line should contain the room title, followed
        #                           by the list of brief exits (as the title of this function
        #                           suggests). If set to FALSE, the list of brief exits comes before
        #                           the title
        #   $bufferObjListRef   - Reference to a list of display buffer objects, one for each line
        #                           in the extracted component
        #   $lineTextListRef    - Reference to a corresponding list of strings, each representing
        #                           text on a single line
        #   $lineOffsetListRef  - Reference to a corresponding list of hash references, containing
        #                           offsets on each line where Axmud colour/style tags occur. Those
        #                           hashes are in the form
        #                           $hash{offset} = reference_to_list_of_Axmud_colour_&_style_tags
        #
        # Return values
        #   'undef' on improper arguments or if any line in @lineList is invalid
        #   1 otherwise

        my (
            $self, $worldObj, $roomObj, $flag, $bufferObjListRef, $lineTextListRef,
            $lineOffsetListRef, $check,
        ) = @_;

        # Local variables
        my (
            $bufferObj, $text, $hashRef, $title, $exitString,
            @bufferObjList, @lineTextList, @lineOffsetList,
        );

        # Check for improper arguments
        if (
            ! defined $worldObj || ! defined $roomObj || ! defined $flag
            || ! defined $bufferObjListRef || ! defined $lineTextListRef
            || ! defined $lineOffsetListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomTitleExit', @_);
        }

        # Ignore everything except the first line
        $bufferObj = $$bufferObjListRef[0];
        $text = $$lineTextListRef[0];
        $hashRef = $$lineOffsetListRef[0];

        if ($flag) {

            # The line appears in the form <title> <list of exits>
            # Find the position of the first left marker; everything to the left of that is the
            #   room title
            OUTER: foreach my $marker ($worldObj->briefExitLeftMarkerList) {

                my %newHash;

                if ($text =~ m/$marker/) {

                    $title = $`;
                    $exitString = substr($text, length($title));

                    # Update offsets in the hash of Axmud colour/style tags
                    foreach my $offset (keys %$hashRef) {

                        if ($offset >= length ($title)) {

                            $newHash{$offset - length($title)} = $$hashRef{$offset};
                        }
                    }

                    $hashRef = \%newHash;

                    last OUTER;
                }
            }

        } else {

            # The line appears in the form <list of exits> <title>
            # Find the position of the first right marker; everything to the right of that is the
            #   room title
            OUTER: foreach my $marker ($worldObj->briefExitRightMarkerList) {

                my %newHash;

                if ($text =~ m/$marker/) {

                    $title = $';
                    $exitString = $` . $&;

                    # Update offsets in the hash of Axmud colour/style tags
                    foreach my $offset (keys %$hashRef) {

                        if ($offset < length ($exitString)) {

                            $newHash{$offset} = $$hashRef{$offset};
                        }
                    }

                    $hashRef = \%newHash;

                    last OUTER;
                }
            }
        }

        if (! $title && ! $exitString) {

            # The exit string is more important, so use the whole line as the exit string
            $exitString = $text;
        }

        # Process the title (if found)
        if ($title) {

            $self->setRoomTitle($roomObj, $title);

            # Preserve the list of exits (required for TTS);
            $self->ivAdd('ttsToReadHash', 'title', $title);
        }

        if ($exitString) {

            # (Again, only the first line is used)
            @bufferObjList = ($bufferObj);
            @lineTextList = ($exitString);
            @lineOffsetList = ($hashRef);

            if (
                ! $self->setRoomExit(
                    $worldObj,
                    $roomObj,
                    'brief',
                    \@bufferObjList,
                    \@lineTextList,
                    \@lineOffsetList,
                )
            ) {
                # The line is invalid (contains non-delimiters)
                return undef;
            }
        }

        return 1;
    }

    sub setRoomCmd {

        # Called by $self->extractComponents (at stage 3)
        # Adds a list of room commands to the non-model room
        #
        # Expected arguments
        #   $worldObj   - The current world profile
        #   $roomObj    - A GA::ModelObj::Room object
        #   @lineList   - A list of lines comprising the room statement component 'room_cmd'
        #
        # Return values
        #   'undef' on improper arguments or if any line in @lineList is invalid
        #   1 otherwise

        my ($self, $worldObj, $roomObj, @lineList) = @_;

        # Local variables
        my (
            @markerList, @cmdList, @modList, @finalList,
            %cmdHash,
        );

        # Check for improper arguments
        if (! defined $worldObj || ! defined $roomObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomCmd', @_);
        }

        # Preserve the original @lineList (required for TTS);
        $self->ivAdd('ttsToReadHash', 'command', join(' ', @lineList));

        # Check that no lines contain any non-delimiters (forbidden substrings)
        foreach my $line (@lineList) {

            foreach my $delim ($worldObj->roomCmdNonDelimiterList) {

                if (index ($line, $delim) > -1) {

                    # Invalid line; don't process any room commands
                    return undef;
                }
            }
        }

        # Remove left/right markers, if any, from each line in @lineList. (It's up to the user to
        #   create left-marker regexes that start with '^' and right-marker regexes that end with
        #   '$')
        @markerList = (
            $worldObj->roomCmdLeftMarkerList,
            $worldObj->roomCmdRightMarkerList,
        );

        foreach my $line (@lineList) {

            foreach my $marker (@markerList) {

                $line =~ s/$marker//;
            }
        }

        # Now extract room commands from every line in turn
        foreach my $line (@lineList) {

            my @thisList = ($line);

            # Sort in reverse order of length, otherwise unexpected problems might occur
            foreach my $delim ($worldObj->roomCmdDelimiterList) {

                my @tempList = @thisList;
                @thisList = ();

                foreach my $item (@tempList) {

                    my $offset;

                    # Can't use Perl split(), because $delim is not a regex
                    do {

                        $offset = index($item, $delim);
                        if ($offset >= 0) {

                            push (@thisList, substr($item, 0, $offset));
                            $item = substr($item, ($offset + length($delim)));

                        } else {

                            push (@thisList, $item);
                        }

                    } until ($offset < 0);
                }
            }

            push (@cmdList, @thisList);
        }

        # Remove any leading/trailing whitespace from strings in @cmdList, convert multiple-
        #   character whitespace into single-character whitespace, convert capitals to lower case,
        #   and remove empty strings
        foreach my $cmd (@cmdList) {

            $cmd = $axmud::CLIENT->trimWhitespace($cmd, TRUE);
            if ($cmd) {

                push (@modList, lc($cmd));
            }
        }

        # Split the commands(s) into single letters, if required
        if ($worldObj->roomCmdSplitCharFlag) {

            @cmdList = @modList;
            @modList = ();
            foreach my $cmd (@cmdList) {

                push (@modList, split('', $cmd));
            }
        }

        # Combine the commands in the non-model room (if any have been added following an earlier
        #   call to this function) with @modList, and eliminate duplicates

        # Go through @modList, eliminating duplicates
        foreach my $cmd ($roomObj->roomCmdList, @modList) {

            if (! exists $cmdHash{$cmd}) {

                $cmdHash{$cmd} = undef;
                push (@finalList, $cmd);
            }
        }

        # Update the non-model room object
        $roomObj->ivPush('roomCmdList', @finalList);
        $roomObj->ivPoke('tempRoomCmdList', $roomObj->roomCmdList);

        # Operation complete
        return 1;
    }

    sub setRoomMudlibPath {

        # Called by $self->extractComponents (at stage 3)
        # Adds a mudlib path to the non-model room
        #
        # Expected arguments
        #   $roomObj    - A GA::ModelObj::Room object
        #   @lineList   - A list of lines comprising the room statement component 'mudlib_path';
        #                   should contain only one line. Only the first non-empty line is used
        #
        # Return values
        #   'undef' on improper arguments or if @lineList doesn't contain the mudlib path
        #   1 otherwise

        my ($self, $roomObj, @lineList) = @_;

        # Check for improper arguments
        if (! defined $roomObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomMudlibPath', @_);
        }

        # Find the first non-empty line, and use it
        foreach my $line (@lineList) {

            if ($line =~ m/\S/) {

                # Remove leading/trailing whitespace
                $line = $axmud::CLIENT->trimWhitespace($line);
                # Set the room's mudlib path
                $roomObj->ivPoke('sourceCodePath', $line);

                return 1;
            }
        }

        # Mudlib path not found
        return undef;
    }

    sub setRoomWeather {

        # Called by $self->extractComponents (at stage 3)
        # Adds text describing the weather, the time of day and so on to a task hash used to display
        #   that information in the task window
        # (The text isn't anywhere stored in the world model)
        #
        # Expected arguments
        #   $componentObj   - The GA::Obj::Component of the type 'weather'
        #   @lineList       - A list of lines comprising that room statement component
        #
        # Return values
        #   'undef' on improper arguments or if the lines in @lineList contain no non-space
        #       characters
        #   1 otherwise

        my ($self, $componentObj, @lineList) = @_;

        # Local variables
        my (
            $string,
            @newList,
        );

        # Check for improper arguments
        if (! defined $componentObj || ! @lineList) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomWeather', @_);
        }

        # Merge the lines into a single string, ignoring any empty lines
        foreach my $line (@lineList) {

            if ($line =~ m/\S/) {

                # Remove leading/trailing whitespace. The TRUE flag means to remove multiple space
                #   characters in the middle of the line, too
                $line = $axmud::CLIENT->trimWhitespace($line, TRUE);
                push (@newList, $line);
            }
        }

        if (! @newList) {

            # No non-space characters found, so there's nothing to display
            return undef;

        } else {

            # If the room statement contains (for some reason) multiple 'weather' components with
            #   the same name, merge them all
            if ($self->ivExists('weatherHash', $componentObj->name)) {

                unshift (@newList, $self->ivShow('weatherHash', $componentObj->name));
            }

            # Store the merged string
            $self->ivAdd('weatherHash', $componentObj->name, join(' ', @newList));

            return 1;
        }
    }

    sub setRoomFromMxp {

        # Called by $self->processMxpProperties (at stage 3)
        # Sets the room title, description, exit list and remote number, if the world has used
        #   MXP tag properties to specify them
        #
        # Expected arguments
        #   $worldObj       - Shortcut to the current world profile object
        #   $roomObj        - A GA::ModelObj::Room object
        #   %mxpPropHash    - A hash of MXP tag properties (should not be empty), in the form
        #                       $mxpPropHash{tag} = string
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $worldObj, $roomObj, %mxpPropHash) = @_;

        # Local variables
        my (
            $title, $descrip, $exitString, $offset, $stopFlag, $num,
            @exitList, @modList,
        );

        # Check for improper arguments
        if (! defined $worldObj || ! defined $roomObj || ! %mxpPropHash) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setRoomFromMxp', @_);
        }

        # Set the room title, if specified
        $title = $mxpPropHash{'RoomName'};
        if (defined $title) {

            $title = $axmud::CLIENT->trimWhitespace($title);
            $roomObj->ivPush('titleList', $title);
            $self->ivAdd('ttsToReadHash', 'title', $title);
        }

        $descrip = $mxpPropHash{'RoomDesc'};
        if (defined $descrip) {

            $descrip = $axmud::CLIENT->trimWhitespace($descrip);
            $roomObj->ivAdd('descripHash', $self->session->worldModelObj->lightStatus, $descrip);
            $self->ivAdd('ttsToReadHash', 'descrip', $descrip);
        }

        $exitString = $mxpPropHash{'RoomExit'};
        if (defined $exitString) {

            # This code is borrowed from $self->setRoomExit, removing most of its features, and
            #   just splitting the string using known delimiters
            $offset = 0;

            # Split $exitString into a list of exits, using the normal delimiters specified by the
            #   current world profile
            do {

                my ($posn, $delim);

                # Find the position of the first delimiter at $offset or after
                foreach my $thisDelim (
                    $worldObj->verboseExitDelimiterList,
                    $worldObj->briefExitDelimiterList,
                ) {
                    my $thisPosn = index(substr($exitString, $offset), $thisDelim);
                    if ($thisPosn >= 0) {

                        if (! defined $posn || $posn > $thisPosn) {

                            $posn = $thisPosn;
                            $delim = $thisDelim;
                        }
                    }
                }

                if (! defined $posn) {

                    # $exitString, from $offset onwards, contains no delimiters
                    push (@exitList, substr($exitString, $offset));
                    $stopFlag = TRUE;

                } else {

                    # $text contains an offset an exit between $offset and ($offset + $posn)
                    push (@exitList, substr($exitString, $offset, $posn));
                    # On the next iteration of this loop, check from the start of the next exit
                    #   string
                    $offset += $posn + length($delim);
                }

            } until ($stopFlag);

            # Remove any leading/trailing whitespace
            # Also, convert the list into groups of three, which is the format $self->processExits
            #   is expecting. This function doesn't test the exit colours, so just behave as if the
            #   default colour was used
            foreach my $exit (@exitList) {

                push (
                    @modList,
                    $axmud::CLIENT->trimWhitespace($exit),
                    $axmud::CLIENT->constTextColour,
                    $axmud::CLIENT->constUnderlayColour,
                );
            }

            # Create exit objects for each exit
            $self->processExits($worldObj, $roomObj, @modList);
        }

        $num = $mxpPropHash{'RoomNum'};
        if (defined $num) {

            $num = $axmud::CLIENT->trimWhitespace($num);
            $roomObj->ivAdd('protocolRoomHash', 'vnum', $num);
        }

        return 1;
    }

    # Room supplementary methods

    sub processExits {

        # Called by $self->setRoomExit
        # Given a non-model room object and a list of exits, sorts the exits into the standard order
        #   and creates exit objects for each exit
        # Processes any exit states and removes any substrings matching exit remove patterns
        #
        # Expected arguments
        #   $worldObj       - The current world profile
        #   $roomObj        - A GA::ModelObj::Room object
        #
        # Optional arguments
        #   @exitList       - A list of exits and the Axmud colour tags applied to the first
        #                       characters. List in groups of 3, in the form
        #                           (exit_string, text_colour, underlay_colour)
        #                   - Might be an empty list, if the room statement contained a line like
        #                       'Obvious exits: none', or if 'none' is one the world profile's
        #                       non-delimiter strings (stored in ->verboseExitNonDelimiterList or
        #                       ->briefExitNonDelimiterList)
        #
        # Return values
        #   'undef' on improper arguments or if @exitList contains an invalid exit
        #   1 otherwise

        my ($self, $worldObj, $roomObj, @exitList) = @_;

        # Local variables
        my (
            $dictObj, $wmObj,
            @objList, @sortedList,
        );

        # Check for improper arguments
        if (! defined $worldObj || ! defined $roomObj) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->processExits', @_);
        }

        # If no exits were specified (e.g. 'Obvious exits: none'), there are no exit objects to add
        if (! @exitList) {

            return 1;
        }

        # Import the current dictionary and world model object
        $dictObj = $self->session->currentDict;
        $wmObj = $self->session->worldModelObj;

        # For each exit, create a non-exit model object
        do {

            my (
                $exitString, $textColour, $underlayColour, $info, $state, $modString, $assisted,
                $exitObj,
            );

            $exitString = shift @exitList;
            $textColour = shift @exitList;
            $underlayColour = shift @exitList;

            # If any parts of the exit match a pattern in $worldObj->exitInfoPatternList, remove
            #   them (but keep the first group substring, if any)
            INNER: foreach my $pattern ($worldObj->exitInfoPatternList) {

                if ($exitString =~ m/$pattern/) {

                    $info = $1;
                    $exitString =~ s/$pattern//;
                    last INNER;
                }
            }

            # If any parts of the exit match a pattern in $worldObj->exitRemovePatternList, remove
            #   them
            foreach my $pattern ($worldObj->exitRemovePatternList) {

                $exitString =~ s/$pattern//gi;
            }

            # Ignore the exit if all characters have been removed
            if ($exitString ne '') {

                # Remove any exit state strings from exits (verbose and brief)
                ($state, $modString) = $wmObj->checkExitState($worldObj, $exitString);

                # GA::Profile::World->exitStateTagHash uses the value 'ignore' for any exit state
                #   strings which should be ignored (and for which GA::Obj::Exit->exitState should
                #   keep its default value of 'normal'
                if ($state eq 'ignore') {

                    $state = 'normal';
                }

                # An exit state can also be indicated by an Axmud colour tag (don't apply this test
                #   if an exit state has already been detected)
                if ($state eq 'normal') {

                    INNER: foreach my $tag ($worldObj->ivKeys('exitStateTagHash')) {

                        if ($tag eq $textColour || $tag eq $underlayColour) {

                            $state = $worldObj->ivShow('exitStateTagHash', $tag);
                            last INNER;
                        }
                    }
                }

                # Deal with custom strings
                if (! $axmud::CLIENT->ivExists('constExitStateHash', $state)) {

                    # $state is a custom string which should be used as the exit's assisted move,
                    #   once it is has been created
                    $assisted = $state;
                    $state = 'normal';

                    # If this custom string contains the @@@ sequence, substitute that sequence for
                    #   the exit direction (i.e. convert 'swim @@@' into 'swim east')
                    $assisted =~ s/@@@/$modString/g;
                }

                # Unabbreviate custom primary/recognised secondary abbreviated directions
                $modString = $dictObj->unabbrevDir($modString);

                # Create an exit object for this exit
                $exitObj = Games::Axmud::Obj::Exit->new(
                    $self->session,
                    $modString,             # Nominal direction with state symbols (if any) removed
                    'non_model',            # Non-exit model object
                );

                if (! $exitObj) {

                    # Line was probably mis-identified as containing exits
                    return undef;

                } else {

                    push (@objList, $exitObj);

                    # Set the exit type, info and state
                    $exitObj->ivPoke('exitType', $dictObj->ivShow('combDirHash', $modString));
                    $exitObj->ivPoke('exitInfo', $info);        # May be 'undef'
                    $exitObj->ivPoke('exitState', $state);      # May be 'normal'

                    if (defined $assisted) {

                        $exitObj->ivAdd(
                            'assistedHash',
                            $self->session->currentWorld->name,
                            $assisted,
                        );
                    }
                }
            }

        } until (! @exitList);

        # Sort @objList into the standard order (i.e. north before south, etc)
        @sortedList = $dictObj->sortExitObjs(@objList);

        # Update the specified room object
        foreach my $exitObj (@sortedList) {

            $roomObj->ivPush('sortedExitList', $exitObj->dir);
            $roomObj->ivAdd('exitNumHash', $exitObj->dir, $exitObj);
        }

        # Sorting complete
        return 1;
    }

    sub chooseDescrip {

        # Called by $self->refreshWin
        # Each room model object can contain 0, 1 or more (verbose) descriptions. This function
        #   chooses the most useful one.
        # If there's a description matching the current light status, that description is returned
        # Otherwise, searches the customisable list of light statues and returns the first
        #   description matching a value in the list
        # If no matching descriptions are found (because none of the room's descriptions matches a
        #   recognised light status), returns 'undef'
        #
        # Expected arguments
        #   $roomObj    - blessed reference of the room object to check
        #
        # Return values
        #   'undef' on improper arguments or if no matching description is found
        #   Otherwise, returns the chosen description (a string)

        my ($self, $roomObj, $check) = @_;

        # Local variables
        my (
            $modelObj,
            %hash,
        );

        # Check for improper arguments
        if (! defined $roomObj || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->chooseDescrip', @_);
        }

        # Import the world model object
        $modelObj = $self->session->worldModelObj;
        # Import the verbose description hash
        %hash = $roomObj->descripHash;

        # If a description matching the current light status exists, return it
        if (exists $hash{$modelObj->lightStatus}) {

            return $hash{$modelObj->lightStatus};

        } else {

            # Otherwise, return the first verbose description matching a recognised light status
            foreach my $status ($modelObj->lightStatusList) {

                if (exists $hash{$status}) {

                    return $hash{$status};
                }
            }

            # No matching description found
            return undef;
        }
    }

    # Other methods

    sub refreshWin {

        # Called by $self->processLine, ->haltAnalysis and ->set_updateFromMsdp
        # Refreshes the task window (if it is open)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $failedExit - If a failed exit was detected, $self->processLine sends the direction of
        #                   the movement command that it thinks caused the failed exit. If the
        #                   direction matches one of the room's known exits, we can mark it as
        #                   (temporarily) unavailable
        #
        # Return values
        #   'undef' on improper arguments, or if the task window isn't open
        #   1 otherwise

        my ($self, $failedExit, $check) = @_;

        # Local variables
        my (
            $dictObj, $roomID, $nextTag, $verboseDescrip, $exitString, $roomCmdString, $corpseCount,
            $bodyPartCount, $protocolRoomString, $protocolExitString, $colour, $posn, $before,
            $after,
            @titleList, @sortedExitList, @objList, @modObjList, @charList, @minionList,
            @sentientList, @creatureList, @corpseList, @otherList, @newOtherList, @moveList,
            %exitNumHash, %otherHash, %protocolRoomHash, %protocolExitHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refreshWin', @_);
        }

        # Don't do anything if the task window isn't open
        if (! $self->taskWinFlag) {

            return undef;
        }

        # Import the current dictonary (for convenience)
        $dictObj = $self->session->currentDict;

        # Import the lists and hashes for display, if they've been set ($self->roomObj may not be
        #   set if there's been a failed exit)
        if ($self->roomObj) {

            @titleList = $self->roomObj->titleList;
            @sortedExitList = $self->roomObj->sortedExitList;
            %exitNumHash = $self->roomObj->exitNumHash;
            @objList = $self->roomObj->tempObjList;

            # Import protocol data hashes (for speed)
            %protocolRoomHash = $self->roomObj->protocolRoomHash;
            %protocolExitHash = $self->roomObj->protocolExitHash;
        }

        # Set the task window's title bar (if open) to show the number of expected room statements
        $self->prepareTitleBar();
        # If ->arrivalTag has been set (by one of the route commands, e.g. ';go') and if the current
        #   room is known and the room doesn't already have a tag, name the room with the supplied
        #   tag
        if (! $self->moveList && $self->arrivalTag && $self->roomObj && ! $self->roomObj->roomTag) {

            $self->roomObj->ivPoke('roomTag', $self->arrivalTag);
            $self->ivEmpty('arrivalTag');
        }

        # If the room has a tag and/or its world model number is known, display them first
        if ($self->roomObj && $self->roomObj->roomTag) {

            # If the automapper displays all tags in upper-case, so should the Locator task
            if ($self->session->worldModelObj->capitalisedRoomTagFlag) {
                $roomID = uc($self->roomObj->roomTag);
            } else {
                $roomID = $self->roomObj->roomTag;
            }
        }

        if ($self->modelNumber) {

            if ($roomID) {

                $roomID .= ' ';
            }

            $roomID .= '#' . $self->modelNumber;
        }

        if ($roomID) {

            # If the automapper's current location isn't set, the room's tag must have been set
            #   following a command like ';drive' or ';quick'. If so, display the text in blue, so
            #   that the user can see it's not a tag used by a room in the world model
            if (
                ! $self->session->mapObj->currentRoom
                && $self->roomObj
                && $self->roomObj->roomTag
            ) {
                $self->insertText('[' . $roomID . '] ', 'empty', 'BLUE');

            } else {

                $self->insertText('[' . $roomID . '] ', 'empty', 'RED');
            }

            # The room title/verbose descrips should use the 'echo' argument to continue writing
            #   immediately after the $roomID string
            $nextTag = 'echo';

        } else {

            # The verbose/brief descrips should use the 'empty' argument to empty the task window
           $nextTag = 'empty';
        }

        # Show a special message if the current location isn't known, or if the room is dark
        if (! $self->roomObj) {

            if (! $self->modelNumber) {
                $self->insertText('<location unknown>', $nextTag, 'RED');
            } else {
                $self->insertText('<waiting for room statement>', $nextTag, 'RED');
            }

            return 1;

        } elsif ($self->roomObj->currentlyDarkFlag) {

            $self->insertText('<dark room>', $nextTag, 'RED');
            return 1;

        } elsif ($self->roomObj->unspecifiedFlag) {

            $self->insertText('<unspecified room>', $nextTag, 'RED');
            return 1;
        }

        # Display protocol room/exit data reported by the server, if available
        if (%protocolRoomHash || %protocolExitHash) {

            if (exists $protocolExitHash{'vnum'}) {

                $protocolRoomString = '#' . $protocolRoomHash{'vnum'};
            }

            if (exists $protocolRoomHash{'xpos'}) {

                if ($protocolRoomString) {
                    $protocolRoomString .= ' X:' . $protocolRoomHash{'xpos'};
                } else {
                    $protocolRoomString = 'X:' . $protocolRoomHash{'xpos'};
                }
            }

            if (exists $protocolRoomHash{'ypos'}) {

                if ($protocolRoomString) {
                    $protocolRoomString .= ' Y:' . $protocolRoomHash{'ypos'};
                } else {
                    $protocolRoomString = 'Y:' . $protocolRoomHash{'ypos'};
                }
            }

            if (exists $protocolRoomHash{'zpos'}) {

                if ($protocolRoomString) {
                    $protocolRoomString .= ' Z:' . $protocolRoomHash{'zpos'};
                } else {
                    $protocolRoomString = 'Z:' . $protocolRoomHash{'zpos'};
                }
            }

            if (exists $protocolRoomHash{'terrain'}) {

                if ($protocolRoomString) {
                    $protocolRoomString .= ' T:' . $protocolRoomHash{'terrain'};
                } else {
                    $protocolRoomString = 'T:' . $protocolRoomHash{'terrain'};
                }
            }

            if (%protocolExitHash) {

                if ($protocolRoomString) {
                    $protocolRoomString .= ' E:';
                } else {
                    $protocolRoomString = 'E:';
                }

                foreach my $abbrevDir (sort {lc($a) cmp lc($b)} (keys %protocolExitHash)) {

                    my $destRoomNum = $protocolExitHash{$abbrevDir};

                    if ($protocolExitString) {
                        $protocolExitString .= ' ' . $abbrevDir . '>' . $destRoomNum;
                    } else {
                        $protocolExitString = $abbrevDir . '>' . $destRoomNum;
                    }
                }

                $protocolRoomString .= $protocolExitString;
            }

            $self->insertText($protocolRoomString, $nextTag, 'YELLOW');
            $nextTag = 'before';
        }

        # Decide which verbose description to use
        $verboseDescrip = $self->chooseDescrip($self->roomObj);

        # Apply limit to size of verbose description, if necessary
        if (
            $self->winDescripLimit
            && $verboseDescrip
            && length ($verboseDescrip) > $self->winDescripLimit
        ) {
            # Reduce the size of the verbose description
            $verboseDescrip = substr($verboseDescrip, 0, $self->winDescripLimit) ;
            # Remove incomplete words at the end
            $verboseDescrip =~ s/\w+$//;
            # Then remove any whitespace that preceded it
            $verboseDescrip =~ s/\s+$//;
            # Then remove the first non-alphanumeric character (want to remove commas and full
            #   stops, but don't want to remove a sequence of such characters, which might be
            #   important)
            $verboseDescrip =~ s/\W$//;
            # Some descrips contain a lot of whitespace (e.g. one containing a menu). In this
            #   window, we want to reduce all whitespace to a single space
            $verboseDescrip =~ s/\s{1,}/ /g;
            # Finally, add an ellipsis
            $verboseDescrip .= '...';
        }

        # Display room title description (in red), if known
        # Display verbose room description (in white), if known
        if (@titleList) {

            $self->insertText($titleList[0], $nextTag, 'RED');
            if (defined $verboseDescrip) {

                $self->insertText($verboseDescrip, 'white');
            }

        } elsif (defined $verboseDescrip) {

            $self->insertText($verboseDescrip, $nextTag, 'white');

        } else {

            $self->insertText('<description unknown>', $nextTag, 'white');
        }

        # If the room's source code path is known, display it
        if ($self->roomObj && $self->roomObj->sourceCodePath) {

            $self->insertText($self->roomObj->sourceCodePath, 'yellow');
        }

        # Display list of exits (in cyan), if known
        if (defined $sortedExitList[0]) {

            foreach my $exit (@sortedExitList) {

                my ($modExit, $autoDir, $exitNum, $exitObj);

                # If it's a secondary direction that's been mapped onto a primary direction,
                #   display the primary direction too
                $modExit = $exit;
                $autoDir = $dictObj->ivShow('secondaryAutoHash', $exit);
                if (defined $autoDir) {

                    $modExit .= '/' . $dictObj->ivShow('primaryDirHash', $autoDir);
                }

                if (defined $failedExit && $exit eq $failedExit) {

                    # This is probably the direction that caused a failed exit string to appear.
                    #   Mark it.
                    $modExit = '*' . $modExit;
                }

                # Find the corresponding exit model object
                if ($self->roomObj) {

                    if ($self->roomObj->modelFlag) {

                        $exitNum = $exitNumHash{$exit};
                        $exitObj = $self->session->worldModelObj->ivShow('modelHash', $exitNum);

                    } else {

                        $exitObj = $exitNumHash{$exit};
                    }
                }

                if ($exitObj && $exitObj->exitState) {

                    if ($exitObj->exitState eq 'open') {
                        $modExit .= '[O]';     # Open
                    } elsif ($exitObj->exitState eq 'closed') {
                        $modExit .= '[C]';     # Closed
                    } elsif ($exitObj->exitState eq 'locked') {
                        $modExit .= '[L]';     # Locked
                    } elsif ($exitObj->exitState eq 'secret') {
                        $modExit .= '[S]';     # Secret
                    } elsif ($exitObj->exitState eq 'secret_open') {
                        $modExit .= '[SO]';     # Secret and open
                    } elsif ($exitObj->exitState eq 'secret_closed') {
                        $modExit .= '[SC]';     # Secret and closed
                    } elsif ($exitObj->exitState eq 'secret_locked') {
                        $modExit .= '[SL]';     # Secret and locked
                    } elsif ($exitObj->exitState eq 'impass') {
                        $modExit .= '[I]';     # Impassable
                    } elsif ($exitObj->exitState eq 'dark') {
                        $modExit .= '[D]';     # Dest room is dark
                    } elsif ($exitObj->exitState eq 'danger') {
                        $modExit .= '[!]';     # Dest room is dangerous
                    } elsif ($exitObj->exitState eq 'emphasis') {
                        $modExit .= '+';     # Dest room is emphasised
                    } elsif ($exitObj->exitState eq 'other') {
                        $modExit .= '[-]';     # Other
                    }
                }

                if ($exitObj && $exitObj->assistedHash) {

                    $modExit .= '@';
                }

                if (! $exitString) {
                    $exitString = $modExit;
                } else {
                    $exitString .= ', '.$modExit;
                }
            }

            $self->insertText($exitString, 'cyan');

        } else {

            $self->insertText('<unknown exits>', 'cyan');
        }

        # If the debug flag is set, display everything on the move list, just underneath the exit
        #   list
        if ($axmud::CLIENT->debugMoveListFlag) {

            if ($self->moveList) {

                # ->moveList contains a list of GA::Buffer::Cmd objects. Compile a list of the
                #   commands they represent
                foreach my $obj ($self->moveList) {

                    push (@moveList, $obj->cmd);
                }

                $self->insertText('[ML: ' . join(' ', @moveList) . ']', 'RED');

            } else {

                $self->insertText('[ML: empty]', 'RED');
            }
        }

        # Display list of room commands, if set
        if ($self->roomObj->roomCmdList) {

            foreach my $roomCmd ($self->roomObj->roomCmdList) {

                if (! $roomCmdString) {

                    $roomCmdString = $roomCmd;

                } else {

                    $roomCmdString .= ', ' . $roomCmd;
                }
            }

            $self->insertText($roomCmdString, 'green');
        }

        # Display weather (etc) information, if set
        if ($self->weatherHash) {

            foreach my $key (sort {lc($a) cmp lc($b)} ($self->ivKeys('weatherHash'))) {

                $self->insertText(
                    ucfirst($key) . ': ' . $self->ivShow('weatherHash', $key),
                    'yellow',
                );
            }
        }

        # If the flags are set, we need to remove all the corpses/body parts from @objList, so they
        #   can be displayed on a single line
        if ($self->combineCorpseFlag || $self->combineBodyPartFlag) {

            foreach my $obj (@objList) {

                if (
                    $self->combineCorpseFlag
                    && $obj->category eq 'portable'
                    && $obj->type eq 'corpse'
                ) {
                    $corpseCount++;

                } elsif (
                    $self->combineBodyPartFlag
                    && $obj->category eq 'portable'
                    && $obj->type eq 'bodypart'
                ) {
                    $bodyPartCount++;

                } else {

                    # Not a corpse or body part, so display on its own line
                    push (@modObjList, $obj);
                }
            }
        }
        # @objList should contain only those things displayed on their own line
        @objList = @modObjList;

        # Now, sort @objList by category - in the order characters, minions, sentients, creatures,
        #   everything else, finally corpses
        foreach my $obj (@objList) {

            if ($obj->category eq 'char') {

                push (@charList, $obj);

            } elsif ($obj->category eq 'minion') {

                push (@minionList, $obj);

            } elsif ($obj->category eq 'sentient') {

                push (@sentientList, $obj);

            } elsif ($obj->category eq 'creature') {

                push (@creatureList, $obj);

            } elsif (
                $obj->category eq 'portable'
                && ($obj->type eq 'corpse' || $obj->type eq 'bodypart')
            ) {
                push (@corpseList, $obj);

            } else {

                push (@otherList, $obj);
            }
        }

        # If there are five torches in @otherList, we'd prefer to display them on a single line,
        #   like we do with corpses. (We don't display multiple orcs, for example, on a single line
        #   because the task marks which ones are alive, and which are dead)
        # ->combineDuplicates compiles a hash in the form
        #   $otherHash{blessed_reference_to_non_model_object} = multiple
        # In this example, it will return a hash containing one key-value pair. The key will be the
        #   first object in @otherList, the corresponding value will be 5
        %otherHash = $self->combineDuplicates(@otherList);
        # The keys of %otherHash are stringified blessed references, so we need to modify @otherList
        #   to eliminate the duplicates that don't appear in %otherHash
        foreach my $obj (@otherList) {

            if (exists $otherHash{$obj}) {

                push (@newOtherList, $obj);
            }
        }

        # Sort alphabetically the sub-lists
        @charList = sort {lc($a->noun) cmp lc($b->noun)} (@charList);
        @minionList = sort {lc($a->noun) cmp lc($b->noun)} (@minionList);
        @sentientList = sort {lc($a->noun) cmp lc($b->noun)} (@sentientList);
        @creatureList = sort {lc($a->noun) cmp lc($b->noun)} (@creatureList);
        @otherList = sort {lc($a->noun) cmp lc($b->noun)} (@otherList);
        @corpseList = sort {lc($a->noun) cmp lc($b->noun)} (@corpseList);
        # Re-combine into a single list, in order
        @objList = (
            @charList,
            @minionList,
            @sentientList,
            @creatureList,
            @newOtherList,
            @corpseList,
        );

        # Display the room contents, if known
        foreach my $obj (@objList) {

            my (
                $nounString, $otherNounString, $adjString, $unknownWordString, $multiple, $column,
                @wordList, @newWordList,
            );

            # Convert lists into strings, with each word separated by a space
            $nounString = $obj->noun;

            if (exists $otherHash{$obj}) {

                # Use the multiple supplied in the call to ->combineDuplicates
                $multiple = $otherHash{$obj};

            } else {

                # Use the object's actual multiple
                $multiple = $obj->multiple;
            }

            # The world model's ->parseObj can take a copy of an 'unknown' word (not a recognised
            #   noun or adjective) and make it the noun, such that the same word appears as both the
            #   main noun, and in the unknown word list
            # As a result, we only want to show the noun word once, whatever it is
            @wordList = $obj->otherNounList;
            @newWordList = ();

            foreach my $word (@wordList) {

                if ($word ne $nounString) {

                    push (@newWordList, $word);
                }
            }
            $otherNounString = join(' ', @newWordList);

            @wordList = ($obj->adjList, $obj->pseudoAdjList);
            @newWordList = ();
            foreach my $word (@wordList) {

                if ($word ne $nounString) {

                    push (@newWordList, $word);
                }
            }
            $adjString = join(' ', @newWordList);

            @wordList = $obj->unknownWordList;
            @newWordList = ();
            foreach my $word (@wordList) {

                if ($word ne $nounString) {

                    push (@newWordList, $word);
                }
            }
            $unknownWordString = join(' ', @newWordList);

            # Display the information, in a single line, in different colours
            #   (Some strings surrounded by square brackets, empty strings aren't displayed at all)
            #   (* means a being that's still alive, - a being that's been killed)
            if (
                $obj->category eq 'char'
                || $obj->category eq 'minion'
                || $obj->category eq 'sentient'
                || $obj->category eq 'creature'
            ) {
                if ($obj->aliveFlag) {
                    $column = '*';
                } else {
                    $column = '-';
                }

            } else {

                $column = ' ';
            }

            # Highlight player characters and minions
            if ($obj->category eq 'char') {

                $colour = 'RED';

            } elsif ($obj->category eq 'minion') {

                if ($obj->ownMinionFlag) {
                    $colour = 'CYAN';
                } else {
                    $colour = 'GREN';
                }

            } else {

                $colour = 'white';
            }

            if (! $self->showParsedFlag) {

                # Show the object before it was parsed, highlighing the noun in yellow
                $posn = index($obj->baseString, $obj->noun);

                if ($posn == -1 || $colour ne 'white' || $obj->noun eq '') {

                    # The actual noun seems to be missing, for some reason
                    # Also, don't highlight the noun if it's a player character or minion
                    $self->insertText($column . $obj->baseString, $colour);

                } else {

                    $before = substr($obj->baseString, 0, $posn);
                    if ($before ne '') {

                        $self->insertText($column . $before, 'white');

                        $self->insertText(
                            substr($obj->baseString, $posn, length($obj->noun)),
                            'echo',
                            'yellow',
                        );

                    } else {

                        $self->insertText(
                            $column . substr($obj->baseString, $posn, length($obj->noun)),
                            'yellow',
                        );
                    }

                    $after = substr($obj->baseString, ($posn + length($obj->noun)));
                    if ($after ne '') {

                        $self->insertText($after, 'echo', 'white');
                    }
                }

            } else {

                # Show information about the object after it was parsed
                $self->insertText($column . $nounString, $colour);

                if ($otherNounString) {

                    $self->insertText(' ' . $otherNounString, 'echo', 'yellow');
                }

                if ($adjString) {

                    $self->insertText(' ' . $adjString, 'echo', 'green');
                }

                if ($unknownWordString) {

                    $self->insertText(' ' . $unknownWordString, 'echo', 'magenta');
                }
            }

            if ($multiple != 1 && $multiple > 0) {      # So, can include 0.333, 0.5, etc

                $self->insertText(' [' . $multiple . ']', 'echo', 'white');

            } elsif ($multiple == -1) {

                $self->insertText(' [m]', 'echo', 'white');
            }
        }

        # Display the number of corpses/body parts, if they were removed from @objList
        if ($corpseCount) {

            $self->insertText('-corpse', 'red');
            if ($corpseCount > 1 || $bodyPartCount) {

                $self->insertText(' [' . $corpseCount . ']', 'echo', 'red');
            }
        }

        if ($bodyPartCount) {

            if ($corpseCount) {
                $self->insertText(' body part', 'echo', 'red');
            } else {
                $self->insertText(' body part', 'red');
            }

            if ($bodyPartCount > 1 || $corpseCount) {

                $self->insertText(' [' . $bodyPartCount . ']', 'echo', 'red');
            }
        }

        return 1;
    }

    sub prepareTitleBar {

        # Called by various functions, including $self->refreshWin
        # Prepares text to display in the task window's title bar (if open), and then displays it
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->prepareTitleBar', @_);
        }

        # v1.1.093 The number of expected room statements is no longer displayed in the automapper
        #   window
        if (! $self->moveList) {

            # No more moves expected. To make the title bar more pleasant to look out, display a
            #   hyphen rather than nothing
            $self->setTaskWinTitle(' - ', TRUE);
#            # Also update the Automapper window's title bar, if it's open
#            if ($self->session->mapWin) {
#
#                $self->session->mapWin->setWinTitle();
#            }

        } else {

            $self->setTaskWinTitle(' ' . scalar $self->moveList . ' ', TRUE);
#            # Also update the Automapper window's title bar, if it's open (and not in 'wait' mode)
#            if ($self->session->mapWin && $self->session->mapWin->mode ne 'wait') {
#
#                $self->session->mapWin->setWinTitle(' (' . scalar $self->moveList . ')');
#            }
        }

        return 1;
    }

    sub combineDuplicates {

        # Called by $self->refreshWin
        # If there are five torches in the room, we want to display them on a single line, but if
        #   there are five orcs in the room, we want to display them on separate lines so the user
        #   can see which are alive and which are dead
        # This function is called with a list of all the objects in the current room's
        #   ->tempObjList which aren't alive and aren't corpses or body parts
        # It compares the objects in the list against each other, trying to eliminate duplicates.
        #   However, we can't set an object's ->multiple, because ->refreshWin (and therefore this
        #   function) might be called more than once; instead, we return a hash in the form
        #   $hash{blessed_reference_to_non_model_object} = multiple
        # When an object is processed, its blessed reference is added to the hash, with the
        #   corresponding multiple set to 1
        # When an object is processed that matches one already in the hash, the object is eliminated
        #   and the hash object's multiple is increased
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   @objList    - A list of objects from the current room's ->tempObjList (which may be
        #                   empty)
        #
        # Return values
        #   The hash of objects described above (in which duplicates have been removed)

        my ($self, @objList) = @_;

        # Local variables
        my (%emptyHash, %newHash, %unStringHash);

        # (No check for improper arguments)

        # The list of objects can be empty
        if (! @objList) {

            return %emptyHash;
        }

        # Otherwise, process each item in @objList in turn
        # The first item is added to %newHash
        # Subsequent items are compared against the items in %newHash. If the subsequent item is a
        #   duplicate of anything already in %newHash, it is eliminated. Otherwise, it is added to
        #   %newHash
        # (Actually, we use a second hash so that we can translate the stringified blessed
        #   references stored as keys in %newHash into the actual blessed references)
        do {

            my ($obj, $flag);

            $obj = shift @objList;

            # Don't try to combine duplicates of objects whose ->multiple is already not 1
            if ($obj->multiple == 1) {

                # Compare this item against every object in @newList
                OUTER: foreach my $newObj (values %unStringHash) {

                    # Are the two objects exactly the same?
                    if ($self->session->worldModelObj->objCompare(100, $obj, $newObj)) {

                        # We have a match. The keys in %newHash are non-model objects; the
                        #   corresponding values are the number of things this object represents
                        $newHash{$newObj} = $newHash{$newObj} + 1;
                        $flag = TRUE;

                        last OUTER;
                    }
                }
            }

            if (! $flag) {

                # $obj isn't a duplicate of anything already in %newHash, so add it to the hash
                $newHash{$obj} = 1;
                # (Need an un-stringified version of the object, too)
                $unStringHash{$obj} = $obj;
            }

        } until (! @objList);

        return %newHash;
    }

    sub countLivingObjects {

        # Can be called by anything
        # Counts the number of (known) living objects in the current room
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $minionFlag     - If set to TRUE, minions are ignored. If set to FALSE (or 'undef'),
        #                       minions are included
        #
        # Return values
        #   'undef' on improper arguments or if the current room isn't known
        #   Otherwise returns the number of objects whose ->aliveFlag is TRUE (includes 'character',
        #       'minion', 'sentient', 'creature' objects and perhaps also some 'custom' objects).
        #       The number returned may be 0

        my ($self, $minionFlag, $check) = @_;

        # Local variables
        my $count;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->countLivingObjects', @_);
        }

        if (! $self->roomObj || ! $self->roomObj->tempObjList) {

            # No objects to count
            return 0;
        }

        # Count the living objects
        $count = 0;
        foreach my $obj ($self->roomObj->tempObjList) {

            if (
                $obj->aliveFlag
                && (! $minionFlag || $obj->category ne 'minion')
            ) {
                $count++;
            }
        }

        return $count;
    }

    sub resetModelRoom {

        # Called by GA::Obj::WorldModel->deleteRooms and GA::Obj::Map->openWin
        # Resets the room model object number stored by the task, when it is known (so that the task
        #   no longer knows the automapper's current room)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetModelRoom', @_);
        }

        # Update IVs
        $self->ivUndef('modelNumber');
        if ($self->roomObj && $self->roomObj->roomTag) {

            $self->roomObj->ivUndef('roomTag');
        }

        # Update the task window
        $self->refreshWin();

        return 1;
    }

    sub resetMoveList {

        # Called by GA::Win::Map->addFailedExitCallback or by any other function
        # Resets the two IVs which tell the task how many room statements to expect, and then
        #   updates the task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetModelRoom', @_);
        }

        # Update IVs
        $self->ivEmpty('cmdObjList');
        $self->ivEmpty('moveList');
        $self->ivUndef('prevCmdBufferNum');
        # Update the task window
        $self->refreshWin();

        return 1;
    }

    sub usePseudoStatement {

        # Called by GA::Obj::Map->setCurrentRoom and ->pseudoWorldCmd when the current session's
        #   status is 'offline'
        # The calling function has constructed a pseudo-statement - an approximation of the text
        #   sent by the world, when the character visits the automapper's current room
        # Update this task's IVs, as if $self->processLine had spotted a room statement in the text
        #   actually received from the world
        #
        # Expected arguments
        #   $dictObj        - The session's current dictionary
        #   $modelRoomObj   - The automapper's current room (a GA::ModelObj::Room object)
        #   $anchorLine     - The display buffer line which is the pseudo-statement's anchor line
        #                       (matches a key in GA::Session->displayBufferHash)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $dictObj, $modelRoomObj, $anchorLine, $check) = @_;

        # Local variables
        my $tempRoomObj;

        # Check for improper arguments
        if (
            ! defined $dictObj || ! defined $modelRoomObj || ! defined $anchorLine
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->usePseudoStatement', @_);
        }

        # $self->processLines should not look for anchor lines in any of the text 'received' from
        #   the world
        $self->ivPoke('lastBufferLine', $self->session->displayBufferLast);
        $self->ivPoke('lastAnchorLine', $anchorLine);
        $self->ivPoke('lastStatementEndLine', $self->session->displayBufferLast);
        # (Use the same line as the 'start' of the room statement, while in offline mode. Luckily,
        #   nothing uses ->lastStatementStartLine at the moment)
        $self->ivPoke('lastStatementStartLine', $self->session->displayBufferLast);

        # Create a new non-model room, whose properties will be set to match $modelRoomObj
        $tempRoomObj = Games::Axmud::ModelObj::Room->new(
            $self->session,
            'temporary',
            'non_model',
        );

        # Store it as the Locator task's current room
        $self->ivPoke('roomObj', $tempRoomObj);
        $self->ivPoke('modelNumber', $modelRoomObj->number);

        # Update its properties
        $tempRoomObj->ivPoke('titleList', $modelRoomObj->titleList);
        $tempRoomObj->ivPoke('descripHash', $modelRoomObj->descripHash);

        # (For non-model rooms, ->sortedExitList shouldn't contain hidden exits)
        foreach my $exitName ($modelRoomObj->sortedExitList) {

            my ($exitNum, $exitObj, $pseudoExitObj);

            $exitNum = $modelRoomObj->ivShow('exitNumHash', $exitName);
            $exitObj = $self->session->worldModelObj->ivShow('exitModelHash', $exitNum);

            if (! $exitObj->hiddenFlag) {

                $tempRoomObj->ivPush('sortedExitList', $exitName);
            }

            $pseudoExitObj = $self->clonePseudoExit($exitObj);
            if ($pseudoExitObj) {

                $tempRoomObj->ivAdd('exitNumHash', $exitName, $pseudoExitObj);
            }
        }

        # (Create a list of 'temporary' objects from $modelRoomObj's (permanent) child object list)
        foreach my $childNum ($modelRoomObj->ivKeys('childHash')) {

            my (
                $childObj,
                @thisList,
            );

            $childObj = $self->session->worldModelObj->ivShow('modelHash', $childNum);

            # There is no world model object-cloning code, so we'll cheat and use each object's
            #   original base string (e.g. 'big hairy orc') and process the string to create a new
            #   non-model object
            @thisList = $self->session->worldModelObj->parseObj(
                $self->session,
                TRUE,               # Treat '5 coins' as a single object
                $childObj->baseString,
            );

            if (@thisList) {

                $tempRoomObj->ivPush('tempObjList', @thisList);
            }
        }

        # (Code adapated from $self->processLine, parts 8-10)
        if (! $self->roomCount) {

           # First room statement found - in future, search forwards, not backwards
            $self->ivPoke('roomCount', 1);

            # If the room has a room tag, display it
            if ($modelRoomObj->roomTag) {

                $self->roomObj->ivPoke('roomTag', $modelRoomObj->roomTag);
            }
        }

        # Display information about the pseudo-room statement in the task window (if it's open and
        #   enabled)
        if ($self->taskWinFlag) {

            $self->refreshWin();
        }

        return 1;
    }

    sub clonePseudoExit {

        # Called by $self->usePseudoStatement to create a non-model exit for the non-model room
        #   stored in $self->roomObj, based on the properties of a model exit
        #
        # Expected arguments
        #   $modelExitObj   - The exit to clone
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise, returns the blessed reference of the new non-model exit object

        my ($self, $modelExitObj, $check) = @_;

        # Local variables
        my $cloneExitObj;

        # Check for improper arguments
        if (! defined $modelExitObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->clonePseudoExit', @_);
        }

        # Create a non-model exit object
        $cloneExitObj = Games::Axmud::Obj::Exit->new(
            $self->session,
            $modelExitObj->dir,     # Nominal direction
            'non_model',            # Non-exit model object
        );

        # Clone the model exit's properties
        $cloneExitObj->ivPoke('exitType', $modelExitObj->exitType);
        $cloneExitObj->ivPoke('hiddenFlag', $modelExitObj->hiddenFlag);
        $cloneExitObj->ivPoke('exitOrnament', $modelExitObj->exitOrnament);
        $cloneExitObj->ivPoke('exitState', $modelExitObj->exitState);
        $cloneExitObj->ivPoke('exitInfo', $modelExitObj->exitInfo);

        return $cloneExitObj;
    }

    sub insertLook {

        # Called by GA::Cmd::InsertLook->do
        # Updates the task's IVs as if the user had typed 'look', so that the task is expecting an
        #   extra room statement
        # Useful at worlds which send unsolicited room statements (e.g. EmpireMud, at dawn and
        #   dusk). The user can set up a trigger to look out for these events. The trigger response
        #   should be to use the client command ';insertlook', which calls this function
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if there's an error
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my ($cage, $bufferObj);

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->insertLook', @_);
        }

        # Get the session's highest-priority command cage
        $cage = $self->session->findHighestCage('cmd');

        # Create a fake buffer object
        $bufferObj = Games::Axmud::Buffer::Cmd->new(
            $self->session,
            'session',
            # Not a real world command
            -1,
            $self->session->findCmd('look'),
            $self->session->sessionTime,
        );

        if (! $bufferObj) {

            return undef;

        } else {

            $bufferObj->interpretCmd($cage);
            $self->add_cmdObj($bufferObj);

            return 1;
        }
    }

    sub insertFailedExit {

        # Called by GA::Cmd::InsertFailedExit->do, when there is exactly one item in $self->moveList
        #   (meaning that the task is expecting a single room statement after a movement or look/
        #   glance command)
        # Looks at the first line of text received from the world after the movement/look/glance
        #   command was sent, and assumes it's a failed message, updating the task's own IVs
        #   accordingly (so it's no longer expecting a room statement)
        # Optionally adds that message to the list in the current world or current room
        #
        # Expected arguments
        #   $mode   - 'room' to update the current room's list of failed exit messages, 'world'
        #               to update the current world profile's list, or 'update' to update neither
        #               list
        #
        # Return values
        #   'undef' on improper arguments or if there's an error
        #   1 otherwise

        my ($self, $mode, $check) = @_;

        # Local variables
        my ($bufferObj, $text, $regex);

        # Check for improper arguments
        if (
            ! defined $mode
            || ($mode ne 'room' && $mode ne 'world' && $mode ne 'update')
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->insertFailedExit', @_);
        }

        # Check we're expecting a single room statement from a single move/look/glance command
        if ((scalar $self->moveList) != 1 && defined $self->prevCmdBufferNum) {

            return undef;

        # $self->prevCmdBufferNum specifies the buffer number of the first line of text received
        #   from the world after that command was processed. Check it's actually been received
        } elsif (! $self->session->ivExists('displayBufferHash', $self->prevCmdBufferNum)) {

            return undef;
        }

        # Retrieve the line of text, shorten it to a reasonable size (currently 40 characters) and
        #   convert it to a regex
        $bufferObj = $self->session->ivShow('displayBufferHash', $self->prevCmdBufferNum);
        $text = substr($bufferObj->stripLine, 0, 40);
        if ($text eq '') {

            return undef;
        }

        $regex = '^' . quotemeta($text);

        # Update failed exit lists, if required too
        if ($mode eq 'room' && $self->session->mapObj->currentRoom) {
            $self->session->mapObj->currentRoom->ivPush('failExitPatternList', $regex);
        } elsif ($mode eq 'world') {
            $self->session->currentWorld->ivPush('failExitPatternList', $regex);
        }

        # The task is no longer expecting any room statements
        $self->resetMoveList();

        return 1;
    }

    sub useRoomCmd {

        # Called by GA::Cmd::RoomCommand->do
        # Removes the first room command from the current room's list, executes it as a world
        #   command, then moves it to the end of the list (so room commands can be executed in a
        #   continuous cycle)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if there is no current room or if the current room has
        #       no room commands
        #   Otherwise returns the room command executed

        my ($self, $check) = @_;

        # Local variables
        my $cmd;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->useRoomCmd', @_);
        }

        if (! $self->roomObj || ! $self->roomObj->tempRoomCmdList) {

            return undef;
        }

        $cmd = $self->roomObj->ivShift('tempRoomCmdList');
        $self->session->worldCmd($cmd);
        $self->roomObj->ivPush('tempRoomCmdList', $cmd);

        return $cmd;
    }

    ##################
    # Response methods

    ##################
    # Accessors - set

    sub set_arrivalTag {

        my ($self, $tag, $check) = @_;

        # Check for improper arguments
        if (defined $check) {           # ($arrivalTag can be 'undef')

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_arrivalTag', @_);
        }

        $self->ivPoke('arrivalTag', $tag);

        return 1;
    }

    sub add_cmdObj {

        my ($self, $obj, $check) = @_;

        # Check for improper arguments
        if (! defined $obj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_cmdObj', @_);
        }

        # If no room statements are expected, store the display buffer number of the first line of
        #   text that will be received after the command is sent to the world
        if (! $self->moveList) {
            $self->ivPoke('prevCmdBufferNum', $self->session->displayBufferCount);
        } else {
            $self->ivUndef('prevCmdBufferNum');
        }

        $self->ivPush('cmdObjList', $obj);
        if ($obj->lookFlag || $obj->glanceFlag || $obj->moveFlag || $obj->followFlag) {

            # It's a look, glance or movement command (including redirect mode commands and
            #   assisted moves)
            $self->ivPush('moveList', $obj);
            # Update the task window's title bar (if open)
            $self->prepareTitleBar();
        }

        return 1;
    }

    sub reset_failExitFlag {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_failExitFlag', @_);
        }

        $self->ivPoke('failExitFlag', FALSE);

        return 1;
    }

    sub reset_involuntaryExitFlag {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->reset_involuntaryExitFlag',
                @_,
            );
        }

        $self->ivPoke('involuntaryExitFlag', FALSE);

        return 1;
    }

    sub set_lastBufferLine {

        my ($self, $line, $check) = @_;

        # Check for improper arguments
        if (! defined $line || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_lastBufferLine', @_);
        }

        $self->ivPoke('lastBufferLine', $line);

        return 1;
    }

    sub set_manualResetFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_manualResetFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('manualResetFlag', TRUE);
        } else {
            $self->ivPoke('manualResetFlag', FALSE);
        }

        return 1;
    }

    sub set_modelNumber {

        my ($self, $number, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_modelNumber', @_);
        }

        # Update IVs
        $self->ivPoke('modelNumber', $number);      # Can be 'undef'

        return 1;
    }

    sub set_updateFromMsdp {

        # Called by GA::Session->processMsdpData
        # When the server (world) reports an MSDP variable and its value, the session calls this
        #   function, so that we can update the Locator task's mapping variables (where appropriate)
        #
        # Expected arguments
        #   $var    - An MSDP variable, e.g. ROOM (the calling function shouldn't send other
        #               variables, but if it does, this function ignores them)
        #   $val    - The corresponding value; may be a scalar, or a list/hash reference
        #   $flag   - Set to TRUE if this is a generic MSDP variable, FALSE if it is a custom
        #               MSDP variable
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $var, $val, $flag, $check) = @_;

        # Local variables
        my (
            $mode,
            %hash, %roomHash, %exitHash,
        );

        # Check for improper arguments
        if (! defined $var || ! defined $val || ! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_updateFromMsdp', @_);
        }

        if ($axmud::CLIENT->debugMsdpFlag) {

            $self->session->writeDebug('LOCATOR MSDP: var ' . $var . ', val ' . $val);
        }

        # Protect against the world sending nested arrays/tables when we're expecting a scalar
        if (ref $val eq 'ARRAY') {
            $mode = 'array';
        } elsif (ref $val eq 'HASH') {
            $mode = 'hash';
        } else {
            $mode = 'scalar';
        }

        # At the moment, only the generic variables 'ROOM' is used; the corresponding value should
        #   be a hash reference
        if ($flag && $var eq 'ROOM' && $mode eq 'hash') {

            %hash = %$val;

            foreach my $thisKey (keys %hash) {

                my (
                    $thisValue,
                    %coordHash, %dirHash,
                );

                $thisValue = $hash{$thisKey};

                if ($thisKey eq 'VNUM') {

                    $roomHash{'vnum'} = $thisValue;

                } elsif ($thisKey eq 'NAME') {

                    $roomHash{'name'} = $thisValue;

                } elsif ($thisKey eq 'AREA') {

                    $roomHash{'area'} = $thisValue;

                } elsif ($thisKey eq 'COORDS') {

                    # $thisValue is a nested table
                    %coordHash = %$thisValue;

                    foreach my $thatKey (keys %coordHash) {

                        my $thatValue = $coordHash{$thatKey};

                        if ($thatKey eq 'X') {
                            $roomHash{'xpos'} = $thatValue;
                        } elsif ($thatKey eq 'Y') {
                            $roomHash{'ypos'} = $thatValue;
                        } elsif ($thatKey eq 'Z') {
                            $roomHash{'zpos'} = $thatValue;
                        }
                    }

                } elsif ($thisKey eq 'TERRAIN') {

                    $roomHash{'terrain'} = $thisValue;

                } elsif ($thisKey eq 'EXITS') {

                    # $thisValue is a nested table
                    %dirHash = %$thisValue;

                    foreach my $thatKey (keys %dirHash) {

                        my $thatValue = $dirHash{$thatKey};

                        # $exitHash{abbrev_dir} = destination_room_vnum
                        $exitHash{$thatKey} = $thatValue;
                    }
                }
            }

            # Update IVs; if no ROOM data was delivered, %roomHash and %exitHash will be empty
            if ($self->roomObj) {

                $self->roomObj->ivPoke('protocolRoomHash', %roomHash);
                $self->roomObj->ivPoke('protocolExitHash', %exitHash);
            }
        }

        # Refresh the task window (in case the room's MSDP data arrives after the room statement
        #   has been processed)
        $self->refreshWin();

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub roomCount
        { $_[0]->{roomCount} }
    sub lastBufferLine
        { $_[0]->{lastBufferLine} }
    sub lastAnchorLine
        { $_[0]->{lastAnchorLine} }
    sub lastStatementEndLine
        { $_[0]->{lastStatementEndLine} }
    sub lastStatementStartLine
        { $_[0]->{lastStatementStartLine} }
    sub restartBufferLine
        { $_[0]->{restartBufferLine} }
    sub roomObj
        { $_[0]->{roomObj} }
    sub prevRoomObj
        { $_[0]->{prevRoomObj} }
    sub modelNumber
        { $_[0]->{modelNumber} }
    sub weatherHash
        { my $self = shift; return %{$self->{weatherHash}}; }
    sub showParsedFlag
        { $_[0]->{showParsedFlag} }

    sub ttsToReadHash
        { my $self = shift; return %{$self->{ttsToReadHash}}; }

    sub useMxpFlag
        { $_[0]->{useMxpFlag} }
    sub mxpPropHash
        { my $self = shift; return %{$self->{mxpPropHash}}; }
    sub mxpPropCurrentHash
        { my $self = shift; return %{$self->{mxpPropCurrentHash}}; }
    sub mxpPropStartLine
        { $_[0]->{mxpPropStartLine} }

    sub tempContentsList
        { my $self = shift; return @{$self->{tempContentsList}}; }

    sub cmdObjList
        { my $self = shift; return @{$self->{cmdObjList}}; }
    sub moveList
        { my $self = shift; return @{$self->{moveList}}; }
    sub prevMoveObj
        { $_[0]->{prevMoveObj} }
    sub prevMove
        { $_[0]->{prevMove} }
    sub prevCmdBufferNum
        { $_[0]->{prevCmdBufferNum} }
    sub failExitFlag
        { $_[0]->{failExitFlag} }
    sub involuntaryExitFlag
        { $_[0]->{involuntaryExitFlag} }
    sub arrivalTag
        { $_[0]->{arrivalTag} }

    sub manualResetFlag
        { $_[0]->{manualResetFlag} }
    sub autoLookMode
        { $_[0]->{autoLookMode} }

    sub winDescripLimit
        { $_[0]->{winDescripLimit} }
    sub combineCorpseFlag
        { $_[0]->{combineCorpseFlag} }
    sub combineBodyPartFlag
        { $_[0]->{combineBodyPartFlag} }
}

{ package Games::Axmud::Task::MapCheck;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the MapCheck task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'map_check_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'map_check_task';
        $self->{prettyName}             = 'MapCheck';
        $self->{shortName}              = 'MC';
        $self->{shortCutIV}             = undef;

        $self->{category}               = 'process';
        $self->{descrip}                = 'Display summary of automapper window drawing activities';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = TRUE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #   (none for this task)

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

#   sub clone {}                # Inherited from generic task

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

#   sub doFirstStage {}         # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            if (! $self->session->mapWin) {

                # Continue waiting
                return $self->ivPoke('stage', 2);

            } else {

                return $self->ivPoke('stage', 3);
            }

        } elsif ($self->stage == 3) {

            my (
                $mapWin, $string,
                @lineList,
            );

            # Import the automapper window (for convenience)
            $mapWin = $self->session->mapWin;

            # On every spin of the task loop, update the task window (if it is still open)
            if (! $mapWin) {

                # Clear the task window, and wait for the automapper window to re-open
                $self->insertQuick();

                return $self->ivPoke('stage', 2);

            } elsif ($self->taskWinFlag) {

                # Add the header
                push (@lineList,
                    'MAPCHECK (Time ' . sprintf("%.3f", $self->session->sessionTime) . ')',
                );

                $string = '   Current region: ';

                if (! defined $mapWin->currentRegionmap) {
                    $string .= 'not set';
                } else {
                    $string .= $mapWin->currentRegionmap->name;
                }

                push (@lineList, $string);

                $string = '   Recent regions: ';
                if (! $mapWin->recentRegionList) {
                    $string .= 'not set';
                } else {
                    $string .= join('/', $mapWin->recentRegionList);
                }

                push (@lineList, $string);

                if (! $mapWin->parchmentHash) {

                    push (@lineList, 'Regions drawn: (none)');

                } else {

                    push (
                        @lineList,
                        'Regions drawn: ' . (scalar $mapWin->ivKeys('parchmentHash')),
                    );

                    foreach my $name (sort {lc($a) cmp lc($b)} ($mapWin->ivKeys('parchmentHash'))) {

                        if (! $mapWin->ivExists('parchmentReadyHash', $name)) {
                            push (@lineList, '   ' . $name);
                        } else {
                            push (@lineList, ' * ' . $name);
                        }
                    }
                }

                push (@lineList, 'Region queue: ' . (scalar $mapWin->parchmentQueueList));
                foreach my $obj ($mapWin->parchmentQueueList) {

                    my $count = $obj->ivPairs('queueRoomEchoHash')
                        + $obj->ivPairs('queueRoomBoxHash')
                        + $obj->ivPairs('queueRoomTextHash')
                        + $obj->ivPairs('queueRoomExitHash')
                        + $obj->ivPairs('queueRoomInfoHash')
                        + $obj->ivPairs('queueLabelHash');

                    push (
                        @lineList,
                        '   ' . $obj->name . ' : ' . $count,
                        '      Ec ' . $obj->ivPairs('queueRoomEchoHash')
                        . ' Bo ' . $obj->ivPairs('queueRoomBoxHash')
                        . ' Te ' . $obj->ivPairs('queueRoomTextHash')
                        . ' Ex ' . $obj->ivPairs('queueRoomExitHash')
                        . ' In ' . $obj->ivPairs('queueRoomInfoHash')
                        . ' La ' . $obj->ivPairs('queueLabelHash')
                    );
                }

                # Display the list in the task window
                $self->insertQuick(join("\n", @lineList));
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    ##################
    # Response methods

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    # No task parameters
}

{ package Games::Axmud::Task::Notepad;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the Notepad task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'notepad_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'notepad_task';
        $self->{prettyName}             = 'Notepad';
        $self->{shortName}              = 'Np';
        $self->{shortCutIV}             = 'notepadTask';    # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Shows persistent notes for each current profile';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pseudo', 'grid'];
        $self->{winmap}                 = 'basic_empty';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = undef;
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # Table objects created for various Gtk3 widgets, stored here because various functions
        #   need them
        $self->{textTableObj}           = undef;
        $self->{comboTableObj}          = undef;

        # When the task starts (or is reset), the current contents of all current profiles'
        #   ->notepadList IVs are stored in this hash, so that if the user clicks the 'Restore
        #   notes' button, the contents of the textview can be magically reset to what it was, when
        #   the task started. Hash in the form
        #   $profNoteHash{profile_name} = reference_to_list_of_notes
        $self->{profNoteHash}           = {};

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{textTableObj}          = $self->textTableObj;
        $clone->{comboTableObj}         = $self->comboTableObj;

        $clone->{profNoteHash}          = {$self->profNoteHash};

        # Cloning complete
        return $clone;
    }

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # Store the current state of all current profiles' ->notepadList, so that if the user clicks
        #   the 'Restore notes' button, the contents of the textview can be magically reset to what
        #   it was, when the task started
        foreach my $profObj ($self->session->ivValues('currentProfHash')) {

            my @list = $profObj->notepadList;

            $self->ivAdd('profNoteHash', $profObj->name, \@list);
        }

        # If the task has recently been updated, quickest way to update the window is to remove all
        #   the current table objects, before creating new ones
        if ($self->hasResetFlag) {

            if (! defined $self->winObj->tableStripObj->removeAllTableObjs()) {

                # Operation failed; task must close
                $self->ivPoke('shutdownFlag', TRUE);
                return 1;
            }
        }

        # Create widgets for the task
        $self->createWidgets();

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    sub createWidgets {

        # Set up the widgets used in the task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $string, $textTableObj, $comboTableObj,
            @comboList,
            %profHash,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->createWidgets', @_);
        }

        # Add a textview to display the notes
        if ($self->session->currentWorld->notepadList) {
            $string = join("\n", $self->session->currentWorld->notepadList);
        } else {
            $string = '';
        }

        # I experimented with the table coordinates to stop the combobox/buttons appearing too
        #   high. For unknown reasons, the best arrangement is to leave a one-row gap between the
        #   textview and the combobox/buttons beneath it
        $textTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::TextView',
            0, 59, 0, 57,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('textViewCallback'),
            'text'          => $string,
            'edit_flag'     => TRUE,
            'colour_scheme' => 'edit',
            'spacing'       => 0,
        );

        # Add a combo to switch between profiles, displaying a list of current profiles in reverse
        #   priority order (so that the current world is always at the top)
        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Label',
            0, 4, 58, 59,
            undef,
            # Init settings
            'text'          => 'Profile:      ',
        );

        %profHash = $self->session->currentProfHash;
        foreach my $category ($self->session->profPriorityList) {

            my $profObj = $profHash{$category};

            if (defined $profObj) {

                delete $profHash{$category};
                push (@comboList, $profObj->name);
            }
        }

        @comboList = reverse @comboList;        # Current world to top of list
        foreach my $profObj (sort {lc($a->name) cmp lc($b->name)} (values %profHash)) {

            push (@comboList, $profObj->name);
        }

        $comboTableObj = $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::ComboBox',
            5, 24, 58, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('comboCallback'),
            'list_ref'      => \@comboList,
        );

        # Add buttons - one to empty the textview of notes, another to restore the displayed notes
        #   to what they were, when the task started (or was last reset)
        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            30, 39, 58, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('clipboardCallback'),
            'text'          => 'Paste',
            'tooltips'      => 'Insert any selected text in the session\'s default tab',
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            40, 49, 58, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('emptyCallback'),
            'text'          => 'Empty',
            'tooltips'      => 'Empty the notes for this profile',
        );

        $self->winObj->tableStripObj->addTableObj(
            'Games::Axmud::Table::Button',
            50, 59, 58, 59,
            undef,
            # Init settings
            'func'          => $self->getMethodRef('restoreCallback'),
            'text'          => 'Restore',
            'tooltips'      => 'Restore the notes that existed when this task started',
        );

        # Store the widgets created, so that callbacks can access them
        $self->ivPoke('textTableObj', $textTableObj);
        $self->ivPoke('comboTableObj', $comboTableObj);

        # Display all the widgets
        $self->winObj->winShowAll($self->_objClass . '->createWidgets');

        return 1;
    }

    ##################
    # Response methods

    sub textViewCallback {

        # Called by a ->signal_connect in a GA::Table::TextView object when the user edits the
        #   textview's contents
        # Stores the textview's contents in the selected profile's IV
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::TextView object for the textview
        #   $textView   - The actual Gtk3::TextView edited
        #   $buffer     - The textview's Gtk3::Buffer
        #   $id         - The callback ID; in this case, an empty string
        #   $text       - The contents of the buffer (a string containing one or more lines
        #                   separated by newline characters)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $textView, $buffer, $id, $text, $check) = @_;

        # Local variables
        my ($profile, $profObj);

        # Check for improper arguments
        if (
            ! defined $tableObj || ! defined $textView || ! defined $buffer || ! defined $id
            || ! defined $text || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->textViewCallback', @_);
        }

        # Get the combobox's active profile
        $profile = $self->comboTableObj->get_text();
        $profObj = $self->session->ivShow('profHash', $profile);

        # Update the selected profile's IV
        if ($profObj) {

            if ($text) {
                $profObj->ivPoke('notepadList', split("\n", $text));
            } else {
                $profObj->ivEmpty('notepadList');
            }
        }

        return 1;
    }

    sub comboCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user selects a
        #   profile in the combobox
        # Updates the contents of the textview to show notes for that profile
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::ComboBox object for the combobox
        #   $comboBox   - The actual Gtk3::ComboBox used
        #   $id         - The callback ID; in this case, an empty string
        #   $profile    - The selected world profile
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $comboBox, $id, $profile, $check) = @_;

        # Local variables
        my $profObj;

        # Check for improper arguments
        if (
            ! defined $tableObj || ! defined $comboBox || ! defined $id || ! defined $profile
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->comboCallback', @_);
        }

        # Get the corresponding profile object
        $profObj = $self->session->ivShow('profHash', $profile);

        # Update the textview
        if ($profObj) {

            $self->textTableObj->set_text(join("\n", $profObj->notepadList));
        }

        return 1;
    }

    sub clipboardCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on the
        #   'Empty notes' button
        # Pastes the contents of the clipboard into the textview
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, a keycode
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my ($textViewObj, $startIter, $endIter, $slice);

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->clipboardCallback', @_);
        }

        $textViewObj = $self->session->defaultTabObj->textViewObj;
        ($startIter, $endIter) = $textViewObj->buffer->get_selection_bounds();
        if (defined $endIter) {

            $slice = $textViewObj->buffer->get_slice($startIter, $endIter, FALSE);

            if (defined $slice && $slice ne '') {

                $self->textTableObj->insert_text($slice);
            }

            return 1;
        }
    }

    sub emptyCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on the
        #   'Empty' button
        # Empties the textview and updates the selected profile's IV
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, a keycode
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->emptyCallback', @_);
        }

        # Empty the textview
        $self->textTableObj->set_text();

        return 1;
    }

    sub restoreCallback {

        # Called by a ->signal_connect in a GA::Table::Button object when the user clicks on the
        #   'Restore' button
        # Restores the notes displayed in the textview for the current profile, when the task
        #   started (or was last reset)
        #
        # Expected arguments
        #   $tableObj   - The GA::Table::Button object for the button
        #   $button     - The actual Gtk3::Button clicked
        #   $id         - The callback ID; in this case, a keycode
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $tableObj, $button, $id, $check) = @_;

        # Local variables
        my $listRef;

        # Check for improper arguments
        if (! defined $tableObj || ! defined $button || ! defined $id || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->restoreCallback', @_);
        }

        # Reset the textview
        $listRef = $self->ivShow('profNoteHash', $self->comboTableObj->get_text());
        if (defined $listRef) {

            $self->textTableObj->set_text(join("\n", @$listRef));
        }

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub textTableObj
        { $_[0]->{textTableObj} }
    sub comboTableObj
        { $_[0]->{comboTableObj} }

    sub profNoteHash
        { my $self = shift; return %{$self->{profNoteHash}}; }
}

{ package Games::Axmud::Task::RawText;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the RawText task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'raw_text_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'raw_text_task';
        $self->{prettyName}             = 'RawText';
        $self->{shortName}              = 'RT';
        $self->{shortCutIV}             = 'rawTextTask';    # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Displays all incoming text, including escape sequences';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #   (none)

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

#   sub clone {}                # Inherited from generic task

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        # If raw text (from before the login sequence was completed) is waiting to be displayed in
        #   this task's window, display it
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my @list;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        @list = $self->session->rawTextBufferList;

        if ($self->winObj && @list) {

            do {

                my ($count, $packetText);

                $count = shift @list;
                $packetText = shift @list;

                $self->insertText('<' . $count . '>', 'WHITE', 'ul_red', 'echo');

                foreach my $string (split(m/\n/, $packetText)) {

                    $self->insertText($string, 'after');
                }

            } until (! @list);
        }

        $self->session->reset_rawTextBufferList();

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    ##################
    # Response methods

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get
}

{ package Games::Axmud::Task::RawToken;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the RawToken task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'raw_token_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'raw_token_task';
        $self->{prettyName}             = 'RawToken';
        $self->{shortName}              = 'Rn';
        $self->{shortCutIV}             = 'rawTokenTask';   # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Displays all incoming text, split into tokens';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        # If TRUE, tokens are displayed one-per-line. If FALSE, tokens are displayed in their real
        #   lines (i.e., a new line is started after a newline token is displayed)
        $self->{splitLineFlag}          = FALSE;
        # If TRUE, the token type is displayed alongside the token itself. If FALSE, only the token
        #   is displayed
        $self->{showTypeFlag}           = FALSE;
        # If TRUE, the packet number is displayed. If FALSE, it's not displayed
        $self->{countPacketFlag}        = FALSE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{splitLineFlag}         = $self->splitLineFlag;
        $clone->{showTypeFlag}          = $self->showTypeFlag;
        $clone->{countPacketFlag}       = $self->countPacketFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the display flags
        $newTask->ivPoke('splitLineFlag', $self->splitLineFlag);
        $newTask->ivPoke('showTypeFlag', $self->showTypeFlag);
        $newTask->ivPoke('countPacketFlag', $self->countPacketFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        # If raw tokens (from before the login sequence was completed) are waiting to be displayed
        #   in this task's window, display them
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $newlineFlag,
            @list,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        @list = $self->session->rawTokenBufferList;
        $newlineFlag = FALSE;

        if ($self->winObj && @list) {

            do {

                my ($count, $listRef);

                $count = shift @list;
                $listRef = shift @list;

                if ($self->countPacketFlag) {

                    $self->insertText('<' . $count . '>', 'WHITE', 'ul_red');
                }

                if (@$listRef) {

                    # @$listRef is in the form (type, argument, type, argument...) where
                    #   'argument' is usually the token itself
                    do {

                        $newlineFlag = $self->displayRawToken(
                            shift @$listRef,
                            shift @$listRef,
                            $newlineFlag,
                        );

                    } until (! @$listRef);
                }

            } until (! @list);
        }

        $self->session->reset_rawTokenBufferList();

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    sub displayRawToken {

        # Called by GA::Session->displayRawIncomingData to display a single raw token
        # Also called by $self->doInit
        #
        # Expected arguments
        #   $type           - The token type
        #   $arg            - Usually the contents of the token itself (but might also be a list
        #                       reference)
        #   $newlineFlag    - A value preserved between successive calls to this function
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the new value of $newlineFlag (TRUE or FALSE), ready for the next call
        #       to this function

        my ($self, $type, $arg, $newlineFlag, $check) = @_;

        # Local variables
        my (
            $colour, $listRef, $listRef2,
            @argList,
        );

        # Check for improper arguments
        if (! defined $type || ! defined $arg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayRawToken', @_);
        }

        $colour = $self->session->ivShow('constRawHash', $type);

        if ($self->showTypeFlag) {

            push (@argList,
                '[' . $type . ']',
                [
                    'RED',
                ],
            );
        }

        # If $type is 'ctrl' or 'seq', then $arg is a reference to a list; otherwise it's a token
        if ($type eq 'ctrl' || $type eq 'seq') {

            push (@argList,
                $$arg[0],
                [
                    $colour,
                ],
            );

        } elsif ($type eq 'nl') {

            push (@argList,
                'NEWLINE',
                [
                    $colour,
                ],
            );

        } elsif ($type eq 'ga') {

            push (@argList,
                'GOAHEAD',
                [
                    $colour,
                ],
            );

        } else {

            push (@argList,
                $arg,
                [
                    $colour,
                ],
            );
        }

        if (! $newlineFlag) {

            $listRef = $argList[1];
            push (@$listRef, 'echo');

        } else {

            $newlineFlag = FALSE;
        }

        # If the token type is displayed, must display the second string on the same line as the
        #   first
        $listRef2 = $argList[3];
        if (defined $listRef2) {

            push (@$listRef2, 'echo');
        }

        # If a newline token has just been displayed, don't add 'echo' on the next iteration of the
        #   calling function's do loop
        if ($type eq 'nl') {

            $newlineFlag = TRUE;
        }

        # Display this token in the task window
        $self->insertMultipleText(@argList);

        return $newlineFlag;
    }

    ##################
    # Response methods

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub splitLineFlag
        { $_[0]->{splitLineFlag} }
    sub showTypeFlag
        { $_[0]->{showTypeFlag} }
    sub countPacketFlag
        { $_[0]->{countPacketFlag} }
}

{ package Games::Axmud::Task::Script;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Script task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'script_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'script_task';
        $self->{prettyName}             = 'Script';
        $self->{shortName}              = 'Sc';
        $self->{shortCutIV}             = undef;            # Built-in task, but not jealous

        $self->{category}               = 'process';
        $self->{descrip}                = 'Runs ' . $axmud::BASIC_ARTICLE . ' script as a task';
        $self->{jealousyFlag}           = FALSE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = FALSE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = FALSE;
        $self->{winPreferList}          = ['entry', 'grid'];
        $self->{winmap}                 = 'entry_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # How many steps the Axbasic script can complete, before returning control to this task
        #   (if set to 0, the script runs until it encounteres a PAUSE, BREAK or END statement.
        #   Otherwise, control is eventually returned to this task, and the script resumes
        #   execution on the next task loop)
        $self->{stepMax}                = 100;
        # The name of the Axbasic script to run (e.g. 'test'); after calling $self->new, the calling
        #   function must call $self->set_scriptName to set it
        $self->{scriptName}             = undef;
        # The corresponding AB::Script
        $self->{scriptObj}              = undef;

        # When statements like WAITTRIG are executed, the name of the active interface that we're
        #   waiting for. When the interface fires (or the timeout occurs), this IV is reset to
        #   'undef'
        $self->{waitForInterface}       = undef;
        # When statements like WAITHP are executed, details about which task and which statistic
        #   we're waiting for. These values are set by a call to $self->setUpMonitoring()
        # The task to monitor ('locator', 'status' or 'task' for a new task that's been created by
        #   the Axbasic script; 'undef' if not waiting for a task)
        $self->{monitorTask}            = undef;
        # The statistic, stored by that task, to monitor
        $self->{monitorStatistic}       = undef;
        # The minimum value that the statistic should reach before resuming the Axbasic script; a
        #   percentage value in the range 0-100 (not necessary for every statement which sets up
        #   monitoring)
        $self->{monitorTarget}          = undef;
        # An optional timeout, in seconds, after which the Axbasic script resumes execution anyway
        $self->{monitorTimeOut}         = undef;

        # When the ADDGAUGE statement is executed, this task creates 'main' window gauges (each
        #   controlled by a GA::Obj::Gauge object)
        # All of this task's gauges are displayed on the same gauge level, exclusive to the task,
        #   which is created if it doesn't exist
        # Shortcut to the strip object (GA::Strip::GaugeBox) which handles gauges in this session's
        #   'main' window; set whenever this task adds it first gauge
        $self->{gaugeStripObj}          = undef;
        # The unique number of the gauge level object for this task (matches a key in
        #   GA::Strip::GaugeBox->gaugeLevelHash). Set to 'undef' if this task currently has no
        #   gauges
        $self->{gaugeLevel}             = undef;
        # A hash of gauges created by ADDGAUGE and ADDCONGAUGE statements. Hash in the form
        #   $gaugeHash{unique_number} = gauge_object
        #   ...where 'unique_number' is the gauge number specified by the ADDGAUGE/ADDCONGAUGE
        #   statement (not related to GA::Obj::Gauge->number) and 'gauge_object' is the
        #   corresponding GA::Obj::Gauge object
        $self->{gaugeHash}              = {};
        # A hash of status bars created by ADDSTATUS and ADDCONSTATUS statements. Status bars are
        #   displayed in the 'main' window's gauge box, in exactly the same way as gauges, except
        #   that unlike gauges, they are drawn as text only. Hash in the form
        #   $statusBarHash{unique_number} = gauge_object
        #   ...where 'unique_number' is the status bar number specified by the ADDSTATUS/
        #   ADDCONSTATUS statement (not related to GA::Obj::Gauge->number, or the gauge numbers used
        #   as keys in $self->gaugeHash) and 'gauge_object' is the corresponding GA::Obj::Gauge
        #   object
        $self->{statusBarHash}          = {};

        # Flag set to TRUE when this task is started with ;runscripttask -w (set to FALSE otherwise)
        # In 'forced window' mode, the task opens an 'entry' task window before starting to execute
        #   the script. Then, it notifies the AB::Script that output from all PRINT statements must
        #   be diverted to the window. This allows the user to run old BASIC scripts - which
        #   obviously don't know how to open Axmud task windows - in their own window, away from the
        #   'main' window
        $self->{forcedWinFlag}          = FALSE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{stepMax}               = $self->stepMax;
        $clone->{scriptName}            = $self->scriptName;
        $clone->{scriptObj}             = $self->scriptObj;

        $clone->{waitForInterface}      = $self->waitForInterface;
        $clone->{monitorTask}           = $self->monitorTask;
        $clone->{monitorStatistic}      = $self->monitorStatistic;
        $clone->{monitorTarget}         = $self->monitorTarget;
        $clone->{monitorTimeOut}        = $self->monitorTimeOut;

        $clone->{gaugeStripObj}         = $self->gaugeStripObj;
        $clone->{gaugeLevel}            = $self->gaugeLevel;
        $clone->{gaugeHash}             = {$self->gaugeHash};
        $clone->{statusBarHash}         = {$self->statusBarHash};

        $clone->{forcedWinFlag}         = $self->forcedWinFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the maximum number of steps per task loop
        $newTask->ivPoke('stepMax', $self->stepMax);
        # Preserve the name of the Axbasic script to run (from the beginning)
        $newTask->ivPoke('scriptName', $self->scriptName);

        # Preserve 'forced window' mode; if the old task was started with a ';runscripttask -w', the
        #   new task should also be forced to open a task window
        $newTask->ivPoke('forcedWindowFlag', $self->forcedWindowFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

    sub openWin {

        # Called by the task's ->main method or by its ->toggleWin method
        # Tries to open each type of task window in $self->winPreferList, halting at the first
        #   successful attempt (or when all attempts fail)
        #
        # Expected arguments
        #   (none besides self)
        #
        # Optional arguments
        #   $winmap     - The winmap to use in a 'grid' window or a pseudo-window (matches a key in
        #                   GA::Client->winmapHash). If 'undef', $self->winmap is used
        #   @preferList - The types of task window to try to open. If not an empty list, this list
        #                   is used rather than $self->winPreferList
        #
        # Return values
        #   'undef' on improper arguments, if $winmap doesn't exist (when specified, checked even if
        #       a 'grid' window isn't opened) or if a task window is not opened
        #   1 if a window is opened

        my ($self, $winmap, @preferList) = @_;

        # (No improper arguments to check)

        # Most of the code is in the generic function
        if ($self->Games::Axmud::Generic::Task::openWin($winmap, @preferList)) {

            # Set a customised window title
            $self->setTitle();

            return 1;

        } else {

            return undef;
        }
    }

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

    sub doShutdown {

        # Called just before the task completes a shutdown
        # For process tasks, called by $self->main. For activity tasks, called by $self->shutdown
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doShutdown', @_);
        }

        # If the Axbasic script is running, destroy any non-persistent interfaces it has created
        if (defined $self->scriptObj) {

            $self->scriptObj->destroyOwnInterfaces();
        }

        # If any gauges/status bars have been created, destroy them
        if ($self->gaugeStripObj && ($self->gaugeHash || $self->statusBarHash)) {

            $self->gaugeStripObj->removeGauges(
                $self->session,
                FALSE,
                $self->ivValues('gaugeHash'),
                $self->ivValues('statusBarHash'),
            );

            $self->gaugeStripObj->removeGaugeLevel($self->session, $self->gaugeLevel);
        }

        return 1;
    }

    sub doReset {

        # Called by $self->main, just before the task completes a reset
        #
        # This function does nothing, so tasks that need to do something special during a reset
        #   should have their own ->doReset function
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # If any gauges/status bars have been created, destroy them
        if ($self->gaugeStripObj && ($self->gaugeHash || $self->statusBarHash)) {

           $self->gaugeStripObj->removeGauges(
                $self->session,
                FALSE,
                $self->ivValues('gaugeHash'),
                $self->ivValues('statusBarHash'),
            );

            $self->gaugeStripObj->removeGaugeLevel($self->session, $self->gaugeLevel);
        }

        return 1;
    }

    sub doFirstStage {

        # Called by $self->main, just before the task completes the first stage ($self->stage)
        # In 'forced window' mode, opens an 'entry' task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doFirstStage', @_);
        }

        # In 'forced window' mode, a task window must always be open (so that PRINT statements can
        #   be diverted there)
        if ($self->forcedWinFlag) {

            # A window must now be open all the time for this task
            $self->ivPoke('requireWinFlag', TRUE);
            # Open an task window with an entry box
            $self->openWin($self->winmap, 'entry_fill', 'pane', 'grid');
        }

        return 1;
    }

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            my ($rawObj, $standardPath, $path, $scriptObj);

            # Quick check that the task has been supplied with an Axbasic script name
            if (! defined $self->scriptName) {

                $self->session->writeError(
                    '\'' . $self->uniqueName . '\' task : no script specified; task is halting',
                    $self->_objClass . '->doStage',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # Check each directory in the GA::Client's list, looking for a file matching
            #   $self->scriptName (but first check in the standard directory)
            $standardPath = $axmud::DATA_DIR . '/scripts/' . $self->scriptName . '.bas';
            if (-e $standardPath) {

                $path = $standardPath;

            } else {

                OUTER: foreach my $dir ($axmud::CLIENT->scriptDirList) {

                    my $thisPath = $dir . '/' . $self->scriptName . '.bas';
                    if (-e $thisPath) {

                        $path = $thisPath;
                        last OUTER;
                    }
                }
            }

            if (! $path) {

                $self->session->writeError(
                    '\'' . $self->uniqueName . '\' task : can\'t find the ' . $axmud::BASIC_NAME
                    . ' script \'' . $self->scriptName . '\'',
                    $self->_objClass . '->doStage',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # Start the script
            $path = $axmud::DATA_DIR . '/scripts/' . $self->scriptName . '.bas';

            # Create a raw script object
            $rawObj = Language::Axbasic::RawScript->new($self->session, $self->scriptName);
            if (! defined $rawObj) {

                $self->session->writeError(
                    '\'' . $self->uniqueName . '\' task : can\'t initialise the '
                    . $axmud::BASIC_NAME . ' script \'' . $self->scriptName . '\'',
                    $self->_objClass . '->doStage',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # Instruct the raw script object to load the .bas script in the 'scripts' directory
            if (! $rawObj->loadFile($path)) {

                $self->session->writeError(
                    '\'' . $self->uniqueName . '\' task : can\'t load the ' . $axmud::BASIC_NAME
                    . ' script \'' . $self->scriptName . '\'',
                    $self->_objClass . '->doStage',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # Create a AB::Script object, which processes the raw script, removing extraneous
            #   whitespace, empty lines, comments, etc
            $scriptObj = Language::Axbasic::Script->new($self->session, $rawObj);
            if (! $scriptObj) {

                $self->session->writeError(
                    '\'' . $self->uniqueName . '\' task : can\'t process the ' . $axmud::BASIC_NAME
                    . ' script \'' . $self->scriptName . '\'',
                    $self->_objClass . '->doStage',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

            # The AB::Script object was created
            $self->ivPoke('scriptObj', $scriptObj);
            # Inform the AB::Script object that it's being run by this task
            $scriptObj->set_task($self);

            # If 'forced window' mode is on, inform the script
            if ($self->forcedWinFlag) {

                $scriptObj->set_forcedWinFlag(TRUE);
            }

            # Move on to the next stage
            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            # If the stage was 4 on the previous task loop, the IVs used for monitoring another
            #   task are still set. Reset them.
            if (defined $self->monitorTask) {

                $self->ivUndef('monitorTask');
                $self->ivUndef('monitorStatistic');
                $self->ivUndef('monitorTarget');
                $self->ivUndef('monitorTimeOut');

                # After a pause, the script's step count (how many statements to execute before
                #   taking a short break) needs to be reset
                $self->scriptObj->ivPoke('stepCount', 0);
            }

            # If the Axbasic script is waiting for a response to an INPUT statement, do nothing
            if ($self->scriptObj->scriptStatus eq 'wait_input') {

                # The Axbasic script is still waiting for a response; repeat this stage
                return $self->ivPoke('stage', 3);
            }

            # Execute the script
            if (! $self->scriptObj->implement()) {

                # If the script's ->implementFlag is set, it is still processing a previous call
                #   (from an earlier spin of the task loop). Wait until the next spin
                if ($self->scriptObj->implementFlag) {

                    return $self->ivPoke('stage', 3);

                } else {

                    $self->session->writeError(
                        '\'' . $self->uniqueName . '\' task : execution of the '
                        . $axmud::BASIC_NAME . ' script \'' . $self->scriptName . '\' failed',
                        $self->_objClass . '->doStage',
                    );

                    $self->ivPoke('shutdownFlag', TRUE);
                    return undef;
                }

            } elsif (
                $self->scriptObj->scriptStatus eq 'paused'
                || $self->scriptObj->scriptStatus eq 'wait_input'
            ) {
                # Programme is paused, or waiting for input. Wait for it to un-pause/receive input
                return $self->ivPoke('stage', 3);

            } elsif (defined $self->monitorTask) {

                # A statement like WAITHP is waiting for this task to monitor some statistic or
                #   other, before resuming execution of the Axbasic programme
                # If a timeout was specified, set it now
                if (defined $self->monitorTimeOut) {

                    $self->ivPoke(
                        'checkTime',
                        ($self->session->sessionTime + $self->monitorTimeOut),
                    );
                }

                # The monitoring takes place in stage 4
                return $self->ivPoke('stage', 4);

            } else {

                # Script execution has finished
                $self->session->writeText(
                    '\'' . $self->uniqueName . '\' task : execution of the ' . $axmud::BASIC_NAME
                    . ' script \'' . $self->scriptName . '\' complete',
                );

                $self->ivPoke('shutdownFlag', TRUE);
                return undef;
            }

        } elsif ($self->stage == 4) {

            my ($otherTaskObj, $charObj, $current);

            # If either the task or the statistic to monitor is unrecognised, simply go back to
            #   stage 3 (resumes execution of the Axbasic programme) as an emergency fallback
            # The four IVs used in monitoring other tasks are reset at the beginning of stage 3,
            #   regarless of how monitoring terminates

            # If a timeout was specified, decide whether it's time to give up waiting
            if (defined $self->monitorTimeOut && $self->checkTime < $self->session->sessionTime) {

                # Resume execution
                return $self->ivPoke('stage', 3);
            }

            # If the task to monitor is no longer set, resume execution
            if (! defined $self->monitorTask) {

                return $self->ivPoke('stage', 3);

            # Monitor a new task that's been created by the Axbasic script
            } elsif ($self->monitorTask eq 'task') {

                if (! $self->session->ivExists('currentTaskHash', $self->monitorStatistic)) {

                    # The new task has terminated. Resume execution
                    return $self->ivPoke('stage', 3);

                } else {

                    # Continue waiting for the new task to terminate
                    return $self->ivPoke('stage', 4);
                }

            # Monitor a pre-existing task, waiting for it to get activated
            } elsif ($self->monitorTask eq 'task_active') {

                $otherTaskObj = $self->session->ivShow('currentTaskHash', $self->monitorStatistic);
                if (! $otherTaskObj) {

                    # The monitored task has terminated (unexpectedly). Wait for a new instance of
                    #   that task to start and become active (which may happen if
                    #   $self->monitorStatistic is a generic task name like 'locator_task', but will
                    #   never happen if it's a unique task name like 'locator_task_54'
                    return $self->ivPoke('stage', 4);
                }

                if ($otherTaskObj->activeFlag) {

                    # The new task has become active. Resume execution of the Axbasic script
                    return $self->ivPoke('stage', 3);

                } else {

                    # Continue waiting for the new task to become active
                    return $self->ivPoke('stage', 4);
                }

            # Monitor a pre-existing task, waiting for it to get disactivated
            } elsif ($self->monitorTask eq 'task_not_active') {

                $otherTaskObj = $self->session->ivShow('currentTaskHash', $self->monitorStatistic);
                if (! $otherTaskObj || ! $otherTaskObj->activeFlag) {

                    # The monitored task has been disactivatd, or has terminated (unexpectedly). In
                    #   either case, resume execution of the Axbasic script
                    return $self->ivPoke('stage', 3);

                } else {

                    # Continue waiting for the new task to become disactivated
                    return $self->ivPoke('stage', 4);
                }

            # Monitor a new task that's been created by the Axbasic script
            } elsif ($self->monitorTask eq 'task') {

                if (! $self->session->ivExists('currentTaskHash', $self->monitorStatistic)) {

                    # The new task has terminated. Resume execution of the Axbasic script
                    return $self->ivPoke('stage', 3);

                } else {

                    # Continue waiting for the new task to terminate
                    return $self->ivPoke('stage', 4);
                }

            # Monitor the Locator task
            } elsif ($self->monitorTask eq 'locator') {

                $otherTaskObj = $self->session->locatorTask;

                # Must resume execution of the Axbasic script if the Locator task is no longer
                #   running...
                if (! $otherTaskObj) {

                    return $self->ivPoke('stage', 3);

                # If the Locator task is still setting up, wait for it to finish
                } elsif ($otherTaskObj->stage < 3) {

                    return $self->ivPoke('stage', 4);

                # Monitor arrivals
                } elsif ($self->monitorStatistic eq 'arrive') {

                    if (
                        ! $otherTaskObj->roomObj
                        || (! $otherTaskObj->moveList && ! $self->session->excessCmdList)
                    ) {
                        # Locator doesn't know about the current location, or is not expecting any
                        #   more room descriptions. Resume execution of this task
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Locator still waiting for arrival
                        return $self->ivPoke('stage', 4);
                    }

                } else {

                    # Unrecognised statistic
                    return $self->ivPoke('stage', 3);
                }

            # Monitor the Status task
            } elsif ($self->monitorTask eq 'status') {

                # Since v1.1.263, the health/magic/energy/guild/social point IVs have moved to the
                #   character profile, but are still being updated by the Status task
                # For that reason, we need to check both the profile and the task

                $otherTaskObj = $self->session->statusTask;
                $charObj = $self->session->currentChar;

                # Must resume execution of the Axbasic script if the Status task is no longer
                #   running (or if no current character profile exists)
                if (! $otherTaskObj || ! $charObj) {

                    return $self->ivPoke('stage', 3);

                # If the Status task is still setting up, wait for it to finish
                } elsif ($otherTaskObj->stage < 3) {

                    return $self->ivPoke('stage', 4);

                # Monitor health points (HP)
                } elsif ($self->monitorStatistic eq 'hp') {

                    if (
                        ! defined $charObj->healthPoints
                        || ! defined $charObj->healthPointsMax
                        # Can't divide by zero
                        || ! $charObj->healthPointsMax
                    ) {
                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);
                    }

                    $current = 100 * ($charObj->healthPoints / $charObj->healthPointsMax);
                    if ($current >= $self->monitorTarget) {

                        # HP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for HP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor magic points (MP)
                } elsif ($self->monitorStatistic eq 'mp') {

                    if (
                        ! defined $charObj->magicPoints
                        || ! defined $charObj->magicPointsMax
                        # Can't divide by zero
                        || ! $charObj->magicPointsMax
                    ) {
                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);
                    }

                    $current = 100 * ($charObj->magicPoints / $charObj->magicPointsMax);
                    if ($current >= $self->monitorTarget) {

                        # MP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for MP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor energy points (EP)
                } elsif ($self->monitorStatistic eq 'ep') {

                    if (
                        ! defined $charObj->energyPoints
                        || ! defined $charObj->energyPointsMax
                        # Can't divide by zero
                        || ! $charObj->energyPointsMax
                    ) {
                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);
                    }

                    $current = 100 * ($charObj->energyPoints / $charObj->energyPointsMax);
                    if ($current >= $self->monitorTarget) {

                        # EP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for EP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor guild points (GP)
                } elsif ($self->monitorStatistic eq 'gp') {

                    if (
                        ! defined $charObj->guildPoints
                        || ! defined $charObj->guildPointsMax
                        # Can't divide by zero
                        || ! $charObj->guildPointsMax
                    ) {
                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);
                    }

                    $current = 100 * ($charObj->guildPoints / $charObj->guildPointsMax);
                    if ($current >= $self->monitorTarget) {

                        # GP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for GP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor social points (SP)
                } elsif ($self->monitorStatistic eq 'sp') {

                    if (
                        ! defined $charObj->socialPoints
                        || ! defined $charObj->socialPointsMax
                        # Can't divide by zero
                        || ! $charObj->socialPointsMax
                    ) {
                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);
                    }

                    $current = 100 * ($charObj->socialPoints / $charObj->socialPointsMax);
                    if ($current >= $self->monitorTarget) {

                        # SP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for SP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor current experience points (XP)
                } elsif ($self->monitorStatistic eq 'xp_current') {

                    if (! defined $charObj->xpCurrent) {

                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);

                    } elsif ($self->xpCurrent >= $self->monitorTarget) {

                        # XP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for XP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor experience points (XP) to next level
                } elsif ($self->monitorStatistic eq 'xp_next_level') {

                    if (! defined $charObj->xpNextLevel) {

                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);

                    } elsif ($self->xpNextLevel <= $self->monitorTarget) {

                        # XP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for XP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor total experience points (XP)
                } elsif ($self->monitorStatistic eq 'xp_total') {

                    if (! defined $charObj->xpTotal) {

                        # Statistic not known; resume execution
                        return $self->ivPoke('stage', 3);

                    } elsif ($self->xpTotal >= $self->monitorTarget) {

                        # XP has recovered; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for XP to recover
                        return $self->ivPoke('stage', 4);
                    }

                # Monitor life status
                } elsif (
                    $self->monitorStatistic eq 'alive' || $self->monitorStatistic eq 'sleep'
                    || $self->monitorStatistic eq 'passout' || $self->monitorStatistic eq 'dead'
                ) {
                    if ($charObj->lifeStatus eq $self->monitorStatistic) {

                        # Life status matches; resume execution
                        return $self->ivPoke('stage', 3);

                    } else {

                        # Continue waiting for the right life status
                        return $self->ivPoke('stage', 4);
                    }

                } else {

                    # Unrecognised statistic
                    return $self->ivPoke('stage', 3);
                }

            # Can't monitor any tasks but the four specified above
            } else {

                return $self->ivPoke('stage', 3);
            }

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub setUpMonitoring {

        # Called by several Axbasic statements to set this task's IVs, used when the task is
        #   monitoring another task (such as the Status task), waiting for a statistic stored by the
        #   other task to change to a certain desired value (after which, execution of the Axbasic
        #   script resumes)
        #
        # Expected arguments
        #   $task       - The task to monitor ('locator', 'attack', 'status', 'divert')
        #   $statistic  - The statistic to monitor, stored as an IV by that task
        #
        # Optional arguments
        #   $target     - The minimum value that the statistic should reach before resuming the
        #                   Axbasic script; a percentage value in the range 0-100 (not necessary
        #                   for every statement which sets up monitoring)
        #   $timeOut    - An optional timeout, in seconds, after which the Axbasic script resumes
        #                   execution anyway
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $task, $statistic, $target, $timeOut, $check) = @_;

        # Check for improper arguments
        if (! defined $task || ! defined $statistic || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setUpMonitoring', @_);
        }

        # Set the IVs
        $self->ivPoke('monitorTask', $task);
        $self->ivPoke('monitorStatistic', $statistic);
        $self->ivPoke('monitorTarget', $target);        # May be 'undef'
        $self->ivPoke('monitorTimeOut', $timeOut);      # May be 'undef'

        # Return control to the LA::Script, which in turn returns control to $self->doStage, stage
        #   3; the next task loop will be stage 4, which does the actual monitoring
        return 1;
    }

    sub resetInterface {

        # Called by LA::Statement::deliface->implement, when it deletes an interface
        # Also called by $self->interfaceWaitSeen when the (temporary) interface fires
        #
        # This function checks whether it's the same interface stored by this task and, if so,
        #   updates IVs
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $interfaceObj   - The interface which has been removed. Set when called by
        #                       LA::Statement::deliface->implement, but 'undef' when called by
        #                       $self->interfaceWaitSeen
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $interfaceObj, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->interfaceObj', @_);
        }

        # (For calls from AB::Statement::deliface, need to check it's the right interface)
        if (! defined $interfaceObj || $self->waitForInterface eq $interfaceObj->name) {

            # Update this task's IVs
            $self->ivUndef('waitForInterface');
            $self->ivPoke('checkTime', 0);

            # Unpause the task. Execution of the script resumes on the next task loop
            if ($self->status eq 'paused') {

                if (defined $self->resumeStatus) {
                    $self->ivPoke('status', $self->resumeStatus);
                } else {
                    $self->ivPoke('status', 'running');     # Defaults
                }

                $self->ivPoke('stage', 3);
                $self->ivUndef('resumeStatus');
            }
        }

        return 1;
    }

    sub pauseUntil {

        # Called by LA::Statement::pause->implement and ::waittrig->implement
        #
        # Pauses this task. If a time is specified, sets the time at which the task should resume
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $time   - The time at which the task should resume (matches GA::Session->sessionTime).
        #               'undef' when called by LA::Statement::waittrig->implement, for a WAITTRIG
        #               statement with no timeout (in which case, the task is paused indefinitely)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $time, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->pauseUntil', @_);
        }

        # Pause the task
        $self->ivPoke('resumeStatus', $self->status);
        $self->ivPoke('status', 'paused');
        # Set the time at which the task will resume
        if (defined $time) {

            $self->ivPoke('checkTime', $time);
        }

        return 1;
    }

    sub insertPrint {

        # Called by LA::Statement::print->implement and ::writewin->implement
        # Inserts some text into the task window. If any of the optional arguments specify Axmud
        #   colour tags and if the window is using its own colour scheme, change the colour scheme
        #   back to default (allowing the coloured text to be seen)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $text       - The text to write. If undefined, the text <<undef>> is written
        #   @args       - List of arguments, in any order:
        #                   - 'empty' (empties the buffer before writing $text)
        #                   - 'before' (prepends a newline character to $text)
        #                   - 'after' (appends a newline character to $text)
        #                   - 'echo' (does not prepend/append a newline character, overrides
        #                       'before' and 'after' if they are specified)
        #                   - one or more Axmud colour tags. Only the first text colour (e.g. 'red')
        #                       is used, and only the first underlay colour ('UL_RED') is used
        #                   - one or more Axmud style tags (e.g. 'italics', 'strike', etc)
        #
        # Notes
        #   If both 'echo' and either of 'before'/'after' are specified (or if neither is
        #       specified), a newline character is NOT appended
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $text, @args) = @_;

        # (No improper arguments to check)

        # If the task window (a 'grid' window or a pseudo-window inside the session's 'main' window)
        #   isn't open, don't need to create widgets
        if (! $self->winObj) {

            return undef;
        }

        # If $self->monochromeFlag is set, inspect the contents of @args. If it contains any colour
        #   tags, the window no longer uses a monochrome colour scheme
        if ($self->monochromeFlag) {

            OUTER: foreach my $item (@args) {

                my ($type) = $axmud::CLIENT->checkColourTags($item);
                if ($type) {

                    # It's a valid colour tag. Reset the window's colour scheme
                    $self->defaultTabObj->paneObj->removeMonochrome($self->defaultTabObj);

                    last OUTER;
                }
            }
        }

        # Pass the arguments on to the task window
        $self->insertText($text, @args);

        return 1;
    }

    sub paintWin {

        # Called by LA::Statement::paintwin->implement
        # Changes the window's colour scheme (and sets a flag, if the window didn't already have its
        #   own colour scheme)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $colour     - The Axmud colour tag to use. Underlay tags are acceptable. If 'undef', the
        #                   default colour scheme is used (and the window is marked as NOT having
        #                   its own colour scheme)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $colour, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->paintWin', @_);
        }

        # If the task window (a 'grid' window or a pseudo-window inside the session's 'main' window)
        #   isn't open, don't need to create widgets
        if (! $self->winObj) {

            return undef;
        }

        if ($colour) {
            $self->defaultTabObj->paneObj->applyMonochrome($self->defaultTabObj, $colour);
        } else {
            $self->defaultTabObj->paneObj->removeMonochrome($self->defaultTabObj);
        }

        return 1;
    }

    sub setTitle {

        # Called by $self->openWin and LA::Statement::settitle->implement
        # Sets the task window's title to include the task name and the script name
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Optional arguments
        #   $text   - The text to use as the window's title (if an empty string or 'undef', a
        #               default title is used)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $text, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setTitle', @_);
        }

        # Reset the title
        if (defined $text && $text ne '') {
            $self->setTaskWinTitle($text);
        } else {
            $self->setTaskWinTitle($self->prettyName . ' (' . $self->scriptName . ')');
        }

        return 1;
    }

    sub addGauge {

        # Called by LA::Statement::addgauge->implement or ::addcongauge->implement
        # Adds a 'main' window gauge, creating a new gauge level if one doesn't already exist for
        #   this task
        #
        # Expected arguments
        #   $number     - A local gauge number for this task
        #                   (not related to GA::Obj::Gauge->number). The Language::Axbasic script
        #                   can specify any integer number >= 0. If this task has already created a
        #                   gauge with that local number, it is replaced
        #   $label      - The label to use. If it's an empty string, this function assigns its own
        #                   label
        #   $addFlag    - Flag matching GA::Obj::Gauge->addFlag. FALSE when called by ADDGAUGE
        #                   statements, TRUE when called by ADDCONGAUGE statements
        #
        # Optional arguments
        #   $fullCol    - The colour of the full portion of the gauge. Must be an Axmud colour tag
        #                   (including standard, xterm and RGB tags). If an empty string, 'undef' or
        #                   an invalid colour tag, the default gauge colour is used
        #   $emptyCol   - The colour of the empty portion of the gauge
        #   $labelCol   - The colour of the label
        #
        # Return values
        #   'undef' on improper arguments or if the gauge can't be added
        #   1 otherwise

        my ($self, $number, $label, $addFlag, $fullCol, $emptyCol, $labelCol, $check) = @_;

        # Local variables
        my ($stripObj, $level, $gaugeObj, $fullType, $emptyType, $labelType);

        # Check for improper arguments
        if (
            ! defined $number || ! defined $label || ! defined $addFlag || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->addGauge', @_);
        }

        # Find the GA::Strip::GaugeBox object for the session's 'main' window (which is the
        #   window we'll use), if we don't already have it
        if (! $self->gaugeStripObj) {

            $stripObj = $self->session->mainWin->ivShow(
                'firstStripHash',
                'Games::Axmud::Strip::GaugeBox',
            );

            if (! $stripObj) {

                return undef;

            } else {

                $self->ivPoke('gaugeStripObj', $stripObj);
            }
        }

        # Add a gauge level for this task, if one doesn't already exist
        if (! defined $self->gaugeLevel) {

            # The TRUE flag means 'don't draw it yet'
            $level = $self->gaugeStripObj->addGaugeLevel($self->session, TRUE);
            if (! defined $level) {

                return undef;

            } else {

                $self->ivPoke('gaugeLevel', $level);
            }
        }

        # If this task has already created a gauge with the local number $number, remove it
        if ($self->ivExists('gaugeHash', $number)) {

            $self->gaugeStripObj->removeGauges(
                $self->session,
                TRUE,
                $self->ivShow('gaugeHash', $number),
            );
        }

        # Conver the specified colour tags into RGB colour tags (or use default colours, if the
        #   specified tags are invalid)
        if (defined $fullCol) {

            ($fullType) = $axmud::CLIENT->checkColourTags($fullCol);
        }

        if (! $fullType) {
            $fullCol = $self->gaugeStripObj->gaugeFullColour;
        } else {
            $fullCol = $axmud::CLIENT->returnRGBColour($fullCol);
        }

        if (defined $emptyCol) {

            ($emptyType) = $axmud::CLIENT->checkColourTags($emptyCol);
        }

        if (! $emptyType) {
            $emptyCol = $self->gaugeStripObj->gaugeEmptyColour;
        } else {
            $emptyCol = $axmud::CLIENT->returnRGBColour($emptyCol);
        }

        if (defined $labelCol) {

            ($labelType) = $axmud::CLIENT->checkColourTags($labelCol);
            if (! $labelType) {
                $labelCol = $self->gaugeStripObj->gaugeLabelColour;
            } else {
                $labelCol = $axmud::CLIENT->returnRGBColour($labelCol);
            }
        }

        # Assign a label, if none was specified
        if ($label eq '') {

            $label = 'Gauge ' . $number;
        }

        # Add the gauge
        $gaugeObj = $self->gaugeStripObj->addGauge(
            $self->session,
            $self->gaugeLevel,
            undef,
            undef,
            $addFlag,
            $label,
            $fullCol,
            $emptyCol,
            $labelCol,
            TRUE,
        );

        if ($gaugeObj) {

            $self->ivAdd('gaugeHash', $number, $gaugeObj);
        }

        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->addGauge');

        return 1;
    }

    sub deleteGauge {

        # Called by LA::Statement::delgauge->implement
        # Deletes a 'main' window gauge, removing the gauge level if there are no gauges/status bars
        #   left for this task
        #
        # Expected arguments
        #   $number     - A local gauge number for this task, matching a key in $self->gaugeHash
        #                   (not related to GA::Obj::Gauge->number)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $number, $check) = @_;

        # Check for improper arguments
        if (! defined $number || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->deleteGauge', @_);
        }

        # Delete the gauge with this local $number, if it exists
        if ($self->ivExists('gaugeHash', $number)) {

          $self->gaugeStripObj->removeGauges(
                $self->session,
                FALSE,
                $self->ivShow('gaugeHash', $number),
            );

            $self->ivDelete('gaugeHash', $number);

            # If there are no more gauges/status bars left for this task, remove the gauge level
            #   assigned to it
            if (! $self->gaugeHash && ! $self->statusBarHash) {

                $self->gaugeStripObj->removeGaugeLevel($self->session, $self->gaugeLevel);
            }
        }

        return 1;
    }

    sub setGauge {

        # Called by LA::Statement::setgauge->implement
        # Sets the values displayed by the gauge, and tells the gauge box to re-draw its gauges
        #
        # Expected arguments
        #   $number     - A local gauge number for this task, matching a key in $self->gaugeHash
        #                   (not related to GA::Obj::Gauge->number)
        #
        # Optional arguments
        #   $val        - The value to use for the full portion of the gauge. Can be set to 'undef'
        #                   if the value supplied by the Axbasic script wasn't a valid decimal
        #                   number
        #   $maxVal     - The value to use for the empty portion of the gauge. Can also be set to
        #                   'undef'
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $number, $val, $maxVal, $check) = @_;

        # Local variables
        my $gaugeObj;

        # Check for improper arguments
        if (! defined $number || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setGauge', @_);
        }

        $gaugeObj = $self->ivShow('gaugeHash', $number);
        # If the gauge with this local $number exists...
        if ($gaugeObj) {

            $gaugeObj->ivPoke('value', $val);
            $gaugeObj->ivPoke('maxValue', $maxVal);

            $self->gaugeStripObj->updateGauges();
        }

        return 1;
    }

    sub addStatusBar {

        # Called by LA::Statement::addstatus->implement or ::addconstatus->implement
        # Adds a 'main' window status bar, creating a new gauge level if one doesn't already exist
        #   for this task
        #
        # Expected arguments
        #   $number     - A local status bar number for this task
        #                   (not related to GA::Obj::Gauge->number). The Axbasic script can specify
        #                   any integer number >= 0. If this task has already created a status bar
        #                   with that local number, it is replaced
        #   $label      - The label to use. If it's an empty string, this function assigns its own
        #                   label
        #   $addFlag    - Flag matching GA::Obj::Gauge->addFlag. FALSE when called by ADDSTATUS
        #                   statements, TRUE when called by ADDCONSTATUS statements
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $number, $label, $addFlag, $check) = @_;

        # Local variables
        my ($stripObj, $level, $gaugeObj);

        # Check for improper arguments
        if (! defined $number || ! defined $label || ! defined $addFlag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->addStatusBar', @_);
        }

        # Find the GA::Strip::GaugeBox object for the session's 'main' window (which is the window
        #   we'll use), if we don't already have it
        if (! $self->gaugeStripObj) {

            $stripObj = $self->session->mainWin->ivShow(
                'firstStripHash',
                'Games::Axmud::Strip::GaugeBox',
            );

            if (! $stripObj) {

                return undef;

            } else {

                $self->ivPoke('gaugeStripObj', $stripObj);
            }
        }

        # Add a gauge level for this task, if one doesn't already exist
        if (! defined $self->gaugeLevel) {

            # The TRUE flag means 'don't draw it yet'
            $level = $self->gaugeStripObj->addGaugeLevel($self->session, TRUE);
            if (! defined $level) {

                return undef;

            } else {

                $self->ivPoke('gaugeLevel', $level);
            }
        }

        # If this task has already created a status bar with the local number $number, remove it
        if ($self->ivExists('statusBarHash', $number)) {

            $self->gaugeStripObj->removeGauges(
                $self->session,
                FALSE,
                $self->ivShow('statusBarHash', $number),
            );
        }

        # Assign a label, if none was specified
        if ($label eq '') {

            $label = 'Bar ' . $number;
        }

        # Add the status bar
        $gaugeObj = $self->gaugeStripObj->addTextGauge(
            $self->session,
            $self->gaugeLevel,
            undef,
            undef,
            $addFlag,
            $label,
        );

        if ($gaugeObj) {

            $self->ivAdd('statusBarHash', $number, $gaugeObj);
        }

        $axmud::CLIENT->desktopObj->updateWidgets($self->_objClass . '->addStatusBar');

        return 1;
    }

    sub deleteStatusBar {

        # Called by LA::Statement::delstatus->implement
        # Deletes a 'main' window status bar, removing the gauge level if there are no gauges/status
        #   bars left for this task
        #
        # Expected arguments
        #   $number     - A local status bar number for this task, matching a key in
        #                   $self->statusBarHash (not related to GA::Obj::Gauge->number)
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $number, $check) = @_;

        # Check for improper arguments
        if (! defined $number || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->deleteStatusBar', @_);
        }

        # Delete the status bar with this local $number, if it exists
        if ($self->ivExists('statusBarHash', $number)) {

          $self->gaugeStripObj->removeGauges(
                $self->session,
                FALSE,
                $self->ivShow('statusBarHash', $number),
            );

            $self->ivDelete('statusBarHash', $number);

            # If there are no more gauges/status bars left for this task, remove the gauge level
            #   assigned to it
            if (! $self->gaugeHash && ! $self->statusBarHash) {

                $self->gaugeStripObj->removeGaugeLevel($self->session, $self->gaugeLevel);
            }
        }

        return 1;
    }

    sub setStatusBar {

        # Called by LA::Statement::setstatus->implement
        # Sets the values displayed by the status bar, and tells the 'main' window to re-draw its
        #   gauges/status bars
        #
        # Expected arguments
        #   $number     - A local status bar number for this task, matching a key in
        #                   $self->statusBarHash (not related to GA::Obj::Gauge->number)
        #
        # Optional arguments
        #   $val        - The value to use that's the equivalent of the full portion of a
        #                   (graphical) gauge. Can be set to 'undef' if the value supplied by the
        #                   Axbasic script wasn't a valid decimal number
        #   $maxVal     - The value to use that's the equivalent of the empty portion of a
        #                   (graphical) gauge. Can also be set to 'undef'
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $number, $val, $maxVal, $check) = @_;

        # Local variables
        my $gaugeObj;

        # Check for improper arguments
        if (! defined $number || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setStatusBar', @_);
        }

        $gaugeObj = $self->ivShow('statusBarHash', $number);
        # If the status bar with this local $number exists...
        if ($gaugeObj) {

            $gaugeObj->ivPoke('value', $val);
            $gaugeObj->ivPoke('maxValue', $maxVal);

            $self->gaugeStripObj->updateGauges();
        }

        return 1;
    }

    ##################
    # Response methods

    sub interfaceWaitSeen {

        # Called by GA::Session->processLineSegment, after that function has called ->checkTriggers;
        #   or by GA::Session->checkAliases, ->checkMacros (etc)
        #
        # The Axbasic statement WAITTRIG causes the script to create a trigger to wait for a
        #   particular pattern in the text received from the world
        #      e.g. The door swings open!
        #
        # When the pattern is found, the script resumes. WAITALIAS, WAITMACRO, WAITTIMER and
        #   WAITHOOK behave in similar ways
        # This function updates IVs and resumes this task, which should be paused. Group substrings
        #   (for triggers/aliases) and additional data (for timers/hooks) are ignored
        #
        # Expected arguments
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   @ignoreList     - Any other data supplied by the calling function is added to this list
        #                       and ignored
        #
        # Return values
        #   'undef' on improper arguments or if $session is the wrong session
        #   1 otherwise

        my ($self, $session, $interfaceNum, @ignoreList) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (! defined $session || ! defined $interfaceNum ) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->interfaceWaitSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # (Standard code commented out, because the temporary interface has already deleted itself)
#        # Get the interface object itself
#        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
#        if (! $obj) {
#
#            return undef;
#        }

        # Respond to the fired interface

        # Update the AB::Script's list of dependent interfaces to remove the one that has just
        #   fired (it's a temporary interface which the GA::Session is going to delete soon)
        $self->scriptObj->updateInterfaces($self->waitForInterface);
        # After a pause, the script's step count (how many statements to execute before taking a
        #   short break) needs to be reset
        $self->scriptObj->set_stepCount(0);

        # Update this task's IVs
        $self->resetInterface();

        return 1;
    }

    sub triggerNotifySeen {

        # Called by GA::Session->processLineSegment, after that function has called ->checkTriggers
        #
        # The Axbasic statement SETTRIG causes the script to create a trigger to notice a particular
        #   pattern in the text received from the  world
        #      e.g. The orc attacks you
        #
        # If SETTRIG specified a second script to run, this function runs it. Otherwise, this
        #   function notifies the script that the pattern was found
        #
        # The trigger interface has the following properties in ->propertyHash:
        #   new_script       - The name of the second script to run (only used in calls to
        #                       $self->triggerExecSeen, 'undef' in calls to this function)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $scriptName);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->triggerNotifySeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger
        $scriptName = $obj->ivShow('propertyHash', 'new_script');
        if ($scriptName) {

            # SETTRIG statement specified a second script to run
            return $self->session->pseudoCmd('runscript ' . $scriptName);

        } else {

            # No second script to to run. Notify the Axbasic programme that one of its dependent
            #   triggers has fired
            $self->scriptObj->interfaceNotification($obj, $modLine, @$grpStringListRef);

            return 1;
        }
    }

    sub aliasNotifySeen {

        # Called by GA::Session->checkAliases
        #
        # The Axbasic statement SETALIAS causes the script to create an alias to notice when a
        #   particular world command is entered
        #      e.g. The orc attacks you
        #
        # If SETALIAS specified a second script to run, this function runs it. Otherwise, this
        #   function notifies the script that the matching command was processed. (The world command
        #   is not sent to the world, as normal)
        #
        # The alias interface has the following properties in ->propertyHash:
        #   new_script       - The name of the second script to run (only used in calls to
        #                       $self->aliasExecSeen, 'undef' in calls to this function)
        #
        # Expected arguments (standard args from GA::Session->checkAliases)
        #   $session         - The calling function's GA::Session
        #   $interfaceNum    - The number of the active alias interface that fired
        #   $worldCmd        - The world command that caused the alias to fire
        #
        # Optional arguments
        #   @groupStringList - A list of group substrings from the pattern match (equivalent of @_,
        #                       may be an empty list)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $worldCmd, @groupStringList) = @_;

        # Local variables
        my ($obj, $scriptName);

        # Check for improper arguments
        if (! defined $session || ! defined $interfaceNum || ! defined $worldCmd) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->aliasNotifySeen', @_);
        }

        # Basic check - the alias should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired alias
        $scriptName = $obj->ivShow('propertyHash', 'new_script');
        if ($scriptName) {

            # SETALIAS statement specified a second script to run
            return $self->session->pseudoCmd('runscript ' . $scriptName);

        } else {

            # No second script to run. Notify the Axbasic programme that one of its dependent
            #   aliases has fired
            $self->scriptObj->interfaceNotification($obj, $worldCmd, @groupStringList);
        }

        return 1;
    }

    sub macroNotifySeen {

        # Called by GA::Session->checkMacros
        #
        # The Axbasic statement SETMACRO causes the script to create a macro to notice a keypress or
        #   a combination of keypresses
        #      e.g. 'ctrl f1'
        #
        # If SETMACRO specified a second script to run, this function runs it. Otherwise, this
        #   function notifies the script that the keycode string was processed (and only the script
        #   will do something in response)
        #
        # The macro interface has the following properties in ->propertyHash:
        #   new_script       - The name of the second script to run (only used in calls to
        #                       $self->macroExecSeen, 'undef' in calls to this function)
        #
        # Expected arguments (standard args from GA::Session->checkMacros)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active macro interface that fired
        #   $keycodeString  - The keycode string for keypress(es) that caused the macro to fire
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $keycodeString, $check) = @_;

        # Local variables
        my ($obj, $scriptName);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $keycodeString
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->macroNotifySeen', @_);
        }

        # Basic check - the macro should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired macro
        $scriptName = $obj->ivShow('propertyHash', 'new_script');
        if ($scriptName) {

            # SETMACRO statement specified a second script to run
            return $self->session->pseudoCmd('runscript ' . $scriptName);

        } else {

            # No second script to to run. Notify the Axbasic programme that one of its dependent
            #   macros has fired
            $self->scriptObj->interfaceNotification($obj, $keycodeString);

            return 1;
        }
    }

    sub timerNotifySeen {

        # Called by GA::Session->checkTimers
        #
        # The Axbasic statement SETTIMER causes the script to notice when a timer it has created has
        #   fired
        #
        # If SETTIMER specified a second script to run, this function runs it. Otherwise, this
        #   function notifies the script that the timer has fired (and only the script will do
        #   something in response)
        #
        # The timer interfaces have the following properties in ->propertyHash:
        #   new_script       - The name of the second script to run (only used in calls to
        #                       $self->timerExecSeen, 'undef' in calls to this function)
        #
        # Expected arguments (standard args from GA::Session->checkTimers)
        #   $session         - The calling function's GA::Session
        #   $interfaceNum    - The number of the active timer interface that fired
        #   $sessionTime     - The value of GA::Session->sessionTime when the hook fired
        #   $otherTime       - For timers whose stimulus is an interval, the time (matches
        #                       GA::Session->sessionTime) at which the timer was due to fire. This
        #                       value will be less than or the same as $sessionTime. For timers
        #                       whose stimulus is a clock time (in the form HH:MM firing once a day,
        #                       or 99:MM firing once an hour, at MM minutes past the hour), the
        #                       stimulus itself
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $sessionTime, $otherTime, $check) = @_;

        # Local variables
        my ($obj, $scriptName);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $sessionTime
            || ! defined $otherTime || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->timerNotifySeen', @_);
        }

        # Basic check - the timer should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired timer
        $scriptName = $obj->ivShow('propertyHash', 'new_script');
        if ($scriptName) {

            # SETTIMER statement specified a second script to run
            return $self->session->pseudoCmd('runscript ' . $scriptName);

        } else {

            # No second script to to run. Notify the Axbasic programme that one of its dependent
            #   timers has fired
            $self->scriptObj->interfaceNotification($obj, $sessionTime, $otherTime);

            return 1;
        }
    }

    sub hookNotifySeen {

        # Called by GA::Session->checkHooks
        #
        # The Axbasic statement SETHOOK causes the script to create a hook to notice when a hook
        #   event has taken place
        #      e.g. 'system_text'
        #
        # If SETHOOK specified a second script to run, this function runs it. Otherwise, this
        #   function notifies the script that the hook event has occured (and only the script wull
        #   do something in response)
        #
        # The hook interface has the following properties in ->propertyHash:
        #   new_script       - The name of the second script to run (only used in calls to
        #                       $self->hookExecSeen, 'undef' in calls to this function)
        #
        # Expected arguments (standard args from GA::Session->checkHooks)
        #   $session         - The calling function's GA::Session
        #   $interfaceNum    - The number of the active hook interface that fired
        #   $hookEvent       - The associated hook event
        #
        # Optional arguments
        #   $hookVar         - Hook data for this hook event, or 'undef' if the hook event doesn't
        #                       provide hook data
        #   $hookVal         - Additional hook data for this hook event, or 'undef' if the hook
        #                       even doesn't provide additional data
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my ($self, $session, $interfaceNum, $hookEvent, $hookVar, $hookVal, $check) = @_;

        # Local variables
        my ($obj, $scriptName);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $hookEvent || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->hookNotifySeen', @_);
        }

        # Basic check - the hook should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired hook
        $scriptName = $obj->ivShow('propertyHash', 'new_script');
        if ($scriptName) {

            # SETHOOK statement specified a second script to run
            return $self->session->pseudoCmd('runscript ' . $scriptName);

        } else {

            # No second script to to run. Notify the Axbasic programme that one of its dependent
            #   hooks has fired
            $self->scriptObj->interfaceNotification($obj, $hookEvent, $hookVar, $hookVal);

            return 1;
        }
    }

    sub entryCallback {

        # Usually called by ->signal_connect in GA::Strip::Entry->setEntrySignals or in
        #   GA::Table::Entry->setActivateEvent, when the user types something in the strip/table
        #   object's Gtk3::Entry and presses RETURN
        # If the script is paused (->scriptStatus is 'paused') and is expecting input (->inputList
        #   is not empty), send the typed value to be stored in one of the script's variables
        # Then, if no more input is expected, resume executing the script
        #
        # Expected arguments
        #   $obj        - The strip or table object whose Gtk3::Entry was used
        #   $entry      - The Gtk3::Entry itself
        #
        # Optional arguments
        #   $id         - A value passed to the table object that identifies the particular
        #                   Gtk3::Entry used (in case the table object uses multiple entries). By
        #                   default, $self->openWin sets $id to the same as $self->uniqueName;
        #                   could be an 'undef' value otherwise
        #   $text       - The text typed in the entry by the user (should not be 'undef')

        my ($self, $obj, $entry, $id, $text, $check) = @_;

        # Local variables
        my $result;

        # Check for improper arguments
        if (! defined $obj || ! defined $entry || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->entryCallback', @_);
        }

        if ($self->winObj && $self->scriptObj->inputList) {

            # Display the last typed value in the task window
            $self->insertText($text, 'echo');

            # Pass the typed value on to the script
            if ($self->scriptObj->setInputData($text)) {

                # If no more input is expected...
                if (
                    $self->scriptObj->scriptStatus eq 'wait_input'
                    && ! $self->scriptObj->inputList
                ) {
                    # The next text displayed should be on a new line (since the user pressed
                    #   RETURN)
                    $self->insertText('', 'before');
                    # Allow execution to resume
                    $self->scriptObj->implement();
                }
            }
        }

        return 1;
    }

    ##################
    # Accessors - set

    sub set_scriptName {

        # Called by GA::Session->setupLogin and ->startInitScripts
        # Should be called immediately after calling $self->new

        my ($self, $name, $check) = @_;

        # Check for improper arguments
        if (! defined $name || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_scriptName', @_);
        }

        # Only set the IV once
        if (! defined $self->scriptName) {

            $self->ivPoke('scriptName', $name);
        }

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub stepMax
        { $_[0]->{stepMax} }
    sub scriptName
        { $_[0]->{scriptName} }
    sub scriptObj
        { $_[0]->{scriptObj} }

    sub waitForInterface
        { $_[0]->{waitForInterface} }
    sub monitorTask
        { $_[0]->{monitorTask} }
    sub monitorStatistic
        { $_[0]->{monitorStatistic} }
    sub monitorTarget
        { $_[0]->{monitorTarget} }
    sub monitorTimeOut
        { $_[0]->{monitorTimeOut} }

    sub gaugeStripObj
        { $_[0]->{gaugeStripObj} }
    sub gaugeLevel
        { $_[0]->{gaugeLevel} }
    sub gaugeHash
        { my $self = shift; return %{$self->{gaugeHash}}; }
    sub statusBarHash
        { my $self = shift; return %{$self->{statusBarHash}}; }

    sub forcedWinFlag
        { $_[0]->{forcedWinFlag} }
}

{ package Games::Axmud::Task::Status;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Status task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'status_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'status_task';
        $self->{prettyName}             = 'Status';
        $self->{shortName}              = 'St';
        $self->{shortCutIV}             = 'statusTask';     # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Intercept information about current character\'s status';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = TRUE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = FALSE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{tabMode}                = 'simple';
        $self->{winUpdateFunc}          = 'refreshWin';
        $self->{monochromeFlag}         = TRUE;
        $self->{noScrollFlag}           = TRUE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'status';
        $self->{ttsAttribHash}          = {
            'status'                    => undef,
            'life'                      => undef,
            'lives'                     => undef,
            'health'                    => undef,
            'magic'                     => undef,
            'energy'                    => undef,
            'guild'                     => undef,
            'social'                    => undef,
            'xp'                        => undef,
            'experience'                => undef,
            'level'                     => undef,
            'align'                     => undef,
            'alignment'                 => undef,
            'age'                       => undef,
            'time'                      => undef,
            'bank'                      => undef,
            'purse'                     => undef,
        };
        $self->{ttsFlagAttribHash}      = {
            'life'                      => FALSE,
        },
        $self->{ttsAlertAttribHash}     = {
            'healthup'                  => undef,
            'healthdown'                => undef,
            'magicup'                   => undef,
            'magicdown'                 => undef,
            'energyup'                  => undef,
            'energydown'                => undef,
            'guildup'                   => undef,
            'guilddown'                 => undef,
            'socialup'                  => undef,
            'socialdown'                => undef,
        };
        $self->{status}                 = 'wait_init';
        # ->activeFlag set to TRUE if the task should actively send commands to the world to gather
        #   data about the character's status every few seconds; set to FALSE if the task should
        #   passively collect status data
        # When TRUE, the task uses gagged triggers by default (lines of text received from the world
        #   containing data are not displayed in the 'main' window). When FALSE, un-gagged triggers
        #   are used by default and the received lines are displayed in the 'main' window
        # However, whether to use gagged triggers (or not) mostly depends on the current world
        #   profile, which can choose to overrule the Status task most of the time (or not)
        # Triggers for bar patterns and stat patterns are not affected by $self->activeFlag because
        #   they have their own gag settings
        # Triggers relating to $self->lifeStatus (e.g. 'You die', 'You fall asleep', 'You wake up'
        #   etc) are never gagged
        $self->{activeFlag}             = FALSE;    # Task can be activated/disactivated

        # Task parameters
        #
        # If ->activeFlag = TRUE, a hash of the commands that should be sent to the world, and how
        #   often they should be sent. Hash in the form
        #       $cmdHash{command} = interval
        $self->{cmdHash}                = {};
        # If ->activeFlag = TRUE, the names of any dependent timers created. Hash in the form
        #   $timerHash{command} = dependent_timer_object
        $self->{timerHash}              = {};

        # Flag set to TRUE every time the task's window should be updated, and set back to FALSE
        #   when it is actually updated
        $self->{updateFlag}             = FALSE;
        # Flag set to TRUE every time the life status changes (so that the background colour of the
        #   task window can be changed), set back to FALSE once the background colour has actually
        #   been changed
        $self->{lifeStatusChangeFlag}   = FALSE;
        # Flag set to TRUE every time the character's health points change from one band to another
        #   (which means the task window's background colour must change) - bands are 0-10%, 11-30%,
        #   31-50%, 51-100%
        $self->{healthChangeFlag}       = FALSE;
        # If this flag is set to TRUE, the task attempts to convert time strings (e.g. 'twenty past
        #   two in the morning') into a 24-hour clock time using the current dictionary. The flag
        #   is set to TRUE at stage 2 if the current dictionary provides enough vocabulary
        $self->{convertTimeFlag}        = FALSE;

        # When this flag is TRUE, the background colour of the task window changes, depending on
        #   whether the character is alive, dead, asleep (etc) and also depending on how many health
        #   points the character has. If FALSE, the colour scheme never changes
        $self->{allowColourFlag}        = TRUE;
        # The colours to use. Each value must be a standard Axmud colour tag
        # Which window background colour to use when character is alive
        $self->{aliveColour}            = 'GREEN';
        # Which colour to use when alive, but health points below 50%
        $self->{alive50Colour}          = 'YELLOW';
        # Which colour to use when alive, but health points below 30%
        $self->{alive30Colour}          = 'yellow';
        # Which colour to use when alive, but health points below 10%
        $self->{alive10Colour}          = 'RED';
        # Which window background colour to use when character is asleep
        $self->{asleepColour}           = 'cyan';
        # Which window background colour to use when character is passed out
        $self->{passedOutColour}        = 'magenta';
        # Which window background colour to use when character is dead
        $self->{deadColour}             = 'black';

        # The Status task tracks five types of variable - character, fixed, pseudo, local, counter
        #   and custom
        # Status task variables have a name_in_this_form. Names must be unique; a custom variable
        #   called 'xp_current' can't be created because there's already a character variable with
        #   that name
        # Character, fixed, local and pseudo variable names are fixed; variable names can't be added
        #   or removed from the set specified by the constant IVs below
        #
        # For character variables like 'health_points', 'xp_current' and 'life_status', values are
        #   stored in the current character profile as soon as they are received
        # For fixed variables, values are retrieved from the current character profile, and some of
        #   those values are updated by this task; but the values are not modified directly by
        #   incoming text (for example, when a line matches a pattern in
        #   GA::Profile::World->groupPatternList)
        # For pseudo variables, $self->getValue compiles a value from other values, whenever
        #   required (so the values are stored in any Status task IV)
        # For local variables, values are stored in this task (and are lost, when the task halts)
        # For counter variables, a current value stored in the character profile is compared against
        #   some baseline value (which is stored in $self->counterBaseHash)
        # Custom variables are created just by specifying them, for example, if
        #   GA::Profile::World->groupPatternList refers to a custom variable called 'my_var', as
        #   soon as a line of text is received matching the pattern, an entry for 'my_var' is
        #   created in $self->customVarHash
        #
        # A hash of character variables, and their corresponding GA::Profile::Char IV
        $self->{constCharVarHash}       = {
            # Character's health/magic/energy/guild/social points
            'health_points'             => 'healthPoints',
            'health_points_max'         => 'healthPointsMax',
            'magic_points'              => 'magicPoints',
            'magic_points_max'          => 'magicPointsMax',
            'energy_points'             => 'energyPoints',
            'energy_points_max'         => 'energyPointsMax',
            'guild_points'              => 'guildPoints',
            'guild_points_max'          => 'guildPointsMax',
            'social_points'             => 'socialPoints',
            'social_points_max'         => 'socialPointsMax',
            # eXperience Points (XP), Quest Points (QP) and a spare set of variables for worlds
            #   which use some other point system for levelling, Other Points (OP)
            'xp_current'                => 'xpCurrent',
            'xp_next_level'             => 'xpNextLevel',
            'xp_total'                  => 'xpTotal',
            'qp_current'                => 'qpCurrent',
            'qp_next_level'             => 'qpNextLevel',
            'qp_total'                  => 'qpTotal',
            'op_current'                => 'opCurrent',
            'op_next_level'             => 'opNextLevel',
            'op_total'                  => 'opTotal',
            # Character's level and alignment
            'level'                     => 'level',
            'alignment'                 => 'alignment',             # Not numeric
            # Local and remote wimpy (the maximum local wimpy is a fixed value)
            'local_wimpy'               => 'localWimpy',
            'remote_wimpy'              => 'remoteWimpy',
            'remote_wimpy_max'          => 'remoteWimpyMax',
            # Character's age (in the unit specified by the pseudo value 'age_unit')
            'age'                       => 'age',
            # Character's wealth
            'bank_balance'              => 'bankBalance',
            'purse_contents'            => 'purseContents',
            # Character's lives
            'life_count'                => 'lifeCount',
            'life_max'                  => 'lifeMax',
        };
        # A hash of fixed variables, and their corresponding GA::Profile::Char IV
        $self->{constFixedVarHash}      = {
            # Character's deaths and life status
            'death_count'               => 'deathCount',
            'life_status'               => 'lifeStatus',            # Not numeric
            # The local wimpy maximum is always 100
            'local_wimpy_max'           => 'constLocalWimpyMax',
            # Fight counts
            'fight_count'               => 'fightCount',
            'kill_count'                => 'killCount',
            'wimpy_count'               => 'wimpyCount',
            'fight_defeat_count'        => 'fightDefeatCount',
            # Interaction counts
            'interact_count'            => 'interactCount',
            'interact_success_count'    => 'interactSuccessCount',
            'interact_fail_count'       => 'interactFailCount',
            'interact_fight_count'      => 'interactFightCount',
            'interact_disaster_count'   => 'interactDisasterCount',
            # Unresolved fight/interaction counts
            'flee_count'                => 'fleeCount',
            'escape_count'              => 'escapeCount',
        };
        # A hash of pseudo variables. The value is generated by $self->getValue as and when required
        $self->{constPseudoVarHash}     = {
            # 'world', 'guild', 'race', 'char' are the names of the current world, guild, race and
            #   character
            'world'                     => undef,
            'guild'                     => undef,
            'race'                      => undef,
            'char'                      => undef,
            # A summary of character's affects and stats, stored in $self->affectHash and ->statHash
            'affects'                   => undef,
            'stats'                     => undef,
            # The value stored in Games::Axmud::Profile::World->charAgeUnit
            'age_unit'                  => undef,
            # 'money_count' is the sum of 'bank_balance' and 'purse_contents'
            'money_count'               => undef,
            # 'coward_count' is the sum of 'flee_count' and 'escape_count'
            'coward_count'              => undef,
            # A short list of currently running standard tasks (e.g. '{St Lc}' for Status, Locator)
            'task'                      => undef,
            # Show whether the Status task is active, or not
            'task_active'               => undef,
            # The user's (real) time
            'local_time'                => undef,
            # The session time, retrieved from GA::Session->sessionTime
            'session_time'              => undef,
            # Values combining values from $self->counterVarHash
            'temp_xp_average'           => undef,
            'temp_money_count'          => undef,
            'temp_timer'                => undef,
            # Strings displaying several fight/interaction values together as a single string
            'fight_string'              => undef,
            'interact_string'           => undef,
            'coward_string'             => undef,
            'temp_fight_string'         => undef,
            'temp_interact_string'      => undef,
            'temp_coward_string'        => undef,
        };
        # Hash of local variables; current values for each variable are stored in
        #   $self->localVarHash
        $self->{constLocalVarHash}      = {
            # The time according to the connected world (fictional and real)
            'time'                      => undef,
            'remote_time'               => undef,
            # Details about the current opponent (for worlds that supply them); including their
            #   relative strength (normally gathered via a 'consider' command)
            'opp_name'                  => undef,
            'opp_level'                 => undef,
            'opp_health'                => undef,
            'opp_health_max'            => undef,
            'opp_strength'              => undef,
        };
        # Hash of counter variables. Values stored in the current character profile are compared
        #   against some baseline value (stored in $self->counterBaseHash and set by a call to
        #   $self->reset_counters)
        # The ';resetcounter' command, in its call to $self->reset_counter, adds some or all of
        #   the keys in this hash to $self->counterVarHash/->counterBaseHash, which sets the
        #   counter 'running'
        $self->{constCounterVarHash}    = {
            'temp_fight_count'          => undef,
            'temp_kill_count'           => undef,
            'temp_wimpy_count'          => undef,
            'temp_fight_defeat_count'   => undef,
            'temp_interact_count'       => undef,
            'temp_interact_success_count'
                                        => undef,
            'temp_interact_fail_count'  => undef,
            'temp_interact_fight_count' => undef,
            'temp_interact_disaster_count'
                                        => undef,
            'temp_escape_count'         => undef,
            'temp_flee_count'           => undef,
            'temp_xp_count'             => undef,
            'temp_quest_count'          => undef,
            'temp_bank_count'           => undef,
            'temp_purse_count'          => undef,
        };
        # Hash of variables which are used to set counter variables. The keys are a subset of keys
        #   in $self->constCharVarHash and ->constFixedVarHash; the values are all keys that appear
        #   in $self->constCounterVarHash
        $self->{constCounterRevHash}    = {
            # from ->constCharVarHash
            'xp_total'                  => 'temp_xp_count',
            'qp_total'                  => 'temp_quest_count',
            'bank_balance'              => 'temp_bank_count',
            'purse_contents'            => 'temp_purse_count',
            # from ->constFixedVarHash
            'fight_count'               => 'temp_fight_count',
            'kill_count'                => 'temp_kill_count',
            'wimpy_count'               => 'temp_wimpy_count',
            'fight_defeat_count'        => 'temp_fight_defeat_count',
            'interact_count'            => 'temp_interact_count',
            'interact_success_count'    => 'temp_interact_success_count',
            'interact_fail_count'       => 'temp_interact_fail_count',
            'interact_fight_count'      => 'temp_interact_fight_count',
            'interact_disaster_count'   => 'temp_interact_disaster_count',
            'flee_count'                => 'temp_flee_count',
            'escape_count'              => 'temp_escape_count',
        };
        # Hash of variables that require calls to $self->checkHealthChange and/or
        #   ->checkPointsChange
        $self->{constPointHash}         = {
            'health_points'             => undef,
            'health_points_max'         => undef,
            'magic_points'              => undef,
            'magic_points_max'          => undef,
            'energy_points'             => undef,
            'energy_points_max'         => undef,
            'guild_points'              => undef,
            'guild_points_max'          => undef,
            'social_points'             => undef,
            'social_points_max'         => undef,
        };

        # A hash of local variables and their corresponding values, initialised by $self->doStage
        #   (at stage 2)
        $self->{localVarHash}           = {};
        # A hash of custom variables and their corresponding values
        $self->{customVarHash}          = {};

        # A hash of counter variables and their corresponding values, initialised by
        #   $self->reset_counters
        # The call to that function sets one or more of the counters 'running', add some or all of
        #   the variables stored as keys in $self->constCounterVarHash to these two hashes
        # A hash of counter variables and their corresponding values, relative to some baseline
        #   value
        $self->{counterVarHash}         = {};
        # A hash of counter variables and their corresponding baseline values
        $self->{counterBaseHash}        = {};
        # Is the fight counter running? (TRUE yes, FALSE no; this IV is a shortcut to checking
        #   whether some counter variables exist in $self->counterVarHash, or not)
        $self->{fightCountFlag}         = FALSE;
        # Is the interaction counter running? (TRUE yes, FALSE no; this IV is a shortcut to checking
        #   whether some counter variables exist in $self->counterVarHash, or not)
        $self->{interactCountFlag}      = FALSE;
        # The time at which any counter was last reset (matches GA::Session->sessionTime)
        $self->{counterStartTime}       = 0;

        # Hash of spells (etc) that currently affect the character, in the form
        #   $affectHash{spell} = undef
        $self->{affectHash}             = {};
        # Hash of character's current stats, including bonuses/penalties. Hash in the form
        #   ->statHash{stat} = value
        $self->{statHash}               = {};

        # Flag set to TRUE if the value of some variables should be displayed in the 'main' window's
        #   gauge box
        $self->{gaugeFlag}              = FALSE;
        # Shortcut to the strip object (GA::Strip::GaugeBox) which handles gauges in this
        #   session's 'main' window; set whenever this task adds it first gauge
        $self->{gaugeStripObj}          = undef;
        # If $self->gaugeFlag is TRUE, the unique number of the GA::Obj::GaugeLevel object created
        #   for this task (representing a horizontal strip of gauges, of which there can be 1 or
        #   more strips within the gauge box)
        $self->{gaugeLevel}             = undef;
        # Flag set to TRUE if numerical values should be displayed on top of the gauges
        $self->{gaugeValueFlag}         = TRUE;
        # List of GA::Obj::Gauge objects created by this task, each one corresponding to a gauge
        #   in the 'main' window
        $self->{gaugeObjList}           = [];
        # Flag set to TRUE in some calls to $self->set_updateFromMsdp, in which the 'main' window
        #   gauges (if visible) must be reset, not just redrawn
        $self->{gaugeResetFlag}         = FALSE;

        # A hash used for TTS alerts. The keys are TTS alert attributes for this task; the
        #   corresponding values are:
        #       -1  - the last alert message was a warning, that the points have fallen below a
        #               certain minimum
        #       +1  - the last alert message was a confirmation, that the points have recovered
        #               above a certain minimum
        #        0  - no alert message has been read out yet
        # NB The initial value is 1; if the player connects to the world and their health points
        #   (for example) are very low, tell them immediately, but no need to tell them if their
        #   health points are very high
        $self->{ttsPointsAlertHash}     = {
            'health'                    => 1,
            'magic'                     => 1,
            'energy'                    => 1,
            'guild'                     => 1,
            'social'                    => 1,
        };
        # Every time $self->checkPointsChange decides it's time to issue an alert, the TTS alert
        #   message to read out is stored here. The message isn't actually read out until
        #   $self->doStage is called, so that if the code receives (for example) a new
        #   ->healthPoints score just before receiving a new ->healthPointsMax score, the task
        #   doesn't erroneously read out an alert
        # This hash gets reset by ->doStage, at stage 4, just after reading out any alerts
        $self->{ttsPointsAlertMsgHash}  = {
            'health'                    => undef,
            'magic'                     => undef,
            'energy'                    => undef,
            'guild'                     => undef,
            'social'                    => undef,
        };
        # Parallel hash telling us whether the alert message to be read is a 'high' or 'low'; each
        #   key's corresponding value is set to -1 or +1, and that value is used to set
        #   $self->ttsPointsAlertHash
        $self->{ttsPointsAlertTypeHash} = {
            'health'                    => undef,
            'magic'                     => undef,
            'energy'                    => undef,
            'guild'                     => undef,
            'social'                    => undef,
        };

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{cmdHash}               = {$self->cmdHash};
        $clone->{timerHash}             = {$self->timerHash};

        $clone->{updateFlag}            = $self->updateFlag;
        $clone->{lifeStatusChangeFlag}  = $self->lifeStatusChangeFlag;
        $clone->{healthChangeFlag}      = $self->healthChangeFlag;
        $clone->{convertTimeFlag}       = $self->convertTimeFlag;

        $clone->{allowColourFlag}       = $self->allowColourFlag;
        $clone->{aliveColour}           = $self->aliveColour;
        $clone->{alive50Colour}         = $self->alive50Colour;
        $clone->{alive30Colour}         = $self->alive30Colour;
        $clone->{alive10Colour}         = $self->alive10Colour;
        $clone->{asleepColour}          = $self->asleepColour;
        $clone->{passedOutColour}       = $self->passedOutColour;
        $clone->{deadColour}            = $self->deadColour;

        # (The constant hashes retain their own values)

        $clone->{localVarHash}          = {$self->localVarHash};
        $clone->{customVarHash}         = {$self->customVarHash};

        $clone->{counterVarHash}        = {$self->counterVarHash};
        $clone->{counterBaseHash}       = {$self->counterBaseHash};
        $clone->{fightCountFlag}        = $self->fightCountFlag;
        $clone->{interactCountFlag}     = $self->interactCountFlag;
        $clone->{counterStartTime}      = $self->counterStartTime;

        $clone->{affectHash}            = {$self->affectHash};
        $clone->{statHash}              = {$self->statHash};

        $clone->{gaugeFlag}             = $self->gaugeFlag;
        $clone->{gaugeStripObj}         = $self->gaugeStripObj;
        $clone->{gaugeLevel}            = $self->gaugeLevel;
        $clone->{gaugeValueFlag}        = $self->gaugeValueFlag;
        $clone->{gaugeObjList}          = [$self->gaugeObjList];
        $clone->{gaugeResetFlag}        = $self->gaugeResetFlag;

        $clone->{ttsPointsAlertHash}    = {$self->ttsPointsAlertHash};
        $clone->{ttsPointsAlertMsgHash} = {$self->ttsPointsAlertMsgHash};
        $clone->{ttsPointsAlertTypeHash}
                                        = {$self->ttsPointsAlertTypeHash};

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the list of commands to send
        $newTask->ivPoke('cmdHash', $self->cmdHash);
        # Preserve the background colour scheme
        $newTask->ivPoke('allowColourFlag', $self->allowColourFlag);
        $newTask->ivPoke('aliveColour', $self->aliveColour);
        $newTask->ivPoke('alive50Colour', $self->alive50Colour);
        $newTask->ivPoke('alive30Colour', $self->alive30Colour);
        $newTask->ivPoke('alive10Colour', $self->alive10Colour);
        $newTask->ivPoke('asleepColour', $self->asleepColour);
        $newTask->ivPoke('passedOutColour', $self->passedOutColour);
        $newTask->ivPoke('deadColour', $self->deadColour);
        # Preserve the counters
        $newTask->ivPoke('counterVarHash', $self->counterVarHash);
        $newTask->ivPoke('counterBaseHash', $self->counterBaseHash);
        $newTask->ivPoke('fightCountFlag', $self->fightCountFlag);
        $newTask->ivPoke('interactCountFlag', $self->interactCountFlag);
        $newTask->ivPoke('counterStartTime', $self->counterStartTime);
        # Preserve some gauge variables
        $newTask->ivPoke('gaugeFlag', $self->gaugeFlag);
        $newTask->ivPoke('gaugeValueFlag', $self->gaugeFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

    sub ttsReadAttrib {

        # Called by GA::Cmd::Read->do and PermRead->do
        # Users can use the client command ';read' to interact with individual tasks, typically
        #   getting them to read out information (e.g. the Status task can read out current health
        #   points)
        # The ';read' command is in the form ';read <attribute>' or ';read <attribute> <value>'.
        #   The ';read' command looks up the <attribute> in GA::Client->ttsAttribHash, which tells
        #   it which task to call
        #
        # Expected arguments
        #   $attrib     - The TTS attribute specified by the calling function. Must be one of the
        #                   keys in $self->ttsAttribHash
        #
        # Optional arguments
        #   $value      - The value specified by the calling function (or 'undef' if none was
        #                   specified)
        #   $noReadFlag - Set to TRUE when called by GA::Cmd::PermRead->do, in which case only this
        #                   task's hash of attributes is updated. If set to FALSE (or 'undef'),
        #                   something is usually read aloud, too
        #
        # Return values
        #   'undef' on improper arguments, if the current character profile isn't set or if the
        #       $attrib doesn't exist in this task's ->ttsConfig
        #   1 otherwise

        my ($self, $attrib, $value, $noReadFlag, $check) = @_;

        # Local variables
        my ($charObj, $string, $iv, $ivMax, $time);

        # Check for improper arguments
        if (! defined $attrib || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->ttsReadAttrib', @_);
        }

        # Import the current character
        $charObj = $self->session->currentChar;
        if (! $charObj) {

            return undef;
        }

        # TTS attributes are case-insensitive
        $attrib = lc($attrib);

        # Check that the specified attribute is actually used by this task (';read' or ';permread'
        #   should carry out this check, but better safe than sorry)
        if (! $self->ivExists('ttsAttribHash', $attrib)) {

            return undef;

        } elsif ($noReadFlag) {

            # When called by GA::Cmd::PermRead->do, don't read out anything, just update the hash
            #   of attributes (when appropriate)

            # (no attributes require an update)

            return 1;

        } else {

            # Custom behaviour for this task

            if ($attrib eq 'status') {

                if (! $charObj->lifeStatus) {
                    $string = 'Life status: not set';
                } else {
                    $string = 'Life status: ' . $charObj->lifeStatus;
                }

            } elsif ($attrib eq 'life' || $attrib eq 'lives') {

                if (
                    ! defined $charObj->lifeCount
                    && ! defined $charObj->deathCount
                    && ! defined $charObj->lifeMax
                ) {
                    $string = 'Lives: not set';

                } else {

                    if (! defined $charObj->lifeCount) {
                        $string = 'Lives: not set, ';
                    } else {
                        $string = 'Lives: ' . $charObj->lifeCount . ', ';
                    }

                    if (! defined $charObj->lifeMax) {
                        $string .= 'maximum: not set, ';
                    } else {
                        $string .= 'maximum: ' . $charObj->lifeMax . ', ';
                    }

                    if (! defined $charObj->deathCount) {
                        $string .= 'deaths: not set.';
                    } else {
                        $string .= 'deaths: ' . $charObj->deathCount . '.';
                    }
                }

            } elsif (
                $attrib eq 'health'
                || $attrib eq 'magic'
                || $attrib eq 'energy'
                || $attrib eq 'guild'
                || $attrib eq 'social'
            ) {
                $iv = $attrib . 'Points';           # e.g. ->healthPoints
                $ivMax = $attrib . 'PointsMax';     # e.g. ->healthPointsMax

                if (! defined $charObj->$iv && ! defined $charObj->$ivMax) {

                    $string = ucfirst($attrib) . ' points: not set';

                } else {

                    if (! defined $charObj->$iv) {
                        $string = ucfirst($attrib) . ' points: not set, ';
                    } else {
                        $string = ucfirst($attrib) . ' points: ' . $charObj->$iv . ', ';
                    }

                    if (! defined $charObj->$ivMax) {
                        $string .= 'maximum: not set.';
                    } else {
                        $string .= 'maximum: ' . $charObj->$ivMax . '.';
                    }
                }

            } elsif ($attrib eq 'xp' || $attrib eq 'experience') {

                if (
                    ! defined $charObj->xpCurrent
                    && ! defined $charObj->xpNextLevel
                    && ! defined $charObj->xpTotal
                ) {
                    $string = 'Experience: not set';

                } else {

                    if (! defined $charObj->xpCurrent) {
                        $string = 'Experience points: not set. ';
                    } else {
                        $string = 'Experience points: ' . $charObj->xpCurrent . '. ';
                    }

                    # Not all worlds use ->xpNextLevel and ->xpTotal
                    if (defined $charObj->xpNextLevel) {

                        $string .= 'To next level: ' . $charObj->xpNextLevel . '. ';
                    }

                    if (defined $charObj->xpTotal) {

                        $string .= 'Total: ' . $charObj->xpTotal . '. ';
                    }
                }

            } elsif ($attrib eq 'level') {

                if (! $charObj->level) {
                    $string = 'Level: not set';
                } else {
                    $string = 'Level: ' . $charObj->level;
                }

            } elsif ($attrib eq 'align' || $attrib eq 'alignment') {

                if (! $charObj->alignment) {
                    $string = 'Alignment: not set';
                } else {
                    $string = 'Alignment: ' . $charObj->alignment;
                }

            } elsif ($attrib eq 'age') {

                if (! $charObj->age) {
                    $string = 'Age: not set';
                } else {
                    $string = 'Age: ' . $charObj->age;
                }

            } elsif ($attrib eq 'time') {

                $time = $self->getValue('time');

                if (! $time) {
                    $string = 'Game time: not set';
                } else {
                    $string = 'Game time: ' . $time;
                }

            } elsif ($attrib eq 'bank') {

                if (! $charObj->bankBalance) {
                    $string = 'Bank balance: not set';
                } else {
                    $string = 'Bank balance: ' . $charObj->bankBalance;
                }

            } elsif ($attrib eq 'purse') {

                if (! $charObj->age) {
                    $string = 'Purse contents: not set';
                } else {
                    $string = 'Purse contents: ' . $charObj->bankBalance;
                }
            }

            # Read out the string
            if (! $string) {
                $self->ttsQuick('General text-to-speech error');
            } else {
                $self->ttsQuick($string);
            }

            # Operation complete
            return 1;
        }
    }

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

    sub ttsSetAlertAttrib {

        # Called by GA::Cmd::Alert->do and PermAlert->do
        # Users can use the client command ';alert' to interact with individual tasks, typically
        #   instructing them to read out information some time later (e.g. the Status task can read
        #   out an alert when health points drop below a certain level or recover to a certain
        #   level)
        # The ';alert' command is in the form ';alert <alert_attribute>' or
        #   ';alert <alert_attribute> <value>'. The ';alert' command looks up the <alert_attribute>
        #   in GA::Client->ttsAlertAttribHash, which tells it which task to call
        #
        # Expected arguments
        #   $alertAttrib    - The TTS attribute specified by the calling function. Must be one of
        #                       the keys in $self->ttsAttribHash
        #
        # Optional arguments
        #   $value          - The value specified by the calling function (or 'undef' if none was
        #                       specified)
        #   $noSpecialFlag  - Set to TRUE when called by GA::Cmd::PermAlert->do, in which case only
        #                       this task's hash of alert attributes is updated. Otherwise set to
        #                       FALSE (or 'undef'), in which case other things can happen when an
        #                       alert is set. For all built-in tasks, there is no difference in
        #                       behaviour
        #
        # Return values
        #   'undef' on improper arguments or if the $attrib doesn't exist in this task's
        #       ->ttsAlertAttribHash
        #   Otherwise returns a confirmation message for the calling function to display

        my ($self, $alertAttrib, $value, $noSpecialFlag, $check) = @_;

        # Local variables
        my (
            $msg,
            @typeList,
        );

        # Check for improper arguments
        if (! defined $alertAttrib || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->ttsSetAlertAttrib', @_);
        }

        # TTS attributes are case-insensitive
        $alertAttrib = lc($alertAttrib);

        # Check that the specified attribute is actually used by this task (';alert' should carry
        #   out this check, but better safe than sorry)
        if (! $self->ivExists('ttsAlertAttribHash', $alertAttrib)) {

            return undef;

        } else {

            # If a current task performs some kind of action, when an alert attribute is set, the
            #   code for the action should be placed here. (Tasks in the global initial tasklist
            #   can't perform an action, of course.)
            if (! $noSpecialFlag) {

                # (no actions to perform)
            }

            # Custom behaviour for this task

            @typeList = ('health', 'magic', 'energy', 'guild', 'social');
            foreach my $type (@typeList) {

                # (e.g. 'healthup', 'healthdown')
                if ($alertAttrib eq $type . 'up' || $alertAttrib eq $type . 'down') {

                    if (! $axmud::CLIENT->intCheck($value, 0, 100)) {

                        # Use a default minimum/maximum percentage
                        if ($alertAttrib eq $type . 'down') {
                            $value = 20;
                        } else {
                            $value = 90;
                        }
                    }

                    $self->ivAdd('ttsAlertAttribHash', $alertAttrib, $value);

                    $msg = 'Text-to-speech ' . $type . ' alert set at ';

                    if ($alertAttrib eq $type . 'down') {
                        $msg .= 'minimum ';
                    } else {
                        $msg .= 'maximum ';
                    }

                    $msg .= $value . '% percent';
                }
            }

            return $msg;
        }
    }

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

    sub openWin {

        # Called by the task's ->main method or by its ->toggleWin method
        # Open a task window for this task
        #
        # Expected arguments
        #   (none besides self)
        #
        # Optional arguments
        #   $winmap     - The winmap to use in the task window (matches a key in
        #                   GA::Client->winmapHash). If 'undef', $self->winmap is used
        #
        # Return values
        #   'undef' on improper arguments, if $winmap (when specified) doesn't exist or if a window
        #       is not opened
        #   1 if a window is opened

        my ($self, $winmap, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->openWin', @_);
        }

        # Most of the code is in the generic function
        $self->Games::Axmud::Generic::Task::openWin($winmap);

        # Set the update flag, so that the task window will display its first text on the next task
        #   loop (in case the task was running without a window, and its window has just been
        #   opened)
        $self->ivPoke('updateFlag', TRUE);
        # Set the life status flag, so that the correct background colour is also displayed
        $self->ivPoke('lifeStatusChangeFlag', TRUE);

        return 1;
    }

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

    sub doShutdown {

        # Called by $self->main, just before the task completes a shutdown
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doShutdown', @_);
        }

        # Delete the independent timers created by this task (independent interfaces are not
        #   automatically deleted when a task ends, unlike dependent interfaces, which are deleted)
        $self->disactivate();

        # Remove any 'main' window gauges. (Assuming that nothing else is putting gauges on this
        #   task's ->gaugeLevel, then GA::Strip::GaugeBox->removeGaugeLevel will automatically be
        #   called)
        if ($self->gaugeObjList) {

            # The FALSE argument means 'don't keep an empty gauge level'
            $self->gaugeStripObj->removeGauges($self->session, FALSE, $self->gaugeObjList);
        }

        return 1;
    }

    sub doReset {

        # Called by $self->main, just before the task completes a reset
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object (not required by this function)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # Delete the independent timers created by this task (independent interfaces are not
        #   automatically deleted when a task ends, unlike dependent interfaces, which are deleted)
        $self->disactivate();

        # Remove any 'main' window gauges
        if ($self->gaugeObjList) {

            $self->gaugeStripObj->removeGaugeLevel($self->session, $self->gaugeLevel);
        }

        return 1;
    }

    sub doFirstStage {

        # Called by $self->main, just before the task completes the first stage ($self->stage)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $msg;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doFirstStage', @_);
        }

        # Display a holding message in the task window, if necessary
        if (! $self->session->loginFlag && ! $self->session->currentChar) {
            $msg = '<waiting for login and current character>';
        } elsif (! $self->session->loginFlag) {
            $msg = '<waiting for login>';
        } elsif (! $self->session->currentChar) {
            $msg = '<waiting for current character>';
        }

        if ($msg) {

            # Empty the task window, and show the message
            $self->insertText($msg, 'empty');
        }

        return 1;
    }

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Can't do anything until the current character has been set (we check for logins at
            #   stage 3)
            if (! $self->session->currentChar) {

                # Repeat this stage indefinitely
                return $self->ivPoke('stage', 2);
            }

            # Initialise the hash of local variables
            $self->ivPoke('localVarHash', $self->constLocalVarHash);

            # Initialise this task's own ->statHash
            $self->ivPoke('statHash', $self->session->currentWorld->statHash);

            # Decide whether to try to convert time strings (e.g. 'twenty past two in the morning')
            #   into 24-hour clock strings using the current dictionary
            # If the dictionary's clock string lists are empty, don't try to convert time strings
            if (
                $self->session->currentDict->clockDayHash
                || $self->session->currentDict->clockHourHash
                || $self->session->currentDict->clockMinuteHash
            ) {
                $self->ivPoke('convertTimeFlag', TRUE);
            } else {
                $self->ivPoke('convertTimeFlag', FALSE);
            }

            # Set the update flag, so that the task window will display its first text on the next
            #   task loop
            $self->ivPoke('updateFlag', TRUE);
            # Set the life status flag, so that the correct background colour is also displayed
            $self->ivPoke('lifeStatusChangeFlag', TRUE);

            # Create 'main' window gauges (if allowed)
            $self->resetGauges();

            # Create triggers to capture statistics from the world
            $self->resetTriggers();

            # Trigger creation complete
            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            my $charObj;

            # Can't do anything until the current character has been set, and has logged in to the
            #   world
            $charObj = $self->session->currentChar;
            if (! $charObj || ! $self->session->loginFlag) {

                # Don't start sending commands, or updating the status screen, until login is
                #   complete. Repeat this stage indefinitely until that time
                return $self->ivPoke('stage', 3);

            } else {

                if ($charObj) {

                    # Get the list of commands the task can send to the world (usually 'score',
                    #   'time' etc) every few seconds
                    $self->ivPoke('cmdHash', $charObj->statusCmdHash);

                    # Set this task's active flag to the value stored in the character profile
                    if (defined $charObj->statusCmdFlag) {

                        $self->ivPoke('activeFlag', $charObj->statusCmdFlag);
                    }
                }

                # If the task is in 'active' mode, we can start sending commands to the world
                if ($self->activeFlag) {

                    # (However, don't auto-activate when the session is connected to a world in
                    #   'offline' mode)
                    if ($self->session->status eq 'offline') {

                        $self->ivPoke('activeFlag', FALSE);

                    } else {

                        $self->activate();
                    }
                }

                return $self->ivPoke('stage', 4);
            }

        } elsif ($self->stage == 4) {

            # Every time the task loop runs, perform any necessary updates

            # Read out a TTS message, if required
            if ($self->lifeStatusChangeFlag) {

                if ($self->ivShow('ttsFlagAttribHash', 'life')) {

                    if ($self->lifeStatus eq 'sleep') {
                        $self->ttsQuick('Life status set to: asleep');
                    } elsif ($self->lifeStatus eq 'passout') {
                        $self->ttsQuick('Life status set to: passed out');
                    } else {
                        $self->ttsQuick('Life status set to: ' . $self->lifeStatus);
                    }
                }
            }

            # If there have been any data updates...
            if ($self->updateFlag || $self->lifeStatusChangeFlag) {

                # Update the task window (if it is still open)
                if ($self->taskWinFlag) {

                    $self->refreshWin();

                } else {

                    # Update 'main' window gauges; this function call is normally made from
                    #   ->refreshWin. It looks inelegant not to call ->setGauges right here, in all
                    #   circumstances, but doing it this way means that anything can call
                    #   ->refreshWin
                    if (! $self->gaugeResetFlag) {

                        $self->setGauges();

                    } else {

                        # New custom variable has been added. Gauges must be reset, not just
                        #   redrawn
                      $self->resetGauges();
                    }

                    $self->ivPoke('updateFlag', FALSE);
                    $self->ivPoke('lifeStatusChangeFlag', FALSE);
                    $self->ivPoke('gaugeResetFlag', FALSE);
                }
            }

            # Read out any TTS alert messages
            foreach my $attrib (sort {$a cmp $b} ($self->ivKeys('ttsPointsAlertMsgHash'))) {

                if ($self->ivShow('ttsPointsAlertMsgHash', $attrib)) {

                    # Read the stored alert message
                    $self->ttsQuick($self->ivShow('ttsPointsAlertMsgHash', $attrib));
                    # Don't read consecutive alert messages of the same type
                    $self->ivAdd(
                        'ttsPointsAlertHash',
                        $attrib,
                        $self->ivShow('ttsPointsAlertTypeHash', $attrib),
                    );
                }
            }

            # Reset the alert message hashes, so that each message is read only once
            $self->ivPoke(
                'ttsPointsAlertMsgHash',
                    'health'    => undef,
                    'magic'     => undef,
                    'energy'    => undef,
                    'guild'     => undef,
                    'social'    => undef,
            );
            # (This IV has the same default state)
            $self->ivPoke('ttsPointsAlertTypeHash', $self->ttsPointsAlertMsgHash);

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 4);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub activate {

        # Called by $self->main or GA::Cmd::ActivateStatusTask->do
        # Starts sending commands to the world (such as 'score', 'sc' etc) every few seconds in
        #   order to keep the Status task's IVs up to date
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my %cmdHash;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->activate', @_);
        }

        # Import the hash of commands and corresponding time intervals
        %cmdHash = $self->cmdHash;
        foreach my $cmd (keys %cmdHash) {

            my ($interval, $timerObj);

            $interval = $cmdHash{$cmd};

            # Each command should be sent for the first time right now, in order to update the
            #   task window as quickly as possible
            $self->session->worldCmd($cmd);

            # Create independent timers to send the command to the world every few seconds
            $timerObj = $self->session->createIndependentInterface(
                'timer',
                $interval,          # Stimulus
                $cmd,           # Response
            );

            # Remember the interface object, so it can be deleted when the task shuts down
            #   (actually, when $self->disactivate is called)
            $self->ivAdd('timerHash', $cmd, $timerObj);
        }

        # Mark the task as being in 'active' mode
        $self->ivPoke('activeFlag', TRUE);
        # Reset the task's triggers, using this flag to set each trigger as 'gagged' (or not) as
        #   appropriate
        $self->resetTriggers();

        $self->refreshWin();

        return 1;
    }

    sub disactivate {

        # Called by $self->main or GA::Cmd::DisactivateStatusTask->do
        # Stops sending commands to the world (usually 'score', 'sc' etc) every few seconds
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->disactivate', @_);
        }

        # Import the hash of independent timer interfaces
        foreach my $obj ($self->ivValues('timerHash')) {

            # Delete the timer. The TRUE argument means 'don't show an error message if the
            #   interface has already been deleted (probably during a disconnection)'
            $self->session->deleteInterface($obj->name, TRUE);
        }

        $self->ivEmpty('timerHash');

        # Mark the task as being in 'disactivated' mode
        $self->ivPoke('activeFlag', FALSE);
        # Reset the task's triggers, using this flag to set each trigger as 'gagged' (or not) as
        #   appropriate (but don't bother if the task is shutting down)
        if (! $self->shutdownFlag) {

            $self->resetTriggers();
        }

        $self->refreshWin();

        return 1;
    }

    sub refreshWin {

        # Called by $self->doStage, stage 4
        # Displays information about the current location in the task window
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $check) = @_;

        # Local variables
        my (
            $charObj, $health, $colour,
            @formatList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->refreshWin', @_);
        }

        # This function can be called by $self->disactivate, when Axmud closes down, in which case
        #   there might not be a window still open. If that's the case, there's nothing for this
        #   function to do
        # Also nothing to do if there's no current character profile
        $charObj = $self->session->currentChar;
        if (! $self->taskWinFlag || ! $charObj) {

            return undef;
        }

        # If there's been a change in life status, or if the character's health points have moved
        #   into a new band, then the task window's background colour must also be changed
        if (
            $self->allowColourFlag
            && ($self->lifeStatusChangeFlag || $self->healthChangeFlag)
        ) {
            if ($charObj->lifeStatus eq 'alive') {

                # If either ->healthPoints or ->healthPointsMax not known, assume health is 100%
                if (
                    ! defined $charObj->healthPoints
                    || ! defined $charObj->healthPointsMax
                    || $charObj->healthPointsMax == 0
                ) {
                    $colour = $self->aliveColour;

                } else {

                    $health = $charObj->healthPoints / $charObj->healthPointsMax;
                    if ($health <= 0.10) {
                        $colour = $self->alive10Colour;
                    } elsif ($health <= 0.30) {
                        $colour = $self->alive30Colour;
                    } elsif ($health <= 0.50) {
                        $colour = $self->alive50Colour;
                    } else {
                        $colour = $self->aliveColour;
                    }
                }

            } elsif ($charObj->lifeStatus eq 'sleep') {

                $colour = $self->asleepColour;

            } elsif ($charObj->lifeStatus eq 'passout') {

                $colour = $self->passedOutColour;

            } elsif ($charObj->lifeStatus eq 'dead') {

                $colour = $self->deadColour;

            } else {

                # Just in case $lifeStatus has been set to a valid value, use the default colour
                $colour = $self->aliveColour;
            }

            # Set the background colour
            $self->defaultTabObj->paneObj->applyMonochrome($self->defaultTabObj, $colour);

            # Update the title bar
            if ($charObj->lifeStatus eq 'sleep') {
                $self->setTaskWinTitle('(SLEEP)', TRUE);
            } elsif ($charObj->lifeStatus eq 'passout') {
                $self->setTaskWinTitle('(PASSED OUT)', TRUE);
            } elsif ($charObj->lifeStatus eq 'dead') {
                $self->setTaskWinTitle('(DEAD)', TRUE);
            } else {
                $self->setTaskWinTitle();
            }

            # Update IVs
            $self->ivPoke('lifeStatusChangeFlag', FALSE);
            $self->ivPoke('healthChangeFlag', FALSE);
        }

        # Get the text to display in the window, with all variables (in the form @variable_name@)
        #   replaced by their corresponding values
        @formatList = $self->processFormatList();
        # Display the resulting text
        $self->insertQuick(join("\n", @formatList));
        # Mark the task window as updated
        $self->ivPoke('updateFlag', FALSE);

        # Set the values displayed by any 'main' window gauges and redraw them (if visible)
        if (! $self->gaugeResetFlag) {

            $self->setGauges();

        } else {

            # New custom variable has been added. Gauges must be reset, not just redrawn
            $self->resetGauges();
            $self->ivPoke('gaugeResetFlag', FALSE);
       }

        return 1;
    }

    sub processFormatList {

        # Called by $self->refreshWin
        # Taking the current world profile's ->statusFormatList as a template, replaces all
        #   variables with their corresponding values
        # Variables are in the form @variable@, where 'variable' is one of the keys in
        #   $self->constCharVarHash, ->constFixedVarHash, ->constPseudoVarHash, ->localVarHash or
        #   ->customVarHash
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   An empty list on improper arguments, or if the task is forced to shut down here
        #   Otherwise, returns the modified @displayList

        my ($self, $taskString, $check) = @_;

        # Local variables
        my (@emptyList, @lineList);

        # Check for improper arguments
        if (defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->processFormatList', @_);
            return @emptyList;
        }

        @lineList = $self->session->currentWorld->statusFormatList;
        foreach my $line (@lineList) {

            my @matchList;

            # Get a list of variables in the form @variable@
            @matchList = $line =~ m/\@([\w\_]+)\@/g;

            foreach my $varName (@matchList) {

                my $value = $self->getValue($varName);

                if (! defined $value) {

                    # Unrecognised variable, or a variable whose corresponding value is not set;
                    #   replace the variable with a ?
                    $line =~ s/\@$varName\@/?/;

                } elsif ($varName eq 'purse_contents' || $varName eq 'bank_balance') {

                    # (For a cash value like $10.50, make sure we display 10.50 not 10.5)
                    $value = $self->roundCashValue($value, TRUE);

                    # Replace the variable with the corresponding value
                    $line =~ s/\@$varName\@/$value/;

                } else {

                    # Commify a numeric value
                    if ($axmud::CLIENT->floatCheck($value)) {

                        $value = $axmud::CLIENT->commify($value);
                    }

                    # Replace the variable with the corresponding value
                    $line =~ s/\@$varName\@/$value/;
                }
            }
        }

        return @lineList;
    }

    sub getValue {

        # Can be called by anything
        # $self->constCharVarHash, ->constFixedVarHash, ->constPseudoVarHash, ->localVarHash or
        #   ->customVarHash specify a set of variables recognised by this task
        # Given one of those variables, returns the corresponding value; either one stored somewhere
        #   else (typically in the current character profile), as an IV in this task, or a value
        #   generated by this function
        #
        # Expected arguments
        #   $var    - One of the variables stored as a key in the hashes above, e.g. 'health_points'
        #
        # Return values
        #   'undef' on improper arguments, if $var is not stored as a key in the hashes above, or if
        #       the variable's corresponding value is not set (i.e. is 'undef')
        #   Otherwise returns the variable's corresponding value

        my ($self, $var, $check) = @_;

        # Local variables
        my (
            $charObj, $iv, $string, $number,
            @taskList,
            %taskHash,
        );

        # Check for improper arguments
        if (! defined $var || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getValue', @_);
        }

        # Import the current character (for convenience)
        $charObj = $self->session->currentChar;
        $string = '';
        $number = 0;

        # Character values
        if ($self->ivExists('constCharVarHash', $var)) {

            # Get the character profile IV, and return its value
            $iv = $self->ivShow('constCharVarHash', $var);

            if ($charObj && exists $charObj->{$iv}) {
                return $charObj->$iv;
            } else {
                return undef;
            }

        # Fixed values
        } elsif ($self->ivExists('constFixedVarHash', $var)) {

            # Get the character profile IV, and return its value
            $iv = $self->ivShow('constFixedVarHash', $var);

            if ($charObj && exists $charObj->{$iv}) {
                return $charObj->$iv;
            } else {
                return undef;
            }

        # Local variables
        } elsif ($self->ivExists('localVarHash', $var)) {

            return $self->ivShow('localVarHash', $var);

        # Counter variables
        } elsif ($self->ivExists('counterVarHash', $var)) {

            if ($self->fightCountFlag || $self->interactCountFlag) {

                $string = $self->ivShow('counterVarHash', $var);
            }

            if (! defined $string || $string eq '') {
                return '(off)';
            } else {
                return $string;
            }

        } elsif ($self->ivExists('constCounterVarHash', $var)) {

            # No counters are running
            return '(off)';

        # Custom variables
        } elsif ($self->ivExists('customVarHash', $var)) {

            return $self->ivShow('customVarHash', $var);

        # Pseudo variables
        } elsif ($self->ivExists('constPseudoVarHash', $var)) {

            if ($var eq 'world') {

                return $self->session->currentWorld->name;

            } elsif ($var eq 'guild') {

                if ($self->session->currentGuild) {
                    return $self->session->currentGuild->name;
                } else {
                    return undef;
                }

            } elsif ($var eq 'race') {

                if ($self->session->currentRace) {
                    return $self->session->currentRace->name;
                } else {
                    return undef;
                }

            } elsif ($var eq 'char') {

                if ($charObj) {
                    return $charObj->name;
                } else {
                    return undef;
                }

            } elsif ($var eq 'affects') {

                foreach my $affect (sort {lc($a) cmp lc($b)} ($self->ivKeys('affectHash'))) {

                    $string .= substr($affect, 0, 3) . ' ';
                }

                # Remove the final trailing space, if there is one
                $string =~ s/\s+$//;

                return $string;

            } elsif ($var eq 'stats') {

                # If the current world's ->statOrderList is set, display stats in that order.
                #   Otherwise, display them alphabetically
                if ($self->session->currentWorld->statOrderList) {

                    # Display in the order specified by the world profile
                    foreach my $stat ($self->session->currentWorld->statOrderList) {

                        if ($self->ivExists('statHash', $stat)) {

                            $string .= substr($stat, 0, 3)  . ': '
                                        . $self->ivShow('statHash', $stat) . ' ';
                        }
                    }

                } else {

                    # Display alphabetically
                    foreach my $stat (sort {lc($a) cmp lc($b)} ($self->ivKeys('statHash'))) {

                        $string .= substr($stat, 0, 3)  . ': '
                                        . $self->ivShow('statHash', $stat) . ' ';
                    }
                }

                # Remove the final trailing space, if there is one
                $string =~ s/\s+$//;

                return $string;

            } elsif ($var eq 'age_unit') {

                $string = $self->session->currentWorld->charAgeUnit;
                # (Convert the unit to a plural, if possible)
                if ($self->session->currentDict->ivExists('timePluralHash', $string)) {

                    $string = $self->session->currentDict->ivShow('timePluralHash', $string);
                }

                return $string;

            } elsif ($var eq 'money_count') {

                if (! $charObj) {

                    return undef;

                } else {

                    if (defined $charObj->bankBalance) {

                        $number += $charObj->bankBalance;
                    }

                    if (defined $charObj->purseContents) {

                        $number += $charObj->purseContents;
                    }

                    return $number;
                }

            } elsif ($var eq 'coward_count') {

                if (! $charObj) {

                    return undef;

                } else {

                    if (defined $charObj->fleeCount) {

                        $number += $charObj->fleeCount;
                    }

                    if (defined $charObj->escapeCount) {

                        $number += $charObj->escapeCount;
                    }

                    return $number;
                }

            } elsif ($var eq 'task') {

                # Create a string representing the tasks in the current tasklist, with one addition
                #   for each type of that's running
                foreach my $taskObj ($self->session->ivValues('currentTaskHash')) {

                    $taskHash{$taskObj->shortName} = undef;
                }

                @taskList = sort {lc($a) cmp lc($b)} (keys %taskHash);
                $string = '[' . join(' ', @taskList) . ']';

                return $string;

            } elsif ($var eq 'task_active') {

                if (! $self->activeFlag) {
                    return '[NOT ACTIVE]';
                } else {
                    return '[ACTIVE]';
                }

            } elsif ($var eq 'local_time') {

                return $axmud::CLIENT->localTime;

            } elsif ($var eq 'session_time') {

                return $self->session->sessionTime;

            } elsif ($var eq 'temp_xp_average') {

                my ($xp, $kill, $success);

                if ($self->fightCountFlag || $self->interactCountFlag) {

                    $xp = $self->getCounterValue('temp_xp_count');
                    $kill = $self->getCounterValue('temp_kill_count');
                    $success = $self->getCounterValue('temp_interact_success_count');

                    if ($xp && ($kill + $success) > 0) {

                        $string = int($xp / ($kill + $success));
                    }
                }

                if ($string eq '') {
                    return 'n/a';
                } else {
                    return $string;
                }

            } elsif ($var eq 'temp_money_count') {

                if ($self->fightCountFlag || $self->interactCountFlag) {

                    $number = $self->getCounterValue('temp_bank_count')
                                + $self->getCounterValue('temp_purse_count');

                }

                return $number;

            } elsif ($var eq 'temp_timer') {

                if ($self->fightCountFlag || $self->interactCountFlag) {

                    $string = int (
                        ($self->session->sessionTime - $self->counterStartTime)
                        / 60        # Both values in seconds, so convert to minutes
                    );

                } else {

                    $string = '(off)';
                }

                return $string;

            } elsif ($var eq 'fight_string') {

                if (! $charObj) {

                    return undef;

                } else {

                    $string = 'Fig: ' . $axmud::CLIENT->commify($charObj->fightCount)
                        . ' Kl: ' . $axmud::CLIENT->commify($charObj->killCount)
                        . ' Wy: ' . $axmud::CLIENT->commify($charObj->wimpyCount)
                        . ' Df: ' . $axmud::CLIENT->commify($charObj->fightDefeatCount);

                    return $string;
                }

            } elsif ($var eq 'interact_string') {

                if (! $charObj) {

                    return undef;

                } else {

                    $string = 'Int: ' . $axmud::CLIENT->commify($charObj->interactCount)
                        . ' Sc: ' . $axmud::CLIENT->commify($charObj->interactSuccessCount)
                        . ' Fl: ' . $axmud::CLIENT->commify($charObj->interactFailCount)
                        . ' Ft: ' . $axmud::CLIENT->commify($charObj->interactFightCount)
                        . ' Ds: ' . $axmud::CLIENT->commify($charObj->interactDisasterCount);

                    return $string;
                }

            } elsif ($var eq 'coward_string') {

                if (! $charObj) {

                    return undef;

                } else {

                    $string = 'Esc: ' . $axmud::CLIENT->commify($charObj->escapeCount)
                        . ' Fl: ' . $axmud::CLIENT->commify($charObj->fleeCount);

                    return $string;
                }

            } elsif ($var eq 'temp_fight_string') {

                if (! $self->fightCountFlag) {

                    return '(Fight count off)';
                }

                $string = 'Fig: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_fight_count'))
                    . ' Kl: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_kill_count'))
                    . ' Wy: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_wimpy_count'))
                    . ' Df: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_fight_defeat_count'));

                return $string;

            } elsif ($var eq 'temp_interact_string') {

                if (! $self->interactCountFlag) {

                    return '(Interact count off)';
                }

                $string = 'Int: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_interact_count'))
                    . ' Sc: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_interact_success_count'))
                    . ' Fl: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_interact_fail_count'))
                    . ' Ft: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_interact_fight_count'))
                    . ' Ds: '
                    . $axmud::CLIENT->commify(
                        $self->getCounterValue('temp_interact_disaster_count'),
                    );

                return $string;

            } elsif ($var eq 'temp_coward_string') {

                if (! $self->fightCountFlag && ! $self->interactCountFlag) {

                    return '(Coward count off)';
                }

                $string = 'Esc: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_escape_count'))
                    . ' Fl: '
                    . $axmud::CLIENT->commify($self->getCounterValue('temp_flee_count'));

                return $string;

            } else {

                # Emergency failsafe (this shouldn't be executed)
                return undef;
            }

        } else {

            # Unrecognised variable
            return undef;
        }
    }

    sub getCounterValue {

        # Called by $self->getValue to extract a numerical value from $self->counterVarHash, or 0 if
        #   the value isn't defined (for use in sums and concatenated strings)
        #
        # Expected arguments
        #   $var        - A variable stored as a key in $self->counterVarHash, e.g.
        #                   'temp_fight_count'
        #
        # Return values
        #   'undef' on improper arguments
        #   Otherwise the specified variable's value (or 0 if the variable's value is 'undef')

        my ($self, $var, $check) = @_;

        # Local variables
        my $val;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->getCounterValue', @_);
        }

        $val = $self->ivShow('counterVarHash', $var);
        if (! defined $val) {

            $val = 0;
        }

        return $val;
    }

    sub setValue {

        # Called by several functions in this task, but can be called by anything (especially by
        #   any code that wants to update the task and the current character profile at the same
        #   time)
        # Sets a character, local or custom variable. Any related counter variable is updated at
        #   the same time
        # Fixed, pseudo and counter variables are ignored, if specified directly
        #
        # Expected arguments
        #   $var    - A character, local or custom variable (stored as a key in
        #               $self->constCharVarHash, ->localVarHash or ->customVarHash
        #
        # Optional arguments
        #   $val    - The value to set (can be 'undef')
        #
        # Return values
        #   'undef' on improper arguments, if $var is not a recognised character/local/custom
        #       variable, or if $val should be stored in the current character profile, but no such
        #       profile exists
        #   1 otherwise

        my ($self, $var, $val, $check) = @_;

        # Local variables
        my ($charObj, $iv, $oppVar, $baseVal, $attrib);

        # Check for improper arguments
        if (! defined $var || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setValue', @_);
        }

        # Ignore fixed, pseudo and counter variables
        if (
            $self->ivExists('constFixedVarHash', $var)
            || $self->ivExists('constPseudoVarHash', $var)
            || $self->ivExists('constCounterVarHash', $var)
        ) {
            return undef;
        }

        # Import the current character profile (for convenience)
        $charObj = $self->session->currentChar;

        # For the variables 'health_points'/'health_points_max', the task window's background colour
        #   must be set before setting the IV (so the previous and new values can be compared)
        if ($charObj) {

            if ($var eq 'health_points') {

                $self->checkHealthChange($val, $charObj->healthPointsMax);

            } elsif ($var eq 'health_points_max') {

                $self->checkHealthChange($charObj->healthPoints, $val);
            }
        }

        # Set character variables (stored in the current character)
        if ($charObj && $self->ivExists('constCharVarHash', $var)) {

            $iv = $self->ivShow('constCharVarHash', $var);

            # Update the current character profile
            $charObj->ivPoke($iv, $val);

        # Set local variables (stored in this task's IVs)
        } elsif ($self->ivExists('localVarHash', $var)) {

            $self->ivAdd('localVarHash', $var, $val);

        # Set custom variables (stored in this task's IVs; the custom variable is created if it
        #   doesn't exist already)
        } else {

            $self->ivAdd('localVarHash', $var, $val);
        }

        # Update counters
        if ($self->ivExists('constCounterRevHash', $var)) {

            # e.g. convert 'temp_xp_count' to 'xp_total'
            $oppVar = $self->ivShow('constCounterRevHash', $var);

            if ($self->ivExists('constCharVarHash', $oppVar)) {
                $iv = $self->ivShow('constCharVarHash', $oppVar);
            } elsif ($self->ivExists('constFixedVarHash', $oppVar)) {
                $iv = $self->ivShow('constFixedVarHash', $oppVar);
            }

            # If the right counter is running, $var will exist in $self->counterVarHash; otherwise
            #   it won't
            if ($iv && $self->ivExists('counterVarHash', $var)) {

                $baseVal = $charObj->$iv;

                if (! defined $val || ! defined $baseVal) {
                    $self->ivAdd('counterVarHash', $var, undef);
                } else {
                    $self->ivAdd('counterVarHash', $var, ($val - $baseVal));
                }
            }
        }

        # For changes to health/magic/energy/guild/social points, Check whether it's necessary to
        #   read a TTS alert
        if ($self->ivExists('constPointHash', $var)) {

            ($attrib) = split(/\_/, $var);
            $self->checkPointsChange($attrib);      # e.g. 'health'
        }

        # The task window must be updated on the next task loop
        $self->ivPoke('updateFlag', TRUE);

        return 1;
    }

    sub resetGauges {

        # Called by $self->doStage, at stage 2/4 or, $self->refreshWin or $self->set_gaugeFlag
        # Sets up gauge(s) for this session's 'main' window, displaying data gathered by this task
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments, if the current character profile isn't set or if this
        #       task isn't allowed to create gauges
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $charObj, $stripObj, $level,
            @formatList,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetGauges', @_);
        }

        # Do nothing if this task shouldn't create gauges
        if (! $self->gaugeFlag) {

            return undef;
        }

        # Import the current character profile (for convenience)
        $charObj = $self->session->currentChar;
        if (! $charObj) {

            return undef;
        }

        # Find the GA::Strip::GaugeBox object for the session's 'main' window (which is the window
        #   we'll use), if we don't already have it
        if (! $self->gaugeStripObj) {

            $stripObj = $self->session->mainWin->ivShow(
                'firstStripHash',
                'Games::Axmud::Strip::GaugeBox',
            );

            if (! $stripObj) {

                return undef;

            } else {

                $self->ivPoke('gaugeStripObj', $stripObj);
            }

        } else {

            $stripObj = $self->gaugeStripObj;
        }

        # Get a list containing the format of the gauges to display
        @formatList = $self->session->currentWorld->gaugeFormatList;

        # If any gauges have already been drawn, destroy them
        if ($self->gaugeObjList) {

            if (! @formatList) {

                # Don't keep an emtpy gauge level
                $stripObj->removeGauges($self->session, FALSE, $self->gaugeObjList);

            } else {

                # Do keep an empty gauge level
                $stripObj->removeGauges($self->session, TRUE, $self->gaugeObjList);
            }
        }

        $self->ivEmpty('gaugeObjList');

        # Import information about gauges controlled by this task (for convenience)
        if (@formatList) {

            # If no gauge level has been created for this task, create one (so that gauges
            #   controlled by other code appear on different levels)
            # NB $self->gaugeLevel will be defined if this task has just been reset
            if (! defined $self->gaugeLevel) {

                $self->ivPoke('gaugeLevel', $stripObj->addGaugeLevel($self->session));
            }

            do {

                my (
                    $var, $maxVar, $addFlag, $label, $fullCol, $emptyCol, $labelCol, $varIV,
                    $maxVarIV, $customKey, $maxCustomKey, $value, $maxValue, $gaugeObj,
                );

                $var = shift @formatList;
                $maxVar = shift @formatList;
                $addFlag = shift @formatList;
                $label = shift @formatList;
                $fullCol = shift @formatList;
                $emptyCol = shift @formatList;
                $labelCol = shift @formatList;

                # Convert $var/$maxVar (e.g. 'health_points', 'health_points_max') into current
                #   character profile IVs (e.g. ->healthPoints, ->healthPointsMax) or, for custom
                #   variables, the corresponding values stored in $self->customVarHash
                if ($self->ivExists('constCharVarHash', $var)) {

                    $varIV = $self->ivShow('constCharVarHash', $var);
                    $value = $charObj->$varIV;

                } elsif ($self->ivExists('constFixedVarHash', $var)) {

                    $varIV = $self->ivShow('constFixedVarHash', $var);
                    $value = $charObj->$varIV;

                } elsif ($self->ivExists('customVarHash', $var)) {

                    $customKey = $var;
                    $value = $self->ivShow('customVarHash', $var);
                }

                if ($self->ivExists('constCharVarHash', $maxVar)) {

                    $maxVarIV = $self->ivShow('constCharVarHash', $maxVar);
                    $maxValue = $charObj->$maxVarIV;

                } elsif ($self->ivExists('constFixedVarHash', $maxVar)) {

                    $maxVarIV = $self->ivShow('constFixedVarHash', $maxVar);
                    $maxValue = $charObj->$maxVarIV;

                } elsif ($self->ivExists('customVarHash', $maxVar)) {

                    $maxCustomKey = $maxVar;
                    $maxValue = $self->ivShow('customVarHash', $maxVar);
                }

                if (defined $value && defined $maxValue && $addFlag) {

                    $maxValue += $value;
                }

                # Sanity check: $var and $maxVar must point at real character profile IVs, or real
                #   keys in $self->customVarHash, but the other values can be 'undef'
                if (
                    (defined $varIV && defined $maxVarIV)
                    || (defined $customKey && defined $maxCustomKey)
                ) {
                    # Create a new gauge
                    $gaugeObj = $stripObj->addGauge(
                        $self->session,
                        $self->gaugeLevel,
                        $value,
                        $maxValue,
                        $addFlag,
                        $label,
                        $fullCol,
                        $emptyCol,
                        $labelCol,
                        $self->gaugeValueFlag,
                    );

                    if ($gaugeObj) {

                        $self->ivPush('gaugeObjList', $gaugeObj);
                        # (The values in the key-value pairs might be 'undef')
                        $gaugeObj->ivAdd('privateHash', 'var_iv', $varIV);
                        $gaugeObj->ivAdd('privateHash', 'max_var_iv', $maxVarIV);
                        $gaugeObj->ivAdd('privateHash', 'custom_key', $customKey);
                        $gaugeObj->ivAdd('privateHash', 'max_custom_key', $maxCustomKey);
                    }
                }

            } until (! @formatList);
        }

        return 1;
    }

    sub setGauges {

        # Called by $self->refreshWin
        # If this task is controlling any gauges in the 'main' window, tell the associated
        #   GA::Obj::Gauge object of the values currently stored by this task, then tell the 'main'
        #   window to redraw the gauges
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->setGauges', @_);
        }

        # Import the current character profile (for convenience)
        $charObj = $self->session->currentChar;

        # Do nothing if this task shouldn't create gauges or if there's no current character profile
        if (! $charObj || ! $self->gaugeFlag || ! $self->gaugeStripObj) {

            return undef;
        }

        foreach my $gaugeObj ($self->gaugeObjList) {

            my ($varIV, $maxVarIV, $customKey, $maxCustomKey, $value, $maxValue);

            $varIV = $gaugeObj->ivShow('privateHash', 'var_iv');
            $maxVarIV = $gaugeObj->ivShow('privateHash', 'max_var_iv');
            $customKey = $gaugeObj->ivShow('privateHash', 'custom_key');
            $maxCustomKey = $gaugeObj->ivShow('privateHash', 'max_custom_key');

            # (Custom variables take precedence over 'standard' variables with the same name)
            if (defined $customKey && defined $maxCustomKey) {

                $value = $self->ivShow('customVarHash', $customKey);
                $maxValue = $self->ivShow('customVarHash', $maxCustomKey);

            } elsif (defined $varIV && defined $maxVarIV) {

                $value = $charObj->$varIV;
                $maxValue = $charObj->$maxVarIV;
            }

            if (defined $value && defined $maxValue && $gaugeObj->addFlag) {

                $maxValue += $value;
            }

            $gaugeObj->ivPoke('value', $value);
            $gaugeObj->ivPoke('maxValue', $maxValue);
        }

        $self->gaugeStripObj->updateGauges();

        return 1;
    }

    sub resetTriggers {

        # Called by $self->main, stage 2, to create (dependent) triggers that capture values from
        #   the world
        # Also called by $self->activate and $self->disactivate to recreate those triggers, with the
        #   gag settings set to match the task's 'active' or 'disactivated' status
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Local variables
        my (
            $worldObj,
            @list,
        );

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetTriggers', @_);
        }

        # Import the current world
        $worldObj = $self->session->currentWorld;

        # If triggers have already been created for this task, remove them before replacing them
        #   with new triggers
        $self->session->tidyInterfaces($self);

        # Bar patterns
        #   e.g. HP (----------) SP (##########)
        @list = $worldObj->barPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $varType, $gag, $barString, $maxBars, $interfaceObj, $flag);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;
            # 'health', 'magic', 'energy', 'guild', 'social', 'xp', 'qp', 'op'
            $varType = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let
            #   $self->activeFlag decide
            $gag = shift @list;
            # The string which represents 1 unit, e.g. '=' or '*'
            $barString = shift @list;
            # How many units equal the maximum
            $maxBars = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $maxBars || ($gag ne 'show' && $gag ne 'hide' && $gag ne 'choose')) {

                $self->writeWarning(
                    'Missing or invalid arguments in current world profile\'s ->barPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            } else {
                $flag = $self->activeFlag;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'barPatternSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->barPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->barPatternSeen to decide
                #   what to do when the trigger fires (specifically, which group substring contains
                #   the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
                $interfaceObj->ivAdd('propertyHash', 'var_type', $varType);
                $interfaceObj->ivAdd('propertyHash', 'bar_string', $barString);
                $interfaceObj->ivAdd('propertyHash', 'max_bars', $maxBars);
            }
        }

        # Group substring patterns
        #   e.g. '^(.*)\((.*)\) health points and (.*)\((.*)\) social points\.$'
        # (...when all we want is nth group substring)
        @list = $worldObj->groupPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $varType, $gag, $interfaceObj, $flag);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;
            # 'health_points', 'health_points_max', 'magic_points', 'magic_points_max',
            #   'energy_points', 'energy_points_max', 'guild_points', 'guild_points_max',
            #   'social_points', 'social_points_max', 'xp_current', 'xp_total', 'level',
            #   'life_count', 'death_count', 'life_max', 'local_wimpy', 'remote_wimpy',
            #   'remote_wimpy_max', 'bank_balance', 'purse_contents'
            $varType = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let
            #   $self->activeFlag decide
            # Create trigger, first checking that the pattern list doesn't contain missing or extra
            #   arguments
            $gag = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $gag || ($gag ne 'show' && $gag ne 'hide' && $gag ne 'choose')) {

                $self->writeWarning(
                    'Missing or invalid arguments in world profile\'s ->groupPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            } else {
                $flag = $self->activeFlag;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'groupPatternSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->groupPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->barPatternSeen to decide
                #   what to do when the trigger fires (specifically, which group substring contains
                #   the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
                $interfaceObj->ivAdd('propertyHash', 'var_type', $varType);
            }
        }

        # Affect patterns
        #   e.g. '(Curse) (Invisibility)'
        @list = $worldObj->affectPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $gag, $interfaceObj, $flag);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let
            #   $self->activeFlag decide
            $gag = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $gag || ($gag ne 'show' && $gag ne 'hide' && $gag ne 'choose')) {

                $self->writeWarning(
                    'Missing or invalid arguments in current world profile\'s'
                    . ' ->affectPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            } else {
                $flag = $self->activeFlag;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'affectPatternSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->affectPatternList'
                    . ' IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->affectPatternSeen to decide
                #   what to do when the trigger fires (specifically, which group substring contains
                #   the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
            }
        }

        # Stat patterns
        #   e.g. 'Int (.*) Dex (.*) Con (.*)'
        @list = $worldObj->statPatternList;
        OUTER: while (@list) {

            my ($pattern, $grpNum, $statType, $gag, $interfaceObj, $flag);

            # The pattern to match
            $pattern = shift @list;
            # Which group substring contains the data we need
            $grpNum = shift @list;
            # A key in the world profile's stat hash (e.g. 'int', 'con', 'dex')
            $statType = shift @list;
            # 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let
            #   $self->activeFlag decide
            $gag = shift @list;

            # Check that @list doesn't contain missing or extra arguments
            if (! defined $gag || ($gag ne 'show' && $gag ne 'hide' && $gag ne 'choose')) {

                $self->writeWarning(
                    'Missing or invalid arguments in current world profile\'s ->statPatternList'
                    . ' IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }

            if ($gag eq 'show') {
                $flag = FALSE;
            } elsif ($gag eq 'hide') {
                $flag = TRUE;
            } else {
                $flag = $self->activeFlag;
            }

            # Create the dependent trigger interface
            $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'statPatternSeen',
                'gag',
                $flag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->statPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;

            } else {

                # Give the trigger some properties that will help $self->statPatternSeen to decide
                #   what to do when the trigger fires (specifically, which group substring contains
                #   the data, and which IV to update with it)
                $interfaceObj->ivAdd('propertyHash', 'grp_num', $grpNum);
                $interfaceObj->ivAdd('propertyHash', 'stat_type', $statType);
            }
        }

        # Age patterns
        OUTER: foreach my $pattern ($worldObj->agePatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'ageSeen',
                'gag',
                $self->activeFlag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->agePatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Time patterns
        OUTER: foreach my $pattern ($worldObj->timePatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'timeSeen',
                'gag',
                $self->activeFlag,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->timePatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Patterns produced in the course of gathering status data that should be ignored (i.e. a
        #   gagged trigger prevents them from being displayed in the 'main' window)
        if ($self->activeFlag) {

            OUTER: foreach my $pattern ($worldObj->statusIgnorePatternList) {

                # Create the dependent trigger interface
                my $interfaceObj = $self->session->createDependentInterface(
                    'trigger',
                    $pattern,
                    $self,
                    'ignorePatternSeen',
                    'gag',
                    $self->activeFlag,
                );

                if (! $interfaceObj) {

                    $self->writeWarning(
                        'Couldn\'t create trigger for current world profile\'s'
                        . ' ->statusIgnorePatternList IV',
                        $self->_objClass . '->resetTriggers',
                    );

                    last OUTER;
                }
            }
        }

        # Create triggers to capture changes in life status

        # Fall asleep patterns
        OUTER: foreach my $pattern ($worldObj->fallAsleepPatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'asleepSeen',
                'gag',
                FALSE,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->fallAsleepPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Wake up patterns
        OUTER: foreach my $pattern ($worldObj->wakeUpPatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'wakeUpSeen',
                'gag',
                FALSE,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->wakeUpPatternList'
                    . ' IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Passed out patterns
        OUTER: foreach my $pattern ($worldObj->passedOutPatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'passedOutSeen',
                'gag',
                FALSE,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->passedOutPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Come around patterns
        OUTER: foreach my $pattern ($worldObj->comeAroundPatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'comeAroundSeen',
                'gag',
                FALSE,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->comeAroundPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Death patterns
        OUTER: foreach my $pattern ($worldObj->deathPatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'deadSeen',
                'gag',
                FALSE,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s ->deathPatternList'
                    . ' IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Resurrection patterns
        OUTER: foreach my $pattern ($worldObj->resurrectPatternList) {

            # Create the dependent trigger interface
            my $interfaceObj = $self->session->createDependentInterface(
                'trigger',
                $pattern,
                $self,
                'resurrectedSeen',
                'gag',
                FALSE,
            );

            if (! $interfaceObj) {

                $self->writeWarning(
                    'Couldn\'t create trigger for current world profile\'s'
                    . ' ->resurrectPatternList IV',
                    $self->_objClass . '->resetTriggers',
                );

                last OUTER;
            }
        }

        # Trigger reset complete
        return 1;
    }

    sub convertAge {

        # Called by $self->ageSeen
        # Converts a list of strings representing the character's age (e.g. 'five months, two days
        #   and one hour') into a single number, representing the character's age in the standard
        #   unit (e.g. 'day', 'second', 'century') specified by the world profile
        #
        # Expected arguments
        #   @lines  - A list of lines (most of the time, contains only one line)
        #
        # Return values
        #   'undef' on improper arguments or if the lines couldn't be converted
        #   Otherwise, returns the character's age in the standard unit

        my ($self, @lines) = @_;

        # Local variables
        my (
            $dictObj, $totalSeconds, $converter, $age, $charUnit,
            @andOrList,
            %timeHash, %convertHash,
        );

        # Check for improper arguments
        if (! @lines) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->convertAge', @_);
        }

        # Import the current dictinary and its and/or list
        $dictObj = $self->session->currentDict;
        @andOrList = ($dictObj->andList, $dictObj->orList);

        # Examine each line in turn. If later ones repeat data contained in earlier ones, the later
        #   ones overwrite the earlier ones
        OUTER: foreach my $line (@lines) {

            my (@wordList);

            # Convert any capital letters into lower-case letters
            $line = lc($line);
            # Remove non-word characters (such as commas and full stops), replacing them with
            #   spaces
            $line =~ s/\W/ /g;

            # Remove the words for 'and' and 'or'
            foreach my $word (@andOrList) {

                $line =~ s/$word//g;
            }

            # Split $line into distinct words
            @wordList = split(m/\s+/, $line);
            INNER: while (@wordList) {

                my ($number, $unit, $integer, $singleUnit);

                # We hope that the first two words remaining in @wordList are a number and a time
                #   unit
                $number = shift @wordList;
                $unit = shift @wordList;
                if (! defined $unit) {


                    # If no unit specified, use the world profile's standard unit
                    if ($self->session->currentWorld->charAgeUnit) {

                        $unit = $self->session->currentWorld->charAgeUnit;

                    } else {

                        # (We found a number, but not a time unit)
                        last INNER;
                    }
                }

                if ($number =~ m/\D/) {

                    # If number contains non-digit characters, see if it's a known number word
                    if ($dictObj->ivExists('numberHash', $number)) {

                        $integer = $dictObj->ivShow('numberHash', $number);

                    } else {

                        # This isn't a recognised number word. Move on to the next line
                        next OUTER;
                    }

                } else {

                    $integer = $number;
                }

                # See if the time unit is one recognised by the dictionary, and if so, make sure
                #   it's in the singular form
                if ($dictObj->ivExists('reverseTimeHash', $unit)) {
                    $singleUnit = $dictObj->ivShow('reverseTimeHash', $unit);
                } elsif ($dictObj->ivExists('reverseTimePluralHash', $unit)) {
                    $singleUnit = $dictObj->ivShow('reverseTimePluralHash', $unit);
                }

                # If both $integer and $singleUnit have been set, we can be confident that the
                #   original $number and $unit were valid values
                if (defined $integer && defined $singleUnit) {

                    # e.g. $timeHash{'second'} = 15;
                    # e.g. $timeHash{'century'} = 2;
                    $timeHash{$singleUnit} = $integer;
                }
            }
        }

        # Convert the characters's total age into seconds
        %convertHash = (
            'second'        => 1,
            'minute'        => 60,
            'hour'          => 3600,        # 1 hour = 3600 seconds
            'day'           => 86400,
            'week'          => 604800,
            'month'         => 2419200,
            'year'          => 31536000,
            'decade'        => 315360000,
            'century'       => 3153600000,
            'millennium'    => 31536000000,
        );

        $totalSeconds = 0;
        foreach my $unit (keys %timeHash) {

            $totalSeconds += ($timeHash{$unit} * $convertHash{$unit});
        }

        if (! %timeHash) {

            # No age found
            return undef;
        }

        # Convert $totalSeconds into the time unit specified by the world profile (default value is
        #   usually 'day')
        $charUnit = $self->session->currentWorld->charAgeUnit;
        if (! exists $convertHash{$charUnit}) {

            # Character's age unit is not recognised (or not set)
            return undef;

        } else {

            # Convert $totalSeconds to a value in the unit $charUnit (e.g. 'day')
            $age = $totalSeconds / $convertHash{$charUnit};
            # Finally, reduce $age to a maximum of two decimal places
            $age = (int($age * 100)) / 100;

            # Return the character's age in the time unit specified by the world profile
            return $age;
        }
    }

    sub convertTime {

        # Called by $self->timeSeen
        # Converts a string (e.g. 'half-past two in the afternoon') into a 24-hour digital time
        #
        # Expected arguments
        #   $longTime   - The string to convert
        #
        # Return values
        #   An empty list on improper arguments or if the time can't be converted
        #   Otherwise, a list containing the 24 hour time, in the form ($hours, $minutes)

        my ($self, $longTime, $check) = @_;

        # Local variables
        my (
            $dictObj, $minutes, $hours, $successFlag,
            @emptyList,
            %dayHash, %hourHash, %minuteHash,
        );

        # Check for improper arguments
        if (! defined $longTime || defined $check) {

            $axmud::CLIENT->writeImproper($self->_objClass . '->convertTime', @_);
            return @emptyList;
        }

        # Import the current dictinary and some of its vocabulary lsits
        $dictObj = $self->session->currentDict;
        %dayHash = $dictObj->clockDayHash;
        %hourHash = $dictObj->clockHourHash;
        %minuteHash = $dictObj->clockMinuteHash;

        $hours = 0;
        $minutes = 0;

        # Remove a.m. / p.m. strings
        OUTER: foreach my $key (keys %dayHash) {

            if ($longTime =~ m/$key/i) {

                # %dayHash{$key} = 0 for a.m., 1 for p.m., so $hours is set to 0 or 12
                $hours = ($dayHash{$key} * 12);
                # Remove the matched string, and don't continue looking for a.m./p.m. strings
                $longTime =~ s/$key//;
                last OUTER;
            }
        }

        # Remove minute strings before hour strings (or 'five past' will be mistaken for 'five
        #   o'clock')
        OUTER: foreach my $key (keys %minuteHash) {

            if ($longTime =~ m/$key/i) {

                # If $minuteHash{$key} is a negative value, it's a time like 'quarter to eleven',
                #   and the value of $hours, 11, should be one less (10)
                if ($minuteHash{$key} < 0) {

                    $hours--;
                    $minutes = $minuteHash{$key} * (-1);

                } else {

                    $minutes = $minuteHash{$key};
                }

                # Remove the matched string, and don't continue looking for minute strings
                $longTime =~ s/$key//;

                last OUTER;
            }
        }

        # Remove hour strings
        $successFlag = FALSE;
        OUTER: foreach my $key (keys %hourHash) {

            if ($longTime =~ m/$key/i) {

                # If it's a.m., $hours = 0; if it's p.m., $hours already equals 12, so this
                #   converts $hours into a 24-hour clock value
                $hours = $hours + $hourHash{$key};
                # If $hours may have been set to -1 above; that's the equivalent of 23 in the
                #   24-hour clock
                if ($hours == -1) {

                    $hours = 23;
                }

                # Remove the matched string, and don't continue looking for hour strings
                $longTime =~ s/$key//;
                $successFlag = TRUE;

                last OUTER;
            }
        }

        if (! $successFlag) {

            # The 'hours' component not found (it doesn't matter too much if the 'minutes' component
            #   wasn't found
            return @emptyList;

        } else {

            # Conversion complete
            return ($hours, $minutes);
        }
    }

    sub checkHealthChange {

        # Called by $self->setValue
        # When the character's health points (GA::Profile::Char->healthPoints) or maximum health
        #   points (GA::Profile::Char->healthPointsMax) change, it might be necessary to change the
        #   background colour of the window
        # If the world doesn't explicitly state when the character wakes up after falling asleep,
        #   when they come around after passing out, or when they resurrect after dying, it might
        #   also be necessary to change the life status to 'alive' if the character's health points
        #   score is read
        # This function decides whether it's necessary, and sets $self->healthChangeFlag to TRUE if
        #   so
        #
        # Expected arguments
        #   $hp     - The new health points value
        #   $hpMax  - The new maximum health points value
        #
        # Return values
        #   'undef' on improper arguments, if there's no current character set or if the task
        #       window's background colour doesn't have to be changed
        #   1 if the colour should be changed

        my ($self, $hp, $hpMax, $check) = @_;

        # Local variables
        my ($charObj, $old, $new);

        # Check for improper arguments
        if (! defined $hp || ! defined $hpMax || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkHealthChange', @_);
        }

        # Do nothing if there's no current character set
        $charObj = $self->session->currentChar;
        if (! $charObj) {

            return undef;
        }

        # If the character's life status isn't 'alive' and the override flag is set, the character
        #   is no longer asleep, passed out or dead. Change the life status to 'alive' again
        if (
            $charObj->lifeStatus ne 'alive'
            && $self->session->currentWorld->lifeStatusOverrideFlag
        ) {
            $charObj->ivPoke('lifeStatus', 'alive');

            # The task window's background colour should be changed
            return $self->ivPoke('healthChangeFlag', TRUE);

        # Otherwise, only consider changing the background colour, if the relevant IVs are non-zero
        } elsif ($self->allowColourFlag && $charObj->healthPoints && $charObj->healthPointsMax) {

            # Get the character's healt points as a percentage of the maximum
            $old = $charObj->healthPoints / $charObj->healthPointsMax;
            $new = $hp / $hpMax;

            if (
                (
                    $new > 0.50 && $old <= 0.50
                ) || (
                    $new > 0.30 && $new <= 0.50 && ($old <= 0.30 || $old > 0.50)
                ) || (
                    $new > 0.10 && $new <= 0.10 && ($old <= 0.10 || $old > 0.30)
                ) || (
                    $new <= 0.10 && $old > 0.10
                )
            ) {
                # The task window's background colour should be changed
                return $self->ivPoke('healthChangeFlag', TRUE);
            }
        }

        # No change to the background colour
        return undef;
    }

    sub checkPointsChange {

        # Called by $self->setValue
        # When the character's health/magic/energy/guild/social points change, check whether it's
        #   time to read out a TTS alert message and, if so, add the message to
        #   $self->ttsPointsAlertMsgHash
        # All messages are then read out by $self->doStage (stage 4), and ->ttsPointsAlertMsgHash
        #   is reset; in this way, we don't erroneously read out an alert message if the code
        #   receives (for example) a new 'health_points' value before a new 'health_points_max'
        #   value
        #
        # Expected arguments
        #   $attrib     - Which points total has changed: 'health', 'magic', 'energy', 'guild',
        #                   'social'
        #
        # Return values
        #   'undef' on improper arguments, if there's no current character set or if nothing is to
        #       be read out
        #   1 if an alert message is to be read out (whether the user hears anything, or not, will
        #       depend on the usual TTS variables)

        my ($self, $attrib, $check) = @_;

        # Local variables
        my ($charObj, $iv, $current, $ivMax, $max, $percent);

        # Check for improper arguments
        if (
            ! defined $attrib
            || (
                $attrib ne 'health' && $attrib ne 'magic' && $attrib ne 'energy'
                && $attrib ne 'guild' && $attrib ne 'social'
            ) || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->checkPointsChange', @_);
        }

        # Do nothing if there's no current character set
        $charObj = $self->session->currentChar;
        if (! $charObj) {

            return undef;
        }

        # Get the current and maximum points
        $iv = $attrib . 'Points';
        $current = $charObj->$iv;
        $ivMax = $attrib . 'PointsMax';
        $max = $charObj->$ivMax;

        if (! defined $current || ! defined $max || $max == 0) {

            # Points percentage not currently known
            return undef;
        }

        # Set the points percentage, in a range 0-100%
        $percent = ($current / $max) * 100;
        # Protect ourselves against unexpected errors
        if ($percent < 0) {
            $percent = 0;
        } elsif ($percent > 100) {
            $percent = 100;
        } else {
            $percent = int($percent);
        }

        if (
            # The user has specified that an alert should be read out, if the points recover above
            #   a certain value
            defined $self->ivShow('ttsAlertAttribHash', $attrib . 'up')         # .e.g 'healthup'
            # The points have recovered to that value
            && $percent >= $self->ivShow('ttsAlertAttribHash', $attrib . 'up')
            # No alert for these points have yet been read out, or the last alert read out was for
            #   a low value
            && $self->ivShow('ttsPointsAlertHash', $attrib) != 1
        ) {
            # Set the alert to be read...
            $self->ivAdd(
                'ttsPointsAlertMsgHash',
                $attrib,
                ucfirst($attrib) . ' points have recovered to ' . $percent . '%',
            );

            # ...and the type of alert that shouldn't be read consecutively
            $self->ivAdd('ttsPointsAlertTypeHash', $attrib, 1);

            return 1;

        } elsif (
            # The user has specified that an alert should be read out, if the points fall below
            #   a certain value
            defined $self->ivShow('ttsAlertAttribHash', $attrib . 'down')       # .e.g 'healthdown'
            # The points have fallen to that value
            && $percent <= $self->ivShow('ttsAlertAttribHash', $attrib . 'down')
            # No alert for these points have yet been read out, or the last alert read out was for
            #   a high value
            && $self->ivShow('ttsPointsAlertHash', $attrib) != -1
        ) {
            # Set the alert to be read...
            $self->ivAdd(
                'ttsPointsAlertMsgHash',
                $attrib,
                ucfirst($attrib) . ' points have fallen to ' . $percent . '%',
            );

            # ...and the type of alert that shouldn't be read consecutively
            $self->ivAdd('ttsPointsAlertTypeHash', $attrib, -1);

            return 1;

        } else {

            # Don't read out an alert
            return undef;
        }
    }

    sub roundCashValue {

        # Called by $self->set_cashValues and ->processFormatList
        # Prevent Perl rounding errors by rounding a cash value to the number of decimal places
        #   specified by GA::Profile::World->currencyRounding
        #
        # Expected arguments
        #   $value      - An integer or floating point value, e.g. 1.124
        #
        # Optional arguments
        #   $formatFlag - TRUE when called by $self->->processFormatList, in which case we use
        #       sprintf() to return a cash value to the correct number of decimal places (e.g.
        #       returns 10.50, not 10.5)

        # Return values
        #   'undef' on improper arguments
        #   Otherwise returns the rounded value

        my ($self, $value, $formatFlag, $check) = @_;

        # Local variables
        my ($rounding, $string);

        # Check for improper arguments
        if (! defined $value || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->roundCashValue', @_);
        }

        $rounding = $self->session->currentWorld->currencyRounding;

        # (If the world profile's ->currencyRounding is set to -1, we don't round anything)
        if ($rounding == -1) {

            return $value;

        } elsif ($formatFlag) {

            $string = "%." . $rounding . "f";       # e.g. %.3f
            $value = sprintf($string, $value);
            # This sometimes produces a value like 7, sometimes like 7.000; make sure integers are
            #   displayed like integers
            if ((int $value) == $value) {

                $value = int $value;
            }

            return $value;

        } else {

            return Math::Round::nearest((10 ** ($rounding * -1)), $value);
        }
    }

    ##################
    # Response methods

    sub barPatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture bar pattern strings
        #   e.g. HP (----------) SP (##########)
        #
        # The world profile's bar pattern list occurs in groups of 6 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #   [2] - 'health', 'magic', 'energy', 'guild', 'social', 'xp_current', 'xp_next_level',
        #           'qp_current', 'qp_next_level', 'op_current', 'op_next_level'
        #   [3] - the string which represents 1 unit, e.g. '*'
        #   [4] - how many units equal the maximum
        #   [5] - 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'no_trigger' not to
        #           create a trigger (if the pattern to match already exists, higher up the list, we
        #           only need a single trigger to detect it)
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #   var_type        - 'health', 'magic', 'energy', 'guild', 'social', 'xp_current',
        #                       'xp_next_level', 'qp_current', 'qp_next_level', 'op_current',
        #                       'op_next_level' (same as [2] )
        #   bar_string      - the string which represents 1 unit, e.g. '*' (same as [3] )
        #   max_bars        - how many units equal the maximum (same as [4] )
        #
        # This function counts the bars in the appropriate group substring and updates the task's
        #   IVs
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $varType, $barString, $maxBars, $grpString, $numBars);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->barPatternSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        $varType = $obj->ivShow('propertyHash', 'var_type');
        $barString = $obj->ivShow('propertyHash', 'bar_string');
        $maxBars = $obj->ivShow('propertyHash', 'max_bars');
        # Get the correct group substring
        $grpString = $$grpStringListRef[$grpNum];
        # Count the number of bars
        $numBars = () = $grpString =~ m/$barString/g;

        # Update the task's instance variables
        if ($varType eq 'health') {

            $self->setValue('health_points_max', $maxBars);
            $self->setValue('health_points', $numBars);

        } elsif ($varType eq 'magic') {

            $self->setValue('magic_points_max', $maxBars);
            $self->setValue('magic_points', $numBars);

        } elsif ($varType eq 'energy') {

            $self->setValue('energy_points_max', $maxBars);
            $self->setValue('energy_points', $numBars);

        } elsif ($varType eq 'guild') {

            $self->setValue('guild_points_max', $maxBars);
            $self->setValue('guild_points', $numBars);

        } elsif ($varType eq 'social') {

            $self->setValue('social_points_max', $maxBars);
            $self->setValue('social_points', $numBars);

        } elsif ($varType eq 'xp_current') {

            $self->setValue('xp_total', $maxBars);
            $self->setValue('xp_current', $numBars);

        } elsif ($varType eq 'xp_next_level') {

            $self->setValue('xp_total', $maxBars);
            $self->setValue('xp_next_level', $numBars);

        } elsif ($varType eq 'qp_current') {

            $self->setValue('qp_total', $maxBars);
            $self->setValue('qp_current', $numBars);

        } elsif ($varType eq 'qp_next_level') {

            $self->setValue('qp_total', $maxBars);
            $self->setValue('qp_next_level', $numBars);

        } elsif ($varType eq 'op_current') {

            $self->setValue('op_total', $maxBars);
            $self->setValue('op_current', $numBars);

        } elsif ($varType eq 'op_next_level') {

            $self->setValue('op_total', $maxBars);
            $self->setValue('op_next_level', $numBars);
        }

        return 1;
    }

    sub groupPatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   patterns containing group substrings, only one of which we want
        #   e.g. '^Hp: (.*)  Gp: (.*)  Xp: (.*)  Sp: (.*)$'
        #
        # The world profile's group pattern list occurs in groups of 4 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #   [2] - a Status task variable; must be a character, local or custom variable
        #   [3] - 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let the
        #           Status task choose
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #   var_type        - 'health_points', etc (same as [2] )
        #
        # This function checks the appropriate group substring and updates IVs. If var_type is
        #   'health_points', also checks whether the character's health points have changed from one
        #   band to another (requiring a change in the task window's background colour)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the variable can't be set or if the variable's new value
        #       can't be extracted from the string
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $varType, $newValue);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->groupPatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        $varType = $obj->ivShow('propertyHash', 'var_type');
        # Get the corresponding IV's new value
        $newValue = $$grpStringListRef[$grpNum];
        if (! defined $newValue) {

            # IV's new value couldn't be extracted from the string
            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $newValue = $axmud::CLIENT->trimWhitespace($newValue);

            # Update IVs
            if (! $self->setValue($varType, $newValue)) {

                return undef;

            } else {

                return 1;
            }
        }
    }

    sub affectPatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   spells (etc) affecting the character
        #   e.g.  'Spells: Curse Invisibility'
        #
        # The world profile's affect pattern list occurs in groups of 4 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #   [2] - 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let the
        #           Status task choose
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #
        # This function checks the appropriate group substring and updates IVs
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the corresponding IV can't be found or if the IV's
        #       new value can't be extracted from the string
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my (
            $obj, $grpNum, $newValue,
            @affectList,
            %affectHash,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->affectPatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        # The value 0 means that the character is not affected by any spells (etc)
        if ($grpNum) {

            # Get the corresponding IV's new value
            $newValue = $$grpStringListRef[$grpNum];
            if (! defined $newValue) {

                # IV's new value couldn't be extracted from the string
                return undef;
            }

            # Every word in $newValue is an affect (or spell)
            @affectList = split(/\s+/, $newValue);
            foreach my $affect (@affectList) {

                $affectHash{$affect} = undef;
            }
        }

        # Update this task's hash. Any affect not captured by this trigger is therefore removed
        #   from the task's hash; if $grpNum was 0, the task's hash is emptied
        $self->ivPoke('affectHash', %affectHash);

        # The task window must be updated on the next task loop
        $self->ivPoke('updateFlag', TRUE);

        return 1;
    }

    sub statPatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture strings matching
        #   the character's current stats
        #   e.g.  'Strength: 10 Dexterity: 15 Constitution: 13 Intelligence: 6 Wisdom: 4'
        #
        # The world profile's stat pattern list occurs in groups of 4 elements, representing
        #   [0] - the pattern to match
        #   [1] - which group substring contains the data we need
        #   [2] - a key in the stat hash (e.g. 'int', 'con', 'dex')
        #   [3] - 'hide' to use a gag trigger, 'show' to use a non-gag trigger, 'choose' to let the
        #           Status task choose
        #
        # The trigger interfaces have the following properties in ->propertyHash:
        #   grp_num         - which group substring contains the data we need (same as [1] )
        #   stat_type       - 'Strength', 'dex', 'foobar' (same as [2] )
        #
        # This function checks the appropriate group substring and updates IVs
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found, if the corresponding IV can't be found or if the IV's
        #       new value can't be extracted from the string
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $statType, $newValue);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->statPatternSeen',
                @_,
            );
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the trigger's properties
        $grpNum = $obj->ivShow('propertyHash', 'grp_num');
        $statType = $obj->ivShow('propertyHash', 'stat_type');
        # Get the corresponding IV's new value
        $newValue = $$grpStringListRef[$grpNum];
        if (! defined $newValue) {

            # IV's new value couldn't be extracted from the string
            return undef;

        } else {

            # Remove any leading or trailing whitespace
            $newValue = $axmud::CLIENT->trimWhitespace($newValue);
        }

        # Update the task's instance variables
        if (! $self->ivExists('statHash', $statType)) {

            # Unrecognised stat
            return undef;

        } else {

            # Replace the stat's value
            $self->ivAdd('statHash', $statType, $newValue);
            $self->ivPoke('updateFlag', TRUE);

            return 1;
        }
    }

    sub ageSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture age strings. All
        #   group substrings are used, but ->propertyHash isn't used
        #
        # The world profile defines a list of patterns that tells us the character's age
        #   e.g. 'You are 15 hours, 39 minutes and 7 seconds old.'
        # ...might match the pattern '^You are (.*) old\.'
        # The function extracts the age from the whole list of group substrings and produces the
        #   character's age in a single value, expressed in the world profile's standard age unit
        #   (default is 'day')
        # If extraction is successful, it updates IVs
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session, if the interface
        #       object can't be found or if the age can't be converted
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my (
            $obj, $age,
            @list,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->ageSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Get all the group substrings, removing index #0, which contains the whole matched string
        shift @$grpStringListRef;

        $age = $self->convertAge(@$grpStringListRef);
        if (! defined $age) {

            # Conversion failed
            return undef;

        } else {

            # Character's age was successfully converted
            $self->setValue('age', $age);
            $self->ivPoke('updateFlag', TRUE);

            return 1;
        }
    }

    sub timeSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture time strings. Only
        #   the first group substring is used, but ->propertyHash isn't needed
        #
        # Useful for worlds that have a local game time that reads something like 'It is half past
        #   two in the morning'
        # The function extracts the time from the first group substring and converts it into a
        #   string containing the time in the 24-hour clock (e.g. '02:45'), if possible, before
        #   storing the converted time as $self->time
        # However, if self->convertTimeFlag is FALSE, no conversion is attempted (and the first
        #   group substring is stored as $self->time)
        # If extraction is successful, it updates IVs
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $grpNum, $hours, $minutes);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->timeSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger
        $grpNum = $$grpStringListRef[1];
        if ($self->convertTimeFlag) {

            ($hours, $minutes) = $self->convertTime($grpNum);
        }

        # If the call to ->convertTime failed (or if no conversion was attempted), $hours is still
        #   set to 'undef'
        if (! defined $hours) {

            $self->setValue('time', $grpNum);

        # Long time string was successfully converted into a shorter string
        } else {

            $self->setValue('time', sprintf('%02d:%02d', $hours, $minutes));
        }

        $self->ivPoke('updateFlag', TRUE);

        return 1;
    }

    sub ignorePatternSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture ignorable strings.
        #   Group substrings and ->propertyHash aren't used.
        #
        # When commands like 'score', 'time' and 'stats' are sent to the world and $self->activeFlag
        #   is set to TRUE (meaning, don't display the response in the 'main' window), the world may
        #   display several lines in response, some of which may not contain any useful data
        # The current world's 'ignore patterns' create triggers which gag those lines and redirect
        #   them to this function - just in case they are needed (for debugging purposes)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my $obj;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->ignorePatternSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger (this function does nothing)
        #   ...

        return 1;
    }

    sub asleepSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture character falling
        #   asleep strings. Group substrings and ->propertyHash aren't used.
        #
        # Updates some IVs, writes to the logfile which monitors characters falling asleep, and
        #   plays a sound effect (if allowed)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my (
            $obj, $charObj, $start, $stop,
            @writeList,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->asleepSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the current character profile
        $charObj = $self->session->currentChar;

        # If the character is not already asleep...
        if ($charObj && $charObj->lifeStatus ne 'sleep') {

            $charObj->ivPoke('lifeStatus', 'sleep');

            # The next time the task window is updated, its background colour will change
            $self->ivPoke('lifeStatusChangeFlag', TRUE);

            if ($session->currentWorld->ivShow('logPrefHash', 'sleep')) {

                # Record lines that lead up to this event, and mark future lines to be recorded
                push (@writeList, 'CHARACTER SLEEP DETECTED');
                push (@writeList, '------------------------');

                if ($axmud::CLIENT->statusEventBeforeCount && $session->displayBufferCount) {

                    if ($session->displayBufferCount < $axmud::CLIENT->statusEventBeforeCount) {

                        $start = 0,
                        $stop = $session->displayBufferLast;

                    } else {

                        $start = $session->displayBufferLast
                                    - $axmud::CLIENT->statusEventBeforeCount + 1;
                        $stop = $session->displayBufferLast;
                    }

                    for (my $lineNum = $start; $lineNum <= $stop; $lineNum++) {

                        my $bufferObj = $session->ivShow('displayBufferHash', $lineNum);

                        if ($session->ivExists('displayBufferHash', $lineNum)) {

                            push (@writeList, $bufferObj->modLine);
                        }
                    }
                }

                # Write previously-received lines to the logfile (if possible)
                foreach my $string (@writeList) {

                    $axmud::CLIENT->writeLog(
                        $session,
                        FALSE,      # Not a 'standard' logfile
                        $string,
                        FALSE,      # Don't precede with a newline character
                        TRUE,       # Use final newline character
                        'sleep',    # Write to this logfile
                    );
                }

                # Mark how many lines after the current one should be written to the same logfile
                if ($axmud::CLIENT->statusEventAfterCount) {

                    $session->set_logAsleepUntilLine(
                        $session->displayBufferLast + $axmud::CLIENT->statusEventAfterCount,
                    );
                }
            }

            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('notify');

            # If there is a current mission, it must be told to give up
            if ($session->currentMission) {

                $session->currentMission->statusTaskChange($charObj->lifeStatus);
            }
        }

        return 1;
    }

    sub wakeUpSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture character
        #   resurrection strings. Group substrings and ->propertyHash aren't used
        #
        # Updates some IVs and writes to the logfile which monitors character resurrections
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $charObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->wakeUpSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the current character profile
        $charObj = $self->session->currentChar;

        # If the character is not already alive...
        if ($charObj && $charObj->lifeStatus ne 'alive') {

            $charObj->ivPoke('lifeStatus', 'alive');

            # The next time the task window is updated, its background colour will change
            $self->ivPoke('lifeStatusChangeFlag', TRUE);
        }

        return 1;
    }

    sub passedOutSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture characer passing
        #   out strings. Group substrings and ->propertyHash aren't used
        #
        # Updates some IVs, writes to the logfile which monitors characters passing out, and plays
        #   a sound effect (if allowed)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my (
            $obj, $charObj, $start, $stop,
            @writeList,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->passedOutSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the current character profile
        $charObj = $self->session->currentChar;

        # If the character is not already passed out...
        if ($charObj && $charObj->lifeStatus ne 'passout') {

            $charObj->ivPoke('lifeStatus', 'passout');

            # The next time the task window is updated, its background colour will change
            $self->ivPoke('lifeStatusChangeFlag', TRUE);

            if ($session->currentWorld->ivShow('logPrefHash', 'passout')) {

                # Record lines that lead up to this event, and mark future lines to be recorded
                push (@writeList, 'CHARACTER PASS OUT DETECTED');
                push (@writeList, '---------------------------');

                if ($axmud::CLIENT->statusEventBeforeCount && $session->displayBufferCount) {

                    if ($session->displayBufferCount < $axmud::CLIENT->statusEventBeforeCount) {

                        $start = 0,
                        $stop = $session->displayBufferLast;

                    } else {

                        $start = $session->displayBufferLast
                                    - $axmud::CLIENT->statusEventBeforeCount + 1;
                        $stop = $session->displayBufferLast;
                    }

                    for (my $lineNum = $start; $lineNum <= $stop; $lineNum++) {

                        my $bufferObj = $session->ivShow('displayBufferHash', $lineNum);

                        if ($session->ivExists('displayBufferHash', $lineNum)) {

                            push (@writeList, $bufferObj->modLine);
                        }
                    }
                }

                # Write previously-received lines to the logfile (if possible)
                foreach my $string (@writeList) {

                    $axmud::CLIENT->writeLog(
                        $session,
                        FALSE,          # Not a 'standard' logfile
                        $string,
                        FALSE,          # Don't precede with a newline character
                        TRUE,           # Use final newline character
                        'passout',      # Write to this logfile
                    );
                }

                # Mark how many lines after the current one should be written to the same logfile
                if ($axmud::CLIENT->statusEventAfterCount) {

                    $session->set_logPassedOutUntilLine(
                        $session->displayBufferLast + $axmud::CLIENT->statusEventAfterCount,
                    );
                }
            }

            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('notify');

            # If there is a current mission, it must be told to give up
            if ($session->currentMission) {

                $session->currentMission->statusTaskChange($charObj->lifeStatus);
            }
        }

        return 1;
    }

    sub comeAroundSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture character coming
        #   around strings. Group substrings and ->propertyHash aren't used.
        #
        # Updates some IVs and writes to the logfile which monitors character revivals
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $charObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->resurrectSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the current character profile
        $charObj = $self->session->currentChar;

        # If the character is not already alive...
        if ($charObj && $charObj->lifeStatus ne 'alive') {

            $charObj->ivPoke('lifeStatus', 'alive');

            # The next time the task window is updated, its background colour will change
            $self->ivPoke('lifeStatusChangeFlag', TRUE);
        }

        return 1;
    }

    sub deadSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture character death
        #   strings. Group substrings and ->propertyHash aren't used
        #
        # Updates some IVs, writes to the logfile which monitors character deaths, and plays a
        #   sound effect (if allowed)
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my (
            $obj, $charObj, $start, $stop,
            @writeList,
        );

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->deadSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the current character profile
        $charObj = $self->session->currentChar;

        # If the character is not already dead...
        if ($charObj->lifeStatus ne 'dead') {

            $charObj->ivPoke('lifeStatus', 'dead');

            # The next time the task window is updated, its background colour will change
            $self->ivPoke('lifeStatusChangeFlag', TRUE);

            if ($session->currentWorld->ivShow('logPrefHash', 'dead')) {

                # Record lines that lead up to this event, and mark future lines to be recorded
                push (@writeList, 'CHARACTER DEATH DETECTED');
                push (@writeList, '------------------------');

                if ($axmud::CLIENT->statusEventBeforeCount && $session->displayBufferCount) {

                    if ($session->displayBufferCount < $axmud::CLIENT->statusEventBeforeCount) {

                        $start = 0,
                        $stop = $session->displayBufferLast;

                    } else {

                        $start = $session->displayBufferLast
                                    - $axmud::CLIENT->statusEventBeforeCount + 1;
                        $stop = $session->displayBufferLast;
                    }

                    for (my $lineNum = $start; $lineNum <= $stop; $lineNum++) {

                        my $bufferObj = $session->ivShow('displayBufferHash', $lineNum);

                        if ($session->ivExists('displayBufferHash', $lineNum)) {

                            push (@writeList, $bufferObj->modLine);
                        }
                    }
                }

                # Write previously-received lines to the logfile (if possible)
                foreach my $string (@writeList) {

                    $axmud::CLIENT->writeLog(
                        $session,
                        FALSE,      # Not a 'standard' logfile
                        $string,
                        FALSE,      # Don't precede with a newline character
                        TRUE,       # Use final newline character
                        'dead',     # Write to this logfile
                    );
                }

                # Mark how many lines after the current one should be written to the same logfile
                if ($axmud::CLIENT->statusEventAfterCount) {

                    $session->set_logDeadUntilLine(
                        $session->displayBufferLast + $axmud::CLIENT->statusEventAfterCount,
                    );
                }
            }

            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('death');

            # If there is a current mission, it must be told to give up
            if ($session->currentMission) {

                $session->currentMission->statusTaskChange($charObj->lifeStatus);
            }
        }

        return 1;
    }

    sub resurrectedSeen {

        # Called by GA::Session->checkTriggers
        #
        # This task's ->resetTriggers function creates some triggers to capture character
        #   resurrection strings. Group substrings and ->propertyHash aren't used.
        #
        # Updates some IVs and writes to the logfile which monitors character resurrections
        #
        # Expected arguments (standard args from GA::Session->checkTriggers)
        #   $session        - The calling function's GA::Session
        #   $interfaceNum   - The number of the active trigger interface that fired
        #   $line           - The line of text received from the world
        #   $stripLine      - $line, with all escape sequences removed
        #   $modLine        - $stripLine, possibly modified by previously-checked triggers
        #   $grpStringListRef
        #                   - Reference to a list of group substrings from the pattern match
        #                       (equivalent of @_)
        #   $matchMinusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @-)
        #   $matchPlusListRef
        #                   - Reference to a list of matched substring offsets (equivalent of @+)
        #
        # Return values
        #   'undef' on improper arguments, or if $session is the wrong session or if the interface
        #       object can't be found
        #   1 otherwise

        my (
            $self, $session, $interfaceNum, $line, $stripLine, $modLine, $grpStringListRef,
            $matchMinusListRef, $matchPlusListRef, $check,
        ) = @_;

        # Local variables
        my ($obj, $charObj);

        # Check for improper arguments
        if (
            ! defined $session || ! defined $interfaceNum || ! defined $line || ! defined $stripLine
            || ! defined $modLine || ! defined $grpStringListRef || ! defined $matchMinusListRef
            || ! defined $matchPlusListRef || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->resurrectedSeen', @_);
        }

        # Basic check - the trigger should belong to the right session
        if ($session ne $self->session) {

            return undef;
        }

        # Get the interface object itself
        $obj = $session->ivShow('interfaceNumHash', $interfaceNum);
        if (! $obj) {

            return undef;
        }

        # Respond to the fired trigger

        # Import the current character profile
        $charObj = $self->session->currentChar;

        # If the character is not already alive...
        if ($charObj && $charObj->lifeStatus ne 'alive') {

            $charObj->ivPoke('lifeStatus', 'alive');

            # The next time the task window is updated, its background colour will change
            $self->ivPoke('lifeStatusChangeFlag', TRUE);
        }

        return 1;
    }

    ##################
    # Accessors - set

    sub set_cashValues {

        # Called by GA::Task::Inventory->updateQuestStats
        # Sets the Status task's cash IVs
        #
        # Expected arguments
        #   $type       - Which type of pattern the Inventory task spotted: 'purse', 'deposit',
        #                   'deposit_only', 'withdraw', 'withdraw_only', 'balance', 'empty_purse'
        #                   or 'empty_bank'
        #   $cashValue  - The equivalent cash value, in the standard denomination specified by the
        #                   current world profile, of the pattern
        #
        # Return values
        #   'undef' on improper arguments or if there is no current character profile
        #   1 on success

        my ($self, $type, $cashValue, $check) = @_;

        # Local variables
        my ($charObj, $purse, $bank, $string);

        # Check for improper arguments
        if (
            ! defined $type || ! defined $cashValue
            || (
                $type ne 'purse'
                && $type ne 'deposit'
                && $type ne 'deposit_only'
                && $type ne 'withdraw'
                && $type ne 'withdraw_only'
                && $type ne 'balance'
                && $type ne 'empty_purse'
                && $type ne 'empty_bank'
            ) || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_cashValues', @_);
        }

        # Import the current character profile (for convenience)
        $charObj = $self->session->currentChar;
        if (! $charObj) {

            return undef;
        }

        # Prevent Perl rounding errors by rounding the value (if the world profile's
        #   ->currencyRounding is set to -1, we don't round anything)
        $cashValue = $self->roundCashValue($cashValue);
        # Get the current purse contents/bank balance, using a zero value if they're not defined
        $purse = $charObj->purseContents;
        if (! defined $purse) {

            $purse = 0;
        }

        $bank = $charObj->bankBalance;
        if (! defined $bank) {

            $bank = 0;
        }

        if ($type eq 'empty_purse') {

            # Character is carrying no money
            $self->setValue('purse_contents', 0);

        } elsif ($type eq 'empty_bank') {

            # Bank account is empty
            $self->setValue('bank_balance', 0);

        } elsif ($type eq 'purse') {

            # The character is carrying this much money
            $self->setValue('purse_contents', $cashValue);

        } elsif ($type eq 'withdraw') {

            # The character has withdrawn some money from their bank account
            $self->setValue('bank_balance', ($bank - $cashValue));
            $self->setValue('purse_contents', ($purse + $cashValue));
            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('withdraw');

        } elsif ($type eq 'withdraw_only') {

            # The character has withdrawn some money from their bank account, but we only update the
            #   ->purseContents
            $self->setValue('purse_contents', ($purse + $cashValue));
            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('withdraw');

        } elsif ($type eq 'deposit') {

            # The character has withdrawn some money from their bank account
            $self->setValue('bank_balance', ($bank + $cashValue));
            $self->setValue('purse_contents', ($purse - $cashValue));
            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('deposit');

        } elsif ($type eq 'deposit_only') {

            # The character has withdrawn some money from their bank account, but we only update the
            #   ->purseContents
            $self->setValue('purse_contents', ($purse - $cashValue));
            # Play a sound effect (if allowed)
            $axmud::CLIENT->playSound('deposit');

        } elsif ($type eq 'balance') {

            # The character has this much money in their bank account
            $self->setValue('bank_balance', $cashValue);
        }

        return 1;
    }

    sub reset_counters {

        # Called by GA::Cmd::ResetCounter->do
        # Resets some or all of the Status task's temporary counters
        #
        # Expected arguments
        #   @list   - A list of single-letter strings specifying which counters to reset:
        #               'a' for all counters
        #               'f' for the fight counter, 'i' for the interaction counter, 'x' for the xp
        #                   counter
        #               'q' for the quest counter, 'm' for the bank and purse counters, 'b' for the
        #                   bank counter, 'p' for the purse counter
        #
        # Return values
        #   'undef' on improper arguments or if no current character profile is set
        #   1 on success

        my ($self, @list) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (! @list) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->reset_counters', @_);
        }

        # Import the current character profile (for convenience)
        $charObj = $self->session->currentChar;
        if (! $charObj) {

            return undef;
        }

        foreach my $counter (@list) {

            if ($counter eq 'a' || $counter eq 'f') {

                $self->ivPoke('fightCountFlag', TRUE);

                $self->ivAdd('counterVarHash', 'temp_fight_count', 0);
                $self->ivAdd('counterBaseHash', 'temp_fight_count', $charObj->fightCount);

                $self->ivAdd('counterVarHash', 'temp_kill_count', 0);
                $self->ivAdd('counterBaseHash', 'temp_kill_count', $charObj->killCount);

                $self->ivAdd('counterVarHash', 'temp_wimpy_count', 0);
                $self->ivAdd('counterBaseHash', 'temp_wimpy_count', $charObj->wimpyCount);

                $self->ivAdd('counterVarHash', 'temp_fight_defeat_count', 0);
                $self->ivAdd('counterBaseHash', '
                    temp_fight_defeat_count',
                    $charObj->fightDefeatCount,
                );
            }

            if ($counter eq 'a' || $counter eq 'i') {

                $self->ivPoke('interactCountFlag', TRUE);

                $self->ivAdd('counterVarHash', 'temp_interact_count', 0);
                $self->ivAdd('counterBaseHash', 'temp_interact_count', $charObj->interactCount);

                $self->ivAdd('counterVarHash', 'temp_interact_success_count', 0);
                $self->ivAdd(
                    'counterBaseHash',
                    'temp_interact_success_count',
                    $charObj->interactSuccessCount,
                );

                $self->ivAdd('counterVarHash', 'temp_interact_fail_count', 0);
                $self->ivAdd(
                    'counterBaseHash',
                    'temp_interact_fail_count',
                    $charObj->interactFailCount,
                );

                $self->ivAdd('counterVarHash', 'temp_interact_fight_count', 0);
                $self->ivAdd(
                    'counterBaseHash',
                    'temp_interact_fight_count',
                    $charObj->interactFightCount,
                );

                $self->ivAdd('counterVarHash', 'temp_interact_disaster_count', 0);
                $self->ivAdd(
                    'counterBaseHash',
                    'temp_interact_disaster_count',
                    $charObj->interactDisasterCount,
                );
            }

            if ($counter eq 'a' || $counter eq 'f' || $counter eq 'i') {

                $self->ivAdd('counterVarHash', 'temp_flee_count', 0);
                $self->ivAdd('counterBaseHash', 'temp_flee_count', $charObj->fleeCount);

                $self->ivAdd('counterVarHash', 'temp_escape_count', 0);
                $self->ivAdd('counterBaseHash', 'temp_escape_count', $charObj->escapeCount);
            }

            if ($counter eq 'a' || $counter eq 'x') {

                $self->ivAdd('counterVarHash', 'temp_xp_count', 0);

                if (defined $charObj->xpTotal) {
                    $self->ivAdd('counterBaseHash', 'temp_xp_count', $charObj->xpTotal);
                } else {
                    $self->ivAdd('counterBaseHash', 'temp_xp_count', 0);
                }
            }

            if ($counter eq 'a' || $counter eq 'q') {

                $self->ivAdd('counterVarHash', 'temp_quest_count', 0);

                if (defined $charObj->qpTotal) {
                    $self->ivAdd('counterBaseHash', 'temp_quest_count', $charObj->qpTotal);
                } else {
                    $self->ivAdd('counterBaseHash', 'temp_quest_count', 0);
                }
            }

            if ($counter eq 'a' || $counter eq 'm' || $counter eq 'b') {

                $self->ivAdd('counterVarHash', 'temp_bank_count', 0);

                if (defined $charObj->bankBalance) {
                    $self->ivAdd('counterBaseHash', 'temp_bank_count', $charObj->bankBalance);
                } else {
                    $self->ivAdd('counterBaseHash', 'temp_bank_count', 0);
                }
            }

            if ($counter eq 'a' || $counter eq 'm' || $counter eq 'p') {

                $self->ivAdd('counterVarHash', 'temp_purse_count', 0);

                if (defined $charObj->purseContents) {
                    $self->ivAdd('counterBaseHash', 'temp_purse_count', $charObj->purseContents);
                } else {
                    $self->ivAdd('counterBaseHash', 'temp_purse_count', 0);
                }
            }

            if ($counter eq 'a' || $counter eq 'f' || $counter eq 'i') {

                $self->ivPoke('counterStartTime', $self->session->sessionTime);
            }
        }

        # Update the task window
        $self->ivPoke('updateFlag', TRUE);

        # Reset complete
        return 1;
    }

    sub inc_fightCount {

        my ($self, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->inc_fightCount', @_);
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivIncrement('fightCount');
            $charObj->ivIncrement('killCount');

            # Update counters (if the right counter is running)
            if ($self->fightCountFlag) {

                $self->ivIncHash('counterVarHash', 'temp_fight_count');
                $self->ivIncHash('counterVarHash', 'temp_kill_count');
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub add_fights {

        my ($self, $fightCount, $killCount, $wimpyCount, $fightDefeatCount, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (
            ! defined $fightCount || ! defined $killCount || ! defined $wimpyCount
            || ! defined $fightDefeatCount || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_fights', @_);
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivPoke('fightCount', $charObj->fightCount + $fightCount);
            $charObj->ivPoke('killCount', $charObj->killCount + $killCount);
            $charObj->ivPoke('wimpyCount', $charObj->wimpyCount + $wimpyCount);
            $charObj->ivPoke('fightDefeatCount', $charObj->fightDefeatCount + $fightDefeatCount);

            # Update counters (if the right counter is running)
            if ($self->fightCountFlag) {

                $self->ivAdd(
                    'counterVarHash',
                    'temp_fight_count',
                    $self->ivShow('counterVarHash', 'temp_fight_count') + $fightCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_kill_count',
                    $self->ivShow('counterVarHash', 'temp_kill_count') + $killCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_wimpy_count',
                    $self->ivShow('counterVarHash', 'temp_wimpy_count') + $wimpyCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_fight_defeat_count',
                    $self->ivShow('counterVarHash', 'temp_fight_defeat_count') + $fightDefeatCount,
                );
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub add_fleesEscapes {

        my ($self, $fleeCount, $escapeCount, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (! defined $fleeCount || ! defined $escapeCount || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_fleesEscapes', @_);
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivPoke('fleeCount', $charObj->fleeCount + $fleeCount);
            $charObj->ivPoke('escapeCount', $charObj->escapeCount + $escapeCount);

            # Update counters (if the right counter is running)
            if ($self->ivExists('counterVarHash', 'temp_escape_count')) {

                $self->ivAdd(
                    'counterVarHash',
                    'temp_escape_count',
                    $self->ivShow('counterVarHash', 'temp_escape_count') + $escapeCount,
                );
            }

            if ($self->ivExists('counterVarHash', 'temp_flee_count')) {

                $self->ivAdd(
                    'counterVarHash',
                    'temp_flee_count',
                    $self->ivShow('counterVarHash', 'temp_flee_count') + $fleeCount,
                );
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub set_gaugeFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_gaugeFlag', @_);
        }

        if ($flag && ! $self->gaugeFlag) {

            $self->ivPoke('gaugeFlag', TRUE);
            if ($self->stage >= 2) {

               $self->resetGauges();
            }

        } elsif (! $flag && $self->gaugeFlag) {

            if (defined $self->gaugeLevel) {

                $self->gaugeStripObj->removeGaugeLevel($self->session, $self->gaugeLevel);
            }

            $self->ivPoke('gaugeFlag', FALSE);
            $self->ivUndef('gaugeStripObj');
            $self->ivUndef('gaugeLevel');
            $self->ivEmpty('gaugeObjList');
        }

        return 1;
    }

    sub set_gaugeValueFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_gaugeValueFlag', @_);
        }

        if ($flag && ! $self->gaugeValueFlag) {

            $self->ivPoke('gaugeValueFlag', TRUE);
            foreach my $gaugeObj ($self->gaugeObjList) {

                $gaugeObj->ivPoke('labelFlag', TRUE);
            }

        } elsif (! $flag && $self->gaugeFlag) {

            $self->ivPoke('gaugeValueFlag', FALSE);
            foreach my $gaugeObj ($self->gaugeObjList) {

                $gaugeObj->ivPoke('labelFlag', FALSE);
            }
        }

        if ($self->gaugeFlag) {

            $self->setGauges();
        }

        return 1;
    }

    sub add_interactions {

        my (
            $self, $interactCount, $interactSuccessCount, $interactFailCount, $interactFightCount,
            $interactDisasterCount, $check
        ) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (
            ! defined $interactCount || ! defined $interactSuccessCount
            || ! defined $interactFailCount || ! defined $interactFightCount
            || ! defined $interactDisasterCount || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->add_interactions', @_);
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivPoke
                ('interactCount',
                $charObj->interactCount + $interactCount,
            );

            $charObj->ivPoke(
                'interactSuccessCount',
                $charObj->interactSuccessCount + $interactSuccessCount,
            );

            $charObj->ivPoke(
                'interactFailCount',
                $charObj->interactFailCount + $interactFailCount,
            );

            $charObj->ivPoke(
                'interactFightCount',
                $charObj->interactFightCount + $interactFightCount,
            );

            $charObj->ivPoke(
                'interactDisasterCount',
                $charObj->interactDisasterCount + $interactDisasterCount,
            );

            # Update counters (if the right counter is running)
            if ($self->interactCountFlag) {

                $self->ivAdd(
                    'counterVarHash',
                    'temp_interact_count',
                    $self->ivShow('counterVarHash', 'temp_interact_count') + $interactCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_interact_success_count',
                    $self->ivShow('counterVarHash', 'temp_interact_success_count')
                        + $interactSuccessCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_interact_fail_count',
                    $self->ivShow('counterVarHash', 'temp_interact_fail_count')
                        + $interactFailCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_interact_fight_count',
                    $self->ivShow('counterVarHash', 'temp_interact_fight_count')
                        + $interactFightCount,
                );

                $self->ivAdd(
                    'counterVarHash',
                    'temp_interact_disaster_count',
                    $self->ivShow('counterVarHash', 'temp_interact_disaster_count')
                        + $interactDisasterCount,
                );
            }


            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub inc_interactSuccessCount {

        my ($self, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->inc_interactSuccessCount',
                @_,
            );
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivIncrement('interactCount');
            $charObj->ivIncrement('interactSuccessCount');

            # Update counters (if the right counter is running)
            if ($self->interactCountFlag) {

                $self->ivIncHash('counterVarHash', 'temp_interact_count');
                $self->ivIncHash('counterVarHash', 'temp_interact_success_count');
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub inc_interactFailCount {

        my ($self, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->inc_interactFailCount', @_);
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivIncrement('interactCount');
            $charObj->ivIncrement('interactFailCount');

            # Update counters (if the right counter is running)
            if ($self->interactCountFlag) {

                $self->ivIncHash('counterVarHash', 'temp_interact_count');
                $self->ivIncHash('counterVarHash', 'temp_interact_fail_count');
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub inc_interactFightCount {

        my ($self, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->inc_interactFightCount', @_);
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivIncrement('interactCount');
            $charObj->ivIncrement('interactFightCount');

            # Update counters (if the right counter is running)
            if ($self->interactCountFlag) {

                $self->ivIncHash('counterVarHash', 'temp_interact_count');
                $self->ivIncHash('counterVarHash', 'temp_interact_fight_count');
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        return 1;
    }

    sub inc_interactDisasterCount {

        my ($self, $check) = @_;

        # Local variables
        my $charObj;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->inc_interactDisasterCount',
                @_,
            );
        }

        $charObj = $self->session->currentChar;
        if ($charObj) {

            # These are fixed variables, so update the current character profile directly
            $charObj->ivIncrement('interactCount');
            $charObj->ivIncrement('interactDisasterCount');

            # Update counters (if the right counter is running)
            if ($self->interactCountFlag) {

                $self->ivIncHash('counterVarHash', 'temp_interact_count');
                $self->ivIncHash('counterVarHash', 'temp_interact_disaster_count');
            }

            # Update the task window
            $self->ivPoke('updateFlag', TRUE);
        }

        # Update the task window
        $self->ivPoke('updateFlag', TRUE);

        return 1;
    }

    sub set_lifeStatusChangeFlag {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper(
                $self->_objClass . '->set_lifeStatusChangeFlag',
                @_,
            );
        }

        $self->ivPoke('lifeStatusChangeFlag', TRUE);

        return 1;
    }

    sub update_profiles {

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->update_profiles', @_);
        }

        if ($self->fightCountFlag || $self->interactCountFlag) {

            $self->reset_counters('a');
            $self->set_fightCountFlag(FALSE);
            $self->set_interactCountFlag(FALSE);
        }

        return 1;
    }

    sub set_updateFlag {

        my ($self, $flag, $check) = @_;

        # Check for improper arguments
        if (! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_updateFlag', @_);
        }

        if ($flag) {
            $self->ivPoke('updateFlag', TRUE);
        } else {
            $self->ivPoke('updateFlag', FALSE);
        }

        return 1;
    }

    sub set_updateFromMsdp {

        # Called by GA::Session->processMsdpData
        # When the server (world) reports an MSDP variable and its value, the session calls this
        #   function, so that we can update the Status task's variables (where appropriate)
        # The task's variables are updated even if the task is disactivated (but only at stage 4)
        #
        # Expected arguments
        #   $var    - An MSDP variable, e.g. EXPERIENCE
        #   $val    - The corresponding value; may be a scalar, or a list/hash reference
        #   $flag   - Set to TRUE if this is a generic MSDP variable, FALSE if it is a custom
        #               MSDP variable
        #
        # Return values
        #   'undef' on improper arguments
        #   1 on success

        my ($self, $var, $val, $flag, $check) = @_;

        # Local variables
        my (
            $mode,
            @list,
            %msdpHash, %hash,
        );

        # Check for improper arguments
        if (! defined $var || ! defined $val || ! defined $flag || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_updateFromMsdp', @_);
        }

        if ($axmud::CLIENT->debugMsdpFlag) {

            $self->session->writeDebug('STATUS MSDP: var ' . $var . ', val ' . $val);
        }

        # Protect against the world sending nested arrays/tables when we're expecting a scalar
        if (ref $val eq 'ARRAY') {
            $mode = 'array';
        } elsif (ref $val eq 'HASH') {
            $mode = 'hash';
        } else {
            $mode = undef;
        }

        # Import the current world profile's hash of MSDP variabless, in the form
        #   $msdpHash{MSDP_VARIABLE} = status_task_variable_name
        %msdpHash = $self->session->currentWorld->msdpStatusVarHash;

        # Deal with any MSDP variables for which the world profile provides a Status task variable
        if (exists $msdpHash{$var}) {

            $self->setValue($msdpHash{$var}, $val);
            # If 'main' window gauges are open, they must be reset, not just redrawn
            $self->ivPoke('gaugeResetFlag', TRUE);

        # Otherwise, only generic MSDP variables for the categories 'Character', 'Combat' and
        #   'World' are used
        } elsif ($flag) {

            if ($var eq 'AFFECTS') {

                # $val should be a list reference; but if it's a scalar, that's ok too (but don't
                #   accept a hash reference)
                if (defined $mode && $mode eq 'array') {
                    @list = %$val;
                } elsif (! $mode) {
                    push (@list, $val);
                }

                if (@list) {

                    # Replace any existing affects in $self->affectHash
                    foreach my $affect (@list) {

                        $hash{$affect} = undef;
                    }

                    $self->ivPoke('affectHash', %hash);
                }

            } elsif ($var eq 'ALIGNMENT' && ! $mode) {
                $self->setValue('alignment', $val);
            } elsif ($var eq 'EXPERIENCE' && ! $mode) {
                $self->setValue('xp_current', $val);
            } elsif ($var eq 'EXPERIENCE_MAX' && ! $mode) {
                $self->setValue('xp_total', $val);
            } elsif ($var eq 'EXPERIENCE_TNL' && ! $mode) {
                $self->setValue('xp_next_level', $val);
            } elsif ($var eq 'EXPERIENCE_TNL_MAX' && ! $mode) {
                $self->setValue('xp_total', $val);
            } elsif ($var eq 'HEALTH' && ! $mode) {
                $self->setValue('health_points', $val);
            } elsif ($var eq 'HEALTH_MAX' && ! $mode) {
                $self->setValue('health_points_max', $val);
            } elsif ($var eq 'LEVEL' && ! $mode) {
                $self->setValue('level', $val);
            } elsif ($var eq 'MANA' && ! $mode) {
                $self->setValue('magic_points', $val);
            } elsif ($var eq 'MANA_MAX' && ! $mode) {
                $self->setValue('magic_points_max', $val);
            } elsif ($var eq 'MONEY' && ! $mode) {
                $self->setValue('purse_contents', $val);
            } elsif ($var eq 'MOVEMENT' && ! $mode) {
                $self->setValue('energy_points', $val);
            } elsif ($var eq 'MOVEMENT_MAX' && ! $mode) {
                $self->setValue('energy_points_max', $val);
            } elsif ($var eq 'OPPONENT_LEVEL' && ! $mode) {
                $self->setValue('opp_level', $val);
            } elsif ($var eq 'OPPONENT_HEALTH' && ! $mode) {
                $self->setValue('opp_health', $val);
            } elsif ($var eq 'OPPONENT_HEALTH_MAX' && ! $mode) {
                $self->setValue('opp_health_max', $val);
            } elsif ($var eq 'OPPONENT_NAME' && ! $mode) {
                $self->setValue('opp_name', $val);
            } elsif ($var eq 'OPPONENT_STRENGTH' && ! $mode) {
                $self->setValue('opp_strength', $val);
            } elsif ($var eq 'WORLD_TIME' && ! $mode) {
                $self->setValue('time', $val);
            }
        }

        # (Regardless of whether an IV was updated, or not, mark the task window to be updated)
        $self->ivPoke('updateFlag', TRUE);

        return 1;
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub cmdHash
        { my $self = shift; return %{$self->{cmdHash}}; }
    sub timerHash
        { my $self = shift; return %{$self->{timerHash}}; }

    sub updateFlag
        { $_[0]->{updateFlag} }
    sub lifeStatusChangeFlag
        { $_[0]->{lifeStatusChangeFlag} }
    sub healthChangeFlag
        { $_[0]->{healthChangeFlag} }
    sub convertTimeFlag
        { $_[0]->{convertTimeFlag} }

    sub allowColourFlag
        { $_[0]->{allowColourFlag} }
    sub aliveColour
        { $_[0]->{aliveColour} }
    sub alive50Colour
        { $_[0]->{alive50Colour} }
    sub alive30Colour
        { $_[0]->{alive30Colour} }
    sub alive10Colour
        { $_[0]->{alive10Colour} }
    sub asleepColour
        { $_[0]->{asleepColour} }
    sub passedOutColour
        { $_[0]->{passedOutColour} }
    sub deadColour
        { $_[0]->{deadColour} }

    sub constCharVarHash
        { my $self = shift; return %{$self->{constCharVarHash}}; }
    sub constFixedVarHash
        { my $self = shift; return %{$self->{constFixedVarHash}}; }
    sub constPseudoVarHash
        { my $self = shift; return %{$self->{constPseudoVarHash}}; }
    sub constLocalVarHash
        { my $self = shift; return %{$self->{constLocalVarHash}}; }
    sub constCounterVarHash
        { my $self = shift; return %{$self->{constCounterVarHash}}; }
    sub constCounterRevHash
        { my $self = shift; return %{$self->{constCounterRevHash}}; }
    sub constPointHash
        { my $self = shift; return %{$self->{constPointHash}}; }

    sub localVarHash
        { my $self = shift; return %{$self->{localVarHash}}; }
    sub customVarHash
        { my $self = shift; return %{$self->{customVarHash}}; }

    sub counterVarHash
        { my $self = shift; return %{$self->{counterVarHash}}; }
    sub counterBaseHash
        { my $self = shift; return %{$self->{counterBaseHash}}; }
    sub fightCountFlag
        { $_[0]->{fightCountFlag} }
    sub interactCountFlag
        { $_[0]->{interactCountFlag} }
    sub counterStartTime
        { $_[0]->{counterStartTime} }

    sub affectHash
        { my $self = shift; return %{$self->{affectHash}}; }
    sub statHash
        { my $self = shift; return %{$self->{statHash}}; }

    sub gaugeFlag
        { $_[0]->{gaugeFlag} }
    sub gaugeStripObj
        { $_[0]->{gaugeStripObj} }
    sub gaugeLevel
        { $_[0]->{gaugeLevel} }
    sub gaugeValueFlag
        { $_[0]->{gaugeValueFlag} }
    sub gaugeObjList
        { my $self = shift; return @{$self->{gaugeObjList}}; }
    sub gaugeResetFlag
        { $_[0]->{gaugeResetFlag} }

    sub ttsPointsAlertHash
        { my $self = shift; return %{$self->{ttsPointsAlertHash}}; }
    sub ttsPointsAlertMsgHash
        { my $self = shift; return %{$self->{ttsPointsAlertMsgHash}}; }
    sub ttsPointsAlertTypeHash
        { my $self = shift; return %{$self->{ttsPointsAlertTypeHash}}; }
}

{ package Games::Axmud::Task::System;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the System task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'system_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'system_task';
        $self->{prettyName}             = 'System';
        $self->{shortName}              = 'Sy';
        $self->{shortCutIV}             = 'systemTask';     # Axmud built-in jealous task

        $self->{category}               = 'activity';
        $self->{descrip}                = 'Diverts system messages into a new window';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters

        # (Ordinary) system message mode: 'original' - ordinary system messages stay in the 'main'
        #   window, 'both' - ordinary system messages shown in both the 'main' window and the task
        #   window, 'task' - ordinary system messages shown only in the task window
        $self->{systemMode}             = 'both';
        # Error message mode: 'original' - error messages stay in the 'main' window, 'both' - error
        #   messages shown in both the 'main' window and the task window, 'task' - error messages
        #   shown only in the task window
        $self->{errorMode}              = 'both';
        # Warning message mode: 'original' - warning messages stay in the 'main' window, 'both' -
        #   warning messages shown in both the 'main' window and the task window, 'task' - error
        #   messages shown only in the task window
        $self->{warningMode}            = 'both';
        # Debug message mode: 'original' - debug messages stay in the 'main' window, 'both' - debug
        #   messages shown in both the 'main' window and the task window, 'task' - debug messages
        #   shown only in the task window
        $self->{debugMode}              = 'both';
        # Improper arguments mode: 'original' - improper arguments messages stay in the 'main'
        #   window, 'both' - messages shown in both the 'main' window and the task window, 'task' -
        #   messages shown only in the task window
        $self->{improperMode}           = 'both';

        # If set to TRUE, messages are shown in the same colour in both the 'main' and task window.
        #   If FALSE, the task window uses default colours
        $self->{colourFlag}             = TRUE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be cloned
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
        #   cloned)
        if (! $self->checkPlugins()) {

            return undef;
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{systemMode}            = $self->systemMode;
        $clone->{errorMode}             = $self->errorMode;
        $clone->{warningMode}           = $self->warningMode;
        $clone->{debugMode}             = $self->debugMode;
        $clone->{improperMode}          = $self->improperMode;

        $clone->{colourFlag}            = $self->colourFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve display modes
        $newTask->ivPoke('systemMode', $self->systemMode);
        $newTask->ivPoke('errorMode', $self->errorMode);
        $newTask->ivPoke('warningMode', $self->warningMode);
        $newTask->ivPoke('debugMode', $self->debugMode);
        $newTask->ivPoke('improperMode', $self->improperMode);

        $newTask->ivPoke('colourFlag', $self->colourFlag);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub init {}                 # Inherited from generic task

    sub doInit {

        # Called by $self->init, just before the task completes its setup ($self->init)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doInit', @_);
        }

        # GA::Obj::TextView->showSystemText can be called several times to display parts of a
        #   system message line; for example, to include a link in a part of it
        # To make those calls the same in both the 'main' and task windows, we've got to use the
        #   same setting of ->newLineDefault that 'main' windows use
        $self->defaultTabObj->textViewObj->set_newLineDefault('after');

        return 1;
    }

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

    ##################
    # Response methods

    sub showSystemText {

        # Called by GA::Obj::TextView->showSystemText
        # Displays an odrinary system message in the task window (in addition to, or instead of,
        #   displaying it in the 'main' window)
        #
        # Expected arguments
        #   $msg        - The system message to display
        #
        # Optional arguments
        #   @args       - The optional list of arguments supplied to the original
        #                   GA::Obj::TextView->showSystemText call, e.g. ('echo', 'blue')
        #
        # Return values
        #   'undef'

        my ($self, $msg, @args) = @_;

        # Check for improper arguments
        if (! defined $msg) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showSystemText', @_);
        }

        # Show the error message
        if (! $self->colourFlag) {
            return $self->insertText($msg, @args);
        } else {
            return $self->insertText($msg, $axmud::CLIENT->customShowSystemTextColour, @args);
        }
    }

    sub showError {

        # Called by GA::Obj::TextView->showError
        # Displays an error message in the task window (in addition to, or instead of, displaying
        #   it in the 'main' window)
        #
        # Expected arguments
        #   $msg    - The error message to display
        #
        # Return values
        #   'undef'

        my ($self, $msg, $check) = @_;

        # Check for improper arguments
        if (! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showError', @_);
        }

        # Show the error message
        if (! $self->colourFlag) {
            return $self->insertText($msg);
        } else {
            return $self->insertText($msg, $axmud::CLIENT->customShowErrorColour);
        }
    }

    sub showWarning {

        # Called by GA::Obj::TextView->showWarning
        # Displays a warning message in the task window (in addition to, or instead of, displaying
        #   it in the 'main' window)
        #
        # Expected arguments
        #   $msg    - The warning message to display
        #
        # Return values
        #   'undef'

        my ($self, $msg, $check) = @_;

        # Check for improper arguments
        if (! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showWarning', @_);
        }

        # Show the warning message
        if (! $self->colourFlag) {
            return $self->insertText($msg);
        } else {
            return $self->insertText($msg, $axmud::CLIENT->customShowWarningColour);
        }
    }

    sub showDebug {

        # Called by GA::Obj::TextView->showDebug
        # Displays a debug message in the task window (in addition to, or instead of, displaying
        #   it in the 'main' window)
        #
        # Expected arguments
        #   $msg    - The debug message to display
        #
        # Return values
        #   'undef'

        my ($self, $msg, $check) = @_;

        # Check for improper arguments
        if (! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showDebug', @_);
        }

        # Show the debug message
        if (! $self->colourFlag) {
            return $self->insertText($msg);
        } else {
            return $self->insertText($msg, $axmud::CLIENT->customShowDebugColour);
        }
    }

    sub showImproper {

        # Called by GA::Obj::TextView->showImproper
        # Displays an improper arguments message in the task window (in addition to, or instead of,
        #   it in the 'main' window)
        #
        # Expected arguments
        #   $msg    - The message to display
        #
        # Return values
        #   'undef'

        my ($self, $msg, $check) = @_;

        # Check for improper arguments
        if (! defined $msg || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->showImproper', @_);
        }

        # Show the message
        if (! $self->colourFlag) {
            return $self->insertText($msg);
        } else {
            return $self->insertText($msg, $axmud::CLIENT->customShowImproperColour);
        }
    }

    ##################
    # Accessors - set

    sub set_mode {

        # Called by GA::Cmd::SetSystemMode->do

        my ($self, $type, $mode, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $type
            || (
                $type ne 'system' && $type ne 'error' && $type ne 'warning' && $type ne 'debug'
                && $type ne 'improper'
            )
            || ! defined $mode
            || ($mode ne 'original' && $mode ne 'both' && $mode ne 'task')
            || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->set_mode', @_);
        }

        # Update IVs
        if ($type eq 'system') {
            $self->ivPoke('systemMode', $mode);
        } elsif ($type eq 'error') {
            $self->ivPoke('errorMode', $mode);
        } elsif ($type eq 'warning') {
            $self->ivPoke('warningMode', $mode);
        } elsif ($type eq 'debug') {
            $self->ivPoke('debugMode', $mode);
        } elsif ($type eq 'improper') {
            $self->ivPoke('improperMode', $mode);
        }
    }

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub systemMode
        { $_[0]->{systemMode} }
    sub errorMode
        { $_[0]->{errorMode} }
    sub warningMode
        { $_[0]->{warningMode} }
    sub debugMode
        { $_[0]->{debugMode} }
    sub improperMode
        { $_[0]->{improperMode} }

    sub colourFlag
        { $_[0]->{colourFlag} }
}

{ package Games::Axmud::Task::TaskList;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instance of the TaskList task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'tasklist_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'tasklist_task';
        $self->{prettyName}             = 'TaskList';
        $self->{shortName}              = 'Tl';
        $self->{shortCutIV}             = 'taskListTask';   # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Display a constantly-updated list of current tasks';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = FALSE;
        $self->{noScrollFlag}           = TRUE;
        $self->{ttsFlag}                = FALSE;
        $self->{ttsConfig}              = undef;
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {};
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #   (none for this task)

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

#   sub clone {}                # Inherited from generic task

#   sub preserve {}             # Inherited from generic task

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

#   sub doReset {}              # Inherited from generic task

#   sub doFirstStage {}         # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # Stage 2 normally checks that the character has logged in to the world, and then sets
            #   some IVs to initial values
            # There's nothing for the TaskList task to do on stage 2, so move on to the next stage
            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            my @lineList;

            # On every spin of the task loop, update the task window (if it is still open)
            if ($self->taskWinFlag) {

                # Add the header
                push (@lineList,
                    'TASKLIST (Time ' . sprintf("%.3f", $self->session->sessionTime) . ')',
                );

                push (@lineList, '   Task (unique) name       Stage  Status');

                # Add the remaining lines, one for each task
                foreach my $taskName (
                    sort {lc($a) cmp lc($b)} ($self->session->ivKeys('currentTaskHash'))
                ) {
                    my ($taskObj, $stage, $column);

                    # Blessed ref of the task
                    $taskObj = $self->session->ivShow('currentTaskHash', $taskName);

                    if ($taskObj->category eq 'process') {
                        $stage = $taskObj->stage;
                    } else {
                        $stage = '-';
                    }

                    if (! defined $stage) {

                        # Avoids error in sprintf() func below
                        $stage = '';
                    }

                    if ($taskObj->activeFlag) {
                        $column = '*';
                    } else {
                        $column = ' ';
                    }
                    $column .= substr($taskObj->category, 0, 1);

                    push (@lineList,
                        sprintf(
                            '%-2.2s %-24.24s %-6.6s %-20.20s',
                            $column,
                            $taskObj->uniqueName,
                            $stage,
                            $taskObj->status,
                        ),
                    );
                }

                # Display the list in the task window
                $self->insertQuick(join("\n", @lineList));
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    ##################
    # Response methods

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    # No task parameters
}

{ package Games::Axmud::Task::Watch;

    use strict;
    use warnings;
    use diagnostics;

    use Glib qw(TRUE FALSE);

    our @ISA = qw(Games::Axmud::Generic::Task Games::Axmud);

    ##################
    # Constructors

    sub new {

        # Creates a new instances of the Watch task
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #
        # Optional arguments
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world), 'custom' (tasks with
        #                   customised initial parameters, which are run when the user demands). If
        #                   set to 'undef', this is a temporary task, created in order to access the
        #                   default values stored in IVs, that will not be added to any tasklist
        #   $profName   - ($taskType = 'current', when called by $self->clone) Name of the
        #                   profile from whose initial tasklist this task was created ('undef' if
        #                   none)
        #               - ($taskType = 'initial') name of the profile in whose initial tasklist this
        #                   task will be. If 'undef', the global initial tasklist is used
        #               - ($taskType = 'custom') 'undef'
        #   $profCategory
        #               - ($taskType = 'current', 'initial') which category the profile falls undef
        #                   (i.e. 'world', 'race', 'char', etc, or 'undef' if no profile)
        #               - ($taskType = 'custom') 'undef'
        #   $customName
        #               - ($taskType = 'current', 'initial') 'undef'
        #               - ($taskType = 'custom') the custom task name, matching a key in
        #                   GA::Session->customTaskHash
        #
        # Return values
        #   'undef' on improper arguments or if the task can't be added to the specified tasklist
        #   Blessed reference to the newly-created object on success

        my ($class, $session, $taskType, $profName, $profCategory, $customName, $check) = @_;

        # Check for improper arguments
        if (! defined $class || ! defined $session || defined $check) {

            return $axmud::CLIENT->writeImproper($class . '->new', @_);
        }

        if ($taskType) {

            # For initial tasks, check that $profName exists
            if (
                $taskType eq 'initial'
                && defined $profName
                && ! $session->ivExists('profHash', $profName)
            ) {
                return $session->writeError(
                    'Can\'t create new task because \'' . $profName . '\' profile doesn\'t exist',
                    $class . '->new',
                );

            # For custom tasks, check that $customName doesn't already exist
            } elsif (
                $taskType eq 'custom'
                && $axmud::CLIENT->ivExists('customTaskHash', $customName)
            ) {
                return $session->writeError(
                    'Can\'t create new custom task because \'' . $customName . '\' is already being'
                    . ' used',
                    $class . '->new',
                );

            } elsif ($taskType ne 'current' && $taskType ne 'initial' && $taskType ne 'custom') {

                return $session->writeError(
                    'Can\'t create new task because \'' . $taskType . '\' is an invalid tasklist',
                    $class . '->new',
                );
            }
        }

        # Task settings
        my $self = Games::Axmud::Generic::Task->new(
            $session,
            $taskType,
            $profName,
            $profCategory,
            $customName,
        );

        $self->{_objName}               = 'watch_task';
        $self->{_objClass}              = $class;
        $self->{_parentFile}            = undef;            # Set below
        $self->{_parentWorld}           = undef;            # Set below
        $self->{_privFlag}              = TRUE,             # All IVs are private

        $self->{name}                   = 'watch_task';
        $self->{prettyName}             = 'Watch';
        $self->{shortName}              = 'Wa';
        $self->{shortCutIV}             = 'watchTask';      # Axmud built-in jealous task

        $self->{category}               = 'process';
        $self->{descrip}                = 'Displays Channels/Chat/Divert task activity';
        $self->{jealousyFlag}           = TRUE;
        $self->{requireLocatorFlag}     = FALSE;
        $self->{profSensitivityFlag}    = FALSE;
        $self->{storableFlag}           = TRUE;
        $self->{delayTime}              = 0;
        $self->{allowWinFlag}           = TRUE;
        $self->{requireWinFlag}         = TRUE;
        $self->{startWithWinFlag}       = TRUE;
        $self->{winPreferList}          = ['pane', 'grid'];
        $self->{winmap}                 = 'basic_fill';
        $self->{winUpdateFunc}          = undef;
        $self->{tabMode}                = 'simple';
        $self->{monochromeFlag}         = TRUE;
        $self->{noScrollFlag}           = FALSE;
        $self->{ttsFlag}                = TRUE;
        $self->{ttsConfig}              = 'watch';
        $self->{ttsAttribHash}          = {};
        $self->{ttsFlagAttribHash}      = {
            'watch'                     => FALSE,
        };
        $self->{ttsAlertAttribHash}     = {};
        $self->{status}                 = 'wait_init';
#       $self->{activeFlag}             = TRUE;             # Task can't be activated/disactivated

        # Task parameters
        #
        # The normal background colour for the window (set when the window is enabled) - set to one
        #   of Axmud's standard colour tags or 'undef' to use the default colour
        $self->{defaultColour}          = undef;
        # The background colour for the task window changes for a few seconds, every time an update
        #   is displayed in it. These IVs are set to one of Axmud's standard colour tags
        # The colour to use for Channels task activity
        $self->{channelsAlertColour}    = 'YELLOW';
        # The colour to use for Chat task activity
        $self->{chatAlertColour}        = 'MAGENTA';
        # The colour to use for Divert task activity
        $self->{divertAlertColour}      = 'GREEN';

        # When an activity notification is received, how many seconds to use the alert background
        #   colour
        $self->{channelsAlertInterval}  = 10;
        $self->{chatAlertInterval}      = 5;
        $self->{divertAlertInterval}    = 10;

        # When an update is received, the time at which the alert background colour should be
        #   replaced by the default background colour (matches GA::Session->sessionTime). Usually
        #   set to 'undef', which means the default background colour is visible
        $self->{resetTime}              = undef;
        # Flag set to TRUE the first time an updated is received (set to FALSE if no update has
        #   been received)
        $self->{firstTextFlag}          = FALSE;

        # Bless task
        bless $self, $class;

        # For all tasks that aren't temporary...
        if ($taskType) {

            # Check that the task doesn't belong to a disabled plugin (in which case, it can't be
            #   added to any current, initial or custom tasklist)
            if (! $self->checkPlugins()) {

                return undef;
            }

            # Set the parent file object
            $self->setParentFileObj($session, $taskType, $profName, $profCategory);

            # Create entries in tasklists, if possible
            if (! $self->updateTaskLists($session)) {

                return undef;
            }
        }

        # Task creation complete
        return $self;
    }

    sub clone {

        # Create a clone of an existing task
        # Usually used upon connection to a world, when every task in the initial tasklists must
        #   be cloned into a new object, representing a task in the current tasklist
        # (Also used when cloning a profile object, since all the tasks in its initial tasklist must
        #   also be cloned)
        #
        # Expected arguments
        #   $session    - The parent GA::Session (not stored as an IV)
        #   $taskType   - Which tasklist this task is being created into - 'current' for the current
        #                   tasklist (tasks which are actually running now), 'initial' (tasks which
        #                   should be run when the user connects to the world). Custom tasks aren't
        #                   cloned (at the moment)
        #
        # Optional arguments
        #   $profName   - ($taskType = 'initial') name of the profile in whose initial tasklist the
        #                   existing task is stored
        #   $profCategory
        #               - ($taskType = 'initial') which category the profile falls under (i.e.
        #                   'world', 'race', 'char', etc)
        #
        # Return values
        #   'undef' on improper arguments
        #   Blessed reference to the newly-created object on success

        my ($self, $session, $taskType, $profName, $profCategory, $check) = @_;

        # Check for improper arguments
        if (
            ! defined $session || ! defined $taskType || defined $check
            || ($taskType ne 'current' && $taskType ne 'initial')
            || ($taskType eq 'initial' && (! defined $profName || ! defined $profCategory))
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->clone', @_);
        }

        # For initial tasks, check that $profName exists
        if (
            $taskType eq 'initial'
            && defined $profName
            && ! $session->ivExists('profHash', $profName)
        ) {
            return $axmud::CLIENT->writeError(
                'Can\'t create cloned task because \'' . $profName . '\' profile doesn\'t exist',
                $self->_objClass . '->clone',
            );
        }

        # Create the new task, using default settings and parameters
        my $clone = $self->_objClass->new($session, $taskType, $profName, $profCategory);

        # Most of the cloned task's settings have default values, but a few are copied from the
        #   original
        $self->cloneTaskSettings($clone);

        # Give the new (cloned) task the same initial parameters as the original one
        $clone->{defaultColour}         = $self->defaultColour;
        $clone->{channelsAlertColour}   = $self->channelsAlertColour;
        $clone->{chatAlertColour}       = $self->chatAlertColour;
        $clone->{divertAlertColour}     = $self->divertAlertColour;

        $clone->{channelsAlertInterval} = $self->channelsAlertInterval;
        $clone->{chatAlertInterval}     = $self->chatAlertInterval;
        $clone->{divertAlertInterval}   = $self->divertAlertInterval;

        $clone->{resetTime}             = $self->resetTime;
        $clone->{firstTextFlag}         = $self->firstTextFlag;

        # Cloning complete
        return $clone;
    }

    sub preserve {

        # Called by $self->main whenever this task is reset, in order to preserve some if its task
        #   parameters (but not necessarily all of them)
        #
        # Expected arguments
        #   $newTask    - The new task which has been created, to which some of this task's instance
        #                   variables might have to be transferred
        #
        # Return values
        #   'undef' on improper arguments, or if $newTask isn't in the GA::Session's current
        #       tasklist
        #   1 on success

        my ($self, $newTask, $check) = @_;

        # Check for improper arguments
        if (! defined $newTask || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->preserve', @_);
        }

        # Check the task is in the current tasklist
        if (! $self->session->ivExists('currentTaskHash', $newTask->uniqueName)) {

            return $self->writeWarning(
                '\'' . $self->uniqueName . '\' task missing from the current tasklist',
                $self->_objClass . '->preserve',
            );
        }

        # Preserve some task parameters (the others are left with their default settings, some of
        #   which will be re-initialised in stage 2)

        # Preserve the background colours
        $newTask->ivPoke('defaultColour', $self->defaultColour);
        $newTask->ivPoke('channelsAlertColour', $self->channelsAlertColour);
        $newTask->ivPoke('chatAlertColour', $self->chatAlertColour);
        $newTask->ivPoke('divertAlertColour', $self->divertAlertColour);
        # Preserve intervals before resetting the background colour
        $newTask->ivPoke('channelsAlertInterval', $self->channelsAlertInterval);
        $newTask->ivPoke('chatAlertInterval', $self->chatAlertInterval);
        $newTask->ivPoke('divertAlertInterval', $self->divertAlertInterval);

        return 1;
    }

#   sub setParentFileObj {}     # Inherited from generic task

#   sub updateTaskLists {}      # Inherited from generic task

#   sub ttsReadAttrib {}        # Inherited from generic task

#   sub ttsSwitchFlagAttrib {}  # Inherited from generic task

#   sub ttsSetAlertAttrib {}    # Inherited from generic task

    ##################
    # Task windows

#   sub toggleWin {}            # Inherited from generic task

#   sub openWin {}              # Inherited from generic task

#   sub closeWin {}             # Inherited from generic task

    ##################
    # Methods

#   sub main {}                 # Inherited from generic task

#   sub doShutdown {}           # Inherited from generic task

    sub doReset {

        # Called just before the task completes a reset
        # For process tasks, called by $self->main. For activity tasks, called by $self->reset
        #
        # This function does nothing, so tasks that need to do something special during a reset
        #   should have their own ->doReset function
        #
        # Expected arguments
        #   $newTaskObj     - The replacement task object
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $newTaskObj, $check) = @_;

        # Check for improper arguments
        if (! defined $newTaskObj || defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doReset', @_);
        }

        # In case the task window is currently a different colour when the task is reset, set it
        #   back to the default colour
        if ($self->defaultTabObj) {

            $self->defaultTabObj->paneObj->applyMonochrome(
                $self->defaultTabObj,
                $self->defaultColour,
            );
        }

        return 1;
    }

#   sub doFirstStage {}         # Inherited from generic task

    sub doStage {

        # Called by $self->main to process all stages (except stage 1)
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments or if this function sets that task's ->status IV to
        #       'finished' or sets its ->shutdownFlag to TRUE
        #   Otherwise, we normally return the new value of $self->stage

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check) {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->doStage', @_);
        }

        if ($self->stage == 2) {

            # (Nothing to do; for compatibility with the Divert task, we'll let Stage 3 be the
            #   repeating loop)

            return $self->ivPoke('stage', 3);

        } elsif ($self->stage == 3) {

            if (defined $self->resetTime && $self->resetTime <= $self->session->sessionTime) {

                # The delay, started the last time an update was received, is over. Revert the task
                #   window to its original background colour
                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->defaultColour,
                );

                $self->ivPoke('resetTime', undef);
            }

            # Repeat this stage indefinitely
            return $self->ivPoke('stage', 3);

        } else {

            # The task stage has somehow been set to an invalid value
            return $self->invalidStage();
        }
    }

    sub displayText {

        # Calledy by GA::Task::Channels->displayText, GA::Task::Chat->writeText or
        #   GA::Task::Divert->displayText
        # Whenever text is displayed in any Channels, Chat or Divert task window, in any session, it
        #   is sent to this function in every Watch task in every session
        #
        # Expected arguments
        #   $type       - Calling task type: 'chat', 'channels' or 'divert'
        #   $world      - The calling session's current world
        #
        # Optional arguments
        #   $char       - The calling session's current character ('undef' if no current character)
        #   $msg        - The message to display. If 'undef', no text is displayed in the task
        #                   window, but the background colour is still changed
        #
        # Return values
        #   'undef' on improper arguments or if the task window isn't open
        #   1 otherwise

        my ($self, $type, $world, $char, $msg, $check) = @_;

        # Local variables
        my ($time, $text);

        # Check for improper arguments
        if (
            ! defined $type || ($type ne 'channels' && $type ne 'chat' && $type ne 'divert')
            || ! defined $world || defined $check
        ) {
            return $axmud::CLIENT->writeImproper($self->_objClass . '->displayText', @_);
        }

        # Don't do anything if the task window isn't open
        if (! $self->taskWinFlag) {

            return undef;
        }

        # Change the task window's background colour, and set the interval at which the background
        #   colour will revert to the default
        if ($type eq 'channels') {

            $time = $self->session->sessionTime + $self->channelsAlertInterval;
            $self->defaultTabObj->paneObj->applyMonochrome(
                $self->defaultTabObj,
                $self->channelsAlertColour,
            );

        } elsif ($type eq 'chat') {

            $time = $self->session->sessionTime + $self->chatAlertInterval;
            $self->defaultTabObj->paneObj->applyMonochrome(
                $self->defaultTabObj,
                $self->chatAlertColour,
            );

        } elsif ($type eq 'divert') {

            $time = $self->session->sessionTime + $self->divertAlertInterval;
            $self->defaultTabObj->paneObj->applyMonochrome(
                $self->defaultTabObj,
                $self->divertAlertColour,
            );
        }

        # (Different types of update have different intervals; don't change the time at which
        #   the window reverts if the new interval finishes before the existing one)
        if (! $self->resetTime || $self->resetTime < $time) {

            $self->ivPoke('resetTime', $time);
        }

        # Display the message (if there is one)
        $text = '';
        if ($char) {

            $text .= $char . '@';
        }

        $text .= $world . ': ';

        if ($msg) {
            $text .= $msg;
        } else {
            $text .= '<no-text alert>';
        }

        if (! $self->firstTextFlag) {

            # This is the first message to be displayed in the task window; need to remove any
            #   holding messages
            $self->insertWithLinks($text, 'empty');
            $self->ivPoke('firstTextFlag', TRUE);

        } else {

            $self->insertWithLinks($text);
        }

        # Read out a TTS message, if required
        if ($self->ivShow('ttsFlagAttribHash', 'watch') && $msg) {

            if ($char) {
                $text = 'Watch. ' . $char . ' at ' . $world . '. ';
            } else {
                $text = 'Watch at ' . $world . '. ';
            }

            $self->ttsQuick($text . $msg);
        }

        return 1;
    }

    sub resetWin {

        # Called by GA::Cmd::EmptyWatchWindow->do
        # Resets the task window - removes any text, and sets the background colour back to the
        #   default
        #
        # Expected arguments
        #   (none besides $self)
        #
        # Return values
        #   'undef' on improper arguments
        #   1 otherwise

        my ($self, $check) = @_;

        # Check for improper arguments
        if (defined $check)  {

            return $axmud::CLIENT->writeImproper($self->_objClass . '->resetWin', @_);
        }

        if ($self->taskWinFlag) {

            # Empty the window of text
            $self->clearBuffer();

            # Use the default background colour
            if ($self->defaultTabObj) {

                $self->defaultTabObj->paneObj->applyMonochrome(
                    $self->defaultTabObj,
                    $self->defaultColour,
                );

                $self->ivPoke('resetTime', undef);
            }

            # If the window's urgency hint is set, then reset it
            if ($self->winObj) {

                $self->winObj->resetUrgent();
            }
        }

        return 1;
    }

    ##################
    # Accessors - set

    ##################
    # Accessors - task settings - get

    # The accessors for task settings are inherited from the generic task

    ##################
    # Accessors - task parameters - get

    sub defaultColour
        { $_[0]->{defaultColour} }
    sub channelsAlertColour
        { $_[0]->{channelsAlertColour} }
    sub chatAlertColour
        { $_[0]->{chatAlertColour} }
    sub divertAlertColour
        { $_[0]->{divertAlertColour} }

    sub channelsAlertInterval
        { $_[0]->{channelsAlertInterval} }
    sub chatAlertInterval
        { $_[0]->{chatAlertInterval} }
    sub divertAlertInterval
        { $_[0]->{divertAlertInterval} }

    sub resetTime
        { $_[0]->{resetTime} }
    sub firstTextFlag
        { $_[0]->{firstTextFlag} }
}

# Package must return a true value
1
