Typist.pk3

Table of Contents

Typist.pk3 turns FPS games into typing exercises.

TODO: add instructions.

1. Event Handler

1.1. EventHandler

// Entry point for Typist.pk3.
class tt_EventHandler : EventHandler
{
  override void worldTick()
  {
    _playerHandler.tick();
    _server.tick();
    self.IsUiProcessor = _playerHandler.isCapturingKeys();
  }

  override bool uiProcess(UiEvent event)
  {
    let character = tt_Character.of(event.type, event.keyChar, event.isCtrl);
    _playerHandler.processKey(character);

    return false;
  }

  override bool inputProcess(InputEvent event)
  {
    _playerHandler.processInput(event.type);
    return false;
  }

  override void playerEntered(PlayerEvent event)
  {
    if (gameState != GS_Level && gameState != GS_StartUp) return;

    self.RequireMouse = true;

    if (_server == NULL) _server = tt_Server.of();

    int playerNumber = event.playerNumber;

    _server.addPlayer(playerNumber);
    tt_GameTweaks.tweakPlayer(players[playerNumber]);

    if (playerNumber == consolePlayer)
      _playerHandler = tt_PlayerSupervisor.of(consolePlayer);
  }

  override void worldThingDied(WorldEvent event)
  {
    _playerHandler.reportDead(event.Thing);
  }

  override void worldLoaded(WorldEvent event)
  {
    bool isTitlemap = (level.mapName ~== "TITLEMAP");
    if (isTitlemap)
      destroy();
  }

  override void worldUnloaded(WorldEvent event)
  {
    self.IsUiProcessor = false;
  }

  override void renderOverlay(RenderEvent event)
  {
    _playerHandler.draw(event);
  }

  override void consoleProcess(ConsoleEvent event)
  {
    string command = event.Name;

    if (command.left(3) != "tt_") return;

    if      (command == "tt_unlock_mode"  ) _playerHandler.unlockMode();
    else if (command == "tt_force_combat" ) _playerHandler.forceCombat();
    else if (command == "tt_reset_targets") _playerHandler.reset(consolePlayer);
  }

  override void networkCommandProcess(NetworkCommand command)
  {
    if (command.command == "tt_target")
    {
      double x = command.readDouble();
      double y = command.readDouble();
      double z = command.readDouble();

      _server.react(command.player, (x, y, z));
    }
  }

  private tt_PlayerHandler _playerHandler;
  private tt_Server        _server;
}

1.2. GameTweaks

// Buddha
server bool tt_buddha_enabled = true;
// Handles game tweaks.
class tt_GameTweaks play
{
  static void tweakPlayer(PlayerInfo player)
  {
    let pawn = player.mo;
    if (pawn == NULL) return;

    makeInvulnerable(pawn);
    increaseDamage(pawn);
    decreaseIncomingDamage(pawn);
    protectFromSelfDamage(pawn);
    disableSeekingMissiles(pawn);
  }

  // Still lose health down to 1 point.
  static private void makeInvulnerable(PlayerPawn pawn)
  {
    if (tt_buddha_enabled)
      pawn.giveInventory("tt_Buddha", 1);
  }

  static private void increaseDamage(PlayerPawn pawn)
  {
    double originalDamage = getDefaultByType(pawn.getClass()).damageMultiply;
    pawn.damageMultiply = originalDamage * 10;
  }

  static private void decreaseIncomingDamage(PlayerPawn pawn)
  {
    double originalFactor = getDefaultByType(pawn.getClass()).damageFactor;
    pawn.damageFactor = originalFactor / 2;
  }

  static private void protectFromSelfDamage(PlayerPawn pawn)
  {
    pawn.selfDamageFactor = 0;
  }

  static private void disableSeekingMissiles(PlayerPawn pawn)
  {
    pawn.bCantSeek = true;
  }
}

1.3. Buddha

class tt_Buddha : PowerBuddha
{
  Default
  {
    // https://zdoom.org/wiki/Powerup_properties
    Powerup.Duration 0x7FFFFFFD;
    +INVENTORY.UNDROPPABLE;
  }
}

2. Player Handler

2.1. PlayerHandler

// Handles the game for one player.
class tt_PlayerHandler abstract
{
  abstract void reset(int playerNumber);

  abstract void processKey(tt_Character character);

  // Type is from InputEvent.EGenericEvent.
  abstract void processInput(int type);

  abstract void tick();

  abstract void reportDead(Actor dead);

  abstract bool isCapturingKeys();

  abstract void unlockMode();

  abstract void forceCombat();

  ui abstract void draw(RenderEvent event);
}

2.2. PlayerSupervisor

// General settings
user int  tt_view_scale = 1;
user bool tt_fast_confirmation = false;

// Command settings
user string tt_command_pass_through = "/pass";

// Sound settings
user bool tt_sound_enabled = true;
user int  tt_sound_theme = 1;
user bool tt_sound_typing_enabled = true;
// Handles Typist.pk3 features for one player.
class tt_PlayerSupervisor : tt_PlayerHandler
{
  static tt_PlayerSupervisor of(int playerNumber)
  {
    let result = new("tt_PlayerSupervisor");
    result.reset(playerNumber);
    return result;
  }

  override void reset(int playerNumber)
  {
    let playerSource = tt_PlayerSourceImpl.of(playerNumber);
    let clock        = tt_TotalClock      .of();

    let soundPlayer = tt_PlayerSoundPlayer
      .of(playerSource,
          tt_BoolCvar.of(playerSource, "tt_sound_enabled"),
          tt_IntCvar.of(playerSource, "tt_sound_theme"));

    let answerReporter   = tt_SoundAnswerReporter  .of(soundPlayer);
    let modeReporter     = tt_SoundModeReporter    .of(soundPlayer);
    let isTypingEnabled  = tt_BoolCvar.of(playerSource, "tt_sound_typing_enabled");
    let keyPressReporter = tt_SoundKeyPressReporter.of(soundPlayer, isTypingEnabled);

    let manualModeSource = tt_SettableMode .of();
    let playerInput      = tt_PlayerInput  .of(manualModeSource, keyPressReporter);
    let deathReporter    = tt_DeathReporter.of();

    let originSource     = tt_PlayerOriginSource.of(playerSource);
    let targetRadar      = tt_TargetRadar       .of(originSource);
    let radarStaleMarker = tt_StaleMarkerImpl   .of(clock);
    let radarCacheDirty  = tt_TargetSourceCache .of(targetRadar, radarStaleMarker);
    let radarCache       = tt_TargetSourcePruner.of(radarCacheDirty);
    let lesson           = makeLesson(playerSource);

    let targetRegistry = makeTargetRegistry(radarCache, lesson, deathReporter, clock);

    let answerStateSource = tt_PressedAnswerState.of();

    let visibleTargetSource = tt_VisibleKnownTargetSource.of(targetRegistry,
                                                             playerSource);
    let pressMatcher = tt_QuestionAnswerMatcher
      .of(visibleTargetSource, playerInput, answerStateSource);

    let hastyMatcher = tt_HastyQuestionAnswerMatcher
      .of(visibleTargetSource, playerInput, answerReporter);

    let fastConfirmation   = tt_BoolCvar.of(playerSource, "tt_fast_confirmation");
    let targetOriginSource = tt_OriginSourceCache
      .of(tt_SelectableOriginSource.of(hastyMatcher, pressMatcher, fastConfirmation),
          tt_StaleMarkerImpl.of(clock));

    let projector      = tt_Projector           .of(visibleTargetSource, playerSource);
    let widgetRegistry = tt_TargetWidgetRegistry.of(projector);
    let widgetSorter   = tt_SorterByDistance    .of(widgetRegistry, originSource);

    let autoModeSource = tt_AutoModeSource.of(visibleTargetSource);
    Array<tt_ModeSource> modeSources = {
      tt_AutomapModeSource.of(),
      manualModeSource,
      tt_DelayedCombatModeSource.of(clock, autoModeSource, radarCache),
      autoModeSource
    };

    let modeSource = tt_ReportedModeSource.of(modeReporter,
                                              tt_ModeCascade.of(modeSources));

    let inputManager = tt_PassThroughInputManager
      .of(tt_InputByModeManager.of(modeSource, playerInput));

    Array<tt_Activatable> commands = {
      tt_PassThrough.of(inputManager,
                        tt_StringCvar.of(playerSource, "tt_command_pass_through"))
    };

    let commandDispatcher = tt_CommandDispatcher.of(playerInput,
                                                    commands,
                                                    answerReporter,
                                                    answerStateSource,
                                                    fastConfirmation);

    let oldModeSource         = tt_SettableMode.of();
    let inputBlockAfterCombat = tt_InputBlockAfterCombat
      .of(playerInput, modeSource, oldModeSource);

    let scaleSetting = tt_PositiveIntCvar.of(playerSource, "tt_view_scale");
    let infoPanel = tt_InfoPanel.of(modeSource,
                                    playerInput,
                                    commandDispatcher,
                                    visibleTargetSource,
                                    scaleSetting);

    Array<tt_View> views = {
      tt_TargetOverlay.of(widgetSorter, playerInput, scaleSetting, modeSource),
      tt_Frame.of(modeSource),
      infoPanel
    };

    let targetSender = tt_TargetOriginSender.of(targetOriginSource);

    Array<tt_Effect> effects = {
      tt_Gunner.of(targetOriginSource, targetSender),
      tt_AnswerResetter.of(answerStateSource, playerInput),
      tt_MatchWatcher.of(answerStateSource, answerReporter, targetOriginSource)
    };

    Array<tt_KeyProcessor> keyProcessors = {inputBlockAfterCombat, answerStateSource};

    _keyProcessor       = tt_KeyProcessors.of(keyProcessors);
    _deathReporter      = deathReporter;
    _targetRegistry     = targetRegistry;
    _view               = tt_ConditionalView.of(tt_Views.of(views));
    _modeSource         = modeSource;
    _targetWidgetSource = projector;
    _commandDispatcher  = commandDispatcher;
    _manualModeSource   = manualModeSource;
    _inputManager       = inputManager;
    _oldModeSource      = oldModeSource;
    _inputBlockAfterCombat = inputBlockAfterCombat;
    _effects            = tt_Effects.of(effects);
  }

  override void processKey(tt_Character character)
  {
    _keyProcessor.processKey(character);
  }

  override void processInput(int type)
  {
    _inputManager.processInput(type);
  }

  override void tick()
  {
    _commandDispatcher.activate();
    _inputManager.manageInput();

    _inputBlockAfterCombat.update();
    _oldModeSource.setMode(_modeSource.getMode());

    _effects.doEffect();
  }

  override void reportDead(Actor dead)
  {
    _deathReporter.reportDead(dead);
  }

  override bool isCapturingKeys()
  {
    return _inputManager.isCapturingKeys();
  }

  override void unlockMode()
  {
    _manualModeSource.setMode(tt_Mode.None);
  }

  override void forceCombat()
  {
    _manualModeSource.setMode(tt_Mode.Combat);
  }

  override void draw(RenderEvent event)
  {
    _view.draw(event);
  }
// Mixed Lesson configuration
user bool tt_is_english_enabled = true;
user bool tt_is_random_enabled  = false;
user bool tt_is_maths_enabled   = false;
user bool tt_is_custom_enabled  = false;
  private static tt_Lesson makeLesson(tt_PlayerSource playerSource)
  {
    let randomLessonSettings = tt_RandomCharactersLessonSettingsImpl.of(playerSource);

    Array<tt_SwitchableLesson> lessons = {
      tt_SwitchableLesson.of(tt_BoolCvar.of(playerSource, "tt_is_random_enabled"),
                             tt_RandomCharactersLesson.of(randomLessonSettings)),
      tt_SwitchableLesson.of(tt_BoolCvar.of(playerSource, "tt_is_maths_enabled"),
                             tt_MathsLesson.of()),
      tt_SwitchableLesson.of(tt_BoolCvar.of(playerSource, "tt_is_english_enabled"),
                             tt_StringSet.of("tt_1000")),
      tt_SwitchableLesson.of(tt_BoolCvar.of(playerSource, "tt_is_custom_enabled"),
                             tt_StringSet.of("typist_custom_text"))
    };

    return tt_MixedLesson.of(lessons);
  }

  private static tt_KnownTargetSource makeTargetRegistry(
    tt_TargetSource   targetSource,
    tt_Lesson         lesson,
    tt_TargetSource   deathReporter,
    tt_Clock          clock)
  {
    let registry      = tt_TargetRegistry .of(targetSource, lesson, deathReporter);
    let staleMarker   = tt_StaleMarkerImpl.of(clock);
    let registryCache = tt_KnownTargetSourceCache.of(registry, staleMarker);

    return registryCache;
  }

  private tt_KeyProcessor       _keyProcessor;
  private tt_KnownTargetSource  _targetRegistry;
  private tt_DeathReporter      _deathReporter;
  private tt_View               _view;
  private tt_ModeSource         _modeSource;
  private tt_TargetWidgetSource _targetWidgetSource;
  private tt_CommandDispatcher  _commandDispatcher;
  private tt_ModeStorage        _manualModeSource;
  private tt_PassThroughInputManager _inputManager;
  private tt_SettableMode       _oldModeSource;
  private tt_InputBlockAfterCombat _inputBlockAfterCombat;
  private tt_Effect             _effects;
}

3. Server

3.1. Server

class tt_Server
{
  static tt_Server of()
  {
    let result = new("tt_Server");
    result._globalChangers = tt_WorldChangers.ofNone();
    return result;
  }

  void addPlayer(int playerNumber)
  {
    let playerSource       = tt_PlayerSourceImpl    .of(playerNumber);
    let originSource       = tt_PlayerOriginSource  .of(playerSource);
    let targetOriginSource = tt_ExternalOriginSource.of();
    let targetRadar        = tt_TargetRadar         .of(originSource);
    let radarStaleMarker   = tt_StaleMarkerImpl     .of(tt_TotalClock.of());
    let radarCacheDirty    = tt_TargetSourceCache   .of(targetRadar, radarStaleMarker);
    let radarCache         = tt_TargetSourcePruner  .of(radarCacheDirty);
    let autoAimSetting     = tt_FloatCvar           .of(playerSource, "autoaim");

    Array<tt_WorldChanger> targetChangers = {
      tt_HorizontalAimer.of(targetOriginSource, playerSource),
      tt_VerticalAimer.of(targetOriginSource, playerSource, autoAimSetting),
      tt_Firer.of(playerSource)
    };

    _targetSources[playerNumber]  = targetOriginSource;
    _targetChangers[playerNumber] = tt_WorldChangers.of(targetChangers);

    Array<tt_WorldChanger> globalChangers = {
      tt_ProjectileSpeedController.of(originSource, playerSource),
      tt_EnemySpeedController.of(radarCache, playerSource)
    };

    _globalChangers.add(tt_WorldChangers.of(globalChangers));
  }

  play void react(int playerNumber, vector3 targetOrigin)
  {
    _targetSources[playerNumber].setOrigin(tt_Origin.of(targetOrigin));
    _targetChangers[playerNumber].changeWorld();
  }

  play void tick() { _globalChangers.changeWorld(); }

  tt_ExternalOriginSource _targetSources[MAXPLAYERS];
  tt_WorldChanger         _targetChangers[MAXPLAYERS];
  tt_WorldChangers        _globalChangers;
}

4. World Changer

4.1. WorldChanger

// This interface represents entities that change the world state.
class tt_WorldChanger abstract
{
  play abstract void changeWorld();
}

4.2. WorldChangers

// Implements tt_WorldChanger by executing several instances of tt_WorldChanger.
class tt_WorldChangers : tt_WorldChanger
{
  static tt_WorldChangers of(Array<tt_WorldChanger> changers)
  {
    let result = new("tt_WorldChangers");
    result._changers.move(changers);
    return result;
  }

  static tt_WorldChangers ofNone() { return new("tt_WorldChangers"); }

  void add(tt_WorldChanger changer) { _changers.push(changer); }

  override void changeWorld()
  {
    foreach (changer : _changers)
      changer.changeWorld();
  }

  private Array<tt_WorldChanger> _changers;
}

4.3. EnemySpeedController

// Implements tt_WorldChanger by slowing down enemies.
class tt_EnemySpeedController : tt_WorldChanger
{
  static tt_EnemySpeedController of(tt_TargetSource targetSource,
                                    tt_PlayerSource playerSource)
  {
    let result = new("tt_EnemySpeedController");
    result._targetSource = targetSource;
    result._playerSource = playerSource;
    return result;
  }

  override void changeWorld()
  {
    let  targets  = _targetSource.getTargets();
    uint nTargets = targets.size();
    int  player   = _playerSource.getNumber();

    for (uint i = 0; i < nTargets; ++i)
    {
      let enemy = targets.at(i).getActor();
      if (!tt_VelocityStorage.isSlowedDown(enemy, player))
        tt_VelocityStorage.slowDown(enemy, player);
    }
  }

  private tt_TargetSource _targetSource;
  private tt_PlayerSource _playerSource;
}

4.4. ProjectileSpeedController

// Implements tt_WorldChanger by slowing down projectiles that fly towards the player.
//
// When a projectile is no longer flying towards the player, its speed is
// restored.
class tt_ProjectileSpeedController : tt_WorldChanger
{
  static tt_ProjectileSpeedController of(tt_OriginSource playerOriginSource,
                                         tt_PlayerSource playerSource)
  {
    let result = new("tt_ProjectileSpeedController");
    result._playerOriginSource = playerOriginSource;
    result._playerSource = playerSource;
    return result;
  }

  override void changeWorld()
  {
    let origin       = _playerOriginSource.getOrigin().getVector();
    let playerRadius = _playerSource.getPawn().radius;
    int player       = _playerSource.getNumber();

    foreach (Actor a : ThinkerIterator.Create("Actor", Thinker.STAT_DEFAULT))
      if (a.bMissile) controlProjectile(a, origin, playerRadius, player);
  }

  private play void controlProjectile(Actor a,
                                      vector3 playerOrigin,
                                      double playerRadius,
                                      int player)
  {
    bool isInRange = tt_Math.isInEffectiveRange(a.pos, playerOrigin);

    if (isInRange && isMovingTowardsPlayer(a, playerOrigin, playerRadius))
    {
      if (!tt_VelocityStorage.isSlowedDown(a, player))
        tt_VelocityStorage.slowDown(a, player);
    }
    else if (tt_VelocityStorage.isSlowedDown(a, player))
    {
      tt_VelocityStorage.restoreVelocity(a, player);
    }
  }

  private play bool isMovingTowardsPlayer(Actor projectile,
                                          vector3 playerPos,
                                          double playerRadius)
  {
    vector3 vel = projectile.vel;
    if (vel == (0, 0, 0)) { return false; } // doesn't move

    double oldDistance = (projectile.pos - vel - playerPos).length();
    double distance    = (projectile.pos -       playerPos).length();

    if (distance > oldDistance) { return false; } // moves from player

    // http://mathworld.wolfram.com/Point-LineDistance3-Dimensional.html
    vector3 x10             = projectile.pos - playerPos;
    vector3 prod            = tt_Math.crossProduct(vel, x10);
    double  lineDistance    = prod.length() / vel.length();
    double  hitDistance     = playerRadius + projectile.radius;
    bool    willTouchPlayer = (hitDistance >= lineDistance);

    return willTouchPlayer;
  }

  private tt_OriginSource _playerOriginSource;
  private tt_PlayerSource _playerSource;
}

4.5. VelocityStorage

// This is a helper class that allows storing the velocity.
// TODO: rewrite with a Behavior?
class tt_VelocityStorage : Inventory
{
  static bool isSlowedDown(Actor other, int byPlayer)
  {
    let storage = tt_VelocityStorage(other.findInventory("tt_VelocityStorage"));
    if (storage == NULL) return false;

    return storage._byWhichPlayer[byPlayer];
  }

  static void slowDown(Actor other, int byPlayer)
  {
    let storage = tt_VelocityStorage(other.findInventory("tt_VelocityStorage"));

    if (storage != NULL)
    {
      storage._byWhichPlayer[byPlayer] = true;
      return;
    }

    storage = tt_VelocityStorage(Actor.spawn("tt_VelocityStorage"));
    storage._velocity = other.vel;
    storage._speed    = other.speed;

    other.addInventory(storage);

    other.vel   *= VELOCITY_SCALE_FACTOR;
    other.speed *= VELOCITY_SCALE_FACTOR;
  }

  static void restoreVelocity(Actor other, int byPlayer)
  {
    let storage = tt_VelocityStorage(other.findInventory("tt_VelocityStorage"));

    storage._byWhichPlayer[byPlayer] = false;

    if (storage.countByPlayers() == 0)
    {
      other.vel   = storage._velocity;
      other.speed = storage._speed;

      other.removeInventory(storage);
      storage.destroy();
    }
  }

  private int countByPlayers()
  {
    int result = 0;
    foreach (byPlayer : _byWhichPlayer)
      result += byPlayer;
    return result;
  }

  // TODO: make velocity scale factor configurable for projectiles and enemies.
  // for actors it was 0.2.
  const VELOCITY_SCALE_FACTOR = 0.1;

  private vector3 _velocity;
  private double  _speed;
  private bool[MAXPLAYERS] _byWhichPlayer;
}

4.6. HorizontalAimer

// Implements tt_WorldChanger interface by rotating the player.
class tt_HorizontalAimer : tt_WorldChanger
{
  static tt_HorizontalAimer of(tt_OriginSource targetOriginSource, tt_PlayerSource playerSource)
  {
    let result = new("tt_HorizontalAimer");

    result._targetOriginSource = targetOriginSource;
    result._playerSource       = playerSource;

    return result;
  }

  override void changeWorld()
  {
    let targetOrigin = _targetOriginSource.getOrigin();
    if (targetOrigin == NULL) { return; }

    let pawn = _playerSource.getPawn();
    if (pawn == NULL) { return; }

    vector3 myPosition    = pawn.pos;
    vector3 otherPosition = targetOrigin.getVector();
    double  angle         = AngleTo(myPosition.XY, otherPosition.XY);

    pawn.A_SetAngle(angle, SPF_INTERPOLATE);
  }

  private static double AngleTo(vector2 myPosition, vector2 otherPosition)
  {
    vector2 xy = otherPosition - myPosition;
    return vectorAngle(xy.x, xy.y);
  }

  private tt_OriginSource _targetOriginSource;
  private tt_PlayerSource _playerSource;
}
{
  let tag = "tt_HorizontalAimer";

  Array<tt_Origin> targetPositions;
  Array<double>    angles;

  targetPositions.push(tt_Origin.of(( 100,  100, 0))); angles.push(  45);
  targetPositions.push(tt_Origin.of((-100, -100, 0))); angles.push(-135);
  targetPositions.push(tt_Origin.of((   0,    0, 0))); angles.push(   0);

  players[consolePlayer].mo.SetOrigin((0, 0, 0), false);

  int nTargetPositions = targetPositions.size();
  for (int i = 0; i < nTargetPositions; ++i)
  {
    let    originSource  = tt_OriginSourceMock.of();
    let    playerSource  = tt_PlayerSourceMock.of();
    let    aimer         = tt_HorizontalAimer.of(originSource, playerSource);
    let    targetOrigin  = targetPositions[i];
    let    pawn          = players[consolePlayer].mo;
    double angle         = angles[i];

    originSource.expect_getOrigin(targetOrigin);
    playerSource.expect_getPawn(pawn);

    // Just for a visual check.
    spawn("DoomImp", targetOrigin.getVector());

    aimer.changeWorld();

    let message = string.format("%s: pawn is oriented at the target, angle: %f",
                                tag,
                                angle);
    it(message, AssertEval(pawn.angle, "~==", angles[i]));
    assertSatisfaction(originSource.getSatisfaction(), tag);
    assertSatisfaction(playerSource.getSatisfaction(), tag);

    cleanUpSpawned();
  }
}

4.7. VerticalAimer

// Implements tt_WorldChanger interface by adjusting the player pitch
// (horizontal angle). If autoaim is enabled, no pitch adjustment is done.
class tt_VerticalAimer : tt_WorldChanger
{
  static tt_VerticalAimer of(tt_OriginSource targetOriginSource,
                             tt_PlayerSource playerSource,
                             tt_FloatSetting autoAimSetting)
  {
    let result = new("tt_VerticalAimer");
    result._targetOriginSource = targetOriginSource;
    result._playerSource       = playerSource;
    result._autoAimSetting     = autoAimSetting;
    return result;
  }

  override void changeWorld()
  {
    if (!isAutoAimEnabled()) setPitch();
  }

  private play bool isAutoAimEnabled()
  {
    return _autoAimSetting.getFloat() > 34.5;
  }

  private play void setPitch()
  {
    let targetOrigin = _targetOriginSource.getOrigin();
    if (targetOrigin == NULL) { return; }

    let pawn = _playerSource.getPawn();
    if (pawn == NULL) { return; }

    vector3 myPosition = pawn.pos;
    myPosition.z += pawn.Height / 2 + pawn.AttackZOffset;

    vector3 otherPosition = targetOrigin.getVector();
    vector3 diff          = myPosition - otherPosition;
    double  angle         = Atan2(diff.z, sqrt(diff.x * diff.x + diff.y * diff.y));

    pawn.A_SetPitch(angle, SPF_INTERPOLATE);
  }

  private tt_OriginSource _targetOriginSource;
  private tt_PlayerSource _playerSource;
  private tt_FloatSetting _autoAimSetting;
}
{
  let tag = "tt_VerticalAimer";
  let targetOriginSource = tt_OriginSourceMock.of();
  let playerSource       = tt_PlayerSourceMock.of();
  let autoAimSetting     = tt_FloatSettingMock.of();

  let aimer = tt_VerticalAimer.of(targetOriginSource, playerSource, autoAimSetting);

  let targetOrigin = tt_Origin.of((550, 500, 500));
  let pawn         = players[consolePlayer].mo;
  pawn.SetOrigin((0, 0, 0), false);

  targetOriginSource.expect_getOrigin(targetOrigin);
  playerSource      .expect_getPawn(pawn);
  autoAimSetting    .expect_getFloat(0);

  aimer.changeWorld();

  assertSatisfaction(targetOriginSource.getSatisfaction(), tag);
  assertSatisfaction(playerSource.getSatisfaction(), tag);
  assertSatisfaction(autoAimSetting.getSatisfaction(), tag);
}

4.8. FirerImpl

// Implements tt_WorldChanger by making the player pawn fire a shot.
class tt_Firer : tt_WorldChanger
{
  static tt_Firer of(tt_PlayerSource playerSource)
  {
    let result = new("tt_Firer");
    result._playerSource = playerSource;
    return result;
  }

  override void changeWorld()
  {
    let  playerInfo = _playerSource.getInfo();
    bool isReady    = isWeaponReady(playerInfo);

    if (isReady)
    {
      let   pawn = _playerSource.getPawn();
      State stat = NULL;
      playerInfo.cmd.buttons |= BT_ATTACK;
      pawn.FireWeapon(stat);
    }
  }

  private static bool isWeaponReady(PlayerInfo player)
  {
    bool isReady = (player.WeaponState & WF_WEAPONREADY)
      || (player.WeaponState & WF_WEAPONREADYALT)
      || player.attackDown;

    return isReady;
  }

  private tt_PlayerSource _playerSource;
}

4.8.1. Test

{
  let        tag          = "tt_Firer";
  let        playerSource = tt_PlayerSourceMock.of();
  let        firer        = tt_Firer.of(playerSource);
  PlayerInfo info         = players[consolePlayer];
  let        pawn         = info.mo;

  playerSource.expect_getInfo(info);
  playerSource.expect_getPawn(pawn);

  int nBullets = pawn.countInv("Clip");
  it(tag .. ": must be 50 bullets before firing", AssertEval(nBullets, "==", 50));

  firer.changeWorld();

  assertSatisfaction(playerSource.getSatisfaction(), tag);

  // Note: this relies on sv_fastweapons 2.
  nBullets = pawn.countInv("Clip");
  it(tag .. ": must spend 1 bullet after firing", AssertEval(nBullets, "==", 49));
}

5. Activatable

5.1. Activatable

// This interface represents a game element that can be activated by the same
// way the target is damaged. Such elements can be considered generic targets.
class tt_Activatable abstract
{
  abstract void activate();

  abstract tt_Strings getCommands();

  abstract bool isVisible();
}

5.1.1. Mock

class tt_ActivatableMock : tt_Activatable
{
  static tt_ActivatableMock of() { return new("tt_ActivatableMock"); }

  mixin tt_Mock;
  override void activate()
  {
    if (_mock_activate_expectation == NULL)
      _mock_activate_expectation = _mock_addExpectation("activate");
    ++_mock_activate_expectation.called;


  }

  void expect_activate(int expected = 1)
  {
    if (_mock_activate_expectation == NULL)
      _mock_activate_expectation = _mock_addExpectation("activate");
    _mock_activate_expectation.expected = expected;
    _mock_activate_expectation.called = 0;


  }


  private tt_Expectation _mock_activate_expectation;

  override tt_Strings getCommands()
  {
    if (_mock_getCommands_expectation == NULL)
      _mock_getCommands_expectation = _mock_addExpectation("getCommands");
    ++_mock_getCommands_expectation.called;

    return _mock_getCommands;
  }

  void expect_getCommands(tt_Strings value, int expected = 1)
  {
    if (_mock_getCommands_expectation == NULL)
      _mock_getCommands_expectation = _mock_addExpectation("getCommands");
    _mock_getCommands_expectation.expected = expected;
    _mock_getCommands_expectation.called = 0;

    _mock_getCommands = value;
  }

  private tt_Strings _mock_getCommands;
  private tt_Expectation _mock_getCommands_expectation;

  override bool isVisible()
  {
    if (_mock_isVisible_expectation == NULL)
      _mock_isVisible_expectation = _mock_addExpectation("isVisible");
    ++_mock_isVisible_expectation.called;

    return _mock_isVisible;
  }

  void expect_isVisible(bool value, int expected = 1)
  {
    if (_mock_isVisible_expectation == NULL)
      _mock_isVisible_expectation = _mock_addExpectation("isVisible");
    _mock_isVisible_expectation.expected = expected;
    _mock_isVisible_expectation.called = 0;

    _mock_isVisible = value;
  }

  private bool _mock_isVisible;
  private tt_Expectation _mock_isVisible_expectation;

}

5.2. PassThrough

class tt_PassThrough : tt_Activatable
{
  static tt_PassThrough of(tt_PassThroughInputManager passThroughInputManager,
                           tt_StringCvar passThroughSetting)
  {
    let result = new("tt_PassThrough");
    result._inputManager = passThroughInputManager;
    result._passThroughSetting = passThroughSetting;
    return result;
  }

  override void activate()
  {
    _inputManager.setPassThrough();
  }

