Greasy Fork is available in English.

TIPP10 Cheat

Fakes lessons on tipp10.com!

// ==UserScript==
// @name         TIPP10 Cheat
// @name:de      TIPP10 Cheat
// @namespace    http://tampermonkey.net/
// @version      0.0.7
// @description  Fakes lessons on tipp10.com!
// @description:de Fake Lektionen auf tipp10.com!
// @author       Sirvierl0ffel
// @match        *://online.tipp10.com/*/training/
// @icon         https://i.imgur.com/zTDoadV.png
// @icon64       https://i.imgur.com/L1SG5wo.png
// @grant        none
// ==/UserScript==

// Icons: https://imgur.com/a/jZHMEQg

/* globals $, TIPP10, language */

(function () {
  'use strict';

  // Ensure the script was not already loaded
  if (window.CHEAT_LOADED) return;
  Object.defineProperty(window, 'CHEAT_LOADED', {
    value: true,
    writable: false,
  });

  const CHEAT_VERSION = '0.0.7';

  //#region HTML
  const CHEAT_CSS = `
/* =========================== Run background ============================ */

#cheat-background {
  display: none;
  line-height: normal;
  font-weight: normal;
  user-select: none;
  background: rgba(0, 0, 0, 0.65);
  z-index: 999999;
  position: absolute;
  width: 100%;
  height: 100%;
}



/* Run state table */

#cheat-run-table {
  width: 600px;
  background: rgba(0, 0, 0, 0.4);
  margin: 10px 10px 10px 10px;
  padding: 5px;
  border: 1px solid rgba(255, 255, 255, 0.4);
}

/* Container with centered child */
.center {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* Cell style */
#cheat-run-table td {
  font-family: 'Courier New', 'Lucida Console', 'monospace', 'sans-serif';
  font-size: 16px;
  color: #ffffff;
}
.cheat-run-table-label {
  font-weight: normal;
  text-align: right;
  margin: 0px 5px 0px 5px;
}
.cheat-run-table-data-cell {
  font-weight: normal;
  white-space: nowrap;
  padding-right: 20px;
  width: 100%;
}

/* Lesson processing states */
.cheat-lessons-done {
  border: 1px solid #00b000;
}
.cheat-lesson-current {
  border: 1px solid #ffff00;
}
.cheat-lessons-undone {
  border: 1px solid #b00000;
}



/* ============================== Cheat panel ============================== */

#cheat {
  color: black;
  line-height: normal;
  font-weight: normal;
  font-family: Roboto, Arial, sans-serif;
  background: #e8e8e8;
  user-select: none;
  position: absolute;
  z-index: 1000000;
  float: right;
  top: 10px;
  right: 10px;
  padding: 10px;
  width: 240px;
  border: 1px solid #c8c8c8;
  border-radius: 5px;
}

/* Cheat panel header */
#cheat-bar {
  height: 40px;
  width: 100%;
  margin-bottom: 14px;
}
#cheat-title {
  font-size: 20px;
  font-weight: bold;
}
#cheat-subtitle {
  font-size: 11px;
  text-align: right;
  color: #808080;
}
#cheat-title-line {
  color: #c0c0c0;
  margin: 0px 1px 0px 1px;
}


/* ============================ Cheat settings ============================= */
#cheat-settings {
}

#cheat-settings td {
  padding: 0px 3px 8px 3px;
  font-size: revert;
  font-family: Roboto, Arial, sans-serif;
  color: #000000;
}

#cheat-settings label {
  font-family: Arial;
  font-size: 13px;
  font-weight: normal;
  margin-left: 5px;
  color: #000000;
  font-size: 14px;
}

#cheat-settings br {
  font-family: Arial;
  font-size: 14px;
  font-weight: normal;
  margin-left: 5px;
  color: #000000;
}

.cheat-input {
  margin: revert;
  font-weight: revert;
  font-family: Calibri;
  font-size: 14px;
  background: #d8d8d8;
  color: #505050;
  width: 100%;
  padding: 2px 2px 3px 6px;
  border: 1px solid #808080;
  border-radius: 2px;
  box-sizing: border-box;
}
.cheat-input:invalid {
  background: #d8c8c8;
  border-color: #ff0000;
}
.cheat-input:disabled {
  color: #a0a0a0;
  border: 1px solid #b8b8b8;
}

#cheat-queue-hint {
  font-family: Arial;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  position: absolute;
  width: 200px;
  height: 14px;
  font-size: 10px;
  color: #d80000;
}

/* Cheat panel bottom */
#cheat-bottom {
  margin-top: 8px;
  text-align: right;
  width: max;
}

#cheat-button {
  color: black;
  font-family: Calibri;
  font-size: 14px;
  width: 80px;
  padding: 3px 3px 3px 3px;
}
`;

  const CHEAT_HTML = `
<div id="cheat-background">
  <div class="center">
    <table id="cheat-run-table">
      <tr>
        <td>
          <div class="cheat-run-table-label">Lessons:</div>
        </td>
        <td class="cheat-run-table-data-cell">
          <div id="cheat-info-lessons">-</div>
        </td>
      </tr>
      <tr>
        <td>
          <div class="cheat-run-table-label">State:</div>
        </td>
        <td class="cheat-run-table-data-cell">
          <div id="cheat-info-state">[00:00:00] -</div>
        </td>
      </tr>
      <tr>
        <td>
          <div class="cheat-run-table-label">Time:</div>
        </td>
        <td class="cheat-run-table-data-cell">
          <div id="cheat-info-time">[00:00:00]</div>
        </td>
      </tr>
    </table>
  </div>
</div>

<div id="cheat">
  <div id="cheat-bar">
    <div id="cheat-title">TIPP10 Cheat</div>
    <hr id="cheat-title-line" />
    <div id="cheat-subtitle">v${CHEAT_VERSION} by Sirvierl0ffel</div>
  </div>

  <div id="cheat-settings">
    <table style="width: 100%;">
      <tr>
        <td>
          <label for="cheat-strokes">Strokes/10m</label><br />
          <input id="cheat-strokes" class="cheat-input" type="number" value="1750" step="50" min="250" max="100000" />
        </td>
        <td>
          <label for="cheat-strokes-random">Random +</label><br />
          <input id="cheat-strokes-random" class="cheat-input" type="number" value="500" step="50" min="0" max="100000" />
        </td>
      </tr>

      <tr>
        <td>
          <label for="cheat-error">Error %</label><br />
          <input id="cheat-error" class="cheat-input" type="number" value="0" step="1" min="0" max="100" />
        </td>
        <td>
          <label for="cheat-error-random">Random +</label><br />
          <input id="cheat-error-random" class="cheat-input" type="number" value="8" step="1" min="0" max="100" />
        </td>
      </tr>

      <tr>
        <td>
          <label for="cheat-interval">Interval (m)</label><br />
          <input id="cheat-interval" class="cheat-input" type="number" value="0" step="1" min="0" max="300" />
        </td>
        <td>
          <label for="cheat-interval-random">Random +</label><br />
          <input id="cheat-interval-random" class="cheat-input" type="number" value="5" step="1" min="0" max="300" />
        </td>
      </tr>

      <tr>
        <td colspan="2">
          <label for="cheat-duration">Lesson Duration (m)</label><br />
          <input id="cheat-duration" class="cheat-input" type="number" value="5" step="1" min="1" max="20" />
        </td>
      </tr>

      <tr>
        <td colspan="2">
          <label for="cheat-queue">Lesson Queue</label><br />
          <input id="cheat-queue" class="cheat-input" type="text" value="1 1 1 2 2" />
          <div id="cheat-queue-hint"></div>
        </td>
      </tr>
    </table>
  </div>

  <div id="cheat-bottom">
    <button id="cheat-button">Run</button>
  </div>
</div>

`;
  //#endregion

  // Keys near other keys for authentic errors, for now only German QWERTZ for Windows is fully supported
  const KEY_NEIGHBOR_MAPS = {
    /* spell-checker: disable */
    def: {
      ' ': '.,',
      '\u00B6': '., ',
      'a': 'sqy',
      'b': 'gv n',
      'c': 'dxv f',
      'd': 'esfc',
      'e': 'wrd34',
      'f': 'rdgcv',
      'g': 'tfvb',
      'h': 'gzbn',
      'i': 'uok',
      'j': 'hk',
      'k': 'jl,',
      'l': 'ka',
      'm': 'n ',
      'n': 'mj',
      'o': 'ip',
      'p': 'o',
      'q': 'was',
      'r': 'etf',
      's': 'awdxz',
      't': 'frg',
      'u': 'uiy',
      'v': 'bcg ',
      'w': 'asdqe',
      'x': 'ys',
      'y': 'thu',
      'z': 'xc',
    },
    de_qwertz_win: {
      '<': '>ay',
      '>': '<ay',
      '^': '°\t1',
      '°': '^\t1',
      ',': ';m.,',
      '.': '.,-',
      ';': ',:',
      '-': '_.\u00F6\u00E4',
      '_': '-:',
      "'": '#+*',
      '#': "'+",
      '+': '*#´',
      ' ': '.,\u00B6',
      '\u00B6': '., ',
      '0': '=op9\u00DF',
      '1': '^2q!',
      '2': '"2qwe13',
      '3': '\u00A724wer',
      '4': '$35er',
      '5': '%46rt',
      '6': '&57t',
      '7': '/68u',
      '8': '(79',
      '9': ')80iop',
      '=': '0OP9\u00DF',
      '!': '1"Q°',
      '"': '2qwe!\u00A7',
      '\u00A7': '3"$wer',
      '$': '435ER',
      '%': '5$&RT',
      '& ': '6%/T',
      '/': '7&(U',
      '(': '8/)',
      ')': '9(=IOP',
      'a': 'sqy',
      'b': 'gv n',
      'c': 'dxv f',
      'd': 'esfc',
      'e': 'wrd34',
      'f': 'rdgcv',
      'g': 'tfvb',
      'h': 'gzbn',
      'i': 'uok',
      'j': 'hk',
      'k': 'jl,',
      'l': 'k\u00F6,.',
      'm': 'n, ',
      'n': 'mj',
      'o': 'ip',
      'p': 'o\u00F6\u00FC+',
      'q': 'was',
      'r': 'etf',
      's': 'awdx',
      't': 'frg',
      'u': 'uiz',
      'v': 'bcg ',
      'w': 'asdqe',
      'x': 'ys<',
      'y': 'xc<',
      'z': 'thu',
      '\u00FC': '\u00F6p\u00E4',
      '\u00F6': 'l\u00E4-',
      '\u00E4': '\u00F6\u00FC#',
    },
    /* spell-checker: enable */
  };

  const START_DELAY_MS = 10000; // Extra delay before starting
  const MESSAGE_DURATION_MS = 5000; // Extra delay between lessons

  // Insert html
  $('<style>').text(CHEAT_CSS).appendTo(document.head);
  $('body').html(CHEAT_HTML + $('body').html());

  let show = false; // TIPP10 presence
  let lastTitle;
  let enabled = getCookie('cheatEnabled', 'true') === 'true';
  let queue = []; // Queue text field as integer array

  // Run variables
  let running = false;
  let canceled = false;
  let startMS = 0;
  let lessons = [];
  let lessonIdx = 0;
  let totalDurationMS = 0;
  let completeMS = 0;

  // Add cheat queue change listener to parse lesson numbers and provide custom validity
  $('#cheat-queue').change(() => {
    let value = $('#cheat-queue').val();
    value = value.trim().replace(/ +(?= )/g, ''); // Trim and remove double spaces
    if (value.length === 0) {
      queueVal('Empty!');
      return;
    }
    let lessonStrings = value.split(' ');
    queue = new Array(lessonStrings.length);
    for (let i = 0; i < lessonStrings.length; i++) {
      queue[i] = parseInt(lessonStrings[i]);
      if (isNaN(queue[i]) || lessonStrings[i].length != String(queue[i]).length) {
        queueVal('No number: "' + lessonStrings[i] + '"');
        return;
      }
      if (queue[i] < 1) {
        queueVal('To small lesson number: ' + lessonStrings[i]);
        return;
      }
      if (queue[i] > 20) {
        queueVal('To large lesson number: ' + lessonStrings[i]);
        return;
      }
    }
    queueVal('');
  });
  function queueVal(message) {
    $('#cheat-queue')[0].setCustomValidity(message);
    $('#cheat-queue-hint').text(message);
  }

  // Input elements
  let inputs = [
    $('#cheat-strokes'),
    $('#cheat-strokes-random'),
    $('#cheat-error'),
    $('#cheat-error-random'),
    $('#cheat-interval'),
    $('#cheat-interval-random'),
    $('#cheat-duration'),
    $('#cheat-queue'),
  ];

  // Link cookies to input elements
  for (let input of inputs) {
    let cookie = input.prop('id').replace('-', '');
    // Cooky stuff
    input.val(getCookie(cookie, input.val()));
    input.change(() => {
      let v = input.val();

      // Invalid when empty
      if (v.length == 0) input[0].setCustomValidity('Empty!');
      else if (input[0].validationMessage === 'Empty!') input[0].setCustomValidity('');

      if (!input.is(':invalid')) setCookie(cookie, v);
    });
    input.keydown(() => input.change()); // Update validity when typing
  }
  function setCookie(c_name, value) {
    let date = new Date(2147483647 * 1000).toUTCString();
    let c_value = escape(value) + '; expires=' + date + ' SameSite=None; Secure';
    document.cookie = c_name + '=' + c_value;
  }
  function getCookie(c_name, def) {
    let i;
    let x;
    let y;
    let ARRcookies = document.cookie.split(';');
    for (i = 0; i < ARRcookies.length; i++) {
      x = ARRcookies[i].substr(0, ARRcookies[i].indexOf('='));
      y = ARRcookies[i].substr(ARRcookies[i].indexOf('=') + 1);
      x = x.replace(/^\s+|\s+$/g, '');
      if (x === c_name) {
        return unescape(y);
      }
    }
    return def;
  }

  refreshVis();
  function refreshVis() {
    $('#cheat').css('display', enabled && show ? 'block' : 'none');
    $('#cheat').focus();
  }

  // Add toggle key listener
  $('body').on('keydown', (evt) => {
    if (evt.key === 'Home') {
      if (running || !show) return;
      enabled = !enabled;
      refreshVis();
      setCookie('cheatEnabled', String(enabled));
    }
  });

  // Stop button state
  let stopHit;
  let to;
  $('#cheat-button').on('click', () => {
    if (running) {
      // Stop button state handling
      if (!stopHit && !canceled) {
        stopHit = true;
        $('#cheat-button').css('color', '#cc0000');
        $('#cheat-button').html('!Stop!');
        to = setTimeout(() => {
          stopHit = false;
          $('#cheat-button').css('color', 'black');
          $('#cheat-button').html('Stop');
        }, 3000);
        return;
      }
      clearTimeout(to);
      stopHit = false;
      $('#cheat-button').css('color', 'black');
      $('#cheat-button').html('Stop');

      // Stop tick
      running = false;

      // Revert document to previous state
      for (let input of inputs) input.prop('disabled', false);
      $('#cheat-background').css('display', 'none');
      $('#cheat-button').html('Run');
      document.title = lastTitle;
    } else {
      // Check TIPP10 presence and input validity
      if (window.TIPP10 == undefined) return;
      for (let input of inputs) {
        input.change();
        if (input.is(':invalid')) return;
      }

      // Update document to running mode
      for (let input of inputs) input.prop('disabled', true);
      $('#cheat-background').css('display', 'block');
      $('#cheat-button').html('Stop');
      lastTitle = document.title;
      document.title = '\uD83D\uDD25\uD83D\uDD25\uD83D\uDD25 TIPP10 \uD83D\uDD25\uD83D\uDD25\uD83D\uDD25'; // Light TIPP10 on fire

      // Prepare run variables and lesson schedule
      canceled = false;
      running = true;
      totalDurationMS = START_DELAY_MS;
      lessons = new Array(queue.length);
      lessonIdx = 0;
      startMS = Date.now();
      completeMS = startMS + START_DELAY_MS - MESSAGE_DURATION_MS;
      for (let i = 0; i < queue.length; i++) {
        let lessonId = queue[i];
        let delayM = Math.floor(random('#cheat-interval', '#cheat-interval-random') + 0.1);
        let durationM = parseEl('#cheat-duration');
        let targetStrokesP10M = Math.floor(random('#cheat-strokes', '#cheat-strokes-random'));
        let targetErrorPct = random('#cheat-error', '#cheat-error-random');
        lessons[i] = new Lesson(targetStrokesP10M, targetErrorPct, delayM, durationM, lessonId);
        if (i > 0) {
          const durationMS = lessons[i].durationM * 60 * 1000;
          const delayMS = lessons[i].delayM * 60 * 1000;
          totalDurationMS += MESSAGE_DURATION_MS + durationMS + delayMS;
        }
      }
      updateScheduleState();

      // Element reading helper functions
      function parseEl(selector) {
        return parseFloat($(selector).val());
      }
      function random(valSelector, randomSelector) {
        let val = parseEl(valSelector);
        let add = parseEl(randomSelector);
        let min = parseFloat($(valSelector).prop('min'));
        let max = parseFloat($(valSelector).prop('max'));
        return Math.min(Math.max(val + Math.random() * add, min), max);
      }
    }
  });

  setInterval(tick, 50);
  function tick() {
    // Ensure TIPP10 instance exists
    if (window.TIPP10 == undefined) {
      // When running, cancel run and display error
      // After the now "Exit" button is hit, running will be false and the panel will disappear
      if (running) {
        if (!canceled) {
          canceled = true;
          clearTimeout(to);
          stopHit = false;
          $('#cheat-button').css('color', 'black');
          $('#cheat-button').html('Exit');
          $('#cheat-info-state').html('[00:00:00] Error: TIPP10 instance not found!');
          $('#cheat-info-time').html('[00:00:00]');
        }
      } else if (show) {
        show = false;
        refreshVis();
      }
    } else if (!show) {
      show = true;
      refreshVis();
    }

    // Ensure a schedule is running
    if (!running || canceled) return;

    let nowMS = Date.now();

    // Update total timer
    $('#cheat-info-time').html('[' + formatMs(totalDurationMS - (nowMS - startMS)) + ']');

    // Do nothing for the specified start delay
    if (nowMS - startMS < START_DELAY_MS) {
      let timeLeft = formatMs(START_DELAY_MS - (nowMS - startMS));
      $('#cheat-info-state').html('[' + timeLeft + '] Starting');
      return;
    }

    let lesson = lessons[lessonIdx];

    if (!lesson.completed) {
      let durationMs = 0;
      let delayMs = 0;

      // Apply delays, if the lesson is not the first one
      if (lessonIdx > 0) {
        durationMs = lesson.durationM * 60 * 1000;
        delayMs = lesson.delayM * 60 * 1000;

        let pastMs = nowMS - completeMS;

        // Cancel completion for the "Faked a Lesson" message display duration
        if (pastMs < MESSAGE_DURATION_MS) {
          let timeLeft = formatMs(MESSAGE_DURATION_MS - pastMs);
          $('#cheat-info-state').html('[' + timeLeft + '] Faked a Lesson');
          return;
        }
        pastMs -= MESSAGE_DURATION_MS;

        // Cancel completion for the delay of the last lesson
        if (pastMs < delayMs) {
          let timeLeft = formatMs(delayMs - pastMs);
          $('#cheat-info-state').html('[' + timeLeft + '] Waiting for Interval');
          return;
        }
        pastMs -= delayMs;

        // Cancel completion for the lessons duration
        if (pastMs < durationMs) {
          let timeLeft = formatMs(durationMs - pastMs);
          $('#cheat-info-state').html('[' + timeLeft + '] Waiting for Lesson Duration');
          return;
        }
      }

      // Complete lesson and go to next
      lesson.complete();
      completeMS += MESSAGE_DURATION_MS + delayMs + durationMs;
      lessonIdx++;
      updateScheduleState();

      // End lesson, if lesson index is over the queues length
      if (lessonIdx == lessons.length) {
        canceled = true;
        clearTimeout(to);
        stopHit = false;
        $('#cheat-button').css('color', 'black');
        $('#cheat-button').html('Done');
        $('#cheat-info-state').html('[00:00:00] Done!');
        $('#cheat-info-time').html('[00:00:00]');
      }
    }
  }

  function formatMs(ms) {
    let seconds = Math.floor(Math.max(ms, 0) / 1000);
    let minutes = Math.floor(seconds / 60);
    let hours = Math.floor(minutes / 60);
    let hoursString = String(hours).padStart(2, '0');
    let minutesString = String(minutes % 60).padStart(2, '0');
    let secondsString = String(seconds % 60).padStart(2, '0');
    return hoursString + ':' + minutesString + ':' + secondsString;
  }

  function updateScheduleState() {
    let done = '';
    let current = '';
    let undone = '';
    for (let i = 0; i < lessonIdx; i++) done += (i > 0 ? ' ' : '') + lessons[i].lessonId;
    current = lessonIdx < lessons.length ? String(lessons[lessonIdx].lessonId) : '';
    for (let i = lessonIdx + 1; i < lessons.length; i++) undone += (i > 0 ? ' ' : '') + lessons[i].lessonId;
    if (done.length > 0) {
      done = '<span class="cheat-lessons-done">' + done + '</span> ';
    }
    if (current.length > 0) {
      current = '<span class="cheat-lesson-current">' + current + '</span>';
    }
    if (undone.length > 0) {
      undone = ' <span class="cheat-lessons-undone">' + undone + '</span>';
    }
    $('#cheat-info-lessons').html(done + current + undone);
  }

  function Lesson(targetStrokesP10M, targetErrorPct, delayM, durationM, lessonId) {
    this.delayM = delayM;
    this.durationM = durationM;
    this.lessonId = lessonId;
    this.completed = false;

    this.complete = function () {
      if (this.completed) throw new Error('Tried to complete already completed lesson!');

      this.completed = true;

      let targetStrokeCount = Math.floor((targetStrokesP10M * durationM) / 10);

      // Generate error indices
      let errorIndices = new Array(targetStrokeCount);
      for (let i = 0; i < errorIndices.length; i++) errorIndices[i] = i;
      for (let j, x, i = errorIndices.length; i; j = parseInt(Math.random() * i), x = errorIndices[--i], errorIndices[i] = errorIndices[j], errorIndices[j] = x);
      errorIndices = errorIndices.slice(0, (targetStrokeCount * targetErrorPct) / 100);

      // Make lesson text request
      TIPP10.main._t131(`/${language}/training/data/init/0/0/${lessonId}/0/`, function (req) {
        let data = JSON.parse(req.responseText);

        let layoutStrokes = data.layout; // Stroke count info
        let lines = data.lesson.split('\n'); // Lines of this lesson
        let keyboardLayout = data.settings.user_keyboard; // Selected keyboard layout

        // Type lines
        let charCount = 0;
        let strokeCount = 0;
        let errorCount = 0;
        let errorString = '';

        let p_e = {};

        let p_lc = '';

        let line;
        typeLoop: while (true) {
          // Get new random line that is not equal to the last
          // one, if the lesson consists of multiple lines
          if (lines.length > 1) {
            let newLine = line;
            while (newLine === line) {
              newLine = lines[Math.floor(Math.random() * lines.length)];
            }
            line = newLine;
          }

          // Type every character and a pilcrow sign
          let realLine = line + '\u00B6';
          for (let i = 0; i < realLine.length; i++) {
            let c = realLine[i];

            // Type error
            if (errorIndices.includes(charCount)) {
              let ec = generateError(realLine, i, c);

              get(ec).ea++;
              get(c).et++;
              errorCount++;
              addChar(ec);
              errorString += '1';
            }

            get(c).eo++;
            charCount++;
            addChar(c);
            errorString += '0';

            // Exit type loop if the target stroke count is reached
            if (strokeCount >= targetStrokeCount) break typeLoop;
          }
        }

        // Generates an authentic error for a character
        function generateError(line, i, c) {
          let doubled = 0.3; // Chances to type the last character
          let switched1 = 0.3; // Chances to type the next character
          let switched2 = 0.1; // Chances to type the next next character
          if (Math.random() < doubled && i > 0 && line[i - 1] !== c) {
            return line[i - 1];
          } else if (Math.random() < switched1 && i + 1 < line.length && line[i + 1] !== c) {
            return line[i + 1];
          } else if (Math.random() < switched2 && i + 2 < line.length && line[i + 2] !== c) {
            return line[i + 2];
          } else {
            let map = KEY_NEIGHBOR_MAPS[keyboardLayout] || KEY_NEIGHBOR_MAPS.def;
            let choices = map[c] || 'abcdefghijklmnopqrstuvwxyz ,.1234567890'; // cspell:disable-line
            let upper = c.toUpperCase();
            if (upper === c) choices += upper + upper;
            let error = c;
            let tries = 0;
            while (error === c) {
              error = choices[Math.floor(Math.random() * choices.length)];
              // Can't find error, so just return space or a, depending on which is an error
              if (tries++ > 50) return c === ' ' ? 'a' : ' ';
            }
            return error;
          }
        }

        // Adds a char to the lesson
        function addChar(c) {
          let code = c.charCodeAt(0);
          if (layoutStrokes[code]) {
            if (layoutStrokes[code][1] != 0) strokeCount++;
            if (layoutStrokes[code][2] != 0) strokeCount++;
          }
          strokeCount++;
          p_lc += c;
        }

        // Gets occurrence and error stats object for character
        function get(c) {
          let code = c.charCodeAt(0);
          if (!p_e[code]) {
            p_e[code] = {
              eo: 0,
              et: 0,
              ea: 0,
            };
          }
          return p_e[code];
        }

        let p_r = {
          csrf: data.csrf,

          // Lesson stats
          l: String(lessonId),
          ta: 0, // lesson task (?)
          t: durationM * 60,
          c: charCount,
          s: strokeCount,
          e: errorCount,
          le: errorString,

          // Lesson settings
          tt: 'ticker', // (?)
          dt: 0, // duration type, 0 = time
          dv: String(durationM), // duration value
          ec: 1, // error correction type, 1 = retype
          eb: 0, // (?)
          es: 0, // (?)
          ak: 0, // (?)
          ac: 0, // (?)
          ah: 0, // (?)
          ap: 0, // (?)
          ab: 0, // (?)
          ai: 1, // (?)
          am: 0, // (?)
        };

        let p_ls = ''; // (?)

        // Make result request
        let r = JSON.stringify(p_r);
        let e = JSON.stringify(p_e);
        let lc = encodeURIComponent(p_lc);
        let ls = encodeURIComponent(p_ls);
        TIPP10.main._t131(`/${language}/training/data/result/`, () => {}, `r=${r}&e=${e}&lc=${lc}&ls=${ls}`);
      });
    };
  }
})();