  override tt_Strings getCommands()
  {
    return tt_Strings.ofOne(_passThroughSetting.get());
  }

  override bool isVisible()
  {
    return true;
  }

  private tt_PassThroughInputManager _inputManager;
  private tt_StringSetting _passThroughSetting;
}

5.3. CommandDispatcher

// Contains Activatables and activates() ones with commands matching answer.
class tt_CommandDispatcher : tt_Activatable
{
  static tt_CommandDispatcher of(tt_AnswerSource       answerSource,
                                 Array<tt_Activatable> activatables,
                                 tt_AnswerReporter     answerReporter,
                                 tt_AnswerStateSource  answerStateSource,
                                 tt_BoolSetting        fastConfirmation)
  {
    let result = new("tt_CommandDispatcher");

    result._answerSource      = answerSource;
    result._activatables.Copy(activatables);
    result._answerReporter    = answerReporter;
    result._answerStateSource = answerStateSource;
    result._fastConfirmation  = fastConfirmation;

    return result;
  }

  override void activate()
  {
    let answerState = _answerStateSource.getAnswerState();
    if (!answerState.isReady() && !_fastConfirmation.get()) return;

    let answer       = _answerSource.getAnswer();
    let answerString = answer.getString();

    foreach (activatable : _activatables)
    {
      bool isActivated = tryActivate(activatable, answerString);

      if (isActivated)
      {
        _answerReporter.reportMatch();
        _answerSource.reset();
        _answerStateSource.reset();
        return;
      }
    }
  }

  override tt_Strings getCommands()
  {
    let result = tt_Strings.of();

    foreach (activatable : _activatables)
    {
      if (!activatable.isVisible()) continue;

      let commands = activatable.getCommands();

      uint nCommands = commands.size();
      for (uint c = 0; c < nCommands; ++c)
        result.add(commands.at(c));
    }

    return result;
  }

  override bool isVisible()
  {
    return true;
  }

  private bool tryActivate(tt_Activatable activatable, string answer)
  {
    let commands = activatable.getCommands();

    uint nCommands = commands.size();
    for (uint c = 0; c < nCommands; ++c)
    {
      string command    = commands.at(c);
      bool   isMatching = (command == answer);

      if (isMatching)
      {
        activatable.activate();
        return true;
      }
    }

    return false;
  }

  private tt_AnswerSource       _answerSource;
  private Array<tt_Activatable> _activatables;
  private tt_AnswerReporter     _answerReporter;
  private tt_AnswerStateSource  _answerStateSource;
  private tt_BoolSetting        _fastConfirmation;
}

5.3.1. Test

{
  let tag = "tt_CommandDispatcher: checkActivate";
  let env = tt_CommandDispatcherTestEnvironment.of();

  let str    = "Hello";
  let answer = tt_Answer.of(str);
  env.answerSource.expect_getAnswer(answer);

  let commands1 = tt_Strings.of();
  let commands2 = tt_Strings.of();
  commands2.add(str);
  env.activatable1.expect_getCommands(commands1);
  env.activatable2.expect_getCommands(commands2);
  env.activatable2.expect_activate();
  env.answerReporter.expect_reportMatch();
  env.answerStateSource
    .expect_getAnswerState(tt_AnswerState.of(tt_AnswerState.Ready));
  env.answerStateSource.expect_reset();
  env.answerSource.expect_reset();

  env.commandDispatcher.activate();

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_CommandDispatcher: checkGetCommands";
  let env = tt_CommandDispatcherTestEnvironment.of();

  let commands1 = tt_Strings.of();
  let commands2 = tt_Strings.of();
  commands1.add("1");
  commands1.add("2");
  commands2.add("3");
  commands2.add("4");
  env.activatable1.expect_getCommands(commands1);
  env.activatable2.expect_getCommands(commands2);
  env.activatable1.expect_isVisible(true);
  env.activatable2.expect_isVisible(true);

  let allCommands = env.commandDispatcher.getCommands();
  let size        = allCommands.size();

  it("tt_CommandDispatcher: check get commands: All commands are collected",
     AssertEval(size, "==", 4));
  it("tt_CommandDispatcher: check get commands: The first command is collected",
     Assert(allCommands.contains("1")));
  it("tt_CommandDispatcher: check get commands: The second command is collected",
     Assert(allCommands.contains("2")));
  it("tt_CommandDispatcher: check get commands: The third command is collected",
     Assert(allCommands.contains("3")));
  it("tt_CommandDispatcher: check get commands: The forth command is collected",
     Assert(allCommands.contains("4")));

  assertSatisfaction(env.getSatisfaction(), tag);
}
class tt_CommandDispatcherTestEnvironment
{
  static tt_CommandDispatcherTestEnvironment of()
  {
    let result = new("tt_CommandDispatcherTestEnvironment");
    result.activatable1      = tt_ActivatableMock.of();
    result.activatable2      = tt_ActivatableMock.of();
    Array<tt_Activatable> activatables = {result.activatable1, result.activatable2};
    result.answerSource      = tt_AnswerSourceMock     .of();
    result.answerReporter    = tt_AnswerReporterMock   .of();
    result.answerStateSource = tt_AnswerStateSourceMock.of();
    result.fastConfirmation  = tt_BoolSettingMock.of();
    result.commandDispatcher = tt_CommandDispatcher.of(result.answerSource,
                                                       activatables,
                                                       result.answerReporter,
                                                       result.answerStateSource,
                                                       result.fastConfirmation);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return activatable1.getSatisfaction()
      .add(activatable2.getSatisfaction())
      .add(answerSource.getSatisfaction())
      .add(answerReporter.getSatisfaction())
      .add(answerStateSource.getSatisfaction())
      .add(fastConfirmation.getSatisfaction());
  }

  tt_ActivatableMock activatable1;
  tt_ActivatableMock activatable2;
  tt_AnswerSourceMock answerSource;
  tt_AnswerReporterMock answerReporter;
  tt_AnswerStateSourceMock answerStateSource;
  tt_BoolSettingMock  fastConfirmation;
  tt_CommandDispatcher commandDispatcher;
}

6. Answer

6.1. Answer

// Represents an answer to a tt_Question.
// See tt_Question.
class tt_Answer
{
  static tt_Answer of(String answer = "")
  {
    let result = new("tt_Answer");
    result._answer = answer;
    return result;
  }

  string getString() const
  {
    return _answer;
  }

  void append(string character)
  {
    _answer = _answer .. character;
  }

  void deleteLastCharacter()
  {
    _answer.deleteLastCharacter();
  }

  private string _answer;
}

6.2. AnswerSource

// This interface represents a source of answers.
class tt_AnswerSource : tt_KeyProcessor abstract
{
  abstract tt_Answer getAnswer();

  // Clears answer.
  abstract void reset();
}

6.2.1. Mock

class tt_AnswerSourceMock : tt_AnswerSource
{
  static tt_AnswerSourceMock of() { return new("tt_AnswerSourceMock"); }

  mixin tt_Mock;
  override tt_Answer getAnswer()
  {
    if (_mock_getAnswer_expectation == NULL)
      _mock_getAnswer_expectation = _mock_addExpectation("getAnswer");
    ++_mock_getAnswer_expectation.called;

    return _mock_getAnswer;
  }

  void expect_getAnswer(tt_Answer value, int expected = 1)
  {
    if (_mock_getAnswer_expectation == NULL)
      _mock_getAnswer_expectation = _mock_addExpectation("getAnswer");
    _mock_getAnswer_expectation.expected = expected;
    _mock_getAnswer_expectation.called = 0;

    _mock_getAnswer = value;
  }

  private tt_Answer _mock_getAnswer;
  private tt_Expectation _mock_getAnswer_expectation;

  override void reset()
  {
    if (_mock_reset_expectation == NULL)
      _mock_reset_expectation = _mock_addExpectation("reset");
    ++_mock_reset_expectation.called;


  }

  void expect_reset(int expected = 1)
  {
    if (_mock_reset_expectation == NULL)
      _mock_reset_expectation = _mock_addExpectation("reset");
    _mock_reset_expectation.expected = expected;
    _mock_reset_expectation.called = 0;


  }


  private tt_Expectation _mock_reset_expectation;

  override void processKey(tt_Character character)
  {
    if (_mock_processKey_expectation == NULL)
      _mock_processKey_expectation = _mock_addExpectation("processKey");
    ++_mock_processKey_expectation.called;


  }

  void expect_processKey(int expected = 1)
  {
    if (_mock_processKey_expectation == NULL)
      _mock_processKey_expectation = _mock_addExpectation("processKey");
    _mock_processKey_expectation.expected = expected;
    _mock_processKey_expectation.called = 0;


  }


  private tt_Expectation _mock_processKey_expectation;

}

6.3. InputBlockAfterCombat

// Implements tt_AnswerSource by taking another tt_AnswerSource,
// and only passing keys to it if a key was pressed down after the game mode
// has changed to Combat.
class tt_InputBlockAfterCombat : tt_AnswerSource
{
  static tt_InputBlockAfterCombat of(tt_AnswerSource answerSource,
                                     tt_ModeSource   modeSource,
                                     tt_ModeSource   oldModeSource)
  {
    let result = new("tt_InputBlockAfterCombat");

    result._answerSource  = answerSource;
    result._modeSource    = modeSource;
    result._oldModeSource = oldModeSource;

    result._isLocked = false;

    return result;
  }

  void update()
  {
    int mode    = _modeSource.getMode();
    int oldMode = _oldModeSource.getMode();

    if (oldMode != tt_Mode.Combat && mode == tt_Mode.Combat)
    {
      _isLocked = true;
    }
  }

  override tt_Answer getAnswer()
  {
    return _answerSource.getAnswer();
  }

  override void processKey(tt_Character character)
  {
    if (character.getEventType() == UiEvent.Type_KeyDown)
    {
      _isLocked = false;
    }

    if (!_isLocked)
    {
      _answerSource.processKey(character);
    }
  }

  override void reset() {}

  private tt_AnswerSource _answerSource;
  private tt_ModeSource   _modeSource;
  private tt_ModeSource   _oldModeSource;

  private bool _isLocked;
}

6.4. PlayerInput

// Implements tt_AnswerSource by receiving player key inputs and
// composing an answer from them.
class tt_PlayerInput : tt_AnswerSource
{
  static tt_PlayerInput of(tt_ModeStorage      modeStorage,
                           tt_KeyPressReporter keyPressReporter)
  {
    let result = new("tt_PlayerInput");

    result._modeStorage      = modeStorage;
    result._keyPressReporter = keyPressReporter;

    result._answer = tt_Answer.of();

    return result;
  }

  override tt_Answer getAnswer()
  {
    return _answer;
  }

  override void processKey(tt_Character character)
  {
    int type = character.getType();
    switch (type)
    {
    case tt_Character.NONE: break;

    case tt_Character.PRINTABLE:
      _answer.append(character.getCharacter());
      _keyPressReporter.report();
      break;

    case tt_Character.BACKSPACE:      _answer.deleteLastCharacter();         break;
    case tt_Character.CTRL_BACKSPACE: reset();                               break;
    case tt_Character.ESCAPE:         _modeStorage.setMode(tt_Mode.Explore); break;
    }
  }

  override void reset()
  {
    _answer = tt_Answer.of();
  }

  private tt_ModeStorage      _modeStorage;
  private tt_KeyPressReporter _keyPressReporter;

  private tt_Answer _answer;
}

6.4.1. Test

{
  let tag = "tt_PlayerInputTest: testPlayerInputCheckInput";
  let env = tt_PlayerInputTestEnvironment.of();

  string input = "abc";
  env.throwStringIntoInput(input);

  let answer       = env.playerInput.getAnswer();
  let answerString = answer.getString();

  it(tag .. ": input must be an answer", Assert(input == answerString));
}
{
  let tag = "tt_PlayerInputTest: testPlayerInputCheckReset";
  let env = tt_PlayerInputTestEnvironment.of();
  int TYPE_CHAR = UiEvent.Type_Char;

  string input1 = "abc";
  string input2 = "def";

  env.throwStringIntoInput(input1);
  let reset = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, true);
  env.playerInput.processKey(reset);
  env.throwStringIntoInput(input2);

  let answer       = env.playerInput.getAnswer();
  let answerString = answer.getString();

  it(tag .. ": second input must be an answer", Assert(input2 == answerString));
}
{
  let tag = "tt_PlayerInputTest: testBackspace";
  let env = tt_PlayerInputTestEnvironment.of();
  int TYPE_CHAR = UiEvent.Type_Char;

  let backspace = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, false);
  let letterA   = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_SMALL_LETTER_A, false);

  //env.playerInput.reset();
  env.playerInput.processKey(backspace);
  env.playerInput.processKey(letterA);
  env.playerInput.processKey(backspace);
  env.playerInput.processKey(letterA);

  let answer       = env.playerInput.getAnswer();
  let answerString = answer.getString();

  it(tag .. ": input after backspace must be valid", Assert(answerString == "a"));
}
{
  let tag = "tt_PlayerInputTest: testCtrlBackspace";
  let env = tt_PlayerInputTestEnvironment.of();
  int TYPE_CHAR = UiEvent.Type_Char;

  let ctrlBackspace = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, true);
  let letterA   = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_SMALL_LETTER_A, false);

  env.playerInput.processKey(letterA);
  env.playerInput.processKey(letterA);
  env.playerInput.processKey(ctrlBackspace);

  let answer       = env.playerInput.getAnswer();
  let answerString = answer.getString();

  it(tag .. ": input after ctrl-backspace must be empty", Assert(answerString == ""));
}
class tt_PlayerInputTestEnvironment
{
  static tt_PlayerInputTestEnvironment of()
  {
    let result = new("tt_PlayerInputTestEnvironment");
    result.modeStorage      = tt_ModeStorageMock.of();
    result.keyPressReporter = tt_KeyPressReporterMock.of();
    result.playerInput      = tt_PlayerInput.of(result.modeStorage,
                                                result.keyPressReporter);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return modeStorage.getSatisfaction().add(keyPressReporter.getSatisfaction());
  }

  void throwStringIntoInput(string str)
  {
    uint inputSize = str.length();
    for (uint i = 0; i < inputSize; ++i)
    {
      let character = tt_Character.of(TYPE_CHAR, str.ByteAt(i), false);
      playerInput.processKey(character);
    }

    let enter = tt_Character.of(TYPE_CHAR, tt_su_Ascii.CARRIAGE_RETURN_CR, false);
    playerInput.processKey(enter);
  }

  const TYPE_CHAR = UiEvent.Type_Char;

  tt_ModeStorageMock      modeStorage;
  tt_KeyPressReporterMock keyPressReporter;
  tt_PlayerInput          playerInput;
}

7. Answer State

7.1. AnswerState

// Represents Answer state.
// See tt_Answer class.
class tt_AnswerState
{
  static tt_AnswerState of(int state)
  {
    let result = new("tt_AnswerState");
    result._state = state;
    return result;
  }

  enum _
  {
    Unknown,
    Preparing,
    Ready,
    Finished
  }

  bool isReady() const
  {
    return (_state >= Ready);
  }

  bool isFinished() const
  {
    return (_state == Finished);
  }

  bool isEqual(tt_AnswerState other)
  {
    return _state == other._state;
  }

  private int _state;
}

7.2. AnswerStateSource

// This interface provides access to tt_AnswerState.
class tt_AnswerStateSource : tt_KeyProcessor abstract
{
  abstract tt_AnswerState getAnswerState();

  abstract void reset();
}

7.2.1. Mock

class tt_AnswerStateSourceMock : tt_AnswerStateSource
{
  static tt_AnswerStateSourceMock of() { return new("tt_AnswerStateSourceMock"); }

  mixin tt_Mock;
  override tt_AnswerState getAnswerState()
  {
    if (_mock_getAnswerState_expectation == NULL)
      _mock_getAnswerState_expectation = _mock_addExpectation("getAnswerState");
    ++_mock_getAnswerState_expectation.called;

    return _mock_getAnswerState;
  }

  void expect_getAnswerState(tt_AnswerState value, int expected = 1)
  {
    if (_mock_getAnswerState_expectation == NULL)
      _mock_getAnswerState_expectation = _mock_addExpectation("getAnswerState");
    _mock_getAnswerState_expectation.expected = expected;
    _mock_getAnswerState_expectation.called = 0;

    _mock_getAnswerState = value;
  }

  private tt_AnswerState _mock_getAnswerState;
  private tt_Expectation _mock_getAnswerState_expectation;

  override void reset()
  {
    if (_mock_reset_expectation == NULL)
      _mock_reset_expectation = _mock_addExpectation("reset");
    ++_mock_reset_expectation.called;


  }

  void expect_reset(int expected = 1)
  {
    if (_mock_reset_expectation == NULL)
      _mock_reset_expectation = _mock_addExpectation("reset");
    _mock_reset_expectation.expected = expected;
    _mock_reset_expectation.called = 0;


  }


  private tt_Expectation _mock_reset_expectation;

  override void processKey(tt_Character character)
  {
    if (_mock_processKey_expectation == NULL)
      _mock_processKey_expectation = _mock_addExpectation("processKey");
    ++_mock_processKey_expectation.called;


  }

  void expect_processKey(int expected = 1)
  {
    if (_mock_processKey_expectation == NULL)
      _mock_processKey_expectation = _mock_addExpectation("processKey");
    _mock_processKey_expectation.expected = expected;
    _mock_processKey_expectation.called = 0;


  }


  private tt_Expectation _mock_processKey_expectation;

}

7.3. PressedAnswerState

// Implements tt_AnswerState by observing Enter and Space keys.
//
// The state is:
// - Preparing when no Enter or Space key is pressed.
// - Ready when Enter or Space key is pressed, but not yet released.
// - Finished when Enter or Space key is released.
//
// Note: space acts the same as Enter key, see tt_Character class for details.
class tt_PressedAnswerState : tt_AnswerStateSource
{
  static tt_PressedAnswerState of()
  {
    let result = new("tt_PressedAnswerState");
    result._answerState = DEFAULT_STATE;
    return result;
  }

  override void processKey(tt_Character character)
  {
    switch (character.getType())
    {
    case tt_Character.ENTER:    _answerState = tt_AnswerState.Ready;     break;
    case tt_Character.ENTER_UP: _answerState = tt_AnswerState.Finished;  break;
    case tt_Character.NONE:     break;
    default:                    _answerState = tt_AnswerState.Preparing; break;
    }
  }

  override tt_AnswerState getAnswerState()
  {
    return tt_AnswerState.of(_answerState);
  }

  override void reset()
  {
    _answerState = DEFAULT_STATE;
  }

  const DEFAULT_STATE = tt_AnswerState.Preparing;

  private int _answerState;
}

8. Character

8.1. Character

// Represents a character.
class tt_Character
{
  static tt_Character of(int type, int code, bool isCtrl)
  {
    let result = new("tt_Character");

    result._eventType = type;
    //Console.printf("type: %d, code: %d", type, code);

    // Normally, KeyUp events aren't registered, but releasing Enter or Space
    // key has special meaning, important for Hold Fire feature.
    if (type == UiEvent.Type_KeyUp &&
        (code == tt_su_Ascii.CARRIAGE_RETURN_CR || code == tt_su_Ascii.SPACE))
    {
      result._type = ENTER_UP;
      return result;
    }

    bool isChar    = (type == UiEvent.Type_Char);
    bool isDown    = (type == UiEvent.Type_KeyDown);
    bool isRepeat  = (type == UiEvent.Type_KeyRepeat);
    bool isControl = (code == tt_su_Ascii.BACKSPACE
                   || code == tt_su_Ascii.CARRIAGE_RETURN_CR
                   || code == tt_su_Ascii.SPACE
                   || code == tt_su_Ascii.ESCAPE);

    if (!isChar && !((isDown || isRepeat) && isControl))
    {
      result._type = NONE;
      return result;
    }

    if      (code == tt_su_Ascii.BACKSPACE)
      result._type = isCtrl ? CTRL_BACKSPACE : BACKSPACE;
    else if (code == tt_su_Ascii.DELETE)             result._type = CTRL_BACKSPACE;
    else if (code == tt_su_Ascii.CARRIAGE_RETURN_CR) result._type = ENTER;
    else if (code == tt_su_Ascii.SPACE)              result._type = ENTER;
    else if (code == tt_su_Ascii.ESCAPE)             result._type = ESCAPE;
    else if (code <  tt_su_Ascii.FIRST_PRINTABLE)    result._type = NONE;
    else
    {
      result._type      = PRINTABLE;
      result._character = string.format("%c", code);
    }

    return result;
  }

  enum _
  {
    NONE,
    PRINTABLE,
    BACKSPACE,
    CTRL_BACKSPACE,
    ENTER,
    ENTER_UP,
    ESCAPE,
  }

  int getType() const { return _type; }

  string getCharacter() const { return _character; }

  int getEventType() const { return _eventType; }

  private int    _type;
  private string _character;
  private int    _eventType;
}

8.1.1. Tests

{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_SMALL_LETTER_A, false);
  it("tt_Character: Small character", Assert(c.getType() == tt_Character.PRINTABLE));
  it("tt_Character: Small character", Assert(c.getCharacter() == "a"));
}
{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_CAPITAL_LETTER_A, false);
  it("tt_Character: Big character", Assert(c.getType() == tt_Character.PRINTABLE));
  it("tt_Character: Big character", Assert(c.getCharacter() == "A"));
}
{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.DIGIT_FOUR, false);
  it("tt_Character: Number", Assert(c.getType() == tt_Character.PRINTABLE));
  it("tt_Character: Number", Assert(c.getCharacter() == "4"));
}
{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, false);
  it("tt_Character: Backspace", Assert(c.getType() == tt_Character.BACKSPACE));
}
{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.CHARACTER_NULL, false);
  it("tt_Character: Non-printable", Assert(c.getType() == tt_Character.NONE));
}
{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, true);
  it( "tt_Character: Ctrl-Backspace",
     Assert(c.getType() == tt_Character.CTRL_BACKSPACE));
}
{
  int TYPE_CHAR = UiEvent.Type_Char;
  let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.CARRIAGE_RETURN_CR, true);
  it("tt_Character: Enter", Assert(c.getType() == tt_Character.ENTER));
}

9. Clock

9.1. Clock

// Provides access to time.
class tt_Clock abstract
{
  // Provides access to getting points in time.
  // Returns a moment in time.
  abstract int getNow();

  // Provides a way to determine how many ticks passed since a moment in time.
  // moment: a moment in time, received from getNow().
  // Returns a number of ticks since  moment.
  abstract int since(int moment);
}

9.1.1. Mock

class tt_ClockMock : tt_Clock
{
  static tt_ClockMock of() { return new("tt_ClockMock"); }

  mixin tt_Mock;
  override int getNow()
  {
    if (_mock_getNow_expectation == NULL)
      _mock_getNow_expectation = _mock_addExpectation("getNow");
    ++_mock_getNow_expectation.called;

    return _mock_getNow;
  }

  void expect_getNow(int value, int expected = 1)
  {
    if (_mock_getNow_expectation == NULL)
      _mock_getNow_expectation = _mock_addExpectation("getNow");
    _mock_getNow_expectation.expected = expected;
    _mock_getNow_expectation.called = 0;

    _mock_getNow = value;
  }

  private int _mock_getNow;
  private tt_Expectation _mock_getNow_expectation;

  override int since(int moment)
  {
    if (_mock_since_expectation == NULL)
      _mock_since_expectation = _mock_addExpectation("since");
    ++_mock_since_expectation.called;

    return _mock_since;
  }

  void expect_since(int value, int expected = 1)
  {
    if (_mock_since_expectation == NULL)
      _mock_since_expectation = _mock_addExpectation("since");
    _mock_since_expectation.expected = expected;
    _mock_since_expectation.called = 0;

    _mock_since = value;
  }

  private int _mock_since;
  private tt_Expectation _mock_since_expectation;

}

9.2. TotalClock

// Implements tt_Clock by getting total time since game start.
class tt_TotalClock : tt_Clock
{
  static tt_TotalClock of()
  {
    let result = new("tt_TotalClock");
    return result;
  }

  override int getNow()
  {
    return Level.totalTime;
  }

  override int since(int moment)
  {
    return getNow() - moment;
  }
}

9.2.1. Test

{
  let clock = tt_TotalClock.of();

  int now1 = clock.getNow();
  int now2 = clock.getNow();

  it("tt_TotalClock: now is now", AssertEval(now1, "==", now2));

  int duration = clock.since(now1);
  it("tt_TotalClock: no time passed", AssertEval(duration, "==", 0));
}

10. Event Reporters

10.1. AnswerReporter

// Interface for reporting answer matching events.
class tt_AnswerReporter abstract
{
  abstract void reportMatch();

  abstract void reportNotMatch();
}

10.1.1. Mock

class tt_AnswerReporterMock : tt_AnswerReporter
{
  static tt_AnswerReporterMock of() { return new("tt_AnswerReporterMock"); }

  mixin tt_Mock;
  override void reportMatch()
  {
    if (_mock_reportMatch_expectation == NULL)
      _mock_reportMatch_expectation = _mock_addExpectation("reportMatch");
    ++_mock_reportMatch_expectation.called;


  }

  void expect_reportMatch(int expected = 1)
  {
    if (_mock_reportMatch_expectation == NULL)
      _mock_reportMatch_expectation = _mock_addExpectation("reportMatch");
    _mock_reportMatch_expectation.expected = expected;
    _mock_reportMatch_expectation.called = 0;


  }


  private tt_Expectation _mock_reportMatch_expectation;

  override void reportNotMatch()
  {
    if (_mock_reportNotMatch_expectation == NULL)
      _mock_reportNotMatch_expectation = _mock_addExpectation("reportNotMatch");
    ++_mock_reportNotMatch_expectation.called;


  }

  void expect_reportNotMatch(int expected = 1)
  {
    if (_mock_reportNotMatch_expectation == NULL)
      _mock_reportNotMatch_expectation = _mock_addExpectation("reportNotMatch");
    _mock_reportNotMatch_expectation.expected = expected;
    _mock_reportNotMatch_expectation.called = 0;


  }


  private tt_Expectation _mock_reportNotMatch_expectation;

}

10.2. SoundAnswerReporter

// Implements tt_AnswerReporter by playing a sound.
class tt_SoundAnswerReporter : tt_AnswerReporter
{
  static tt_SoundAnswerReporter of(tt_SoundPlayer soundPlayer)
  {
    let result = new("tt_SoundAnswerReporter");
    result._soundPlayer = soundPlayer;
    return result;
  }

  override void reportMatch()
  {
    _soundPlayer.playSound("tt/match");
  }

  override void reportNotMatch()
  {
    _soundPlayer.playSound("tt/not-match");
  }

  private tt_SoundPlayer _soundPlayer;
}

10.3. KeyPressReporter

// Interface for reporting key press events.
class tt_KeyPressReporter abstract
{
  abstract void report();
}

10.3.1. Mock

class tt_KeyPressReporterMock : tt_KeyPressReporter
{
  static tt_KeyPressReporterMock of() { return new("tt_KeyPressReporterMock"); }

  mixin tt_Mock;
  override void report()
  {
    if (_mock_report_expectation == NULL)
      _mock_report_expectation = _mock_addExpectation("report");
    ++_mock_report_expectation.called;


  }

  void expect_report(int expected = 1)
  {
    if (_mock_report_expectation == NULL)
      _mock_report_expectation = _mock_addExpectation("report");
    _mock_report_expectation.expected = expected;
    _mock_report_expectation.called = 0;


  }


  private tt_Expectation _mock_report_expectation;

}

10.4. SoundKeyPressReporter

// Implements tt_KeyPressReporter by playing a sound.
// The sound won't play if it's disabled in settings.
class tt_SoundKeyPressReporter : tt_KeyPressReporter
{
  static tt_SoundKeyPressReporter of(tt_SoundPlayer soundPlayer,
                                     tt_BoolSetting isEnabledSetting)
  {
    let result = new("tt_SoundKeyPressReporter");
    result._soundPlayer      = soundPlayer;
    result._isEnabledSetting = isEnabledSetting;
    return result;
  }

  override void report()
  {
    if (_isEnabledSetting.get()) _soundPlayer.playSound("tt/click");
  }

  private tt_SoundPlayer _soundPlayer;
  private tt_BoolSetting _isEnabledSetting;
}

10.5. ModeReporter

// Interface for reporting mode change events.
class tt_ModeReporter abstract
{
  abstract void report(int mode);
}

10.5.1. Mock

class tt_ModeReporterMock : tt_ModeReporter
{
  static tt_ModeReporterMock of() { return new("tt_ModeReporterMock"); }

  mixin tt_Mock;
  override void report(int mode)
  {
    if (_mock_report_expectation == NULL)
      _mock_report_expectation = _mock_addExpectation("report");
    ++_mock_report_expectation.called;


  }

  void expect_report(int expected = 1)
  {
    if (_mock_report_expectation == NULL)
      _mock_report_expectation = _mock_addExpectation("report");
    _mock_report_expectation.expected = expected;
    _mock_report_expectation.called = 0;


  }


  private tt_Expectation _mock_report_expectation;

}

10.6. SoundModeReporter

// Implements tt_ModeReporter by playing the corresponding sound for each mode.
class tt_SoundModeReporter : tt_ModeReporter
{
  static tt_SoundModeReporter of(tt_SoundPlayer soundPlayer)
  {
    let result = new("tt_SoundModeReporter");
    result._soundPlayer = soundPlayer;
    return result;
  }

  override void report(int mode)
  {
    switch (mode)
    {
    case tt_Mode.Unknown:
      Console.printf("%s: report: unknown mode!", getClassName());
      break;
    case tt_Mode.Combat:  _soundPlayer.playSound("tt/combat");  break;
    case tt_Mode.Explore: _soundPlayer.playSound("tt/explore"); break;
    case tt_Mode.None:    break;
    }
  }

  private tt_SoundPlayer _soundPlayer;
}

10.7. SoundPlayer

// This is an interface for playing sounds.
class tt_SoundPlayer abstract
{
  abstract void playSound(String soundId);
}

10.8. PlayerSoundPlayer

// Implements tt_SoundPlayer by playing sounds for a player.
// The sounds won't play if they are disabled in settings.
class tt_PlayerSoundPlayer : tt_SoundPlayer
{
  static tt_PlayerSoundPlayer of(tt_PlayerSource playerSource,
                                 tt_BoolSetting  enabledSetting,
                                 tt_IntSetting   themeSetting)
  {
    let result = new("tt_PlayerSoundPlayer");
    result._playerSource = playerSource;
    result._enabledSetting = enabledSetting;
    result._themeSetting   = themeSetting;
    return result;
  }

  override void playSound(String soundId)
  {
    if (isDisabled()) return;

    let player = _playerSource.getPawn();
    int theme  = _themeSetting.get();
    soundId.appendFormat("%d", theme);

    player.a_StartSound(soundId, CHAN_AUTO, SOUND_FLAGS);
  }

  private bool isDisabled()
  {
    return (!_enabledSetting.get());
  }

  const SOUND_FLAGS = CHANF_UI | CHANF_OVERLAP | CHANF_LOCAL;

  private tt_PlayerSource _playerSource;
  private tt_BoolSetting  _enabledSetting;
  private tt_IntSetting   _themeSetting;
}

10.9. Sounds

// Global Typist sound settings ////////////////////////////////////////////////

// Do not randomize pitch shift value.
$pitchshiftrange 0

// 1. Default sound theme //////////////////////////////////////////////////////

tt/combat1    "sounds/Default/danger1.ogg"
tt/explore1   "sounds/Default/safe1.ogg"
tt/click1-1   "sounds/Default/typea1.ogg"
tt/click1-2   "sounds/Default/typea2.ogg"
tt/click1-3   "sounds/Default/typea3.ogg"
tt/click1-4   "sounds/Default/typea4.ogg"
tt/click1-5   "sounds/Default/typea5.ogg"
tt/match1     "sounds/Default/success1.ogg"
tt/not-match1 "sounds/Default/fail1.ogg"

$random tt/click1 { tt/click1-1 tt/click1-2 tt/click1-3 tt/click1-4 tt/click1-5 }

$volume tt/combat1  0.4
$volume tt/explore1 0.6
$volume tt/match1   0.4

// 2. SNES sound theme /////////////////////////////////////////////////////////

tt/combat2    "sounds/SNES/danger2.ogg"
tt/explore2   "sounds/SNES/safe2.ogg"
tt/click2-1   "sounds/SNES/typeb1.ogg"
tt/click2-2   "sounds/SNES/typeb2.ogg"
tt/click2-3   "sounds/SNES/typeb3.ogg"
tt/click2-4   "sounds/SNES/typeb4.ogg"
tt/click2-5   "sounds/SNES/typeb5.ogg"
tt/match2     "sounds/SNES/success2.ogg"
tt/not-match2 "sounds/SNES/sneserrors.ogg"

$random tt/click2 { tt/click2-1 tt/click2-2 tt/click2-3 tt/click2-4 tt/click2-5 }

// 4. Dakka sound theme ////////////////////////////////////////////////////////

tt/combat4    "sounds/Dakka/danger4.ogg"
tt/explore4   "sounds/Dakka/safe4.ogg"
tt/click4-1   "sounds/Dakka/typed1.ogg"
tt/click4-2   "sounds/Dakka/typed2.ogg"
tt/click4-3   "sounds/Dakka/typed3.ogg"
tt/click4-4   "sounds/Dakka/typed4.ogg"
tt/click4-5   "sounds/Dakka/typed5.ogg"
tt/match4     "sounds/Dakka/success4.ogg"
tt/not-match4 "sounds/Dakka/fail4.ogg"

$random tt/click4 { tt/click4-1 tt/click4-2 tt/click4-3 tt/click4-4 tt/click4-5 }

// 5. GroceryStore sound theme /////////////////////////////////////////////////

tt/combat5    "sounds/GroceryStore/danger5.ogg"
tt/explore5   "sounds/GroceryStore/safe5.ogg"
tt/click5-1   "sounds/GroceryStore/typee1.ogg"
tt/click5-2   "sounds/GroceryStore/typee2.ogg"
tt/click5-3   "sounds/GroceryStore/typee3.ogg"
tt/click5-4   "sounds/GroceryStore/typee4.ogg"
tt/click5-5   "sounds/GroceryStore/typee5.ogg"
tt/match5     "sounds/GroceryStore/success5.ogg"
tt/not-match5 "sounds/GroceryStore/fail5.ogg"

$random tt/click5 { tt/click5-1 tt/click5-2 tt/click5-3 tt/click5-4 tt/click5-5 }

$volume tt/click5 0.2

11. Input Manager

11.1. InputByModeManager

// Implements tt_InputManager by examining the current and old Typist mode.
// Input is reset when the game mode is changed.
class tt_InputByModeManager : tt_InputManager
{
  static tt_InputByModeManager of(tt_ModeSource modeSource,
                                  tt_PlayerInput playerInput)
  {
    let result = new("tt_InputByModeManager");

    result._modeSource  = modeSource;
    result._playerInput = playerInput;

    result._oldMode = tt_Mode.Unknown;

    return result;
  }

  override void manageInput()
  {
    int  mode             = _modeSource.getMode();
    bool isCapturingKeys  = (mode == tt_Mode.Combat);
    bool wasCapturingKeys = (_oldMode != tt_Mode.Combat);

    if (wasCapturingKeys && isCapturingKeys == false)
    {
      _playerInput.reset();
    }

    _oldMode = mode;
  }

  override bool isCapturingKeys()
  {
    int mode = _modeSource.getMode();
    return (mode == tt_Mode.Combat);
  }

  private tt_ModeSource  _modeSource;
  private tt_PlayerInput _playerInput;

  private int _oldMode;
}

11.2. PassThroughInputManager

// Doesn't capture keys when pass throug is set, otherwise acts as base.
class tt_PassThroughInputManager : tt_InputManager
{
  static tt_PassThroughInputManager of(tt_InputManager base)
  {
    let result = new("tt_PassThroughInputManager");
    result._base = base;
    result._passThrough = PassThroughDisabled;
    return result;
  }

  override void manageInput()
  {
    _base.manageInput();
  }

  override bool isCapturingKeys()
  {
    if (_passThrough != PassThroughDisabled) return false;

    return _base.isCapturingKeys();
  }

  void setPassThrough()
  {
    _passThrough = WaitingForKeyDown;
  }

  void processInput(int type)
  {
    switch (_passThrough)
    {
      case PassThroughDisabled:
        return;

      case WaitingForKeyDown:
        if (type == InputEvent.Type_KeyDown)
          _passThrough = WaitingForKeyUp;
        return;

      case WaitingForKeyUp:
        if (type == InputEvent.Type_KeyUp)
          _passThrough = PassThroughDisabled;
        return;
    }
  }

  private tt_InputManager _base;
  private int _passThrough;

  enum _
  {
    PassThroughDisabled,
    WaitingForKeyDown,
    WaitingForKeyUp
  }
}

11.3. InputManager

// Helps managing user input.
class tt_InputManager abstract
{
  abstract void manageInput();

  abstract bool isCapturingKeys();
}

12. Key Processor

12.1. KeyProcessor

// This interface represents an entity that processes input keys.
class tt_KeyProcessor abstract
{
  abstract void processKey(tt_Character character);
}

12.2. KeyProcessors

// Implements tt_KeyProcessor interface by calling several instances
// of tt_KeyProcessor.
class tt_KeyProcessors : tt_KeyProcessor
{
  static tt_KeyProcessors of(Array<tt_KeyProcessor> keyProcessors)
  {
    let result = new("tt_KeyProcessors");
    result._keyProcessors.copy(keyProcessors);
    return result;
  }

  override void processKey(tt_Character character)
  {
    foreach (keyProcessor : _keyProcessors)
      keyProcessor.processKey(character);
  }

  private Array<tt_KeyProcessor> _keyProcessors;
}

13. Known Target

13.1. KnownTarget

// Represents a target that already has been seen and registered.
class tt_KnownTarget
{
  static tt_KnownTarget of(tt_Target target, tt_Question question)
  {
    let result = new("tt_KnownTarget");

    result._target   = target;
    result._question = question;

    return result;
  }

  tt_Target getTarget() const { return _target; }

  tt_Question getQuestion() const { return _question; }

  private tt_Target   _target;
  private tt_Question _question;
}

13.2. KnownTargets

// Represents a list of known targets.
class tt_KnownTargets
{
  static tt_KnownTargets of()
  {
    return new("tt_KnownTargets");
  }

  // Returns a target in this list.
  tt_KnownTarget at(uint index) const
  {
    return _targets[index];
  }

  // Returns a number of targets in this list.
  uint size() const
  {
    return _targets.size();
  }

  // Returns true if this target list contains a target with the specified id.
  bool contains(tt_Target target) const
  {
    return (find(target) != size());
  }

  tt_KnownTarget findTarget(tt_Target target) const
  {
    uint index = find(target);
    return (index == size()) ? NULL : at(index);
  }

  // Adds a target to this list.
  void add(tt_KnownTarget target)
  {
    _targets.push(target);
  }

  void addMany(tt_KnownTargets targets)
  {
    uint nTargets = targets.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      _targets.push(targets.at(i));
    }
  }

  // Removes a target from the list.
  // If the target is not in the list, does nothing.
  void remove(tt_Target target)
  {
    uint index = find(target);
    if (index != size()) { _targets.Delete(index); }
  }

  // Searches for a target with a particular id.
  // Returns index on success, the total number of targets on failure.
  private uint find(tt_Target target) const
  {
    uint nTargets = size();
    for (uint i = 0; i < nTargets; ++i)
    {
      if (_targets[i].getTarget().isEqual(target)) { return i; }
    }
    return nTargets;
  }

  private Array<tt_KnownTarget> _targets;
}

13.3. KnownTargetSource

// This interface represents a source of known targets.
// See tt_KnownTarget.
class tt_KnownTargetSource abstract
{
  // Returns the currently registered (known) targets.
  abstract tt_KnownTargets getTargets() const;

  // Returns true if there are no targets in this source.
  abstract bool isEmpty() const;
}

13.3.1. Mock

class tt_KnownTargetSourceMock : tt_KnownTargetSource
{
  static tt_KnownTargetSourceMock of() { return new("tt_KnownTargetSourceMock"); }

  mixin tt_Mock;
  override tt_KnownTargets getTargets()
  {
    if (_mock_getTargets_expectation == NULL)
      _mock_getTargets_expectation = _mock_addExpectation("getTargets");
    ++_mock_getTargets_expectation.called;

    return _mock_getTargets;
  }

  void expect_getTargets(tt_KnownTargets value, int expected = 1)
  {
    if (_mock_getTargets_expectation == NULL)
      _mock_getTargets_expectation = _mock_addExpectation("getTargets");
    _mock_getTargets_expectation.expected = expected;
    _mock_getTargets_expectation.called = 0;

    _mock_getTargets = value;
  }

  private tt_KnownTargets _mock_getTargets;
  private tt_Expectation _mock_getTargets_expectation;

  override bool isEmpty()
  {
    if (_mock_isEmpty_expectation == NULL)
      _mock_isEmpty_expectation = _mock_addExpectation("isEmpty");
    ++_mock_isEmpty_expectation.called;

    return _mock_isEmpty;
  }

  void expect_isEmpty(bool value, int expected = 1)
  {
    if (_mock_isEmpty_expectation == NULL)
      _mock_isEmpty_expectation = _mock_addExpectation("isEmpty");
    _mock_isEmpty_expectation.expected = expected;
    _mock_isEmpty_expectation.called = 0;

    _mock_isEmpty = value;
  }

  private bool _mock_isEmpty;
  private tt_Expectation _mock_isEmpty_expectation;

}

13.4. KnownTargetSourceCache

// Implements tt_KnownTargetSource by reading other
// tt_KnownTargetSource only if the data is stale.
class tt_KnownTargetSourceCache : tt_KnownTargetSource
{
  static tt_KnownTargetSourceCache of(tt_KnownTargetSource targetSource,
                                      tt_StaleMarker staleMarker)
  {
    let result = new("tt_KnownTargetSourceCache");

    result._targetSource = targetSource;
    result._staleMarker  = staleMarker;

    return result;
  }

  override tt_KnownTargets getTargets()
  {
    ensureUpdated();
    return _targets;
  }

  override bool isEmpty()
  {
    ensureUpdated();
    return (_targets.size() == 0);
  }

  private void ensureUpdated()
  {
    if (_staleMarker.isStale())
    {
      _targets = _targetSource.getTargets();
    }
  }

  private tt_KnownTargetSource _targetSource;
  private tt_StaleMarker       _staleMarker;

  private tt_KnownTargets _targets;
}

13.5. TargetRegistry

// Implements tt_KnownTargetSource by reading from targets from
// tt_TargetSource, assigning them questions, and storing them.
//
// Deactivated targets are removed from storage.
class tt_TargetRegistry : tt_KnownTargetSource
{
  static tt_TargetRegistry of(tt_TargetSource targetSource,
                              tt_Lesson lesson,
                              tt_TargetSource disabledTargetSource)
  {
    let result = new("tt_TargetRegistry");

    result._targetSource         = targetSource;
    result._lesson       = lesson;
    result._disabledTargetSource = disabledTargetSource;

    result._registry = tt_KnownTargets.of();

    return result;
  }

  override tt_KnownTargets getTargets()
  {
    update();
    return _registry;
  }

  override bool isEmpty()
  {
    update();
    return (_registry.size() == 0);
  }

  private void update()
  {
    let newTargets = _targetSource.getTargets();
    merge(newTargets);

    let disabledTargets = _disabledTargetSource.getTargets();
    subtract(disabledTargets);

    pruneNulls();
  }

  // Adds targets that are not already registered to the registry.
  //
  // Given that tt_KnownTargets.contains() is O(n), this function is O(n^2).
  // Optimization possible.
  private void merge(tt_Targets targets)
  {
    uint nTargets        = targets.size();
    let  newKnownTargets = tt_KnownTargets.of();

    for (uint i = 0; i < nTargets; ++i)
    {
      let target   = targets.at(i);
      let existing = _registry.findTarget(target);

      if (existing == NULL)
      {
        let knownTarget = makeKnownTarget(target);

        if (knownTarget != NULL)
        {
          newKnownTargets.add(knownTarget);
        }
      }
    }

    _registry.addMany(newKnownTargets);
  }

  // Given that tt_KnownTargets.remove() is at least O(n), this function is
  // at least O(n^2).
  // Optimization possible.
  private void subtract(tt_Targets targets)
  {
    uint nTargets = targets.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      _registry.remove(targets.at(i));
    }
  }

  private tt_KnownTarget makeKnownTarget(tt_Target target) const
  {
    let question = _lesson.getQuestion();

    if (question == NULL)
    {
      return NULL;
    }

    let newKnownTarget = tt_KnownTarget.of(target, question);

    return newKnownTarget;
  }

  private void pruneNulls()
  {
    let pruned = tt_KnownTargets.of();

    uint nTargets = _registry.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      let target      = _registry.at(i).getTarget();
      let targetActor = target.getActor();

      if (targetActor != NULL)
      {
        pruned.add(_registry.at(i));
      }
    }

    _registry = pruned;
  }

  private tt_TargetSource   _targetSource;
  private tt_Lesson _lesson;
  private tt_TargetSource   _disabledTargetSource;

  private tt_KnownTargets _registry;
}

13.5.1. Test

{
  let tag = "tt_TargetRegistry: emptyCheck";
  let env = tt_TargetRegistryTestEnvironment.of();

  env.targetSource        .expect_getTargets(tt_Targets.of());
  env.disabledTargetSource.expect_getTargets(tt_Targets.of());

  it(tag .. ": is empty", Assert(env.targetRegistry.isEmpty()));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_TargetRegistry: addCheck";
  let env = tt_TargetRegistryTestEnvironment.of();

  let target1 = tt_Target.of(spawn("Demon", (0, 0, 0)));
  let target2 = tt_Target.of(spawn("Demon", (0, 0, 0)));
  let targets = tt_Targets.of();
  targets.add(target1);
  targets.add(target2);

  env.targetSource.expect_getTargets(targets);
  env.disabledTargetSource.expect_getTargets(tt_Targets.of());
  env.lesson.expect_getQuestion(tt_QuestionMock.of(), 2);

  let knownTargets = env.targetRegistry.getTargets();

  it(tag .. ": is two targets", AssertEval(knownTargets.size(), "==", 2));

  assertSatisfaction(env.getSatisfaction(), tag);
  cleanUpSpawned();
}
{
  let tag = "tt_TargetRegistry: addExistingCheck";
  let env = tt_TargetRegistryTestEnvironment.of();

  // First, add a single target.
  let demon1  = spawn("Demon", (0, 0, 0));
  let target  = tt_Target.of(demon1);
  let targets = tt_Targets.of();
  targets.add(target);

  env.targetSource.expect_getTargets(targets);
  env.disabledTargetSource.expect_getTargets(tt_Targets.of());
  env.lesson.expect_getQuestion(tt_QuestionMock.of());

  let knownTargets = env.targetRegistry.getTargets();

  it(tag .. ": is one target", AssertEval(knownTargets.size(), "==", 1));

  assertSatisfaction(env.getSatisfaction(), tag);

  // Second, add the same target again. Only a single target must remain
  // registered.
  env.targetSource.expect_getTargets(targets);
  env.disabledTargetSource.expect_getTargets(tt_Targets.of());
  env.lesson.expect_getQuestion(NULL, 0);

  knownTargets = env.targetRegistry.getTargets();

  it(tag .. ": is one target", AssertEval(knownTargets.size(), "==", 1));

  assertSatisfaction(env.getSatisfaction(), tag);
  cleanUpSpawned();
}
{
  let tag = "tt_TargetRegistry: remove";
  let env = tt_TargetRegistryTestEnvironment.of();

  // First, add two targets.
  let demon1  = spawn("Demon", (0, 0, 0));
  let demon2  = spawn("Demon", (0, 0, 0));
  let target1 = tt_Target.of(demon1);
  let target2 = tt_Target.of(demon2);
  let targets = tt_Targets.of();
  targets.add(target1);
  targets.add(target2);

  env.targetSource.expect_getTargets(targets);
  env.disabledTargetSource.expect_getTargets(tt_Targets.of());
  env.lesson.expect_getQuestion(tt_QuestionMock.of(), 2);

  let knownTargets = env.targetRegistry.getTargets();

  it(tag .. ": is two targets", AssertEval(knownTargets.size(), "==", 2));

  assertSatisfaction(env.getSatisfaction(), tag);

  // Second, remove one target.
  let disabledTarget  = tt_Target.of(demon1);
  let disabledTargets = tt_Targets.of();
  disabledTargets.add(disabledTarget);

  env.targetSource.expect_getTargets(tt_Targets.of());
  env.disabledTargetSource.expect_getTargets(disabledTargets);
  env.lesson.expect_getQuestion(NULL, 0);

  knownTargets = env.targetRegistry.getTargets();

  it(tag .. ": is one target now", AssertEval(knownTargets.size(), "==", 1));

  assertSatisfaction(env.getSatisfaction(), tag);
  cleanUpSpawned();
}
class tt_TargetRegistryTestEnvironment
{
  static tt_TargetRegistryTestEnvironment of()
  {
    let result = new("tt_TargetRegistryTestEnvironment");
    result.targetSource         = tt_TargetSourceMock.of();
    result.lesson               = tt_LessonMock.of();
    result.disabledTargetSource = tt_TargetSourceMock.of();
    result.targetRegistry       = tt_TargetRegistry.of(result.targetSource,
                                                       result.lesson,
                                                       result.disabledTargetSource);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return targetSource.getSatisfaction()
      .add(lesson.getSatisfaction())
      .add(disabledTargetSource.getSatisfaction());
  }

  tt_TargetSourceMock   targetSource;
  tt_LessonMock lesson;
  tt_TargetSourceMock   disabledTargetSource;

  tt_KnownTargetSource  targetRegistry;
}

13.6. VisibleKnownTargetSource

// Implements tt_KnownTargetSource by providing only targets visible to player.
// Doesn't cache.
class tt_VisibleKnownTargetSource : tt_KnownTargetSource
{
  static tt_VisibleKnownTargetSource of(tt_KnownTargetSource base,
                                        tt_PlayerSource playerSource)
  {
    let result = new("tt_VisibleKnownTargetSource");
    result._base = base;
    result._playerSource = playerSource;
    return result;
  }

  override tt_KnownTargets getTargets() const
  {
    let result = tt_KnownTargets.of();
    if (_base.isEmpty()) return result;

    let  pawn        = _playerSource.getPawn();
    let  baseTargets = _base.getTargets();
    uint targetCount = baseTargets.size();

    for (uint i = 0; i < targetCount; ++i)
    {
      let target = baseTargets.at(i);
      if (isVisible(target, pawn)) result.add(target);
    }

    return result;
  }

  override bool isEmpty() const
  {
    if (_base.isEmpty()) return true;

    let  pawn        = _playerSource.getPawn();
    let  baseTargets = _base.getTargets();
    uint targetCount = baseTargets.size();
    for (uint i = 0; i < targetCount; ++i)
      if (isVisible(baseTargets.at(i), pawn)) return false;

    return true;
  }

  // Play-const hack: Actor.isVisible(...) is not const, but should be.
  private play bool isVisible(tt_KnownTarget target, Actor pawn) const
  {
    return pawn.isVisible(target.getTarget().getActor(), ALL_AROUND);
  }

  const ALL_AROUND = 1; // true

  private tt_KnownTargetSource _base;
  private tt_PlayerSource _playerSource;
}

13.6.1. Tests

{
  let tag = "tt_VisibleKnownTargetSource: no targets";
  let env = tt_VisibleKnownTargetSourceTestEnvironment.of();

  env.baseSource.expect_isEmpty(true, 2);

  bool isEmpty = env.source.isEmpty();
  let  targets = env.source.getTargets();

  it(tag .. "-> empty", Assert(isEmpty));
  it(tag .. "-> no targets", AssertEval(targets.size(), "==", 0));
  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_VisibleKnownTargetSource: visible targets";
  let env = tt_VisibleKnownTargetSourceTestEnvironment.of();

  let knownTargets = tt_KnownTargets.of();
  let target       = tt_Target.of(spawn("Demon", (0, 0, 0)));
  let question     = tt_QuestionMock.of();
  let knownTarget  = tt_KnownTarget.of(target, question);
  knownTargets.add(knownTarget);

  env.baseSource  .expect_isEmpty(false, 2);
  env.baseSource  .expect_getTargets(knownTargets, 2);
  env.playerSource.expect_getPawn(players[consolePlayer].mo, 2);

  bool isEmpty = env.source.isEmpty();
  let  targets = env.source.getTargets();

  it(tag .. "-> not empty", Assert(!isEmpty));
  it(tag .. "-> targets", AssertEval(targets.size(), "==", 1));

  assertSatisfaction(env.getSatisfaction(), tag);

  cleanUpSpawned();
}
{
  let tag = "tt_VisibleKnownTargetSource: invisible targets";
  let env = tt_VisibleKnownTargetSourceTestEnvironment.of();

  let knownTargets = tt_KnownTargets.of();
  let target       = tt_Target.of(spawn("Demon", (9999999, 0, 0)));
  let question     = tt_QuestionMock.of();
  let knownTarget  = tt_KnownTarget.of(target, question);
  knownTargets.add(knownTarget);

  env.baseSource  .expect_isEmpty(false, 2);
  env.baseSource  .expect_getTargets(knownTargets, 2);
  env.playerSource.expect_getPawn(players[consolePlayer].mo, 2);

  bool isEmpty = env.source.isEmpty();
  let  targets = env.source.getTargets();

  it(tag .. "-> empty", Assert(isEmpty));
  it(tag .. "-> no targets", AssertEval(targets.size(), "==", 0));

  assertSatisfaction(env.getSatisfaction(), tag);

  cleanUpSpawned();
}
class tt_VisibleKnownTargetSourceTestEnvironment
{
  static tt_VisibleKnownTargetSourceTestEnvironment of()
  {
    let result = new("tt_VisibleKnownTargetSourceTestEnvironment");
    result.baseSource   = tt_KnownTargetSourceMock.of();
    result.playerSource = tt_PlayerSourceMock.of();
    result.source       = tt_VisibleKnownTargetSource.of(result.baseSource,
                                                         result.playerSource);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return baseSource.getSatisfaction().add(playerSource.getSatisfaction());
  }

  tt_KnownTargetSourceMock    baseSource;
  tt_PlayerSourceMock         playerSource;
  tt_VisibleKnownTargetSource source;
}

14. Lesson

14.1. Lesson

// Interface for getting Questions.
class tt_Lesson abstract
{
  abstract tt_Question getQuestion();
}

14.1.1. Mock

class tt_LessonMock : tt_Lesson
{
  static tt_LessonMock of() { return new("tt_LessonMock"); }

  mixin tt_Mock;
  override tt_Question getQuestion()
  {
    if (_mock_getQuestion_expectation == NULL)
      _mock_getQuestion_expectation = _mock_addExpectation("getQuestion");
    ++_mock_getQuestion_expectation.called;

    return _mock_getQuestion;
  }

  void expect_getQuestion(tt_Question value, int expected = 1)
  {
    if (_mock_getQuestion_expectation == NULL)
      _mock_getQuestion_expectation = _mock_addExpectation("getQuestion");
    _mock_getQuestion_expectation.expected = expected;
    _mock_getQuestion_expectation.called = 0;

    _mock_getQuestion = value;
  }

  private tt_Question _mock_getQuestion;
  private tt_Expectation _mock_getQuestion_expectation;

}

14.2. MathsLesson

// Implements tt_Lesson by composing arithmetic tasks.
class tt_MathsLesson : tt_Lesson
{
  static tt_MathsLesson of()
  {
    let result = new("tt_MathsLesson");
    return result;
  }

  override tt_Question getQuestion()
  {
    int operation = random[typist](Addition, Division);

    switch (operation)
    {
    case Addition:       return makeAdditionQuestion();
    case Subtraction:    return makeSubtractionQuestion();
    case Multiplication: return makeMultiplicationQuestion();
    case Division:       return makeDivisionQuestion();
    }

    Console.printf("%s: getQuestion: unknown operation!", getClassName());
    return NULL;
  }

  private tt_Question makeAdditionQuestion()
  {
    int leftAddend  = random[typist](11, 49);
    int rightAddend = random[typist](11, 50);
    int sum         = leftAddend + rightAddend;

    string description = string.format("%d + %d", leftAddend,  rightAddend);
    string answer      = string.format("%d", sum);

    let question = tt_Match.of(answer, description);
    return question;
  }

  private tt_Question makeSubtractionQuestion()
  {
    int minuend    = random[typist](50, 99);
    int subtrahend = random[typist](11, 50);
    int difference = minuend - subtrahend;

    string description = string.format("%d - %d", minuend, subtrahend);
    string answer      = string.format("%d", difference);

    let question = tt_Match.of(answer, description);
    return question;
  }

  private tt_Question makeMultiplicationQuestion()
  {
    int multiplicand = random[typist](2, 9);
    int multiplier   = random[typist](2, 9);
    int product      = multiplicand * multiplier;

    string description = string.format("%d * %d", multiplicand, multiplier);
    string answer      = string.format("%d", product);

    let question = tt_Match.of(answer, description);
    return question;
  }

  private tt_Question makeDivisionQuestion()
  {
    int quotient = random[typist](2, 9);
    int divisor  = random[typist](2, 9);
    int dividend = quotient * divisor;

    string description = string.format("%d / %d", dividend, divisor);
    string answer      = string.format("%d", quotient);

    let question = tt_Match.of(answer, description);
    return question;
  }

  enum Operations
  {
    Addition,
    Subtraction,
    Multiplication,
    Division,
  }
}
{
  let question = tt_MathsLesson.of().getQuestion();

  it("tt_MathsLesson: question isn't equal to the answer",
    AssertFalse(question.isRight(question.getDescription())));
}

14.3. MixedLesson

class tt_SwitchableLesson
{
  static tt_SwitchableLesson of(tt_BoolSetting setting, tt_Lesson lesson)
  {
    let result = new("tt_SwitchableLesson");
    result._setting = setting;
    result._lesson  = lesson;
    return result;
  }

  bool isEnabled() { return _setting.get(); }
  tt_Lesson lesson() { return _lesson; }

  private tt_BoolSetting _setting;
  private tt_Lesson      _lesson;
}

class tt_MixedLesson : tt_Lesson
{
  static tt_MixedLesson of(Array<tt_SwitchableLesson> lessons)
  {
    let result = new("tt_MixedLesson");
    result._lessons.move(lessons);
    return result;
  }

  override tt_Question getQuestion()
  {
    _enabledLessons.clear();

    foreach (lesson : _lessons)
      if (lesson.isEnabled()) _enabledLessons.push(lesson.lesson());

    uint nEnabledLessons = _enabledLessons.size();
    if (nEnabledLessons == 0)
    {
      Console.printf("All lessons disabled");
      return tt_FallbackQuestion.of();
    }

    uint randomLessonIndex = random[typist](0, nEnabledLessons - 1);

    return _enabledLessons[randomLessonIndex].getQuestion();
  }

  private Array<tt_SwitchableLesson> _lessons;
  private Array<tt_Lesson> _enabledLessons;
}

14.4. RandomCharactersLesson

// Implements tt_Lesson by composing a question from groups
// of characters enabled by settings.
class tt_RandomCharactersLesson : tt_Lesson
{
  static tt_RandomCharactersLesson of(tt_RandomCharactersLessonSettings settings)
  {
    let result = new("tt_RandomCharactersLesson");
    result._settings = settings;
    return result;
  }

  override tt_Question getQuestion()
  {
    string characters = composeCharacterRange();
    int    length     = _settings.getLessonLength();
    string picked     = pick(characters, length);

    if (picked.length() == 0)
    {
      Console.printf("Random characters lesson: no characters enabled");
      return tt_FallbackQuestion.of();
    }

    return tt_Match.of(picked, picked);
  }

  // This function is guaranteed to return non-empty strings.
  private string composeCharacterRange()
  {
    string characters;

    if (_settings.isUppercaseLettersEnabled()) characters.appendFormat("%s", UPPERCASE_LETTERS);
    if (_settings.isLowercaseLettersEnabled()) characters.appendFormat("%s", LOWERCASE_LETTERS);
    if (_settings.isNumbersEnabled()) characters.appendFormat("%s", NUMBERS);
    if (_settings.isPunctuationEnabled()) characters.appendFormat("%s", PUNCTUATION);
    if (_settings.isSymbolsEnabled())
    {
      // GZDoom cannot handle "\\" in a string, so add it manually.
      characters.AppendFormat("%s%c", SYMBOLS, tt_su_Ascii.REVERSE_SOLIDUS);
    }
    if (_settings.isCustomCharactersEnabled())
    {
      characters.AppendFormat("%s", _settings.getCustomCharacters());
    }

    return characters;
  }

  // This function is guaranteed to return non-empty strings.
  private static string pick(string characters, int number)
  {
    if (characters.length() == 0) return "";

    string result;
    int    lastCharacter = characters.CodePointCount() - 1;

    for (int i = 0; i < number; ++i)
    {
      int randomIndex = random[typist](0, lastCharacter);
      int character   = getCodePointAt(characters, randomIndex);

      result.appendFormat("%c", character);
    }

    return result;
  }

  // Attention! O(n)
  private static int getCodePointAt(String str, int index)
  {
    int letterCode;
    int charPos = 0;
    for (int i = 0; i <= index; ++i)
    {
      [letterCode, charPos] = str.GetNextCodePoint(charPos);
    }

    return letterCode;
  }

  const LOWERCASE_LETTERS = "abcdefghijklmnopqrstuvwxyz";
  const UPPERCASE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  const NUMBERS           = "0123456789";
  const PUNCTUATION       = ",.();:-'\"?!/";
  const SYMBOLS           = "~`@#$%^&*+=[]{}<>|";

  private tt_RandomCharactersLessonSettings _settings;
}

14.5. RandomNumberSource

// Implements tt_Lesson by producing questions that contain
// string composed from random numbers and should match exactly to the answers.
class tt_RandomNumberSource : tt_Lesson
{
  static tt_RandomNumberSource of()
  {
    let result = new("tt_RandomNumberSource");
    return result;
  }

  override tt_Question getQuestion()
  {
    let stringLength = 3;
    let str          = "";

    for (int i = 0; i < stringLength; ++i)
    {
      int number = random[typist](tt_su_Ascii.DIGIT_ZERO, tt_su_Ascii.DIGIT_NINE);
      str.AppendFormat("%c", number);
    }

    let question = tt_Match.of(str, str);

    return question;
  }
}

14.6. StringSet

// Implements tt_Lesson by reading a lump with words and
// randomly selecting words from this lump.
class tt_StringSet : tt_Lesson
{
  static tt_StringSet of(String lumpName)
  {
    int    lump     = Wads.findLump(lumpName, 0, Wads.AnyNamespace);
    string contents = Wads.readLump(lump);
    Array<string> words;
    tt_su_su.splitByWords(contents, words);

    Array<string> filteredWords;
    filterWords(words, filteredWords);

    let result = new("tt_StringSet");

    result._lumpName = lumpName;
    result._words.move(filteredWords);

    return result;
  }

  override tt_Question getQuestion()
  {
    int nWords = int(_words.size());
    if (nWords == 0)
    {
      Console.printf("%s: getQuestion: no words in lump %s.",
                     getClassName(),
                     _lumpName);
      return tt_FallbackQuestion.of();
    }

    int    wordIndex = random[typist](0, nWords - 1);
    string word      = _words[wordIndex];
    let    question  = tt_Match.of(word, word);

    return question;
  }

  // Removes too short words, removes duplicates.
  private static void filterWords(Array<String> input, out Array<String> result)
  {
    // Use map to remove duplicates.
    Map<string, int> wordSet;

    foreach (word : input)
    {
      if (word.codePointCount() > 1)
        wordSet.insert(word, 0);
    }

    foreach (word, value : wordSet)
      result.push(word);
  }

  private string        _lumpName;
  private Array<string> _words;
}
{
  let    stringSet   = tt_StringSet.of("tt_test_words");
  let    question    = stringSet.getQuestion();
  string description = question.getDescription();

  it("tt_StringSet: Question must be valid", AssertNotNull(question));
  it("tt_StringSet: Description", Assert(description == "привет"));
}
привет

15. Math

// Namespace for math-related functions.
class tt_Math
{
  static bool isInEffectiveRange(vector3 p1, vector3 p2)
  {
    double distance = (p1 - p2).length();
    bool   inRange  = distance < MAX_DISTANCE;

    return inRange;
  }

  static vector3 crossProduct(vector3 u, vector3 v)
  {
    vector3 result;
    result.x = u.y * v.z - u.z * v.y;
    result.y = u.z * v.x - u.x * v.z;
    result.z = u.x * v.y - u.y * v.x;
    return result;
  }

  // Max effective distance.
  const MAX_DISTANCE = 700;
}

16. Mode

16.1. Mode

// Represents the mode in which Typist operates.
class tt_Mode
{
  enum _
  {
    Unknown, // Should never be used. Only for detecting uninitialized variables.
    Combat,  // Typist is focused on destroying the targets.
    Explore, // Typist is focused on movement and exploration.
    None,    // None of the above.
  }
}

16.2. ModeSource

// This interface represents a source of modes.
// See: tt_Mode.
class tt_ModeSource abstract
{
  abstract int getMode();
}

16.2.1. Mock

class tt_ModeSourceMock : tt_ModeSource
{
  static tt_ModeSourceMock of() { return new("tt_ModeSourceMock"); }

  mixin tt_Mock;
  override int getMode()
  {
    if (_mock_getMode_expectation == NULL)
      _mock_getMode_expectation = _mock_addExpectation("getMode");
    ++_mock_getMode_expectation.called;

    return _mock_getMode;
  }

  void expect_getMode(int value, int expected = 1)
  {
    if (_mock_getMode_expectation == NULL)
      _mock_getMode_expectation = _mock_addExpectation("getMode");
    _mock_getMode_expectation.expected = expected;
    _mock_getMode_expectation.called = 0;

    _mock_getMode = value;
  }

  private int _mock_getMode;
  private tt_Expectation _mock_getMode_expectation;

}

16.3. AutoModeSource

// Implements tt_ModeSource by examining the specified tt_KnownTargetSource.
class tt_AutoModeSource : tt_ModeSource
{
  static tt_AutoModeSource of(tt_KnownTargetSource knownTargetSource)
  {
    let result = new("tt_AutoModeSource");
    result._knownTargetSource = knownTargetSource;
    return result;
  }

  override int getMode()
  {
    return _knownTargetSource.isEmpty() ? tt_Mode.Explore : tt_Mode.Combat;
  }

  private tt_KnownTargetSource _knownTargetSource;
}

16.3.1. Tests

{
  let tag = "tt_AutoModeSource: no targets";
  let knownTargetSource = tt_KnownTargetSourceMock.of();
  let autoModeSource    = tt_AutoModeSource.of(knownTargetSource);

  knownTargetSource.expect_isEmpty(true);

  int mode = autoModeSource.getMode();

  it(tag .. ": no targets -> Explore", AssertEval(mode, "==", tt_Mode.Explore));
  assertSatisfaction(knownTargetSource.getSatisfaction(), tag);
}
{
  let tag = "tt_AutoModeSource: targets";
  let knownTargetSource = tt_KnownTargetSourceMock.of();
  let autoModeSource    = tt_AutoModeSource.of(knownTargetSource);

  knownTargetSource.expect_isEmpty(false);

  int mode = autoModeSource.getMode();

  it(tag .. ": targets -> Combat", AssertEval(mode, "==", tt_Mode.Combat));
  assertSatisfaction(knownTargetSource.getSatisfaction(), tag);
}

16.4. DelayedCombatModeSource

// Implements tt_ModeSource by reading other tt_ModeSource, and switching to
// Exploration mode only if some time has passed or there is no enemies around.
class tt_DelayedCombatModeSource : tt_ModeSource
{
  static tt_DelayedCombatModeSource of(tt_Clock        clock,
                                       tt_ModeSource   modeSource,
                                       tt_TargetSource targetSource)
  {
    let result = new("tt_DelayedCombatModeSource");

    result._clock        = clock;
    result._modeSource   = modeSource;
    result._targetSource = targetSource;

    result._switchDetected = false;
    result._oldMode        = tt_Mode.None;
    result._switchToExploreMoment = 0;

    return result;
  }

  override int getMode()
  {
    int topMode = _modeSource.getMode();

    if (topMode != tt_Mode.Explore)
    {
      // let others decide.
      _oldMode = topMode;
      return tt_Mode.None;
    }

    bool wasCombat        = _oldMode == tt_Mode.Combat;
    bool isExplore        =  topMode == tt_Mode.Explore;
    bool areEnemiesAround = !_targetSource.getTargets().isEmpty();

    if (wasCombat && isExplore && areEnemiesAround)
    {
      _switchDetected        = true;
      _switchToExploreMoment = _clock.getNow();
    }

    _oldMode = topMode;

    if (!_switchDetected)
    {
      return tt_Mode.None;
    }

    bool timeIsUp = _clock.since(_switchToExploreMoment) > DELAY;

    if (timeIsUp)
    {
      _switchDetected = false;
    }

    return timeIsUp ? tt_Mode.None : tt_Mode.Combat;
  }

  const DELAY = TICRATE * 1; // 1 second

  private tt_Clock        _clock;
  private tt_ModeSource   _modeSource;
  private tt_TargetSource _targetSource;

  private int _switchDetected;
  private int _oldMode;
  private int _switchToExploreMoment;
}
// C - Combat Mode
// E - Exploration Mode
// N - None Mode (let other decide)
//
// |-----|-----|---------|-------------|--------|-------------------------|
// | old | new | enemies | time is up? | result | test                    |
// |-----|-----|---------|-------------|--------|-------------------------|
// |  *  |  C  |    *    |      *      | None   | checkNewCombat          |
// |  C  |  E  |   no    |      *      | None   | checkNoEnemies          |
// |  C  |  E  |   yes   |     no      | Combat | checkEnemiesStillCombat |
// |  C  |  E  |   yes   |     yes     | None   | checkEnemiesTimeIsUp    |
// |  E  |  *  |    *    |      *      | None   | checkOldExploration     |
// |-----|-----|---------|-------------|--------|-------------------------|
{
  let tag = "tt_DelayedCombatModeSource: checkNewCombat";
  let env = tt_DelayedCombatModeSourceTestEnvironment.of();

  env.modeSource.expect_getMode(tt_Mode.Combat, 2);
  env.clock.expect_getNow(0, 0);
  env.clock.expect_since(0, 0);

  int result1 = env.delay.getMode();
  it(tag .. ": new combat -> None", AssertEval(result1, "==", tt_Mode.None));

  int result2 = env.delay.getMode();
  it(tag .. ": again, combat -> None", AssertEval(result2, "==", tt_Mode.None));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_DelayedCombatModeSource: checkNoEnemies";
  let env = tt_DelayedCombatModeSourceTestEnvironment.of();

  // set up history: it was combat.
  env.modeSource.expect_getMode(tt_Mode.Combat);
  env.delay.getMode();

  env.modeSource.expect_getMode(tt_Mode.Explore);
  env.targetSource.expect_getTargets(tt_Targets.of());

  int result = env.delay.getMode();
  it(tag .. ": no enemies", AssertEval(result, "==", tt_Mode.None));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_DelayedCombatModeSource: checkEnemiesStillCombat";
  let env = tt_DelayedCombatModeSourceTestEnvironment.of();

  // set up history: it was combat.
  env.modeSource.expect_getMode(tt_Mode.Combat);
  env.delay.getMode();

  { // set expectations
    env.modeSource.expect_getMode(tt_Mode.Explore);

    let targets = tt_Targets.of();
    targets.add(NULL);
    env.targetSource.expect_getTargets(targets);

    env.clock.expect_getNow(0);
    env.clock.expect_since(0);
  }

  int result = env.delay.getMode();
  it(tag .. ": still combat", AssertEval(result, "==", tt_Mode.Combat));
  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_DelayedCombatModeSource: checkEnemiesTimeIsUp";
  let env = tt_DelayedCombatModeSourceTestEnvironment.of();

  // set up history: it was combat.
  env.modeSource.expect_getMode(tt_Mode.Combat);
  env.delay.getMode();

  { // set expectations
    env.modeSource.expect_getMode(tt_Mode.Explore);

    let targets = tt_Targets.of();
    targets.add(NULL);
    env.targetSource.expect_getTargets(targets);

    env.clock.expect_getNow(0);
    env.clock.expect_since(999);
  }

  int result = env.delay.getMode();
  it(tag .. ": no more combat", AssertEval(result, "==", tt_Mode.None));
  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_DelayedCombatModeSource: checkOldExploration";
  let env = tt_DelayedCombatModeSourceTestEnvironment.of();

  env.modeSource.expect_getMode(tt_Mode.Explore, 2);
  env.clock.expect_getNow(0, 0);
  env.clock.expect_since(0, 0);
  env.targetSource.expect_getTargets(tt_Targets.of(), 2);

  int result1 = env.delay.getMode();
  it(tag .. ": old Exploration -> None", AssertEval(result1, "==", tt_Mode.None));

  int result2 = env.delay.getMode();
  it(tag .. ": again, old Exploration -> None",
    AssertEval(result2, "==", tt_Mode.None));

  assertSatisfaction(env.getSatisfaction(), tag);
}
class tt_DelayedCombatModeSourceTestEnvironment
{
  static tt_DelayedCombatModeSourceTestEnvironment of()
  {
    let result = new("tt_DelayedCombatModeSourceTestEnvironment");
    result.clock        = tt_ClockMock.of();
    result.modeSource   = tt_ModeSourceMock.of();
    result.targetSource = tt_TargetSourceMock.of();

    result.delay        = tt_DelayedCombatModeSource.of(result.clock,
                                                        result.modeSource,
                                                        result.targetSource);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return clock.getSatisfaction()
      .add(modeSource.getSatisfaction())
      .add(targetSource.getSatisfaction());
  }

  tt_ClockMock        clock;
  tt_ModeSourceMock   modeSource;
  tt_TargetSourceMock targetSource;

  tt_DelayedCombatModeSource delay;
}

16.5. ModeCascade

// Implements ModeSource by taking the first mode from ModeSources
// list that is not NONE.
class tt_ModeCascade : tt_ModeSource
{
  static tt_ModeCascade of(Array<tt_ModeSource> modeSources)
  {
    let result = new("tt_ModeCascade");
    result._modeSources.move(modeSources);
    return result;
  }

  override int getMode()
  {
    foreach (source : _modeSources)
    {
      int mode = source.getMode();
      if (mode != tt_Mode.None) return mode;
    }

    return tt_Mode.None;
  }

  private Array<tt_ModeSource> _modeSources;
}
{
  Array<tt_ModeSource> sources;
  let cascade = tt_ModeCascade.of(sources);

  int mode = cascade.getMode();

  it("tt_ModeCascade: check zero sources: No source -> no mode",
     AssertEval(mode, "==", tt_Mode.None));
}
{
  let source1 = tt_ModeSourceMock.of();
  let source2 = tt_ModeSourceMock.of();

  source1.expect_getMode(tt_Mode.Explore);
  source2.expect_getMode(tt_Mode.Combat);

  Array<tt_ModeSource> sources = {source1, source2};
  int mode = tt_ModeCascade.of(sources).getMode();

  it("tt_ModeCascade: check cascade first: Must be the first mode",
     AssertEval(mode, "==", tt_Mode.Explore));
}
{
  let source1 = tt_ModeSourceMock.of();
  let source2 = tt_ModeSourceMock.of();

  source1.expect_getMode(tt_Mode.None);
  source2.expect_getMode(tt_Mode.Combat);

  Array<tt_ModeSource> sources = {source1, source2};
  int mode = tt_ModeCascade.of(sources).getMode();

  it("tt_ModeCascade: check cascade second: Must be the second mode",
     AssertEval(mode, "==", tt_Mode.Combat));
}

16.6. ModeStorage

// This is an interface for storing and retrieving mode.
class tt_ModeStorage : tt_ModeSource abstract
{
  abstract void setMode(int mode);
}

16.6.1. Mock

class tt_ModeStorageMock : tt_ModeStorage
{
  static tt_ModeStorageMock of() { return new("tt_ModeStorageMock"); }

  mixin tt_Mock;
  override void setMode(int mode)
  {
    if (_mock_setMode_expectation == NULL)
      _mock_setMode_expectation = _mock_addExpectation("setMode");
    ++_mock_setMode_expectation.called;


  }

  void expect_setMode(int expected = 1)
  {
    if (_mock_setMode_expectation == NULL)
      _mock_setMode_expectation = _mock_addExpectation("setMode");
    _mock_setMode_expectation.expected = expected;
    _mock_setMode_expectation.called = 0;


  }


  private tt_Expectation _mock_setMode_expectation;

  override int getMode()
  {
    if (_mock_getMode_expectation == NULL)
      _mock_getMode_expectation = _mock_addExpectation("getMode");
    ++_mock_getMode_expectation.called;

    return _mock_getMode;
  }

  void expect_getMode(int value, int expected = 1)
  {
    if (_mock_getMode_expectation == NULL)
      _mock_getMode_expectation = _mock_addExpectation("getMode");
    _mock_getMode_expectation.expected = expected;
    _mock_getMode_expectation.called = 0;

    _mock_getMode = value;
  }

  private int _mock_getMode;
  private tt_Expectation _mock_getMode_expectation;

}

16.7. ReportedModeSource

// Implements tt_ModeSource by reading other mode source, and
// reporting an event when the mode has changed.
class tt_ReportedModeSource : tt_ModeSource
{
  static tt_ReportedModeSource of(tt_ModeReporter reporter, tt_ModeSource modeSource)
  {
    let result = new("tt_ReportedModeSource");

    result._reporter   = reporter;
    result._modeSource = modeSource;

    result._oldMode = tt_Mode.None;

    return result;
  }

  override int getMode()
  {
    int newMode = _modeSource.getMode();

    if (newMode != _oldMode)
    {
      if (_oldMode != tt_Mode.None)
      {
        _reporter.report(newMode);
      }

      _oldMode = newMode;
    }

    return newMode;
  }

  private tt_ModeReporter _reporter;
  private tt_ModeSource   _modeSource;

  private int _oldMode;
}
{
  let tag = "tt_ReportedModeSource: checkInitial";
  let env = tt_ReportedModeSourceTestEnvironment.of();

  int expected = tt_Mode.Explore;
  env.modeSource.expect_getMode(expected);

  int mode = env.reportedMode.getMode();

  it(tag .. ": explore after init", AssertEval(mode, "==", expected));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_ReportedModeSource: checkExplorationToCombat";
  let env = tt_ReportedModeSourceTestEnvironment.of();

  env.reporter.expect_report();

  env.modeSource.expect_getMode(tt_Mode.Explore);
  int mode1 = env.reportedMode.getMode();

  env.modeSource.expect_getMode(tt_Mode.Combat);
  int mode2 = env.reportedMode.getMode();

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_ReportedModeSource: checkCombatToExploration";
  let env = tt_ReportedModeSourceTestEnvironment.of();

  env.reporter.expect_report();

  env.modeSource.expect_getMode(tt_Mode.Combat);
  int mode1 = env.reportedMode.getMode();

  env.modeSource.expect_getMode(tt_Mode.Explore);
  int mode2 = env.reportedMode.getMode();

  assertSatisfaction(env.getSatisfaction(), tag);
}
class tt_ReportedModeSourceTestEnvironment
{
  static tt_ReportedModeSourceTestEnvironment of()
  {
    let result = new("tt_ReportedModeSourceTestEnvironment");
    result.reporter     = tt_ModeReporterMock.of();
    result.modeSource   = tt_ModeSourceMock.of();
    result.reportedMode = tt_ReportedModeSource.of(result.reporter,
                                                   result.modeSource);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return reporter.getSatisfaction().add(modeSource.getSatisfaction());
  }

  tt_ModeReporterMock   reporter;
  tt_ModeSourceMock     modeSource;
  tt_ReportedModeSource reportedMode;
}

16.8. SettableMode

// Implements ModeStorage by simply storing the mode that was set.
class tt_SettableMode : tt_ModeStorage
{
  static tt_SettableMode of()
  {
    let result = new("tt_SettableMode");

    result._mode = tt_Mode.None;

    return result;
  }

  override int getMode()
  {
    return _mode;
  }

  override void setMode(int mode)
  {
    _mode = mode;
  }

  private int _mode;
}
{
  let settableMode = tt_SettableMode.of();
  int before       = tt_Mode.Combat;

  settableMode.setMode(before);
  int after = settableMode.getMode();

  it("tt_SettableMode: mode must be the same", AssertEval(before, "==", after));
}

16.9. AutomapModeSource

// Implements tt_ModeSource by choosing Explore mode on the automap.
class tt_AutomapModeSource : tt_ModeSource
{
  static tt_AutomapModeSource of()
  {
    return new("tt_AutomapModeSource");
  }

  override int getMode()
  {
    return automapActive ? tt_Mode.Explore : tt_Mode.None;
  }
}

17. Origin

17.1. Origin

// Represents a point in space.
// Note that the Origin position cannot change once set.
class tt_Origin
{
  static tt_Origin of(vector3 pos)
  {
    let result = new("tt_Origin");
    result._pos = pos;
    return result;
  }

  vector3 getVector() const { return _pos; }

  private vector3 _pos;
}

17.2. OriginSource

// This interface represents a source of origins.
class tt_OriginSource abstract
{
  // Returns the origin (coordinate in 3D space).
  // Getting the origin doesn't change it.
  abstract tt_Origin getOrigin();
}

17.2.1. Mock

class tt_OriginSourceMock : tt_OriginSource
{
  static tt_OriginSourceMock of() { return new("tt_OriginSourceMock"); }

  mixin tt_Mock;
  override tt_Origin getOrigin()
  {
    if (_mock_getOrigin_expectation == NULL)
      _mock_getOrigin_expectation = _mock_addExpectation("getOrigin");
    ++_mock_getOrigin_expectation.called;

    return _mock_getOrigin;
  }

  void expect_getOrigin(tt_Origin value, int expected = 1)
  {
    if (_mock_getOrigin_expectation == NULL)
      _mock_getOrigin_expectation = _mock_addExpectation("getOrigin");
    _mock_getOrigin_expectation.expected = expected;
    _mock_getOrigin_expectation.called = 0;

    _mock_getOrigin = value;
  }

  private tt_Origin _mock_getOrigin;
  private tt_Expectation _mock_getOrigin_expectation;

}

17.3. HastyQuestionAnswerMatcher

// Implements OriginSource by finding an origin for a known target
// that fits to for the answer.
class tt_HastyQuestionAnswerMatcher : tt_OriginSource
{
  static tt_HastyQuestionAnswerMatcher of(tt_KnownTargetSource knownTargetSource,
                                          tt_AnswerSource      answerSource,
                                          tt_AnswerReporter    reporter)
  {
    let result = new("tt_HastyQuestionAnswerMatcher");
    result._knownTargetSource = knownTargetSource;
    result._answerSource      = answerSource;
    result._reporter          = reporter;
    return result;
  }

  override tt_Origin getOrigin()
  {
    let targets = _knownTargetSource.getTargets();
    if (targets == NULL || targets.size() == 0) { return NULL; }

    let answer = _answerSource.getAnswer();
    if (answer == NULL) { return NULL; }

    let result = findMatchedTarget(targets, answer);

    if (result != NULL)
    {
      _reporter.reportMatch();
      _answerSource.reset();
    }

    return result;
  }

  private tt_Origin findMatchedTarget(tt_KnownTargets targets, tt_Answer answer)
  {
    string answerString = answer.getString();
    uint nTargets = targets.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      let target   = targets.at(i);
      let question = target.getQuestion();

      if (!question.isRight(answerString)) continue;

      let result = target.getTarget().getPosition();
      return result;
    }

    return NULL;
  }

  private tt_KnownTargetSource _knownTargetSource;
  private tt_AnswerSource      _answerSource;
  private tt_AnswerReporter    _reporter;
}

17.4. OriginSourceCache

// Implements OriginSource by reading other OriginSource only if origin is stale.
class tt_OriginSourceCache : tt_OriginSource
{
  static tt_OriginSourceCache of(tt_OriginSource originSource,
                                 tt_StaleMarker staleMarker)
  {
    let result = new("tt_OriginSourceCache");

    result._originSource = originSource;
    result._staleMarker  = staleMarker;

    result._origin = NULL;

    return result;
  }

  override tt_Origin getOrigin()
  {
    if (_staleMarker.isStale())
    {
      _origin = _originSource.getOrigin();
    }

    return _origin;
  }

  private tt_OriginSource _originSource;
  private tt_StaleMarker  _staleMarker;

  private tt_Origin _origin;
}

17.5. PlayerOriginSource

// Implements tt_OriginSource by providing the center of the player pawn.
class tt_PlayerOriginSource : tt_OriginSource
{
  static tt_PlayerOriginSource of(tt_PlayerSource playerSource)
  {
    let result = new("tt_PlayerOriginSource");

    result._playerSource = playerSource;

    return result;
  }

  override tt_Origin getOrigin()
  {
    let pawn = _playerSource.getPawn();
    let pos  = pawn.pos;
    pos.z += pawn.height / 2;

    return tt_Origin.of(pos);
  }

  private tt_PlayerSource _playerSource;
}
{
  let tag = "tt_PlayerOriginSource";

  double x = 1;
  double y = 2;
  double z = 3;
  let player = PlayerPawn(spawn("DoomPlayer", (x, y, z)));

  let playerSource = tt_PlayerSourceMock.of();
  let originSource = tt_PlayerOriginSource.of(playerSource);

  playerSource.expect_getPawn(player);

  let origin  = originSource.getOrigin().getVector();

  it(tag .. ": X matches",  AssertEval(x, "==", origin.x));
  it(tag .. ": Y matches",  AssertEval(y, "==", origin.y));
  it(tag .. ": Z in range", AssertEval(z, "<=", origin.z));
  it(tag .. ": Z in range", AssertEval(z + player.Height, ">=", origin.z));
  assertSatisfaction(playerSource.getSatisfaction(), tag);

  cleanUpSpawned();
}

17.6. QuestionAnswerMatcher

// Implements OriginSource by finding an origin for a known target that fits to
// for the answer. Searches far matching target only if answer state is Ready.
class tt_QuestionAnswerMatcher : tt_OriginSource
{
  static tt_QuestionAnswerMatcher of(tt_KnownTargetSource knownTargetSource,
                                     tt_AnswerSource      answerSource,
                                     tt_AnswerStateSource answerStateSource)
  {
    let result = new("tt_QuestionAnswerMatcher");
    result._knownTargetSource = knownTargetSource;
    result._answerSource      = answerSource;
    result._answerStateSource = answerStateSource;
    return result;
  }

  override tt_Origin getOrigin()
  {
    let targets = _knownTargetSource.getTargets();
    if (targets == NULL || targets.size() == 0) { return NULL; }

    let answer = _answerSource.getAnswer();
    if (answer == NULL) { return NULL; }

    let answerState = _answerStateSource.getAnswerState();
    if (!answerState.isReady()) { return NULL; }

    let result = findMatchedTarget(targets, answer);

    return result;
  }

  private tt_Origin findMatchedTarget(tt_KnownTargets targets, tt_Answer answer)
  {
    string answerString = answer.getString();
    uint nTargets = targets.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      let target   = targets.at(i);
      let question = target.getQuestion();

      if (!question.isRight(answerString)) continue;

      let result = target.getTarget().getPosition();
      return result;
    }

    return NULL;
  }

  private tt_KnownTargetSource _knownTargetSource;
  private tt_AnswerSource      _answerSource;
  private tt_AnswerStateSource _answerStateSource;
}

17.6.1. Test

{
  let tag = "tt_QuestionAnswerMatcher: checkNullKnownTargets";
  let env = tt_QuestionAnswerMatcherTestEnvironment.of();

  env.targetSource.expect_getTargets(NULL);

  let origin = env.matcher.getOrigin();

  it(tag .. ": NULL known targets -> NULL origin", AssertNull(origin));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_QuestionAnswerMatcher: checkZeroKnownTargets";
  let env = tt_QuestionAnswerMatcherTestEnvironment.of();

  let targets = tt_KnownTargets.of();
  env.targetSource.expect_getTargets(targets);

  let origin = env.matcher.getOrigin();

  it(tag .. "Zero known targets -> NULL origin", AssertNull(origin));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_QuestionAnswerMatcher: checkNullKnownTarget";
  let env = tt_QuestionAnswerMatcherTestEnvironment.of();

  let targets = tt_KnownTargets.of();
  targets.add(NULL);
  env.targetSource.expect_getTargets(targets);
  env.answerSource.expect_getAnswer(NULL);

  let origin = env.matcher.getOrigin();

  it(tag .. ": NULL known target -> NULL origin", AssertNull(origin));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_QuestionAnswerMatcher: checkNullAnswer";
  let env = tt_QuestionAnswerMatcherTestEnvironment.of();

  let knownTargets = tt_KnownTargets.of();
  let target       = tt_Target.of(NULL);
  let question     = tt_QuestionMock.of();
  let knownTarget  = tt_KnownTarget.of(target, question);
  knownTargets.add(knownTarget);
  env.targetSource.expect_getTargets(knownTargets);
  env.answerSource.expect_getAnswer(NULL);

  let origin = env.matcher.getOrigin();

  it(tag .. ": NULL answer -> NULL origin", AssertNull(origin));

  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_QuestionAnswerMatcher: checkKnownTargetAndAnswerMatch";
  let env = tt_QuestionAnswerMatcherTestEnvironment.of();

  let knownTargets = tt_KnownTargets.of();
  let target       = tt_Target.of(spawn("Demon", (0, 0, 0)));
  let question     = tt_QuestionMock.of();
  let knownTarget  = tt_KnownTarget.of(target, question);
  knownTargets.add(knownTarget);
  env.targetSource.expect_getTargets(knownTargets);
  question.expect_isRight(true);

  let answer = tt_Answer.of("abc");
  env.answerSource.expect_getAnswer(answer);
  env.stateSource.expect_getAnswerState(tt_AnswerState.of(tt_AnswerState.Ready));

  let origin = env.matcher.getOrigin();

  assertSatisfaction(question.getSatisfaction(), tag);
  it(tag .. ": match: valid origin", AssertNotNull(origin));

  assertSatisfaction(env.getSatisfaction(), tag);
  cleanUpSpawned();
}
{
  let tag = "tt_QuestionAnswerMatcher: checkKnownTargetAndAnswerNoMatch";
  let env = tt_QuestionAnswerMatcherTestEnvironment.of();

  let knownTargets = tt_KnownTargets.of();
  let target       = tt_Target.of(NULL);
  let question     = tt_QuestionMock.of();
  let knownTarget  = tt_KnownTarget.of(target, question);
  knownTargets.add(knownTarget);
  env.targetSource.expect_getTargets(knownTargets);
  question.expect_isRight(false);

  let answer = tt_Answer.of("abc");
  env.answerSource.expect_getAnswer(answer);
  env.stateSource.expect_getAnswerState(tt_AnswerState.of(tt_AnswerState.Ready));

  let origin = env.matcher.getOrigin();

  assertSatisfaction(question.getSatisfaction(), tag);
  it(tag .. ": no match: NULL origin" , AssertNull(origin));

  assertSatisfaction(env.getSatisfaction(), tag);
}
class tt_QuestionAnswerMatcherTestEnvironment
{
  static tt_QuestionAnswerMatcherTestEnvironment of()
  {
    let result = new("tt_QuestionAnswerMatcherTestEnvironment");
    result.targetSource = tt_KnownTargetSourceMock.of();
    result.answerSource = tt_AnswerSourceMock.of();
    result.stateSource  = tt_AnswerStateSourceMock.of();

    result.matcher = tt_QuestionAnswerMatcher.of(result.targetSource,
                                                 result.answerSource,
                                                 result.stateSource);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return targetSource.getSatisfaction()
      .add(answerSource.getSatisfaction())
      .add(stateSource.getSatisfaction());
  }

  tt_KnownTargetSourceMock targetSource;
  tt_AnswerSourceMock      answerSource;
  tt_AnswerStateSourceMock stateSource;
  tt_QuestionAnswerMatcher matcher;
}

17.7. SelectableOriginSource

// Implements OriginSource by selecting one of the two supplied
// origin sources based on a value of tt_BoolSetting.
class tt_SelectableOriginSource : tt_OriginSource
{
  static tt_SelectableOriginSource of(tt_OriginSource source1,
                                      tt_OriginSource source2,
                                      tt_BoolSetting  fastConfirmation)
  {
    let result = new("tt_SelectableOriginSource");
    result._source1 = source1;
    result._source2 = source2;
    result._fastConfirmation = fastConfirmation;
    return result;
  }

  override tt_Origin getOrigin()
  {
    return _fastConfirmation.get()
      ? _source1.getOrigin()
      : _source2.getOrigin();
  }

  private tt_OriginSource _source1;
  private tt_OriginSource _source2;
  private tt_BoolSetting  _fastConfirmation;
}

17.8. ExternalOriginSource

// Implements tt_OriginSource by receiving the source from elsewhere.
class tt_ExternalOriginSource : tt_OriginSource
{
  static tt_ExternalOriginSource of()
  {
    let result = new("tt_ExternalOriginSource");
    return result;
  }

  override tt_Origin getOrigin()
  {
    return _origin;
  }

  void setOrigin(tt_Origin origin)
  {
    _origin = origin;
  }

  private tt_Origin _origin;
}

18. Player

18.1. PlayerSource

// Interface for getting player info and player pawn.
class tt_PlayerSource abstract
{
  abstract PlayerInfo getInfo();

  abstract PlayerPawn getPawn();

  abstract int getNumber();
}

18.1.1. Mock

class tt_PlayerSourceMock : tt_PlayerSource
{
  static tt_PlayerSourceMock of() { return new("tt_PlayerSourceMock"); }

  mixin tt_Mock;
  override PlayerInfo getInfo()
  {
    if (_mock_getInfo_expectation == NULL)
      _mock_getInfo_expectation = _mock_addExpectation("getInfo");
    ++_mock_getInfo_expectation.called;

    return _mock_getInfo;
  }

  void expect_getInfo(PlayerInfo value, int expected = 1)
  {
    if (_mock_getInfo_expectation == NULL)
      _mock_getInfo_expectation = _mock_addExpectation("getInfo");
    _mock_getInfo_expectation.expected = expected;
    _mock_getInfo_expectation.called = 0;

    _mock_getInfo = value;
  }

  private PlayerInfo _mock_getInfo;
  private tt_Expectation _mock_getInfo_expectation;

  override PlayerPawn getPawn()
  {
    if (_mock_getPawn_expectation == NULL)
      _mock_getPawn_expectation = _mock_addExpectation("getPawn");
    ++_mock_getPawn_expectation.called;

    return _mock_getPawn;
  }

  void expect_getPawn(PlayerPawn value, int expected = 1)
  {
    if (_mock_getPawn_expectation == NULL)
      _mock_getPawn_expectation = _mock_addExpectation("getPawn");
    _mock_getPawn_expectation.expected = expected;
    _mock_getPawn_expectation.called = 0;

    _mock_getPawn = value;
  }

  private PlayerPawn _mock_getPawn;
  private tt_Expectation _mock_getPawn_expectation;

  override int getNumber()
  {
    if (_mock_getNumber_expectation == NULL)
      _mock_getNumber_expectation = _mock_addExpectation("getNumber");
    ++_mock_getNumber_expectation.called;

    return _mock_getNumber;
  }

  void expect_getNumber(int value, int expected = 1)
  {
    if (_mock_getNumber_expectation == NULL)
      _mock_getNumber_expectation = _mock_addExpectation("getNumber");
    _mock_getNumber_expectation.expected = expected;
    _mock_getNumber_expectation.called = 0;

    _mock_getNumber = value;
  }

  private int _mock_getNumber;
  private tt_Expectation _mock_getNumber_expectation;

}

18.2. PlayerSourceImpl

// Implements tt_PlayerSource by returning player by player number.
class tt_PlayerSourceImpl : tt_PlayerSource
{
  static tt_PlayerSourceImpl of(int playerNumber)
  {
    let result           = new ("tt_PlayerSourceImpl");
    result._playerNumber = playerNumber;
    return result;
  }

  override PlayerInfo getInfo() { return players[_playerNumber]; }

  override PlayerPawn getPawn() { return getInfo().mo; }

  override int getNumber() { return _playerNumber; }

  private int _playerNumber;
}

18.2.1. Test

{
  // Info, unlike pawns, exist even for non-existent players.
  for (int playerNumber = 0; playerNumber < MAXPLAYERS; ++playerNumber)
  {
    let source = tt_PlayerSourceImpl.of(playerNumber);
    let info   = source.getInfo();
    let note   = "tt_PlayerSourceImpl: player info (%d) must be not NULL";

    it(string.format(note, playerNumber), Assert(info != NULL));
  }
}
{
  let source = tt_PlayerSourceImpl.of(consolePlayer);
  let pawn   = source.getPawn();
  let note   = "tt_PlayerSourceImpl: must get main player (%d) actor";

  it(string.format(note, consolePlayer), AssertNotNull(pawn));
}
{
  let note = "tt_PlayerSourceImpl: other player (%d) must be null";

  // Since tests are run on single-player game, no other players must exist.
  for (int i = 1; i < MAXPLAYERS; ++i)
  {
    int playerNumber = (consolePlayer + i) % MAXPLAYERS;
    let source       = tt_PlayerSourceImpl.of(playerNumber);
    let pawn         = source.getPawn();

    it(string.format(note, playerNumber), AssertNull(pawn));
  }
}

19. Question

19.1. Question

// This interface represents a question.
class tt_Question abstract
{
  abstract bool isRight(string answer);

  abstract string getDescription();

  abstract string getHintFor(string answer);
}

19.1.1. Mock

class tt_QuestionMock : tt_Question
{
  static tt_QuestionMock of() { return new("tt_QuestionMock"); }

  mixin tt_Mock;
  override bool isRight(string answer)
  {
    if (_mock_isRight_expectation == NULL)
      _mock_isRight_expectation = _mock_addExpectation("isRight");
    ++_mock_isRight_expectation.called;

    return _mock_isRight;
  }

  void expect_isRight(bool value, int expected = 1)
  {
    if (_mock_isRight_expectation == NULL)
      _mock_isRight_expectation = _mock_addExpectation("isRight");
    _mock_isRight_expectation.expected = expected;
    _mock_isRight_expectation.called = 0;

    _mock_isRight = value;
  }

  private bool _mock_isRight;
  private tt_Expectation _mock_isRight_expectation;

  override string getDescription()
  {
    if (_mock_getDescription_expectation == NULL)
      _mock_getDescription_expectation = _mock_addExpectation("getDescription");
    ++_mock_getDescription_expectation.called;

    return _mock_getDescription;
  }

  void expect_getDescription(string value, int expected = 1)
  {
    if (_mock_getDescription_expectation == NULL)
      _mock_getDescription_expectation = _mock_addExpectation("getDescription");
    _mock_getDescription_expectation.expected = expected;
    _mock_getDescription_expectation.called = 0;

    _mock_getDescription = value;
  }

  private string _mock_getDescription;
  private tt_Expectation _mock_getDescription_expectation;

  override string getHintFor(string answer)
  {
    if (_mock_getHintFor_expectation == NULL)
      _mock_getHintFor_expectation = _mock_addExpectation("getHintFor");
    ++_mock_getHintFor_expectation.called;

    return _mock_getHintFor;
  }

  void expect_getHintFor(string value, int expected = 1)
  {
    if (_mock_getHintFor_expectation == NULL)
      _mock_getHintFor_expectation = _mock_addExpectation("getHintFor");
    _mock_getHintFor_expectation.expected = expected;
    _mock_getHintFor_expectation.called = 0;

    _mock_getHintFor = value;
  }

  private string _mock_getHintFor;
  private tt_Expectation _mock_getHintFor_expectation;

}

19.2. FallbackQuestion

class tt_FallbackQuestion : tt_Question
{
  static tt_FallbackQuestion of() { return new("tt_FallbackQuestion"); }

  override bool isRight(string answer) { return false; }

  override string getDescription()
  {
    return StringTable.localize("TT_FALLBACK_QUESTION");
  }

  override string getHintFor(string answer) { return getDescription(); }
}

19.3. Match

// Implements tt_Question. The answer is right for this kind of question if it
// matches the string contained in this question.
class tt_Match : tt_Question
{
  static tt_Match of(string answer, string description)
  {
    let result = new("tt_Match");
    result._answer      = answer;
    result._description = description;
    return result;
  }

  override bool isRight(string answer)
  {
    return (_answer == answer);
  }

  override string getDescription()
  {
    return _description;
  }

  override string getHintFor(string answer)
  {
    return getColoredMatch(_answer, answer);
  }

  private static string getColoredMatch(string origin, string matched)
  {
    string result;

    int originLength  = origin .codePointCount();
    int matchedLength = matched.codePointCount();
    int nChars        = min(originLength, matchedLength);
    int originPos     = 0;
    int matchedPos    = 0;

    for (int i = 0; i < nChars; ++i)
    {
      let [originCode,  nextOriginPos ] = origin .getNextCodePoint(originPos );
      let [matchedCode, nextMatchedPos] = matched.getNextCodePoint(matchedPos);

      int colorCode = (originCode == matchedCode)
        ? tt_su_Ascii.HYPHEN_MINUS // Use the base color.
        : tt_TextColorCodes.WrongAnswer;

      result.appendFormat("\c%c%c", colorCode, matchedCode);

      originPos  = nextOriginPos;
      matchedPos = nextMatchedPos;
    }

    // Everything that is beyond origin is wrong.
    if (matchedLength > originLength)
    {
      result.appendFormat("\c%c%s",
                          tt_TextColorCodes.WrongAnswer,
                          matched.mid(matchedPos));
    }

    return result;
  }

  private string _answer;
  private string _description;
}

20. Settings

20.1. BoolSetting

class tt_BoolSetting abstract
{
  abstract bool isDefined();

  abstract bool get();
}

20.1.1. Mock

class tt_BoolSettingMock : tt_BoolSetting
{
  static tt_BoolSettingMock of() { return new("tt_BoolSettingMock"); }

  mixin tt_Mock;
  override bool isDefined()
  {
    if (_mock_isDefined_expectation == NULL)
      _mock_isDefined_expectation = _mock_addExpectation("isDefined");
    ++_mock_isDefined_expectation.called;

    return _mock_isDefined;
  }

  void expect_isDefined(bool value, int expected = 1)
  {
    if (_mock_isDefined_expectation == NULL)
      _mock_isDefined_expectation = _mock_addExpectation("isDefined");
    _mock_isDefined_expectation.expected = expected;
    _mock_isDefined_expectation.called = 0;

    _mock_isDefined = value;
  }

  private bool _mock_isDefined;
  private tt_Expectation _mock_isDefined_expectation;

  override bool get()
  {
    if (_mock_get_expectation == NULL)
      _mock_get_expectation = _mock_addExpectation("get");
    ++_mock_get_expectation.called;

    return _mock_get;
  }

  void expect_get(bool value, int expected = 1)
  {
    if (_mock_get_expectation == NULL)
      _mock_get_expectation = _mock_addExpectation("get");
    _mock_get_expectation.expected = expected;
    _mock_get_expectation.called = 0;

    _mock_get = value;
  }

  private bool _mock_get;
  private tt_Expectation _mock_get_expectation;

}

20.2. IntSetting

class tt_IntSetting abstract
{
  abstract bool isDefined();

  abstract int get();
}

20.3. FloatSetting

class tt_FloatSetting abstract
{
  abstract bool isDefined();

  abstract double getFloat();
}

20.3.1. Mock

class tt_FloatSettingMock : tt_FloatSetting
{
  static tt_FloatSettingMock of() { return new("tt_FloatSettingMock"); }

  mixin tt_Mock;
  override bool isDefined()
  {
    if (_mock_isDefined_expectation == NULL)
      _mock_isDefined_expectation = _mock_addExpectation("isDefined");
    ++_mock_isDefined_expectation.called;

    return _mock_isDefined;
  }

  void expect_isDefined(bool value, int expected = 1)
  {
    if (_mock_isDefined_expectation == NULL)
      _mock_isDefined_expectation = _mock_addExpectation("isDefined");
    _mock_isDefined_expectation.expected = expected;
    _mock_isDefined_expectation.called = 0;

    _mock_isDefined = value;
  }

  private bool _mock_isDefined;
  private tt_Expectation _mock_isDefined_expectation;

  override double getFloat()
  {
    if (_mock_getFloat_expectation == NULL)
      _mock_getFloat_expectation = _mock_addExpectation("getFloat");
    ++_mock_getFloat_expectation.called;

    return _mock_getFloat;
  }

  void expect_getFloat(double value, int expected = 1)
  {
    if (_mock_getFloat_expectation == NULL)
      _mock_getFloat_expectation = _mock_addExpectation("getFloat");
    _mock_getFloat_expectation.expected = expected;
    _mock_getFloat_expectation.called = 0;

    _mock_getFloat = value;
  }

  private double _mock_getFloat;
  private tt_Expectation _mock_getFloat_expectation;

}

20.4. StringSetting

class tt_StringSetting abstract
{
  abstract bool isDefined();

  abstract string get();
}

20.5. BoolCvar

// Provides access to a user or server bool Cvar.
class tt_BoolCvar : tt_BoolSetting
{
  static tt_BoolCvar of(tt_PlayerSource playerSource, string name)
  {
    let result = new("tt_BoolCvar");
    result._cvar = Cvar.getCvar(name, playerSource.getInfo());
    if (result._cvar != NULL && result._cvar.getRealType() != Cvar.CVAR_Bool)
      throwAbortException("%s Cvar is not bool", name);
    return result;
  }

  override bool isDefined() { return (_cvar != NULL); }
  override bool get()   { return _cvar.getInt();  }

  private Cvar _cvar;
}

20.6. PositiveIntCvar

// Provides access to a user or server bool Cvar. Protects against values < 1.
class tt_PositiveIntCvar : tt_IntSetting
{
  static tt_PositiveIntCvar of(tt_PlayerSource playerSource, string name)
  {
    let result = new("tt_PositiveIntCvar");
    result._cvar = Cvar.getCvar(name, playerSource.getInfo());
    if (result._cvar != NULL && result._cvar.getRealType() != Cvar.CVAR_Int)
      throwAbortException("%s Cvar is not int", name);
    return result;
  }

  override bool isDefined() { return (_cvar != NULL); }
  override int  get()       { return max(1, _cvar.getInt());  }

  private Cvar _cvar;
}

20.7. IntCvar

// Provides access to a user or server bool Cvar.
class tt_IntCvar : tt_IntSetting
{
  static tt_IntCvar of(tt_PlayerSource playerSource, string name)
  {
    let result = new("tt_IntCvar");
    result._cvar = Cvar.getCvar(name, playerSource.getInfo());
    if (result._cvar != NULL && result._cvar.getRealType() != Cvar.CVAR_Int)
      throwAbortException("%s Cvar is not int", name);
    return result;
  }

  override bool isDefined() { return (_cvar != NULL); }
  override int  get()       { return _cvar.getInt();  }

  private Cvar _cvar;
}

20.8. FloatCvar

// Provides access to a user or server float Cvar.
class tt_FloatCvar : tt_FloatSetting
{
  static tt_FloatCvar of(tt_PlayerSource playerSource, string name)
  {
    let result = new("tt_FloatCvar");
    result._cvar = Cvar.getCvar(name, playerSource.getInfo());
    if (result._cvar != NULL && result._cvar.getRealType() != Cvar.CVAR_Float)
      throwAbortException("%s Cvar is not float", name);
    return result;
  }

  override bool   isDefined() { return (_cvar != NULL); }
  override double getFloat()  { return _cvar.getFloat(); }

  private Cvar _cvar;
}

20.9. StringCvar

// Provides access to a user or server string Cvar.
class tt_StringCvar : tt_StringSetting
{
  static tt_StringCvar of(tt_PlayerSource playerSource, string name)
  {
    let result = new("tt_StringCvar");
    result._cvar = Cvar.getCvar(name, playerSource.getInfo());
    if (result._cvar != NULL && result._cvar.getRealType() != Cvar.CVAR_String)
      throwAbortException("%s Cvar is not string", name);
    return result;
  }

  override bool   isDefined() { return (_cvar != NULL); }
  override string get() { return _cvar.getString(); }

  private Cvar _cvar;
}

20.10. RandomCharactersLessonSettings

// Represents settings for tt_RandomCharactersLesson.
class tt_RandomCharactersLessonSettings abstract
{
  abstract int getLessonLength();

  abstract bool isUppercaseLettersEnabled();

  abstract bool isLowercaseLettersEnabled();

  abstract bool isNumbersEnabled();

  abstract bool isPunctuationEnabled();

  abstract bool isSymbolsEnabled();

  abstract bool isCustomCharactersEnabled();

  abstract string getCustomCharacters();
}

20.10.1. RandomCharactersLessonSettingsImpl

// Random Character Lesson configuration
user int    tt_rc_length = 3;
user bool   tt_rc_uppercase_letters_enabled = false;
user bool   tt_rc_lowercase_letters_enabled = true;
user bool   tt_rc_numbers_enabled           = false;
user bool   tt_rc_punctuation_enabled       = false;
user bool   tt_rc_symbols_enabled           = false;
user bool   tt_rc_custom_enabled            = false;
user string tt_rc_custom = "";
// Implements tt_RandomCharactersLessonSettings by returning Cvar contents.
class tt_RandomCharactersLessonSettingsImpl : tt_RandomCharactersLessonSettings
{
  static tt_RandomCharactersLessonSettingsImpl of(tt_PlayerSource playerSource)
  {
    let result = new("tt_RandomCharactersLessonSettingsImpl");

    result._lessonLength       = tt_PositiveIntCvar.of(playerSource,
                                                       "tt_rc_length");
    result._isUppercaseEnabled = tt_BoolCvar.of(playerSource,
                                                "tt_rc_uppercase_letters_enabled");
    result._isLowercaseEnabled = tt_BoolCvar.of(playerSource,
                                                "tt_rc_lowercase_letters_enabled");
    result._isNumbersEnabled   = tt_BoolCvar.of(playerSource,
                                                "tt_rc_numbers_enabled");
    result._isPunctuationEnabled = tt_BoolCvar.of(playerSource,
                                                  "tt_rc_punctuation_enabled");
    result._isSymbolsEnabled     = tt_BoolCvar.of(playerSource,
                                                  "tt_rc_symbols_enabled");
    result._isCustomEnabled      = tt_BoolCvar.of(playerSource,
                                                  "tt_rc_custom_enabled");
    result._customCharacters     = tt_StringCvar.of(playerSource, "tt_rc_custom");

    return result;
  }

  override int  getLessonLength()           { return _lessonLength.get(); }
  override bool isUppercaseLettersEnabled() { return _isUppercaseEnabled.get(); }
  override bool isLowercaseLettersEnabled() { return _isLowercaseEnabled.get(); }
  override bool isNumbersEnabled()          { return _isNumbersEnabled.get(); }
  override bool isPunctuationEnabled()      { return _isPunctuationEnabled.get(); }
  override bool isSymbolsEnabled()          { return _isSymbolsEnabled.get(); }
  override bool isCustomCharactersEnabled() { return _isCustomEnabled.get(); }
  override string getCustomCharacters()     { return _customCharacters.get(); }

  private tt_PositiveIntCvar _lessonLength;
  private tt_BoolCvar   _isUppercaseEnabled;
  private tt_BoolCvar   _isLowercaseEnabled;
  private tt_BoolCvar   _isNumbersEnabled;
  private tt_BoolCvar   _isPunctuationEnabled;
  private tt_BoolCvar   _isSymbolsEnabled;
  private tt_BoolCvar   _isCustomEnabled;
  private tt_StringCvar _customCharacters;
}

21. Stale Marker

21.1. StaleMarker

// This interface provides information when its instance becomes stale.
class tt_StaleMarker abstract
{
  // Update stale status.
  // Attention! Calling this function may change the state of tt_StaleMarker.
  // Returns true if this instance is currently stale.
  abstract bool isStale();
}

21.2. StaleMarkerImpl

// Implements tt_StaleMarker by observing a tt_Clock.
class tt_StaleMarkerImpl : tt_StaleMarker
{
  // Creates an instance of tt_StaleMarkerImpl.
  // clock: dependency, a clock to be observed.
  // updateTicks: in how much ticks this marker becomes stale.
  static tt_StaleMarkerImpl of(tt_Clock clock, int updateTicks = 1)
  {
    let result = new("tt_StaleMarkerImpl");

    result._clock       = clock;

    result._updateTicks = updateTicks;
    result._isEmpty     = true;
    result._oldMoment   = 0;

    return result;
  }

  override bool isStale()
  {
    if (!shouldUpdate()) return false;

    _oldMoment = _clock.getNow();
    _isEmpty   = false;

    return true;
  }

  private bool shouldUpdate() const
  {
    if (_isEmpty) return true;

    int  passed     = _clock.since(_oldMoment);
    bool isObsolete = (passed >= _updateTicks);

    return isObsolete;
  }

  private tt_Clock _clock;

  private int  _updateTicks;
  private bool _isEmpty;
  private int  _oldMoment;
}

21.2.1. Tests

{
  let tag = "tt_StaleMarker: checkFirstRead";
  let env = tt_StaleMarkerImplTestEnvironment.of();
  env.clock.expect_getNow(0);

  bool isStale = env.staleMarker.isStale();
  it(tag .. ": first read: stale", Assert(isStale));
  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_StaleMarker: checkNotYetStale";
  let env = tt_StaleMarkerImplTestEnvironment.of();
  env.clock.expect_getNow(0);

  bool isStale1 = env.staleMarker.isStale();

  env.clock.expect_since(0);
  bool isStale2 = env.staleMarker.isStale();
  it(tag .. ": same tick: not stale", Assert(!isStale2));
  assertSatisfaction(env.getSatisfaction(), tag);
}
{
  let tag = "tt_StaleMarker: checkAlreadyStale";
  let env = tt_StaleMarkerImplTestEnvironment.of();
  env.clock.expect_getNow(0, 2);

  bool isStale1 = env.staleMarker.isStale();

  env.clock.expect_since(1);
  bool isStale2 = env.staleMarker.isStale();
  it(tag .. ": new tick: stale", Assert(isStale2));
  assertSatisfaction(env.getSatisfaction(), tag);
}
class tt_StaleMarkerImplTestEnvironment
{
  static tt_StaleMarkerImplTestEnvironment of()
  {
    let result = new("tt_StaleMarkerImplTestEnvironment");
    result.clock = tt_ClockMock.of();
    result.staleMarker = tt_StaleMarkerImpl.of(result.clock, 1);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    return clock.getSatisfaction();
  }

  tt_ClockMock   clock;
  tt_StaleMarker staleMarker;
}

22. Strings

22.1. Strings

// Represents a set of strings.
class tt_Strings
{
  static tt_Strings of()
  {
    let result = new("tt_Strings");
    return result;
  }

  static tt_Strings ofOne(String s)
  {
    let result = new("tt_Strings");
    result.add(s);
    return result;
  }

  uint size() const
  {
    return _strings.size();
  }

  string at(uint i) const
  {
    return _strings[i];
  }

  bool contains(String str) const
  {
    uint foundIndex = _strings.Find(str);
    bool isFound    = (foundIndex != size());

    return isFound;
  }

  void add(String str)
  {
    _strings.push(str);
  }

  private Array<String> _strings;
}

22.1.1. Test

{
  let strings = tt_Strings.of();
  let size    = strings.size();

  it("tt_Strings: New Strings is empty", AssertEval(size, "==", 0));
}
{
  let strings = tt_Strings.of();
  let str     = "a";

  strings.add(str);
  let size = strings.size();

  it("tt_Strings: Element must be added", AssertEval(size, "==", 1));
  it("tt_Strings: Element must be the same", Assert(strings.at(0) == str));
}

23. Target

23.1. Target

// Represents an attack target.
class tt_Target
{
  static tt_Target of(Actor a)
  {
    let result = new("tt_Target");

    result._actor = a;

    return result;
  }

  // Get position in game space of this target.
  tt_Origin getPosition() const
  {
    vector3 position = _actor.pos;
    position.z += _actor.height / 2;

    let result = tt_Origin.of(position);

    return result;
  }

  bool isEqual(tt_Target other) const
  {
    return other._actor == _actor;
  }

  Actor getActor() const
  {
    return _actor;
  }

  private Actor _actor;
}

23.2. Targets

// Represent a list of Targets.
class tt_Targets
{
  static tt_Targets of() { return new("tt_Targets"); }

  // Returns a target in this list.
  tt_Target at(uint index) const { return _targets[index]; }

  // Returns a number of targets in this list.
  uint size() const { return _targets.size(); }

  // Returns true if this target list contains a target with the specified id.
  bool contains(tt_Target target) const { return find(target) != size(); }

  bool isEmpty() const { return (size() == 0); }

  // Adds a target to this list.
  void add(tt_Target target) { _targets.push(target); }

  // Searches for a target with a particular id.
  // Returns index on success, the total number of targets on failure.
  private uint find(tt_Target target) const
  {
    uint nTargets = size();
    for (uint i = 0; i < nTargets; ++i)
      if (_targets[i].isEqual(target)) return i;
    return nTargets;
  }

  private Array<tt_Target> _targets;
}

23.3. TargetSource

// This interface represents a source of targets.
// See: tt_Target.
class tt_TargetSource abstract
{
  abstract tt_Targets getTargets();
}

23.3.1. Mock

class tt_TargetSourceMock : tt_TargetSource
{
  static tt_TargetSourceMock of() { return new("tt_TargetSourceMock"); }

  mixin tt_Mock;
  override tt_Targets getTargets()
  {
    if (_mock_getTargets_expectation == NULL)
      _mock_getTargets_expectation = _mock_addExpectation("getTargets");
    ++_mock_getTargets_expectation.called;

    return _mock_getTargets;
  }

  void expect_getTargets(tt_Targets value, int expected = 1)
  {
    if (_mock_getTargets_expectation == NULL)
      _mock_getTargets_expectation = _mock_addExpectation("getTargets");
    _mock_getTargets_expectation.expected = expected;
    _mock_getTargets_expectation.called = 0;

    _mock_getTargets = value;
  }

  private tt_Targets _mock_getTargets;
  private tt_Expectation _mock_getTargets_expectation;

}

23.4. TargetRadar

// Implements tt_TargetSource by scanning the world around the
// supplied origin for actors suitable to be targets.
class tt_TargetRadar : tt_TargetSource
{
  static tt_TargetRadar of(tt_OriginSource originSource)
  {
    let result = new("tt_TargetRadar");

    result._originSource = originSource;

    return result;
  }

  override tt_Targets getTargets()
  {
    let result = tt_Targets.of();

    let origin = _originSource.getOrigin().getVector();

    let iterator = ThinkerIterator.Create("Actor", Thinker.STAT_DEFAULT);
    Actor a;
    while (a = Actor(iterator.Next()))
    {
      if (tt_Math.isInEffectiveRange(a.pos, origin) && isSuitableForTargeting(a))
      {
        result.add(tt_Target.of(a));
      }
    }

    return result;
  }

  private static bool isSuitableForTargeting(Actor a)
  {
    bool isMonster    = a.bIsMonster;
    bool isAlive      = (a.Health > 0);
    bool isFriendly   = a.bFriendly;
    bool isMissile    = a.bMissile;
    bool isDamageable = !a.bNoDamage;
    bool isNoDamage   = (a.Damage == 0);
    bool isMissileSuitable = false;
    bool isSuitable   = (  (  (isMonster && isDamageable)
                           || (isMissile && !isNoDamage && isMissileSuitable)
                           )
                        && isAlive
                        && !isFriendly
                        );

    return isSuitable;
  }

  private tt_OriginSource _originSource;
}

23.4.1. Test

{
  let tag = "tt_TargetRadar: checkActorsAround";
  let env = tt_TargetRadarTestEnvironment.of();

  Array<Actor> actors =
  {
    spawn("DoomImp", ( 5,  0,  0)),
    spawn("DoomImp", (-5,  0,  0)),
    spawn("DoomImp", ( 0,  5,  0)),
    spawn("DoomImp", ( 0, -5,  0)),
    spawn("DoomImp", ( 0,  0,  5)),
    spawn("DoomImp", ( 0,  0, -5))
  };

  env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

  let targets  = env.targetRadar.getTargets();
  uint nActors = actors.size();
  for (uint i = 0; i < nActors; ++i)
  {
    let a = tt_Target.of(actors[i]);
    it(string.format(tag .. ": actor %d is present in list", i),
       Assert(targets.contains(a)));
  }

  assertSatisfaction(env.originSource.getSatisfaction(), tag);
  cleanUpSpawned();
}
{
  let tag = "tt_TargetRadar: checkDistantActor";
  let env = tt_TargetRadarTestEnvironment.of();

  env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

  let distantActor  = spawn("DoomImp", (1000, 0, 0));
  let distantTarget = tt_Target.of(distantActor);
  let targets       = env.targetRadar.getTargets();

  it(tag .. ": distant actor is not in list",
     AssertFalse(targets.contains(distantTarget)));

  assertSatisfaction(env.originSource.getSatisfaction(), tag);
  cleanUpSpawned();
}
{
  let tag = "tt_TargetRadar: checkNonLivingActor";
  let env = tt_TargetRadarTestEnvironment.of();

  env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

  let nonLiving       = spawn("Medikit", (1, 0, 0));
  let targets         = env.targetRadar.getTargets();
  let nonLivingTarget = tt_Target.of(nonLiving);

  it(tag .. ": non-living actor is not in list",
     AssertFalse(targets.contains(nonLivingTarget)));

  assertSatisfaction(env.originSource.getSatisfaction(), tag);
  cleanUpSpawned();
}
{
  let tag = "tt_TargetRadar: checkDeadActor";
  let env = tt_TargetRadarTestEnvironment.of();

  env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

  let deadActor  = spawnDead("DoomImp", (1, 0, 0));
  let targets    = env.targetRadar.getTargets();
  let deadTarget = tt_Target.of(deadActor);

  it(tag .. ": dead actor is not in list",
     AssertFalse(targets.contains(deadTarget)));

  assertSatisfaction(env.originSource.getSatisfaction(), tag);
  cleanUpSpawned();
}
class tt_TargetRadarTestEnvironment
{
  static tt_TargetRadarTestEnvironment of()
  {
    let result = new("tt_TargetRadarTestEnvironment");
    result.originSource = tt_OriginSourceMock.of();
    result.targetRadar  = tt_TargetRadar.of(result.originSource);
    return result;
  }

  tt_OriginSourceMock originSource;
  tt_TargetRadar      targetRadar;
}

23.5. DeathReporter

// Implements tt_TargetSource by collecting reports of
// dead things as a list of DisabledTargets.
class tt_DeathReporter : tt_TargetSource
{
  static tt_DeathReporter of()
  {
    let result = new("tt_DeathReporter");

    result._targets = tt_Targets.of();

    return result;
  }

  void reportDead(Actor thing)
  {
    let newDisabled = tt_Target.of(thing);
    _targets.add(newDisabled);
  }

  override tt_Targets getTargets()
  {
    let result = _targets;
    _targets = tt_Targets.of();
    return result;
  }

  private tt_Targets _targets;
}
{
  let _deathReporter = tt_DeathReporter.of();
  let targetsBefore  = _deathReporter.getTargets();
  it("tt_DeathReporter: No targets before reporting",
    AssertEval(targetsBefore.size(), "==", 0));

  let something = spawn("DoomImp", (0, 0, 0));
  _deathReporter.reportDead(something);
  let targetsAfter = _deathReporter.getTargets();
  it("tt_DeathReporter: Single target after reporting",
    AssertEval(targetsAfter.size(), "==", 1));

  let targetsAfterAfter = _deathReporter.getTargets();
  it("tt_DeathReporter: No new targets",
    AssertEval(targetsAfterAfter.size(), "==", 0));

  cleanUpSpawned();
}

23.6. TargetSourceCache

// Implements tt_TargetSource by calling other tt_TargetSource only
// if previously received target is stale.
class tt_TargetSourceCache : tt_TargetSource
{
  static tt_TargetSourceCache of(tt_TargetSource targetSource,
                                 tt_StaleMarker staleMarker)
  {
    let result = new("tt_TargetSourceCache");

    result._targetSource = targetSource;
    result._staleMarker  = staleMarker;

    return result;
  }

  override tt_Targets getTargets()
  {
    if (_staleMarker.isStale())
    {
      _targets = _targetSource.getTargets();
    }

    return _targets;
  }

  private tt_TargetSource _targetSource;
  private tt_StaleMarker  _staleMarker;

  private tt_Targets _targets;
}

23.7. TargetSourcePruner

// Implements tt_TargetSource by pruning other tt_TargetSource from
// targets with null actors.
class tt_TargetSourcePruner : tt_TargetSource
{
  static tt_TargetSourcePruner of(tt_TargetSource targetSource)
  {
    let result = new("tt_TargetSourcePruner");
    result._targetSource = targetSource;
    return result;
  }

  override tt_Targets getTargets()
  {
    let targets = _targetSource.getTargets();

    tt_Targets result = tt_Targets.of();

    uint nTargets = targets.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      tt_Target target = targets.at(i);
      if (target.getActor() != NULL)
      {
        result.add(target);
      }
    }

    return result;
  }

  private tt_TargetSource _targetSource;
}

24. Target Widget

24.1. TargetWidget

// Represents a target displayed on the screen.
class tt_TargetWidget
{
  static tt_TargetWidget of(tt_KnownTarget target, vector2 position)
  {
    let result = new("tt_TargetWidget");

    result._target   = target;
    result._position = position;

    return result;
  }

  tt_KnownTarget getTarget() const
  {
    return _target;
  }

  vector2 getPosition() const
  {
    return _position;
  }

  double getDistanceTo(vector3 other)
  {
    let worldPosition = _target.getTarget().getPosition().getVector();
    let distance      = (worldPosition - other).Length();

    return distance;
  }

  void setPosition(vector2 position)
  {
    _position = position;
  }

  private tt_KnownTarget _target;
  private vector2        _position;
}

24.2. TargetWidgets

// Represents a list of target widgets.
class tt_TargetWidgets
{
  static tt_TargetWidgets of() { return new("tt_TargetWidgets"); }

  // Returns a target in this list.
  tt_TargetWidget at(uint index) const { return _widgets[index]; }

  // Returns a number of targets in this list.
  uint size() const { return _widgets.size(); }

  tt_TargetWidget find(tt_Target id) const
  {
    foreach (widget : _widgets)
      if (widget.getTarget().getTarget().isEqual(id)) return widget;

    return NULL;
  }

  bool containsWidget(tt_TargetWidget widget) const
  {
    foreach (widgetItem : _widgets)
      if (widget == widgetItem) return true;

    return false;
  }

  tt_TargetWidgets copy() const
  {
    let result = tt_TargetWidgets.of();
    result._widgets.Reserve(size());
    result._widgets.Copy(_widgets);

    return result;
  }

  // Adds a target to this list.
  void add(tt_TargetWidget widget) { _widgets.push(widget); }

  void set(uint i, tt_TargetWidget widget)  { _widgets[i] = widget; }

  private Array<tt_TargetWidget> _widgets;
}

24.3. TargetWidgetSource

// This interface provides a source of target widgets.
class tt_TargetWidgetSource abstract
{
  // Get a list of target widgets.
  // Returns a list of target widgets.
  ui abstract tt_TargetWidgets getWidgets(RenderEvent event);
}

24.4. Projector

// Implements TargetWidgetSource by accumulating Target Widgets.
// Attention: this class has no tests. Modifications must be checked manually.
class tt_Projector : tt_TargetWidgetSource
{
  static tt_Projector of(tt_KnownTargetSource knownTargetSource,
                         tt_PlayerSource playerSource)
  {
    let result = new("tt_Projector");

    result._knownTargetSource = knownTargetSource;
    result._playerSource      = playerSource;
    result._cvarRenderer      = tt_IntCvar.of(playerSource, "vid_rendermode");

    result._glProjection = new("tt_le_GlScreen");
    result._swProjection = new("tt_le_SwScreen");

    return result;
  }

  override tt_TargetWidgets getWidgets(RenderEvent event)
  {
    let targets = _knownTargetSource.getTargets();
    let info    = _playerSource.getInfo();
    let result  = tt_TargetWidgets.of();

    prepareProjection();

    _projection.CacheResolution();
    _projection.CacheFov(info.fov);
    _projection.OrientForRenderOverlay(event);
    _projection.BeginProjection();

    tt_le_Viewport viewport;
    viewport.FromHud();

    uint nTargets = targets.size();
    for (uint i = 0; i < nTargets; ++i)
    {
      let target = targets.at(i);

      let targetActor = target.getTarget().getActor();
      if (targetActor == NULL)
      {
        continue;
      }

      vector3 targetPos = target.getTarget().getPosition().getVector();
      vector2 position;
      bool    isPositionSuccessful;
      [position, isPositionSuccessful] = makeDrawPos(targetPos, viewport);

      if (isPositionSuccessful)
      {
        let widget = tt_TargetWidget.of(target, position);
        result.add(widget);
      }
    }

    return result;
  }

  // Calculates the screen position (draw position).
  // Returns screen position and success flag.
  private ui vector2, bool makeDrawPos(vector3 targetPos, tt_le_Viewport viewport)
  {
    _projection.ProjectWorldPos(targetPos);

    if(!_projection.IsInFront())
    {
      return (0, 0), false;
    }

    vector2 drawPos = viewport.SceneToWindow(_projection.ProjectToNormal());

    return drawPos, true;
  }

  private void prepareProjection()
  {
    if(_cvarRenderer.isDefined())
    {
      switch (_cvarRenderer.get())
      {
      case 0:
      case 1:  _projection = _swProjection; break;
      default: _projection = _glProjection; break;
      }
    }
    else // cannot get render mode.
    {
      _projection = _glProjection;
    }
  }

  private tt_KnownTargetSource _knownTargetSource;
  private tt_PlayerSource      _playerSource;

  private tt_le_ProjScreen _projection;
  private tt_le_GlScreen   _glProjection;
  private tt_le_SwScreen   _swProjection;

  private transient bool _isInitialized;

  private tt_IntCvar _cvarRenderer;
}

24.5. SorterByDistance

// Implements TargetWidgetSource by taking another TargetWidgetSource
// and sorting the widgets from it by a distance to origin from OriginSource.
//
// Sorting algorithm: merge sort
// https://en.wikipedia.org/wiki/Merge_sort
class tt_SorterByDistance : tt_TargetWidgetSource
{
  static tt_SorterByDistance of(tt_TargetWidgetSource targetWidgetSource,
                                tt_OriginSource originSource)
  {
    let result = new("tt_SorterByDistance");

    result._targetWidgetSource = targetWidgetSource;
    result._originSource       = originSource;

    return result;
  }

  override tt_TargetWidgets getWidgets(RenderEvent event)
  {
    let widgets = _targetWidgetSource.getWidgets(event);
    let origin  = _originSource.getOrigin().getVector();
    let sorted  = sort(widgets, origin);

    return sorted;
  }

  static tt_TargetWidgets sort(tt_TargetWidgets widgets, vector3 origin)
  {
    let result    = widgets;
    let workplace = widgets.copy();

    TopDownSplitMerge(workplace, 0, widgets.size(), result, origin);

    return result;
  }

  private static void TopDownSplitMerge(tt_TargetWidgets B,
                                        uint             begin,
                                        uint             end,
                                        tt_TargetWidgets A,
                                        vector3          origin)
  {
    if ((end - begin) < 2) // if run size == 1 consider it sorted
    {
      return;
    }

    // split the run longer than 1 item into halves
    uint middle = (end + begin) / 2; // mid point

    // recursively sort both runs from array A into B
    TopDownSplitMerge(A, begin,  middle, B, origin); // sort the left  run
    TopDownSplitMerge(A, middle,    end, B, origin); // sort the right run

    // merge the resulting runs from array B into A
    TopDownMerge(B, begin, middle, end, A, origin);
  }

  private static void TopDownMerge(tt_TargetWidgets A,
                                   uint             begin,
                                   uint             middle,
                                   uint             end,
                                   tt_TargetWidgets B,
                                   vector3          origin)
  {
    uint i = begin;
    uint j = middle;

    // While there are elements in the left or right runs...
    for (uint k = begin; k < end; ++k)
    {
      // If left run head exists and is >= existing right run head.
      if (i < middle
          && (j >= end || A.at(i).getDistanceTo(origin) >= A.at(j).getDistanceTo(origin)))
      {
        B.set(k, A.at(i));
        ++i;
      }
      else
      {
        B.set(k, A.at(j));
        ++j;
      }
    }
  }

  private tt_TargetWidgetSource _targetWidgetSource;
  private tt_OriginSource       _originSource;
}
{
  let tag = "tt_SorterByDistance : checkEmpty";

  let before = tt_TargetWidgets.of();
  let origin = tt_Origin.of((0, 0, 0));
  let after  = tt_SorterByDistance.sort(before, origin.getVector());

  it(tag .. ": empty collection must remain empty",
     AssertEval(after.size(), "==", 0));
}
{
  let tag = "tt_SorterByDistance : checkSorted";

  let origin = tt_Origin.of((0, 0, 0));
  let before = tt_TargetWidgets.of();
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 2))));
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 1))));
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 0))));

  it(tag .. ": Before: sorted",
     Assert(tt_SorterByDistanceTest.isSorted(before, origin.getVector())));

  let after = tt_SorterByDistance.sort(before, origin.getVector());

  it(tag .. ": size of collection must the same",
     AssertEval(after.size(), "==", before.size()));
  it(tag .. ": contains same elements",
     Assert(tt_SorterByDistanceTest.isSameElements(before, after)));
  it(tag .. ": after: sorted",
     Assert(tt_SorterByDistanceTest.isSorted(after, origin.getVector())));

  cleanUpSpawned();
}
{
  let tag = "tt_SorterByDistance : checkReverse";

  let origin = tt_Origin.of((0, 0, 0));
  let before = tt_TargetWidgets.of();
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 0))));
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 1))));
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 2))));

  it(tag .. ": before: not sorted",
     Assert(!tt_SorterByDistanceTest.isSorted(before, origin.getVector())));

  let after = tt_SorterByDistance.sort(before, origin.getVector());

  it(tag .. ": size of collection must the same",
     AssertEval(after.size(), "==", before.size()));
  it(tag .. ": contains same elements",
     Assert(tt_SorterByDistanceTest.isSameElements(before, after)));
  it(tag .. ": after: sorted",
     Assert(tt_SorterByDistanceTest.isSorted(after, origin.getVector())));

  cleanUpSpawned();
}
{
  let tag = "tt_SorterByDistance : middle";

  let origin = tt_Origin.of((0, 0, 0));
  let before = tt_TargetWidgets.of();
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 1))));
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 2))));
  before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 0))));

  it(tag .. ": before: not sorted",
     Assert(!tt_SorterByDistanceTest.isSorted(before, origin.getVector())));

  let after = tt_SorterByDistance.sort(before, origin.getVector());

  it(tag .. ": size of collection must the same",
     AssertEval(after.size(), "==", before.size()));
  it(tag .. ": contains same elements",
     Assert(tt_SorterByDistanceTest.isSameElements(before, after)));
  it(tag .. ": after: sorted",
     Assert(tt_SorterByDistanceTest.isSorted(after, origin.getVector())));

  cleanUpSpawned();
}
class tt_SorterByDistanceTest
{
  static bool isSorted(tt_TargetWidgets targetWidgets, vector3 origin)
  {
    uint nWidgets = targetWidgets.size();

    if (nWidgets < 2) return true;

    for (uint i = 1; i < nWidgets; ++i)
    {
      let prevDistance = targetWidgets.at(i - 1).getDistanceTo(origin);
      let thisDistance = targetWidgets.at(i    ).getDistanceTo(origin);

      if (prevDistance < thisDistance) return false;
    }

    return true;
  }

  static bool isSameElements(tt_TargetWidgets t1, tt_TargetWidgets t2)
  {
    uint nWidgets1 = t1.size();
    uint nWidgets2 = t2.size();

    if (nWidgets1 != nWidgets2) return false;

    for (uint i = 0; i < nWidgets1; ++i)
      if (!t2.containsWidget(t1.at(i))) return false;

    for (uint i = 0; i < nWidgets2; ++i)
      if (!t1.containsWidget(t2.at(i))) return false;

    return true;
  }

  static tt_TargetWidget createAtPosition(Actor anActor)
  {
    let target      = tt_Target.of(anActor);
    let question    = tt_QuestionMock.of();
    let knownTarget = tt_KnownTarget.of(target, question);
    let widget      = tt_TargetWidget.of(knownTarget, (0, 0));

    return widget;
  }
}

24.6. TargetWidgetRegistry

// Implements TargetWidgetSource by storing target widgets, getting
// new widgets from the source, and updating the coordinates of the widgets that
// are already registered.
class tt_TargetWidgetRegistry : tt_TargetWidgetSource
{
  static tt_TargetWidgetRegistry of(tt_TargetWidgetSource source)
  {
    let result = new("tt_TargetWidgetRegistry");

    result._source   = source;
    result._registry = tt_TargetWidgets.of();

    return result;
  }

  override tt_TargetWidgets getWidgets(RenderEvent event)
  {
    let widgets     = _source.getWidgets(event);
    let newRegistry = tt_TargetWidgets.of();

    uint nWidgets = widgets.size();
    for (uint i = 0; i < nWidgets; ++i)
    {
      let widget   = widgets.at(i);
      let target   = widget.getTarget().getTarget();
      let existing = _registry.find(target);

      if (existing == NULL)
      {
        newRegistry.add(widget);
      }
      else
      {
        newRegistry.add(existing);
        let newPosition      = widget.getPosition();
        let existingPosition = existing.getPosition();
        let middle           = (newPosition * 0.3 + existingPosition * 0.7);
        existing.setPosition(middle);
      }
    }

    // Widgets that are not new or not updated are thrown away.
    _registry = newRegistry;

    return _registry;
  }

  private tt_TargetWidgetSource _source;
  private tt_TargetWidgets      _registry;
}

25. Colors

Colors can be set externally:

  • Copy tt_colors.zs,
  • In the copy, set new colors,
  • Load the copy after Typist.pk3 (-file Typist.pk3 tt_colors.zs).
class tt_TextColors
{
  enum _
  {
    Base = Font.CR_WHITE,
  }
}

// See https://zdoom.org/wiki/Print#Colors for possible colors.
class tt_TextColorCodes
{
  enum _
  {
    WrongAnswer = tt_su_Ascii.LATIN_SMALL_LETTER_G, // red
  }
}

class tt_RgbColors
{
  enum _
  {
    Dim      = 0x000000, // Dims the background for text boxes.

    Question = 0xF4AF31, // Base color for question boxes.

    AnswerCombat      = 0xFF0000, // Base color for answer boxes in Combat mode.
    AnswerExploration = 0x999999, // Base color for answer boxes in Exploration mode.
  }
}

26. View

26.1. View

// This interface represents a view - something that displays something.
class tt_View abstract
{
  ui abstract void draw(RenderEvent event);
}

26.2. Views

// Implements View by allowing several Views to be drawn.
class tt_Views : tt_View
{
  static tt_Views of(Array<tt_View> views)
  {
    let result = new("tt_Views");
    result._views.move(views);
    return result;
  }

  override void draw(RenderEvent event)
  {
    foreach (view : _views)
      view.draw(event);
  }

  private Array<tt_View> _views;
}

26.3. Frame

class tt_Frame : tt_View
{
  static tt_Frame of(tt_ModeSource modeSource)
  {
    let result = new("tt_Frame");
    result._modeSource = modeSource;
    result._alphaInterpolator = tt_DoubleInterpolator.of();
    return result;
  }

  override void draw(RenderEvent event)
  {
    double destination = (_modeSource.getMode() == tt_Mode.Combat) ? 1.0 : 0.0;

    _alphaInterpolator.reset(destination, 0.1);
    // TODO: untie from framerate?
    _alphaInterpolator.update();

    double alpha = _alphaInterpolator.getValue();

    if (alpha ~== 0.0) return;

    let frameTexture = TexMan.checkForTexture("tt-frame", TexMan.Type_Any);
    int screenWidth  = Screen.getWidth();
    int screenHeight = Screen.getHeight();
    int frameWidth   = screenWidth / 32;

    Screen.drawTexture( frameTexture
                      , NOT_ANIMATED
                      , 0
                      , 0
                      , DTA_DestWidth     , frameWidth
                      , DTA_DestHeight    , screenHeight
                      , DTA_KeepRatio     , true
                      , DTA_VirtualWidth  , screenWidth
                      , DTA_VirtualHeight , screenHeight
                      , DTA_Alpha         , alpha
                      );

    Screen.drawTexture( frameTexture
                      , NOT_ANIMATED
                      , screenWidth - frameWidth
                      , 0
                      , DTA_FlipX         , true
                      , DTA_DestWidth     , frameWidth
                      , DTA_DestHeight    , screenHeight
                      , DTA_KeepRatio     , true
                      , DTA_VirtualWidth  , screenWidth
                      , DTA_VirtualHeight , screenHeight
                      , DTA_Alpha         , alpha
                      );
  }

  const NOT_ANIMATED = 0; // false

  private tt_ModeSource _modeSource;
  private tt_DoubleInterpolator _alphaInterpolator;
}
class tt_DoubleInterpolator
{
  static tt_DoubleInterpolator of() { return new("tt_DoubleInterpolator"); }

  ui void update()
  {
    _currentValue = (_destination > _currentValue)
      ? min(_destination, _currentValue + _step)
      : max(_destination, _currentValue - _step);
  }

  ui double getValue() const { return _currentValue; }

  ui void reset(double destination, double step)
  {
    _destination = destination;
    _step = step;
  }

  private ui double _destination;
  private ui double _currentValue;
  private ui double _step;
}

26.4. ConditionalView

// Implements a view by taking another view, and calling draw()
// only if conditions are met.
//
// The list of conditions:
// - not in a menu
// - automap is closed
//
// Attention! This class reads data from global scope.
class tt_ConditionalView : tt_View
{
  static tt_ConditionalView of(tt_View view)
  {
    let result = new("tt_ConditionalView");
    result._view = view;
    return result;
  }

  override void draw(RenderEvent event)
  {
    if (!menuActive && !automapActive) _view.draw(event);
  }

  private tt_View _view;
}

26.5. InfoPanel

// Implements View by collecting and displaying various information:
// - game mode
// - list of commands
// - current input string
// - several targets
class tt_InfoPanel : tt_View
{
  static tt_InfoPanel of(tt_ModeSource        modeSource,
                         tt_AnswerSource      answerSource,
                         tt_Activatable       activatable,
                         tt_KnownTargetSource knownTargetSource,
                         tt_IntSetting        scaleSetting)
  {
    let result = new("tt_InfoPanel");

    result._modeSource   = modeSource;
    result._answerSource = answerSource;
    result._activatable  = activatable;
    result._targetSource = knownTargetSource;
    result._scaleSetting = scaleSetting;

    return result;
  }

  override void draw(RenderEvent _)
  {
    let targets      = _targetSource.getTargets();
    let targetCount  = targets.size();
    let commands     = _activatable.getCommands();
    let commandCount = commands.size();
    if (targetCount == 0 && commandCount == 0) return;

    int scale       = _scaleSetting.get();
    int screenWidth = Screen.getWidth();
    int halfScreen  = screenWidth / 2;
    int y           = int(Y_START + (VERTICAL_MARGIN * scale));
    let answer      = _answerSource.getAnswer().getString();
    int color       = tt_Drawing.getColorForMode(_modeSource.getMode());

    int xRight = halfScreen;
    int xLeft  = halfScreen;

    // 1. Draw the first target in the center.
    if (targetCount > 0)
    {
      let    question       = targets.at(0).getQuestion();
      string questionString = question.getDescription();
      string hintedAnswer   = question.getHintFor(answer);
      tt_Drawing.drawTarget((halfScreen, y),
                            questionString,
                            hintedAnswer,
                            scale,
                            tt_Drawing.CENTERED,
                            color);

      // TODO: make drawTarget return target width?
      int targetWidth = tt_Drawing.getWidthForTarget(questionString,
                                                     hintedAnswer,
                                                     scale);
      xRight += targetWidth / 2 + HORIZONTAL_MARGIN + tt_Drawing.FRAME;
      xLeft  -= targetWidth / 2 + HORIZONTAL_MARGIN - tt_Drawing.FRAME;
    }

    // 2. Draw the targets to the right while there is space.
    uint i = 1;
    for (; i < targetCount; ++i)
    {
      let    question       = targets.at(i).getQuestion();
      string questionString = question.getDescription();
      string hintedAnswer   = question.getHintFor(answer);
      int    targetWidth    = tt_Drawing.getWidthForTarget(questionString,
                                                           hintedAnswer,
                                                           scale);

      if (xRight + targetWidth > screenWidth) break;

      tt_Drawing.drawTarget((xRight, y),
                            questionString,
                            hintedAnswer,
                            scale,
                            tt_Drawing.NOT_CENTERED,
                            color);

      xRight += targetWidth + HORIZONTAL_MARGIN;
    }

    // 3. Draw the commands to the left while there is space.
    for (uint c = 0; c < commandCount; ++c)
    {
      let  command      = commands.at(c);
      let  question     = tt_Match.of(command, command);
      let  hintedAnswer = question.getHintFor(answer);
      int  targetWidth  = tt_Drawing.getWidthForTarget(command, hintedAnswer, scale);
      bool isCentered   = targetCount == 0 && c == 0;
      let  position     = isCentered ? (halfScreen, y) : (xLeft - targetWidth, y);

      if (xLeft - targetWidth < 0) break;

      tt_Drawing.drawTarget(position,
                            command,
                            hintedAnswer,
                            scale,
                            isCentered,
                            color);

      xLeft -= targetWidth + HORIZONTAL_MARGIN;
    }

    // 4. Draw the remaining targets to the left while there is space.
    for (; i < targetCount; ++i)
    {
      let    question       = targets.at(i).getQuestion();
      string questionString = question.getDescription();
      string hintedAnswer   = question.getHintFor(answer);
      int    targetWidth    = tt_Drawing.getWidthForTarget(questionString,
                                                           hintedAnswer,
                                                           scale);

      if (xLeft - targetWidth < 0) break;

      tt_Drawing.drawTarget((xLeft - targetWidth, y),
                            questionString,
                            hintedAnswer,
                            scale,
                            tt_Drawing.NOT_CENTERED,
                            color);

      xLeft -= targetWidth + HORIZONTAL_MARGIN;
    }
  }

  const Y_START = 10;

  const HORIZONTAL_MARGIN = 2;
  const VERTICAL_MARGIN   = 20;

  private tt_ModeSource        _modeSource;
  private tt_AnswerSource      _answerSource;
  private tt_Activatable       _activatable;
  private tt_KnownTargetSource _targetSource;
  private tt_IntSetting        _scaleSetting;
}

26.6. TargetOverlay

// Implement tt_View by getting a list of Target Widgets and drawing them.
class tt_TargetOverlay : tt_View
{
  static tt_TargetOverlay of(tt_TargetWidgetSource targetWidgetSource,
                             tt_AnswerSource       answerSource,
                             tt_IntSetting         scaleSetting,
                             tt_ModeSource         modeSource)
  {
    let result = new("tt_TargetOverlay");

    result._targetWidgetSource = targetWidgetSource;
    result._answerSource       = answerSource;
    result._scaleSetting       = scaleSetting;
    result._modeSource         = modeSource;

    return result;
  }

  override void draw(RenderEvent event)
  {
    let widgets = _targetWidgetSource.getWidgets(event);
    let answer  = _answerSource.getAnswer().getString();
    int mode    = _modeSource.getMode();
    int color   = tt_Drawing.getColorForMode(mode);
    int scale   = _scaleSetting.get();

    uint nWidgets = widgets.size();
    for (uint i = 0; i < nWidgets; ++i)
    {
      let    widget         = widgets.at(i);
      let    question       = widget.getTarget().getQuestion();
      string questionString = question.getDescription();
      string hintedAnswer   = question.getHintFor(answer);
      let    position       = widget.getPosition();

      tt_Drawing.drawTarget(position,
                            questionString,
                            hintedAnswer,
                            scale,
                            CENTERED,
                            color);
    }
  }

  const CENTERED = 1; // true

  private tt_TargetWidgetSource _targetWidgetSource;
  private tt_AnswerSource       _answerSource;
  private tt_IntSetting         _scaleSetting;
  private tt_ModeSource         _modeSource;
}

26.7. Drawing

// Namespace for common drawing functions.
// TODO: clean up this mess.
class tt_Drawing ui
{
  enum _
  {
    NOT_CENTERED = 0,
    CENTERED = 1
  }

  static int getWidthForTarget(String question, string answer, int scale)
  {
    Font fnt         = NewSmallFont;
    int  width       = max(fnt.StringWidth(question), fnt.StringWidth(answer));
    int  borderWidth = makeBorderWidth(width) * scale;
    return borderWidth;
  }

  static void drawTarget(vector2 pos,
                         string  question,
                         string  answer,
                         int     scale,
                         bool    isCentered,
                         int     color)
  {
    Font fnt          = NewSmallFont;
    int  screenWidth  = Screen.GetWidth()  / scale;
    int  screenHeight = Screen.GetHeight() / scale;

    let  position = pos / scale;
    int  height   = fnt.GetHeight();
    int  width    = max(fnt.StringWidth(question), fnt.StringWidth(answer));

    double xStart = isCentered
                  ? (position.x - width / 2)
                  : position.x;
    int x = int(Clamp(xStart,     FRAME, screenWidth  - FRAME - width         ));
    int y = int(Clamp(position.y, FRAME, screenHeight - FRAME * 3 - height * 2));

    drawBoxes(x, y, width, height, screenWidth, screenHeight, color);
    drawText(x, y, height, fnt, question, answer, screenWidth, screenHeight);
  }

  static int getColorForMode(int mode)
  {
    return (mode == tt_Mode.Combat)
      ? tt_RgbColors.AnswerCombat
      : tt_RgbColors.AnswerExploration;
  }

  private static int makeBorderWidth(int width)
  {
    int borderWidth  = FRAME * 2 + width;
    return borderWidth;
  }

  private static void drawBoxes(int x,
                                int y,
                                int width,
                                int lineHeight,
                                int screenWidth,
                                int screenHeight,
                                int color)
  {
    // TODO: replace with null?
    // Texture is necessary for drawing, but in fact it isn't used.
    // The color is specified with DTA_FillColor.
    let dummyTexture = TexMan.CheckForTexture("tt-white", TexMan.Type_Any);

    drawBox(dummyTexture,
            x,
            y,
            width,
            lineHeight,
            screenWidth,
            screenHeight,
            tt_RgbColors.Question);

    int lowerY = y + lineHeight + FRAME * 2;
    drawBox(dummyTexture, x, lowerY, width, lineHeight, screenWidth, screenHeight, color);
  }

  private static void drawBox( TextureID tex
              , int x
              , int y
              , int width
              , int lineHeight
              , int screenWidth
              , int screenHeight
              , int color
              )
  {
    // TODO: replace drawTexture with drawShapeFill
    // Screen.drawShapeFill(Color col, double amount, Shape2D s)

    { // border
      int borderX      = x - FRAME;
      int borderY      = y - FRAME;
      int borderWidth  = makeBorderWidth(width);
      int borderHeight = FRAME * 2 + lineHeight;

      Screen.DrawTexture( tex
                        , NOT_ANIMATED
                        , borderX
                        , borderY
                        , DTA_DestWidth     , borderWidth
                        , DTA_DestHeight    , borderHeight
                        , DTA_FillColor     , color
                        , DTA_KeepRatio     , true
                        , DTA_VirtualWidth  , screenWidth
                        , DTA_VirtualHeight , screenHeight
                        , DTA_Alpha         , BORDER_ALPHA
                        );
    }

    { // background
      int backgroundX      = x - PADDING;
      int backgroundY      = y - PADDING;
      int backgroundWidth  = PADDING * 2 + width;
      int backgroundHeight = PADDING * 2 + lineHeight;

      Screen.DrawTexture( tex
                        , NOT_ANIMATED
                        , backgroundX
                        , backgroundY
                        , DTA_DestWidth     , backgroundWidth
                        , DTA_DestHeight    , backgroundHeight
                        , DTA_FillColor     , tt_RgbColors.Dim
                        , DTA_KeepRatio     , true
                        , DTA_VirtualWidth  , screenWidth
                        , DTA_VirtualHeight , screenHeight
                        , DTA_Alpha         , BACKGROUND_ALPHA
                        );
    }
  }

  private static void drawText( int    x
               , int    y
               , int    height
               , Font   fnt
               , string question
               , string answer
               , int    screenWidth
               , int    screenHeight
               )
  {
    Screen.DrawText( fnt
                   , tt_TextColors.Base
                   , x
                   , y
                   , "$" .. question
                   , DTA_KeepRatio     , true
                   , DTA_VirtualWidth  , screenWidth
                   , DTA_VirtualHeight , screenHeight
                   );
    Screen.DrawText( fnt
                   , tt_TextColors.Base
                   , x
                   , y + height + FRAME * 2
                   , "$" .. answer
                   , DTA_KeepRatio     , true
                   , DTA_VirtualWidth  , screenWidth
                   , DTA_VirtualHeight , screenHeight
                   );
  }

  const BORDER       = 1;
  const PADDING      = 2;
  const FRAME        = BORDER + PADDING;

  const NOT_ANIMATED = 0; // false

  const BORDER_ALPHA     = 0.2;
  const BACKGROUND_ALPHA = 0.2;
}

27. Effect

27.1. Effect

// Interface for any non-play effects.
class tt_Effect abstract
{
  abstract void doEffect();
}

27.1.1. Mock

class tt_EffectMock : tt_Effect
{
  static tt_EffectMock of() { return new("tt_EffectMock"); }

  mixin tt_Mock;
  override void doEffect()
  {
    if (_mock_doEffect_expectation == NULL)
      _mock_doEffect_expectation = _mock_addExpectation("doEffect");
    ++_mock_doEffect_expectation.called;


  }

  void expect_doEffect(int expected = 1)
  {
    if (_mock_doEffect_expectation == NULL)
      _mock_doEffect_expectation = _mock_addExpectation("doEffect");
    _mock_doEffect_expectation.expected = expected;
    _mock_doEffect_expectation.called = 0;


  }


  private tt_Expectation _mock_doEffect_expectation;

}

27.1.2. Effects

class tt_Effects : tt_Effect
{
  static tt_Effects of(Array<tt_Effect> effects)
  {
    let result = new("tt_Effects");
    result._effects.move(effects);
    return result;
  }

  override void doEffect()
  {
    foreach (effect : _effects)
      effect.doEffect();
  }

  private Array<tt_Effect> _effects;
}

27.1.3. Gunner

// Implements tt_Effect by calling other tt_Effect if there is some tt_Origin.
class tt_Gunner : tt_Effect
{
  static tt_Gunner of(tt_OriginSource originSource, tt_Effect effect)
  {
    let result = new("tt_Gunner");
    result._originSource = originSource;
    result._effect       = effect;
    return result;
  }

  override void doEffect()
  {
    if (_originSource.getOrigin() != NULL) _effect.doEffect();
  }

  private tt_OriginSource _originSource;
  private tt_Effect       _effect;
}
  1. Tests
    {
      let tag = "tt_Gunner: null origin";
      let env = tt_GunnerTestEnvironment.of();
    
      env.originSource.expect_getOrigin(NULL);
    
      env.gunner.doEffect();
    
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_Gunner: valid origin";
      let env = tt_GunnerTestEnvironment.of();
    
      let origin = tt_Origin.of((0, 0, 0));
    
      env.originSource.expect_getOrigin(origin);
      env.effect.expect_doEffect();
    
      env.gunner.doEffect();
    
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    
    class tt_GunnerTestEnvironment
    {
      static tt_GunnerTestEnvironment of()
      {
        let result = new("tt_GunnerTestEnvironment");
        result.originSource = tt_OriginSourceMock.of();
        result.effect       = tt_EffectMock.of();
        result.gunner       = tt_Gunner.of(result.originSource, result.effect);
        return result;
      }
    
      tt_Satisfaction getSatisfaction() const
      {
        return originSource.getSatisfaction()
          .add(effect.getSatisfaction());
      }
    
      tt_OriginSourceMock originSource;
      tt_EffectMock       effect;
      tt_Gunner           gunner;
    }
    

27.2. AnswerResetter

class tt_AnswerResetter : tt_Effect
{
  static tt_AnswerResetter of(tt_AnswerStateSource answerStateSource,
                              tt_AnswerSource      answerSource)
  {
    let result = new("tt_AnswerResetter");

    result._answerStateSource = answerStateSource;
    result._answerSource      = answerSource;
    result._oldAnswerState    = tt_AnswerState.of(tt_AnswerState.Unknown);

    return result;
  }

  override void doEffect()
  {
    let newAnswerState = _answerStateSource.getAnswerState();
    if (!_oldAnswerState.isFinished() && newAnswerState.isFinished())
    {
      _answerStateSource.reset();
      _answerSource.reset();
    }

    _oldAnswerState = newAnswerState;
  }

  private tt_AnswerStateSource _answerStateSource;
  private tt_AnswerSource      _answerSource;

  private tt_AnswerState _oldAnswerState;
}

27.3. MatchWatcher

// Watches for answer state source and reports match or not match
// when the state changes from Preparing to Ready.
//
// Match is determined by tt_OriginSource result being not NULL.
class tt_MatchWatcher : tt_Effect
{
  static tt_MatchWatcher of(tt_AnswerStateSource answerStateSource,
                            tt_AnswerReporter    answerReporter,
                            tt_OriginSource      originSource)
  {
    let result = new("tt_MatchWatcher");

    result._answerStateSource = answerStateSource;
    result._answerReporter    = answerReporter;
    result._originSource      = originSource;
    result._oldAnswerState    = tt_AnswerState.of(tt_AnswerState.Unknown);

    return result;
  }

  override void doEffect()
  {
    let newAnswerState = _answerStateSource.getAnswerState();
    if (!_oldAnswerState.isReady() && newAnswerState.isReady())
    {
      let isMatched = (_originSource.getOrigin() != NULL);
      if (isMatched)
      {
        _answerReporter.reportMatch();
      }
      else
      {
        _answerReporter.reportNotMatch();
      }
    }

    _oldAnswerState = newAnswerState;
  }

  private tt_AnswerStateSource _answerStateSource;
  private tt_AnswerReporter    _answerReporter;
  private tt_OriginSource      _originSource;

  private tt_AnswerState _oldAnswerState;
}

27.4. TargetOriginSender

class tt_TargetOriginSender : tt_Effect
{
  static tt_TargetOriginSender of(tt_OriginSource targetOriginSource)
  {
    let result = new("tt_TargetOriginSender");
    result._targetOriginSource = targetOriginSource;
    return result;
  }

  override void doEffect()
  {
    vector3 origin = _targetOriginSource.getOrigin().getVector();
    EventHandler.sendNetworkCommand("tt_target",
                                    NET_DOUBLE, origin.x,
                                    NET_DOUBLE, origin.y,
                                    NET_DOUBLE, origin.z);
  }

  private tt_OriginSource _targetOriginSource;
}

28. Options Menu

28.1. Options

TODO: rewrite with PlainTranslator.

AddOptionMenu OptionsMenu
{
  tt_AnimatedSubmenu "$TT_TITLE", tt_Options
}

OptionMenu tt_Options
{
  Title      "$TT_TITLE"

  Submenu    "$TT_CONTROLS_TITLE_OUT"         , tt_Controls

  StaticText ""
  Submenu    "General Options"                , tt_GeneralOptions
  Submenu    "$TT_LESSON_OPTIONS_TITLE_OUT"   , tt_LessonOptions
  Submenu    "$TT_SOUND_OPTIONS_TITLE_OUT"    , tt_SoundOptions
}

OptionMenu tt_GeneralOptions
{
  Title      "Typist.pk3 General Options"

  StaticText ""
  Option     "Automatic word confirmation", tt_fast_confirmation, OnOff

  StaticText ""
  Slider     "$TT_TARGET_SCALE", tt_view_scale, 1, 4, 1, 0

  StaticText ""
  StaticText "Reduce distractions"

  StaticText ""
  Option     "$TT_BUDDHA_ENABLED", tt_buddha_enabled, OnOff
  StaticText "$TT_BUDDHA_NOTE"

  StaticText ""
  Option     "Infinite ammo",      sv_infiniteammo, OnOff

  StaticText ""
  Option     "HUD",          screenblocks, tt_HudValues
  Option     "Show score",   tt_lp_show, OnOff
  Slider     "Pain flash",   blood_fade_scalar,  0, 1.0, 0.1
  Slider     "Pickup flash", pickup_fade_scalar, 0, 1.0, 0.1
}

OptionValue tt_HudValues
{
  10, "Standard"
  11, "Alternative"
  12, "No HUD"
}

OptionMenu tt_LessonOptions
{
  Title      "$TT_LESSON_OPTIONS_TITLE_IN"

  Option     "$TT_LESSON_1000"   , tt_is_english_enabled , OnOff
  Option     "$TT_LESSON_RANDOM" , tt_is_random_enabled  , OnOff
  Option     "$TT_LESSON_MATH"   , tt_is_maths_enabled   , OnOff
  Option     "$TT_CUSTOM_TEXT"   , tt_is_custom_enabled  , OnOff

  StaticText ""
  Submenu    "$TT_RANDOM_LESSON_TITLE", tt_RandomLesson

  StaticText ""
  Command    "$TT_APPLY", tt_reset_targets
  StaticText "$TT_QUESTION_SOURCE_NOTE"

  StaticText ""
  StaticText "$TT_CUSTOM_LESSON_HELP_TEXT"
}

OptionMenu tt_Controls
{
  Title      "$TT_CONTROLS_TITLE_IN"

  Control    "$TT_UNLOCK", tt_unlock_mode
  Control    "$TT_COMBAT", tt_force_combat

  StaticText ""
  TextField  "Pass Through command", tt_command_pass_through
  StaticText "Allows a single action key to be pressed without exiting Combat mode."

  StaticText ""
  Control    "$TT_SCORE", zc_top
}

OptionMenu tt_SoundOptions
{
  Title  "$TT_SOUND_OPTIONS_TITLE_IN"

  Option "$TT_SOUND_ENABLED"        , tt_sound_enabled        , OnOff
  Option "$TT_SOUND_TYPING_ENABLED" , tt_sound_typing_enabled , OnOff
  Option "$TT_SOUND_THEME"          , tt_sound_theme          , tt_SoundThemes
}

OptionMenu tt_RandomLesson
{
  Title  "$TT_RANDOM_LESSON_TITLE_FULL"

  Slider "$TT_RANDOM_LESSON_LENGTH", tt_rc_length, 1, 10, 1, 0

  StaticText ""
  Option "$TT_RANDOM_LESSON_UPPERCASE"   , tt_rc_uppercase_letters_enabled , OnOff
  Option "$TT_RANDOM_LESSON_LOWERCASE"   , tt_rc_lowercase_letters_enabled , OnOff
  Option "$TT_RANDOM_LESSON_NUMBERS"     , tt_rc_numbers_enabled           , OnOff
  Option "$TT_RANDOM_LESSON_PUNCTUATION" , tt_rc_punctuation_enabled       , OnOff
  Option "$TT_RANDOM_LESSON_SYMBOLS"     , tt_rc_symbols_enabled           , OnOff

  StaticText ""
  Option     "$TT_RANDOM_LESSON_CUSTOM"       , tt_rc_custom_enabled , OnOff
  TextField  "$TT_RANDOM_LESSON_CUSTOM_CHARS" , tt_rc_custom
}
OptionValue tt_SoundThemes
{
  1, "Default"
  2, "SNES"
  4, "Dakka"
  5, "Grocery Store"
}

// Score Menu
OptionMenu tt_lp_TopMenu
{
  class tt_lp_Top
  Title "Top Points"
}

28.2. Keys

Alias tt_unlock_mode   "event tt_unlock_mode"
Alias tt_force_combat  "event tt_force_combat"
Alias tt_reset_targets "event tt_reset_targets"

Alias zc_top "openMenu tt_lp_TopMenu"

AddKeySection "$TT_TITLE" tt_keys
AddMenuKey "$TT_UNLOCK" tt_unlock_mode
AddMenuKey "$TT_COMBAT" tt_force_combat
AddMenuKey "$TT_SCORE"  zc_top

28.3. AnimatedSubmenu

class OptionMenuItemtt_AnimatedSubmenu : OptionMenuItemSubmenu
{
  // Signature mirrors OptionMenuItemSubmenu.Init().
  OptionMenuItemtt_AnimatedSubmenu Init( string label
                                       , Name   command
                                       , int    param    = 0
                                       , bool   centered = false
                                       )
  {
    Super.Init(label, command, param, centered);

    _originalLabel  = stringTable.Localize(label);
    _originalLength = _originalLabel.CodePointCount();
    _period         = DELAY_TICS + _originalLength * CHARACTER_TIMEOUT_TICS;

    return self;
  }

  override int Draw(OptionMenuDescriptor desc, int y, int indent, bool selected)
  {
    int highlightedLetterIndex = _state / CHARACTER_TIMEOUT_TICS;

    if (highlightedLetterIndex < _originalLength)
    {
      int letterCode;
      int charPos = 0;
      for (int i = 0; i < highlightedLetterIndex; ++i)
      {
        [letterCode, charPos] = _originalLabel.GetNextCodePoint(charPos);
      }

      string left           = _originalLabel.Left(charPos);
      [letterCode, charPos] = _originalLabel.GetNextCodePoint(charPos);
      string right          = _originalLabel.Mid(charPos, _originalLabel.Length() - charPos);

      mLabel = string.format("%s\cd%c\c-%s", left, letterCode, right);
    }
    else
    {
      mLabel = _originalLabel;
    }

    ++_state;
    if (_state >= _period)
    {
      _state = 0;
    }

    return Super.Draw(desc, y, indent, selected);
  }

  const DELAY_TICS = 5 * TICRATE;
  const CHARACTER_TIMEOUT_TICS = 3;

  private int    _state;
  private int    _period;
  private string _originalLabel;
  private int    _originalLength;
}

28.4. Language

[enu default]

// Menus ///////////////////////////////////////////////////////////////////////

TT_TITLE = "Typist.pk3 v0.7.4";

TT_UNLOCK = "Unlock Game Mode";
TT_COMBAT = "Force Combat Mode";
TT_SCORE  = "Open Score";

TT_TARGET_SCALE               = "Target text scale";

TT_QUESTION_SOURCE            = "Lesson";
TT_APPLY                      = "Apply";
TT_QUESTION_SOURCE_NOTE       = "Or existing targets will retain question from the previous Lesson.";

TT_CONTROLS_TITLE_OUT         = "Controls";
TT_CONTROLS_TITLE_IN          = "Typist.pk3 Controls";

TT_BUDDHA_ENABLED = "Player cannot die";
TT_BUDDHA_NOTE    = "Applies on new level start.";

TT_LESSON_OPTIONS_TITLE_OUT = "Lesson Options";
TT_LESSON_OPTIONS_TITLE_IN  = "Typist.pk3 Lesson Options";

TT_LESSON_MATH = "Arithmetic";
TT_LESSON_1000 = "1000 Basic English Words";
TT_CUSTOM_TEXT = "Custom Text";

TT_CUSTOM_LESSON_HELP_TEXT = "\cfHow to set up Custom Text lesson\c-\
\
1. Find any text or book in ASCII .txt file (UTF-8 may also work).\
2. Rename text file to `typist_custom_text.txt`.\
3. Load `typist_custom_text.txt` with GZDoom alongside Typist.pk3.\
4. Select Custom Text in Typist options menu.";

TT_NIX_LESSON = "*nix Command Line";

TT_LESSON_RANDOM              = "Random Characters";
TT_RANDOM_LESSON_TITLE        = "Random Characters Lesson Configuration";
TT_RANDOM_LESSON_LENGTH       = "Length";
TT_RANDOM_LESSON_UPPERCASE    = "A-Z";
TT_RANDOM_LESSON_LOWERCASE    = "a-z";
TT_RANDOM_LESSON_NUMBERS      = "0-9";
TT_RANDOM_LESSON_PUNCTUATION  = "Punctuation";
TT_RANDOM_LESSON_SYMBOLS      = "Other characters";
TT_RANDOM_LESSON_CUSTOM       = "Custom string";
TT_RANDOM_LESSON_CUSTOM_CHARS = "Custom string:";

// Sound menus /////////////////////////////////////////////////////////////////

TT_SOUND_OPTIONS_TITLE_OUT = "Sound Options";
TT_SOUND_OPTIONS_TITLE_IN  = "Typist.pk3 Sound Options";

TT_SOUND_THEME          = "Sound theme";
TT_SOUND_ENABLED        = "Sound effects";
TT_SOUND_TYPING_ENABLED = "Typing sound";

// Helper string
TT_FALLBACK_QUESTION = "<empty lesson>";

29. Mod setup

version 4.14.2

#include "zscript/menu/tt_option_menu_item_animated_submenu.zs"
#include "zscript/effect/tt_target_origin_sender.zs"
#include "zscript/effect/tt_match_watcher.zs"
#include "zscript/effect/tt_answer_resetter.zs"
#include "zscript/effect/tt_gunner.zs"
#include "zscript/effect/tt_effect.zs"
#include "zscript/view/tt_drawing.zs"
#include "zscript/view/tt_target_overlay.zs"
#include "zscript/view/tt_info_panel.zs"
#include "zscript/view/tt_conditional_view.zs"
#include "zscript/interpolator/tt_double_interpolator.zs"
#include "zscript/view/tt_frame.zs"
#include "zscript/view/tt_views.zs"
#include "zscript/view/tt_view.zs"
#include "zscript/target_widget/tt_target_widget_registry.zs"
#include "zscript/target_widget/tt_sorter_by_distance.zs"
#include "zscript/target_widget/tt_projector.zs"
#include "zscript/target_widget/tt_target_widget_source.zs"
#include "zscript/target_widget/tt_target_widgets.zs"
#include "zscript/target_widget/tt_target_widget.zs"
#include "zscript/target/tt_target_source_pruner.zs"
#include "zscript/target/tt_target_source_cache.zs"
#include "zscript/target/tt_death_reporter.zs"
#include "zscript/target/tt_target_radar.zs"
#include "zscript/target/tt_target_source.zs"
#include "zscript/target/tt_targets.zs"
#include "zscript/target/tt_target.zs"
#include "zscript/strings/tt_strings.zs"
#include "zscript/stale_marker/tt_stale_marker_impl.zs"
#include "zscript/stale_marker/tt_stale_marker.zs"
#include "zscript/settings/tt_random_characters_lesson_settings_impl.zs"
#include "zscript/settings/tt_random_characters_lesson_settings.zs"
#include "zscript/settings/tt_cvars.zs"
#include "zscript/settings/tt_settings.zs"
#include "zscript/settings/tt_setting.zs"
#include "zscript/question/tt_match.zs"
#include "zscript/question/tt_question.zs"
#include "zscript/player/tt_player_source_impl.zs"
#include "zscript/player/tt_player_source.zs"
#include "zscript/origin/tt_selectable_origin_source.zs"
#include "zscript/origin/tt_question_answer_matcher.zs"
#include "zscript/origin/tt_player_origin_source.zs"
#include "zscript/origin/tt_origin_source_cache.zs"
#include "zscript/origin/tt_hasty_question_answer_matcher.zs"
#include "zscript/origin/tt_origin_source.zs"
#include "zscript/origin/tt_origin.zs"
#include "zscript/mode/tt_automap_mode_source.zs"
#include "zscript/mode/tt_settable_mode.zs"
#include "zscript/mode/tt_reported_mode_source.zs"
#include "zscript/mode/tt_mode_storage.zs"
#include "zscript/mode/tt_mode_cascade.zs"
#include "zscript/mode/tt_delayed_combat_mode_source.zs"
#include "zscript/mode/tt_auto_mode_source.zs"
#include "zscript/mode/tt_mode_source.zs"
#include "zscript/mode/tt_mode.zs"
#include "zscript/math/tt_math.zs"
#include "zscript/lesson/tt_string_set.zs"
#include "zscript/lesson/tt_random_number_source.zs"
#include "zscript/lesson/tt_random_characters_lesson.zs"
#include "zscript/lesson/tt_mixed_lesson.zs"
#include "zscript/lesson/tt_maths_lesson.zs"
#include "zscript/lesson/tt_question_source.zs"
#include "zscript/known_target/tt_visible_known_target_source.zs"
#include "zscript/known_target/tt_target_registry.zs"
#include "zscript/known_target/tt_known_target_source_cache.zs"
#include "zscript/known_target/tt_known_target_source.zs"
#include "zscript/known_target/tt_known_targets.zs"
#include "zscript/known_target/tt_known_target.zs"
#include "zscript/key_processor/tt_key_processors.zs"
#include "zscript/key_processor/tt_key_processor.zs"
#include "zscript/input_manager/tt_input_manager.zs"
#include "zscript/input_manager/tt_pass_through_input_manager.zs"
#include "zscript/input_manager/tt_input_by_mode_manager.zs"
#include "zscript/event_reporters/tt_player_sound_player.zs"
#include "zscript/event_reporters/tt_sound_player.zs"
#include "zscript/event_reporters/tt_sound_mode_reporter.zs"
#include "zscript/event_reporters/tt_mode_reporter.zs"
#include "zscript/event_reporters/tt_sound_key_press_reporter.zs"
#include "zscript/event_reporters/tt_key_press_reporter.zs"
#include "zscript/event_reporters/tt_sound_answer_reporter.zs"
#include "zscript/event_reporters/tt_answer_reporter.zs"
#include "zscript/clock/tt_total_clock.zs"
#include "zscript/clock/tt_clock.zs"
#include "zscript/character/tt_character.zs"
#include "zscript/answer_state/tt_pressed_answer_state.zs"
#include "zscript/answer_state/tt_answer_state_source.zs"
#include "zscript/answer_state/tt_answer_state.zs"
#include "zscript/answer/tt_player_input.zs"
#include "zscript/answer/tt_input_block_after_combat.zs"
#include "zscript/answer/tt_answer_source.zs"
#include "zscript/answer/tt_answer.zs"
#include "zscript/activatable/tt_command_dispatcher.zs"
#include "zscript/activatable/tt_pass_through.zs"
#include "zscript/activatable/tt_activatable.zs"
#include "zscript/firer/tt_firer.zs"
#include "zscript/aimer/tt_vertical_aimer.zs"
#include "zscript/aimer/tt_horizontal_aimer.zs"
#include "zscript/world_changer/tt_velocity_storage.zs"
#include "zscript/world_changer/tt_projectile_speed_controller.zs"
#include "zscript/world_changer/tt_enemy_speed_controller.zs"
#include "zscript/world_changer/tt_world_changers.zs"
#include "zscript/world_changer/tt_world_changer.zs"
#include "zscript/server/tt_server.zs"
#include "zscript/player_handler/tt_player_supervisor.zs"
#include "zscript/player_handler/tt_player_handler.zs"
#include "zscript/buddha/tt_buddha.zs"
#include "zscript/game_tweaks/tt_game_tweaks.zs"
#include "zscript/event_handler/tt_event_handler.zs"
#include "tt_colors.zs"

#include "zscript/tt_le_libeye.zs"
#include "zscript/tt_lp_LazyPoints.zs"
#include "zscript/tt_su_StringUtils.zs"
GameInfo
{
  EventHandlers =
    "tt_lp_Dispatcher",
    "tt_lp_StaticView",
    "tt_EventHandler"
}
// Variables for score
nosave string tt_lp_score = "";
user   bool   tt_lp_show  = true;

30. Tests

version 4.14.2

#include "zscript/test.zs"
#include "zscript/environments.zs"
#include "zscript/mocks.zs"
class tt_Test : Clematis
{
  override void testSuites()
  {
    Describe("Typist tests");
    addTests();
    EndDescribe();
  }

  play void addTests() const
  {
    {
      let tag = "tt_HorizontalAimer";

      Array<tt_Origin> targetPositions;
      Array<double>    angles;

      targetPositions.push(tt_Origin.of(( 100,  100, 0))); angles.push(  45);
      targetPositions.push(tt_Origin.of((-100, -100, 0))); angles.push(-135);
      targetPositions.push(tt_Origin.of((   0,    0, 0))); angles.push(   0);

      players[consolePlayer].mo.SetOrigin((0, 0, 0), false);

      int nTargetPositions = targetPositions.size();
      for (int i = 0; i < nTargetPositions; ++i)
      {
        let    originSource  = tt_OriginSourceMock.of();
        let    playerSource  = tt_PlayerSourceMock.of();
        let    aimer         = tt_HorizontalAimer.of(originSource, playerSource);
        let    targetOrigin  = targetPositions[i];
        let    pawn          = players[consolePlayer].mo;
        double angle         = angles[i];

        originSource.expect_getOrigin(targetOrigin);
        playerSource.expect_getPawn(pawn);

        // Just for a visual check.
        spawn("DoomImp", targetOrigin.getVector());

        aimer.changeWorld();

        let message = string.format("%s: pawn is oriented at the target, angle: %f",
                                    tag,
                                    angle);
        it(message, AssertEval(pawn.angle, "~==", angles[i]));
        assertSatisfaction(originSource.getSatisfaction(), tag);
        assertSatisfaction(playerSource.getSatisfaction(), tag);

        cleanUpSpawned();
      }
    }
    {
      let tag = "tt_VerticalAimer";
      let targetOriginSource = tt_OriginSourceMock.of();
      let playerSource       = tt_PlayerSourceMock.of();
      let autoAimSetting     = tt_FloatSettingMock.of();

      let aimer = tt_VerticalAimer.of(targetOriginSource, playerSource, autoAimSetting);

      let targetOrigin = tt_Origin.of((550, 500, 500));
      let pawn         = players[consolePlayer].mo;
      pawn.SetOrigin((0, 0, 0), false);

      targetOriginSource.expect_getOrigin(targetOrigin);
      playerSource      .expect_getPawn(pawn);
      autoAimSetting    .expect_getFloat(0);

      aimer.changeWorld();

      assertSatisfaction(targetOriginSource.getSatisfaction(), tag);
      assertSatisfaction(playerSource.getSatisfaction(), tag);
      assertSatisfaction(autoAimSetting.getSatisfaction(), tag);
    }
    {
      let        tag          = "tt_Firer";
      let        playerSource = tt_PlayerSourceMock.of();
      let        firer        = tt_Firer.of(playerSource);
      PlayerInfo info         = players[consolePlayer];
      let        pawn         = info.mo;

      playerSource.expect_getInfo(info);
      playerSource.expect_getPawn(pawn);

      int nBullets = pawn.countInv("Clip");
      it(tag .. ": must be 50 bullets before firing", AssertEval(nBullets, "==", 50));

      firer.changeWorld();

      assertSatisfaction(playerSource.getSatisfaction(), tag);

      // Note: this relies on sv_fastweapons 2.
      nBullets = pawn.countInv("Clip");
      it(tag .. ": must spend 1 bullet after firing", AssertEval(nBullets, "==", 49));
    }
    {
      let tag = "tt_CommandDispatcher: checkActivate";
      let env = tt_CommandDispatcherTestEnvironment.of();

      let str    = "Hello";
      let answer = tt_Answer.of(str);
      env.answerSource.expect_getAnswer(answer);

      let commands1 = tt_Strings.of();
      let commands2 = tt_Strings.of();
      commands2.add(str);
      env.activatable1.expect_getCommands(commands1);
      env.activatable2.expect_getCommands(commands2);
      env.activatable2.expect_activate();
      env.answerReporter.expect_reportMatch();
      env.answerStateSource
        .expect_getAnswerState(tt_AnswerState.of(tt_AnswerState.Ready));
      env.answerStateSource.expect_reset();
      env.answerSource.expect_reset();

      env.commandDispatcher.activate();

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_CommandDispatcher: checkGetCommands";
      let env = tt_CommandDispatcherTestEnvironment.of();

      let commands1 = tt_Strings.of();
      let commands2 = tt_Strings.of();
      commands1.add("1");
      commands1.add("2");
      commands2.add("3");
      commands2.add("4");
      env.activatable1.expect_getCommands(commands1);
      env.activatable2.expect_getCommands(commands2);
      env.activatable1.expect_isVisible(true);
      env.activatable2.expect_isVisible(true);

      let allCommands = env.commandDispatcher.getCommands();
      let size        = allCommands.size();

      it("tt_CommandDispatcher: check get commands: All commands are collected",
         AssertEval(size, "==", 4));
      it("tt_CommandDispatcher: check get commands: The first command is collected",
         Assert(allCommands.contains("1")));
      it("tt_CommandDispatcher: check get commands: The second command is collected",
         Assert(allCommands.contains("2")));
      it("tt_CommandDispatcher: check get commands: The third command is collected",
         Assert(allCommands.contains("3")));
      it("tt_CommandDispatcher: check get commands: The forth command is collected",
         Assert(allCommands.contains("4")));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_PlayerInputTest: testPlayerInputCheckInput";
      let env = tt_PlayerInputTestEnvironment.of();

      string input = "abc";
      env.throwStringIntoInput(input);

      let answer       = env.playerInput.getAnswer();
      let answerString = answer.getString();

      it(tag .. ": input must be an answer", Assert(input == answerString));
    }
    {
      let tag = "tt_PlayerInputTest: testPlayerInputCheckReset";
      let env = tt_PlayerInputTestEnvironment.of();
      int TYPE_CHAR = UiEvent.Type_Char;

      string input1 = "abc";
      string input2 = "def";

      env.throwStringIntoInput(input1);
      let reset = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, true);
      env.playerInput.processKey(reset);
      env.throwStringIntoInput(input2);

      let answer       = env.playerInput.getAnswer();
      let answerString = answer.getString();

      it(tag .. ": second input must be an answer", Assert(input2 == answerString));
    }
    {
      let tag = "tt_PlayerInputTest: testBackspace";
      let env = tt_PlayerInputTestEnvironment.of();
      int TYPE_CHAR = UiEvent.Type_Char;

      let backspace = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, false);
      let letterA   = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_SMALL_LETTER_A, false);

      //env.playerInput.reset();
      env.playerInput.processKey(backspace);
      env.playerInput.processKey(letterA);
      env.playerInput.processKey(backspace);
      env.playerInput.processKey(letterA);

      let answer       = env.playerInput.getAnswer();
      let answerString = answer.getString();

      it(tag .. ": input after backspace must be valid", Assert(answerString == "a"));
    }
    {
      let tag = "tt_PlayerInputTest: testCtrlBackspace";
      let env = tt_PlayerInputTestEnvironment.of();
      int TYPE_CHAR = UiEvent.Type_Char;

      let ctrlBackspace = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, true);
      let letterA   = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_SMALL_LETTER_A, false);

      env.playerInput.processKey(letterA);
      env.playerInput.processKey(letterA);
      env.playerInput.processKey(ctrlBackspace);

      let answer       = env.playerInput.getAnswer();
      let answerString = answer.getString();

      it(tag .. ": input after ctrl-backspace must be empty", Assert(answerString == ""));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_SMALL_LETTER_A, false);
      it("tt_Character: Small character", Assert(c.getType() == tt_Character.PRINTABLE));
      it("tt_Character: Small character", Assert(c.getCharacter() == "a"));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.LATIN_CAPITAL_LETTER_A, false);
      it("tt_Character: Big character", Assert(c.getType() == tt_Character.PRINTABLE));
      it("tt_Character: Big character", Assert(c.getCharacter() == "A"));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.DIGIT_FOUR, false);
      it("tt_Character: Number", Assert(c.getType() == tt_Character.PRINTABLE));
      it("tt_Character: Number", Assert(c.getCharacter() == "4"));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, false);
      it("tt_Character: Backspace", Assert(c.getType() == tt_Character.BACKSPACE));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.CHARACTER_NULL, false);
      it("tt_Character: Non-printable", Assert(c.getType() == tt_Character.NONE));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.BACKSPACE, true);
      it( "tt_Character: Ctrl-Backspace",
         Assert(c.getType() == tt_Character.CTRL_BACKSPACE));
    }
    {
      int TYPE_CHAR = UiEvent.Type_Char;
      let c = tt_Character.of(TYPE_CHAR, tt_su_Ascii.CARRIAGE_RETURN_CR, true);
      it("tt_Character: Enter", Assert(c.getType() == tt_Character.ENTER));
    }
    {
      let clock = tt_TotalClock.of();

      int now1 = clock.getNow();
      int now2 = clock.getNow();

      it("tt_TotalClock: now is now", AssertEval(now1, "==", now2));

      int duration = clock.since(now1);
      it("tt_TotalClock: no time passed", AssertEval(duration, "==", 0));
    }
    {
      let tag = "tt_TargetRegistry: emptyCheck";
      let env = tt_TargetRegistryTestEnvironment.of();

      env.targetSource        .expect_getTargets(tt_Targets.of());
      env.disabledTargetSource.expect_getTargets(tt_Targets.of());

      it(tag .. ": is empty", Assert(env.targetRegistry.isEmpty()));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_TargetRegistry: addCheck";
      let env = tt_TargetRegistryTestEnvironment.of();

      let target1 = tt_Target.of(spawn("Demon", (0, 0, 0)));
      let target2 = tt_Target.of(spawn("Demon", (0, 0, 0)));
      let targets = tt_Targets.of();
      targets.add(target1);
      targets.add(target2);

      env.targetSource.expect_getTargets(targets);
      env.disabledTargetSource.expect_getTargets(tt_Targets.of());
      env.lesson.expect_getQuestion(tt_QuestionMock.of(), 2);

      let knownTargets = env.targetRegistry.getTargets();

      it(tag .. ": is two targets", AssertEval(knownTargets.size(), "==", 2));

      assertSatisfaction(env.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_TargetRegistry: addExistingCheck";
      let env = tt_TargetRegistryTestEnvironment.of();

      // First, add a single target.
      let demon1  = spawn("Demon", (0, 0, 0));
      let target  = tt_Target.of(demon1);
      let targets = tt_Targets.of();
      targets.add(target);

      env.targetSource.expect_getTargets(targets);
      env.disabledTargetSource.expect_getTargets(tt_Targets.of());
      env.lesson.expect_getQuestion(tt_QuestionMock.of());

      let knownTargets = env.targetRegistry.getTargets();

      it(tag .. ": is one target", AssertEval(knownTargets.size(), "==", 1));

      assertSatisfaction(env.getSatisfaction(), tag);

      // Second, add the same target again. Only a single target must remain
      // registered.
      env.targetSource.expect_getTargets(targets);
      env.disabledTargetSource.expect_getTargets(tt_Targets.of());
      env.lesson.expect_getQuestion(NULL, 0);

      knownTargets = env.targetRegistry.getTargets();

      it(tag .. ": is one target", AssertEval(knownTargets.size(), "==", 1));

      assertSatisfaction(env.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_TargetRegistry: remove";
      let env = tt_TargetRegistryTestEnvironment.of();

      // First, add two targets.
      let demon1  = spawn("Demon", (0, 0, 0));
      let demon2  = spawn("Demon", (0, 0, 0));
      let target1 = tt_Target.of(demon1);
      let target2 = tt_Target.of(demon2);
      let targets = tt_Targets.of();
      targets.add(target1);
      targets.add(target2);

      env.targetSource.expect_getTargets(targets);
      env.disabledTargetSource.expect_getTargets(tt_Targets.of());
      env.lesson.expect_getQuestion(tt_QuestionMock.of(), 2);

      let knownTargets = env.targetRegistry.getTargets();

      it(tag .. ": is two targets", AssertEval(knownTargets.size(), "==", 2));

      assertSatisfaction(env.getSatisfaction(), tag);

      // Second, remove one target.
      let disabledTarget  = tt_Target.of(demon1);
      let disabledTargets = tt_Targets.of();
      disabledTargets.add(disabledTarget);

      env.targetSource.expect_getTargets(tt_Targets.of());
      env.disabledTargetSource.expect_getTargets(disabledTargets);
      env.lesson.expect_getQuestion(NULL, 0);

      knownTargets = env.targetRegistry.getTargets();

      it(tag .. ": is one target now", AssertEval(knownTargets.size(), "==", 1));

      assertSatisfaction(env.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_VisibleKnownTargetSource: no targets";
      let env = tt_VisibleKnownTargetSourceTestEnvironment.of();

      env.baseSource.expect_isEmpty(true, 2);

      bool isEmpty = env.source.isEmpty();
      let  targets = env.source.getTargets();

      it(tag .. "-> empty", Assert(isEmpty));
      it(tag .. "-> no targets", AssertEval(targets.size(), "==", 0));
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_VisibleKnownTargetSource: visible targets";
      let env = tt_VisibleKnownTargetSourceTestEnvironment.of();

      let knownTargets = tt_KnownTargets.of();
      let target       = tt_Target.of(spawn("Demon", (0, 0, 0)));
      let question     = tt_QuestionMock.of();
      let knownTarget  = tt_KnownTarget.of(target, question);
      knownTargets.add(knownTarget);

      env.baseSource  .expect_isEmpty(false, 2);
      env.baseSource  .expect_getTargets(knownTargets, 2);
      env.playerSource.expect_getPawn(players[consolePlayer].mo, 2);

      bool isEmpty = env.source.isEmpty();
      let  targets = env.source.getTargets();

      it(tag .. "-> not empty", Assert(!isEmpty));
      it(tag .. "-> targets", AssertEval(targets.size(), "==", 1));

      assertSatisfaction(env.getSatisfaction(), tag);

      cleanUpSpawned();
    }
    {
      let tag = "tt_VisibleKnownTargetSource: invisible targets";
      let env = tt_VisibleKnownTargetSourceTestEnvironment.of();

      let knownTargets = tt_KnownTargets.of();
      let target       = tt_Target.of(spawn("Demon", (9999999, 0, 0)));
      let question     = tt_QuestionMock.of();
      let knownTarget  = tt_KnownTarget.of(target, question);
      knownTargets.add(knownTarget);

      env.baseSource  .expect_isEmpty(false, 2);
      env.baseSource  .expect_getTargets(knownTargets, 2);
      env.playerSource.expect_getPawn(players[consolePlayer].mo, 2);

      bool isEmpty = env.source.isEmpty();
      let  targets = env.source.getTargets();

      it(tag .. "-> empty", Assert(isEmpty));
      it(tag .. "-> no targets", AssertEval(targets.size(), "==", 0));

      assertSatisfaction(env.getSatisfaction(), tag);

      cleanUpSpawned();
    }
    {
      let question = tt_MathsLesson.of().getQuestion();

      it("tt_MathsLesson: question isn't equal to the answer",
        AssertFalse(question.isRight(question.getDescription())));
    }
    {
      let    stringSet   = tt_StringSet.of("tt_test_words");
      let    question    = stringSet.getQuestion();
      string description = question.getDescription();

      it("tt_StringSet: Question must be valid", AssertNotNull(question));
      it("tt_StringSet: Description", Assert(description == "привет"));
    }
    {
      let tag = "tt_AutoModeSource: no targets";
      let knownTargetSource = tt_KnownTargetSourceMock.of();
      let autoModeSource    = tt_AutoModeSource.of(knownTargetSource);

      knownTargetSource.expect_isEmpty(true);

      int mode = autoModeSource.getMode();

      it(tag .. ": no targets -> Explore", AssertEval(mode, "==", tt_Mode.Explore));
      assertSatisfaction(knownTargetSource.getSatisfaction(), tag);
    }
    {
      let tag = "tt_AutoModeSource: targets";
      let knownTargetSource = tt_KnownTargetSourceMock.of();
      let autoModeSource    = tt_AutoModeSource.of(knownTargetSource);

      knownTargetSource.expect_isEmpty(false);

      int mode = autoModeSource.getMode();

      it(tag .. ": targets -> Combat", AssertEval(mode, "==", tt_Mode.Combat));
      assertSatisfaction(knownTargetSource.getSatisfaction(), tag);
    }

    // C - Combat Mode
    // E - Exploration Mode
    // N - None Mode (let other decide)
    //
    // |-----|-----|---------|-------------|--------|-------------------------|
    // | old | new | enemies | time is up? | result | test                    |
    // |-----|-----|---------|-------------|--------|-------------------------|
    // |  *  |  C  |    *    |      *      | None   | checkNewCombat          |
    // |  C  |  E  |   no    |      *      | None   | checkNoEnemies          |
    // |  C  |  E  |   yes   |     no      | Combat | checkEnemiesStillCombat |
    // |  C  |  E  |   yes   |     yes     | None   | checkEnemiesTimeIsUp    |
    // |  E  |  *  |    *    |      *      | None   | checkOldExploration     |
    // |-----|-----|---------|-------------|--------|-------------------------|
    {
      let tag = "tt_DelayedCombatModeSource: checkNewCombat";
      let env = tt_DelayedCombatModeSourceTestEnvironment.of();

      env.modeSource.expect_getMode(tt_Mode.Combat, 2);
      env.clock.expect_getNow(0, 0);
      env.clock.expect_since(0, 0);

      int result1 = env.delay.getMode();
      it(tag .. ": new combat -> None", AssertEval(result1, "==", tt_Mode.None));

      int result2 = env.delay.getMode();
      it(tag .. ": again, combat -> None", AssertEval(result2, "==", tt_Mode.None));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_DelayedCombatModeSource: checkNoEnemies";
      let env = tt_DelayedCombatModeSourceTestEnvironment.of();

      // set up history: it was combat.
      env.modeSource.expect_getMode(tt_Mode.Combat);
      env.delay.getMode();

      env.modeSource.expect_getMode(tt_Mode.Explore);
      env.targetSource.expect_getTargets(tt_Targets.of());

      int result = env.delay.getMode();
      it(tag .. ": no enemies", AssertEval(result, "==", tt_Mode.None));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_DelayedCombatModeSource: checkEnemiesStillCombat";
      let env = tt_DelayedCombatModeSourceTestEnvironment.of();

      // set up history: it was combat.
      env.modeSource.expect_getMode(tt_Mode.Combat);
      env.delay.getMode();

      { // set expectations
        env.modeSource.expect_getMode(tt_Mode.Explore);

        let targets = tt_Targets.of();
        targets.add(NULL);
        env.targetSource.expect_getTargets(targets);

        env.clock.expect_getNow(0);
        env.clock.expect_since(0);
      }

      int result = env.delay.getMode();
      it(tag .. ": still combat", AssertEval(result, "==", tt_Mode.Combat));
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_DelayedCombatModeSource: checkEnemiesTimeIsUp";
      let env = tt_DelayedCombatModeSourceTestEnvironment.of();

      // set up history: it was combat.
      env.modeSource.expect_getMode(tt_Mode.Combat);
      env.delay.getMode();

      { // set expectations
        env.modeSource.expect_getMode(tt_Mode.Explore);

        let targets = tt_Targets.of();
        targets.add(NULL);
        env.targetSource.expect_getTargets(targets);

        env.clock.expect_getNow(0);
        env.clock.expect_since(999);
      }

      int result = env.delay.getMode();
      it(tag .. ": no more combat", AssertEval(result, "==", tt_Mode.None));
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_DelayedCombatModeSource: checkOldExploration";
      let env = tt_DelayedCombatModeSourceTestEnvironment.of();

      env.modeSource.expect_getMode(tt_Mode.Explore, 2);
      env.clock.expect_getNow(0, 0);
      env.clock.expect_since(0, 0);
      env.targetSource.expect_getTargets(tt_Targets.of(), 2);

      int result1 = env.delay.getMode();
      it(tag .. ": old Exploration -> None", AssertEval(result1, "==", tt_Mode.None));

      int result2 = env.delay.getMode();
      it(tag .. ": again, old Exploration -> None",
        AssertEval(result2, "==", tt_Mode.None));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      Array<tt_ModeSource> sources;
      let cascade = tt_ModeCascade.of(sources);

      int mode = cascade.getMode();

      it("tt_ModeCascade: check zero sources: No source -> no mode",
         AssertEval(mode, "==", tt_Mode.None));
    }
    {
      let source1 = tt_ModeSourceMock.of();
      let source2 = tt_ModeSourceMock.of();

      source1.expect_getMode(tt_Mode.Explore);
      source2.expect_getMode(tt_Mode.Combat);

      Array<tt_ModeSource> sources = {source1, source2};
      int mode = tt_ModeCascade.of(sources).getMode();

      it("tt_ModeCascade: check cascade first: Must be the first mode",
         AssertEval(mode, "==", tt_Mode.Explore));
    }
    {
      let source1 = tt_ModeSourceMock.of();
      let source2 = tt_ModeSourceMock.of();

      source1.expect_getMode(tt_Mode.None);
      source2.expect_getMode(tt_Mode.Combat);

      Array<tt_ModeSource> sources = {source1, source2};
      int mode = tt_ModeCascade.of(sources).getMode();

      it("tt_ModeCascade: check cascade second: Must be the second mode",
         AssertEval(mode, "==", tt_Mode.Combat));
    }
    {
      let tag = "tt_ReportedModeSource: checkInitial";
      let env = tt_ReportedModeSourceTestEnvironment.of();

      int expected = tt_Mode.Explore;
      env.modeSource.expect_getMode(expected);

      int mode = env.reportedMode.getMode();

      it(tag .. ": explore after init", AssertEval(mode, "==", expected));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_ReportedModeSource: checkExplorationToCombat";
      let env = tt_ReportedModeSourceTestEnvironment.of();

      env.reporter.expect_report();

      env.modeSource.expect_getMode(tt_Mode.Explore);
      int mode1 = env.reportedMode.getMode();

      env.modeSource.expect_getMode(tt_Mode.Combat);
      int mode2 = env.reportedMode.getMode();

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_ReportedModeSource: checkCombatToExploration";
      let env = tt_ReportedModeSourceTestEnvironment.of();

      env.reporter.expect_report();

      env.modeSource.expect_getMode(tt_Mode.Combat);
      int mode1 = env.reportedMode.getMode();

      env.modeSource.expect_getMode(tt_Mode.Explore);
      int mode2 = env.reportedMode.getMode();

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let settableMode = tt_SettableMode.of();
      int before       = tt_Mode.Combat;

      settableMode.setMode(before);
      int after = settableMode.getMode();

      it("tt_SettableMode: mode must be the same", AssertEval(before, "==", after));
    }
    {
      let tag = "tt_PlayerOriginSource";

      double x = 1;
      double y = 2;
      double z = 3;
      let player = PlayerPawn(spawn("DoomPlayer", (x, y, z)));

      let playerSource = tt_PlayerSourceMock.of();
      let originSource = tt_PlayerOriginSource.of(playerSource);

      playerSource.expect_getPawn(player);

      let origin  = originSource.getOrigin().getVector();

      it(tag .. ": X matches",  AssertEval(x, "==", origin.x));
      it(tag .. ": Y matches",  AssertEval(y, "==", origin.y));
      it(tag .. ": Z in range", AssertEval(z, "<=", origin.z));
      it(tag .. ": Z in range", AssertEval(z + player.Height, ">=", origin.z));
      assertSatisfaction(playerSource.getSatisfaction(), tag);

      cleanUpSpawned();
    }
    {
      let tag = "tt_QuestionAnswerMatcher: checkNullKnownTargets";
      let env = tt_QuestionAnswerMatcherTestEnvironment.of();

      env.targetSource.expect_getTargets(NULL);

      let origin = env.matcher.getOrigin();

      it(tag .. ": NULL known targets -> NULL origin", AssertNull(origin));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_QuestionAnswerMatcher: checkZeroKnownTargets";
      let env = tt_QuestionAnswerMatcherTestEnvironment.of();

      let targets = tt_KnownTargets.of();
      env.targetSource.expect_getTargets(targets);

      let origin = env.matcher.getOrigin();

      it(tag .. "Zero known targets -> NULL origin", AssertNull(origin));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_QuestionAnswerMatcher: checkNullKnownTarget";
      let env = tt_QuestionAnswerMatcherTestEnvironment.of();

      let targets = tt_KnownTargets.of();
      targets.add(NULL);
      env.targetSource.expect_getTargets(targets);
      env.answerSource.expect_getAnswer(NULL);

      let origin = env.matcher.getOrigin();

      it(tag .. ": NULL known target -> NULL origin", AssertNull(origin));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_QuestionAnswerMatcher: checkNullAnswer";
      let env = tt_QuestionAnswerMatcherTestEnvironment.of();

      let knownTargets = tt_KnownTargets.of();
      let target       = tt_Target.of(NULL);
      let question     = tt_QuestionMock.of();
      let knownTarget  = tt_KnownTarget.of(target, question);
      knownTargets.add(knownTarget);
      env.targetSource.expect_getTargets(knownTargets);
      env.answerSource.expect_getAnswer(NULL);

      let origin = env.matcher.getOrigin();

      it(tag .. ": NULL answer -> NULL origin", AssertNull(origin));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_QuestionAnswerMatcher: checkKnownTargetAndAnswerMatch";
      let env = tt_QuestionAnswerMatcherTestEnvironment.of();

      let knownTargets = tt_KnownTargets.of();
      let target       = tt_Target.of(spawn("Demon", (0, 0, 0)));
      let question     = tt_QuestionMock.of();
      let knownTarget  = tt_KnownTarget.of(target, question);
      knownTargets.add(knownTarget);
      env.targetSource.expect_getTargets(knownTargets);
      question.expect_isRight(true);

      let answer = tt_Answer.of("abc");
      env.answerSource.expect_getAnswer(answer);
      env.stateSource.expect_getAnswerState(tt_AnswerState.of(tt_AnswerState.Ready));

      let origin = env.matcher.getOrigin();

      assertSatisfaction(question.getSatisfaction(), tag);
      it(tag .. ": match: valid origin", AssertNotNull(origin));

      assertSatisfaction(env.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_QuestionAnswerMatcher: checkKnownTargetAndAnswerNoMatch";
      let env = tt_QuestionAnswerMatcherTestEnvironment.of();

      let knownTargets = tt_KnownTargets.of();
      let target       = tt_Target.of(NULL);
      let question     = tt_QuestionMock.of();
      let knownTarget  = tt_KnownTarget.of(target, question);
      knownTargets.add(knownTarget);
      env.targetSource.expect_getTargets(knownTargets);
      question.expect_isRight(false);

      let answer = tt_Answer.of("abc");
      env.answerSource.expect_getAnswer(answer);
      env.stateSource.expect_getAnswerState(tt_AnswerState.of(tt_AnswerState.Ready));

      let origin = env.matcher.getOrigin();

      assertSatisfaction(question.getSatisfaction(), tag);
      it(tag .. ": no match: NULL origin" , AssertNull(origin));

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      // Info, unlike pawns, exist even for non-existent players.
      for (int playerNumber = 0; playerNumber < MAXPLAYERS; ++playerNumber)
      {
        let source = tt_PlayerSourceImpl.of(playerNumber);
        let info   = source.getInfo();
        let note   = "tt_PlayerSourceImpl: player info (%d) must be not NULL";

        it(string.format(note, playerNumber), Assert(info != NULL));
      }
    }
    {
      let source = tt_PlayerSourceImpl.of(consolePlayer);
      let pawn   = source.getPawn();
      let note   = "tt_PlayerSourceImpl: must get main player (%d) actor";

      it(string.format(note, consolePlayer), AssertNotNull(pawn));
    }
    {
      let note = "tt_PlayerSourceImpl: other player (%d) must be null";

      // Since tests are run on single-player game, no other players must exist.
      for (int i = 1; i < MAXPLAYERS; ++i)
      {
        int playerNumber = (consolePlayer + i) % MAXPLAYERS;
        let source       = tt_PlayerSourceImpl.of(playerNumber);
        let pawn         = source.getPawn();

        it(string.format(note, playerNumber), AssertNull(pawn));
      }
    }
    {
      let tag = "tt_StaleMarker: checkFirstRead";
      let env = tt_StaleMarkerImplTestEnvironment.of();
      env.clock.expect_getNow(0);

      bool isStale = env.staleMarker.isStale();
      it(tag .. ": first read: stale", Assert(isStale));
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_StaleMarker: checkNotYetStale";
      let env = tt_StaleMarkerImplTestEnvironment.of();
      env.clock.expect_getNow(0);

      bool isStale1 = env.staleMarker.isStale();

      env.clock.expect_since(0);
      bool isStale2 = env.staleMarker.isStale();
      it(tag .. ": same tick: not stale", Assert(!isStale2));
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_StaleMarker: checkAlreadyStale";
      let env = tt_StaleMarkerImplTestEnvironment.of();
      env.clock.expect_getNow(0, 2);

      bool isStale1 = env.staleMarker.isStale();

      env.clock.expect_since(1);
      bool isStale2 = env.staleMarker.isStale();
      it(tag .. ": new tick: stale", Assert(isStale2));
      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let strings = tt_Strings.of();
      let size    = strings.size();

      it("tt_Strings: New Strings is empty", AssertEval(size, "==", 0));
    }
    {
      let strings = tt_Strings.of();
      let str     = "a";

      strings.add(str);
      let size = strings.size();

      it("tt_Strings: Element must be added", AssertEval(size, "==", 1));
      it("tt_Strings: Element must be the same", Assert(strings.at(0) == str));
    }
    {
      let tag = "tt_TargetRadar: checkActorsAround";
      let env = tt_TargetRadarTestEnvironment.of();

      Array<Actor> actors =
      {
        spawn("DoomImp", ( 5,  0,  0)),
        spawn("DoomImp", (-5,  0,  0)),
        spawn("DoomImp", ( 0,  5,  0)),
        spawn("DoomImp", ( 0, -5,  0)),
        spawn("DoomImp", ( 0,  0,  5)),
        spawn("DoomImp", ( 0,  0, -5))
      };

      env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

      let targets  = env.targetRadar.getTargets();
      uint nActors = actors.size();
      for (uint i = 0; i < nActors; ++i)
      {
        let a = tt_Target.of(actors[i]);
        it(string.format(tag .. ": actor %d is present in list", i),
           Assert(targets.contains(a)));
      }

      assertSatisfaction(env.originSource.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_TargetRadar: checkDistantActor";
      let env = tt_TargetRadarTestEnvironment.of();

      env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

      let distantActor  = spawn("DoomImp", (1000, 0, 0));
      let distantTarget = tt_Target.of(distantActor);
      let targets       = env.targetRadar.getTargets();

      it(tag .. ": distant actor is not in list",
         AssertFalse(targets.contains(distantTarget)));

      assertSatisfaction(env.originSource.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_TargetRadar: checkNonLivingActor";
      let env = tt_TargetRadarTestEnvironment.of();

      env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

      let nonLiving       = spawn("Medikit", (1, 0, 0));
      let targets         = env.targetRadar.getTargets();
      let nonLivingTarget = tt_Target.of(nonLiving);

      it(tag .. ": non-living actor is not in list",
         AssertFalse(targets.contains(nonLivingTarget)));

      assertSatisfaction(env.originSource.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let tag = "tt_TargetRadar: checkDeadActor";
      let env = tt_TargetRadarTestEnvironment.of();

      env.originSource.expect_getOrigin(tt_Origin.of((0, 0, 0)));

      let deadActor  = spawnDead("DoomImp", (1, 0, 0));
      let targets    = env.targetRadar.getTargets();
      let deadTarget = tt_Target.of(deadActor);

      it(tag .. ": dead actor is not in list",
         AssertFalse(targets.contains(deadTarget)));

      assertSatisfaction(env.originSource.getSatisfaction(), tag);
      cleanUpSpawned();
    }
    {
      let _deathReporter = tt_DeathReporter.of();
      let targetsBefore  = _deathReporter.getTargets();
      it("tt_DeathReporter: No targets before reporting",
        AssertEval(targetsBefore.size(), "==", 0));

      let something = spawn("DoomImp", (0, 0, 0));
      _deathReporter.reportDead(something);
      let targetsAfter = _deathReporter.getTargets();
      it("tt_DeathReporter: Single target after reporting",
        AssertEval(targetsAfter.size(), "==", 1));

      let targetsAfterAfter = _deathReporter.getTargets();
      it("tt_DeathReporter: No new targets",
        AssertEval(targetsAfterAfter.size(), "==", 0));

      cleanUpSpawned();
    }
    {
      let tag = "tt_SorterByDistance : checkEmpty";

      let before = tt_TargetWidgets.of();
      let origin = tt_Origin.of((0, 0, 0));
      let after  = tt_SorterByDistance.sort(before, origin.getVector());

      it(tag .. ": empty collection must remain empty",
         AssertEval(after.size(), "==", 0));
    }
    {
      let tag = "tt_SorterByDistance : checkSorted";

      let origin = tt_Origin.of((0, 0, 0));
      let before = tt_TargetWidgets.of();
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 2))));
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 1))));
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 0))));

      it(tag .. ": Before: sorted",
         Assert(tt_SorterByDistanceTest.isSorted(before, origin.getVector())));

      let after = tt_SorterByDistance.sort(before, origin.getVector());

      it(tag .. ": size of collection must the same",
         AssertEval(after.size(), "==", before.size()));
      it(tag .. ": contains same elements",
         Assert(tt_SorterByDistanceTest.isSameElements(before, after)));
      it(tag .. ": after: sorted",
         Assert(tt_SorterByDistanceTest.isSorted(after, origin.getVector())));

      cleanUpSpawned();
    }
    {
      let tag = "tt_SorterByDistance : checkReverse";

      let origin = tt_Origin.of((0, 0, 0));
      let before = tt_TargetWidgets.of();
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 0))));
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 1))));
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 2))));

      it(tag .. ": before: not sorted",
         Assert(!tt_SorterByDistanceTest.isSorted(before, origin.getVector())));

      let after = tt_SorterByDistance.sort(before, origin.getVector());

      it(tag .. ": size of collection must the same",
         AssertEval(after.size(), "==", before.size()));
      it(tag .. ": contains same elements",
         Assert(tt_SorterByDistanceTest.isSameElements(before, after)));
      it(tag .. ": after: sorted",
         Assert(tt_SorterByDistanceTest.isSorted(after, origin.getVector())));

      cleanUpSpawned();
    }
    {
      let tag = "tt_SorterByDistance : middle";

      let origin = tt_Origin.of((0, 0, 0));
      let before = tt_TargetWidgets.of();
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 1))));
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 2))));
      before.add(tt_SorterByDistanceTest.createAtPosition(spawn("Demon", (0, 0, 0))));

      it(tag .. ": before: not sorted",
         Assert(!tt_SorterByDistanceTest.isSorted(before, origin.getVector())));

      let after = tt_SorterByDistance.sort(before, origin.getVector());

      it(tag .. ": size of collection must the same",
         AssertEval(after.size(), "==", before.size()));
      it(tag .. ": contains same elements",
         Assert(tt_SorterByDistanceTest.isSameElements(before, after)));
      it(tag .. ": after: sorted",
         Assert(tt_SorterByDistanceTest.isSorted(after, origin.getVector())));

      cleanUpSpawned();
    }
    {
      let tag = "tt_Gunner: null origin";
      let env = tt_GunnerTestEnvironment.of();

      env.originSource.expect_getOrigin(NULL);

      env.gunner.doEffect();

      assertSatisfaction(env.getSatisfaction(), tag);
    }
    {
      let tag = "tt_Gunner: valid origin";
      let env = tt_GunnerTestEnvironment.of();

      let origin = tt_Origin.of((0, 0, 0));

      env.originSource.expect_getOrigin(origin);
      env.effect.expect_doEffect();

      env.gunner.doEffect();

      assertSatisfaction(env.getSatisfaction(), tag);
    }
  }

  // Note: don't forget to call cleanUpSpawned at the end of the test case!
  protected play Actor spawn(class<Actor> type, vector3 pos) const
  {
    let result = Actor.spawn(type, pos);
    _spawned.push(result);
    return result;
  }

  // Note: don't forget to call cleanUpSpawned at the end of the test case!
  protected play Actor spawnDead(class<Actor> type, vector3 pos) const
  {
    let result = Actor.spawn(type, pos);
    result.a_Die();
    _spawned.push(result);
    return result;
  }

  protected play void cleanUpSpawned() const
  {
    foreach (anActor : _spawned)
      anActor.destroy();

    _spawned.clear();
  }

  protected void assertSatisfaction(tt_Satisfaction satisfaction, string tag)
  {
    foreach (mock, isSatisfied : satisfaction.values)
      it(tag .. ": " .. mock, Assert(isSatisfied));
  }

  Array<Actor> _spawned;
}

class tt_Satisfaction
{
  static tt_Satisfaction of()
  {
    return new("tt_Satisfaction");
  }

  tt_Satisfaction add(tt_Satisfaction other)
  {
    foreach (tag, value : other.values)
      values.insert(tag, value);
    return self;
  }

  tt_Satisfaction push(string tag, bool value)
  {
    values.insert(tag, value);
    return self;
  }

  Map<string, bool> values;
}

30.1. Mock Macro

class tt_Expectation
{
  static tt_Expectation of(string methodName)
  {
    let result = new("tt_Expectation");
    result.methodName = methodName;
    result.expected = 0;
    result.called = 0;
    return result;
  }

  void expect(int expectedCount)
  {
    expected = expectedCount;
    called = 0;
  }

  string methodName;
  int expected;
  int called;
}

mixin class tt_Mock
{
  private tt_Expectation _mock_addExpectation(string methodName)
  {
    let result = tt_Expectation.of(methodName);
    _expectations.push(result);
    return result;
  }

  tt_Satisfaction getSatisfaction() const
  {
    let result = tt_Satisfaction.of();
    let name = getClassName();
    foreach (expectation : _expectations)
    {
      bool isSatisfied = expectation.expected == expectation.called;
      string status = name .. ": " .. expectation.methodName;
      if (!isSatisfied)
      {
        status.appendFormat(" (expected: %d, called: %d)",
                            expectation.expected,
                            expectation.called);
      }

      result.push(status, isSatisfied);
    }
    return result;
  }

  private Array<tt_Expectation> _expectations;
}

Created: 2026-01-04 Sun 07:06