view rlgterm.js @ 163:0f6da35b27a0

RLGWebD: overhaul the list of current games. The /status WebSocket now only sends a complete list when opened. At 40-second intervals, it sends a list of games that have been updated in the last minute. The client now uses this to keep its own list.
author John "Elwin" Edwards
date Sun, 04 Jan 2015 16:55:57 -0500
parents a2a25b7631f1
children bf518a00190b
line wrap: on
line source

/* rlgterm.js: Roguelike Gallery's driver for termemu.js */

/* Data on the available games. */
var games = {
  "rogue3": {
    "name": "Rogue V3",
    "uname": "rogue3"
  },
  "rogue4": {
    "name": "Rogue V4",
    "uname": "rogue4"
  },
  "rogue5": {
    "name": "Rogue V5",
    "uname": "rogue5"
  },
  "srogue": {
    "name": "Super-Rogue",
    "uname": "srogue"
  },
  "arogue5": {
    "name": "Advanced Rogue 5",
    "uname": "arogue5"
  }
};

var session = {
  /* The session id assigned by the server. */
  connect: false,
  /* Login name and key are now in sessionStorage. */
  /* Whether the game is being played or just watched. */
  playing: false,
  /* WebSocket for communication */
  sock: null
};

/* The interval ID for checking the status of current games. */
var statInterval = null;
/* How frequently to check. */
var statDelta = 8000;
/* A WebSocket to listen for status events. */
var statsock = null;
/* List of currently active games. */
var currentList = [];
/* Last time the list was updated. */
var currentTS = null;

function writeData(hexstr) {
  var codenum;
  var codes = [];
  var nc;
  var u8wait = 0; /* Stores bits from previous bytes of multibyte sequences. */
  var expect = 0; /* The number of 10------ bytes expected. */
  /* UTF-8 translation. */
  for (var i = 0; i < hexstr.length; i += 2) {
    nc = Number("0x" + hexstr.substr(i, 2));
    if (nc < 0x7F) {
      /* 0------- */
      codes.push(nc);
      /* Any incomplete sequence will be discarded. */
      u8wait = 0;
      expect = 0;
    }
    else if (nc < 0xC0) {
      /* 10------ : part of a multibyte sequence */
      if (expect > 0) {
        u8wait <<= 6;
        u8wait += (nc & 0x3F);
        expect--;
        if (expect == 0) {
          codes.push(u8wait);
          u8wait = 0;
        }
      }
      else {
        /* Assume an initial byte was missed. */
        u8wait = 0;
      }
    }
    /* These will all discard any incomplete sequence. */
    else if (nc < 0xE0) {
      /* 110----- : introduces 2-byte sequence */
      u8wait = (nc & 0x1F);
      expect = 1;
    }
    else if (nc < 0xF0) {
      /* 1110---- : introduces 3-byte sequence */
      u8wait = (nc & 0x0F);
      expect = 2;
    }
    else if (nc < 0xF8) {
      /* 11110--- : introduces 4-byte sequence */
      u8wait = (nc & 0x07);
      expect = 3;
    }
    else if (nc < 0xFC) {
      /* 111110-- : introduces 5-byte sequence */
      u8wait = (nc & 0x03);
      expect = 4;
    }
    else if (nc < 0xFE) {
      /* 1111110- : introduces 6-byte sequence */
      u8wait = (nc & 0x01);
      expect = 5;
    }
    else {
      /* 1111111- : should never appear */
      u8wait = 0;
      expect = 0;
    }
    /* Supporting all 31 bits is probably overkill... */
  }
  termemu.write(codes);
  return;
}

function errHandler() {
  message("Unable to connect to the server.", "warn");
}

function sendback(str) {
  /* For responding to terminal queries. */
  if (session.sock) {
    session.sock.send(JSON.stringify({"t": "d", "d": str}));
  }
  return;
}

function sendkey(ev) {
  if (!session.playing)
    return;
  var keynum = ev.keyCode;
  var code;
  if (keynum >= 65 && keynum <= 90) {
    /* Letters. */
    if (ev.ctrlKey)
      keynum -= 64;
    else if (!ev.shiftKey)
      keynum += 32;
    code = keynum.toString(16);
    if (code.length < 2)
      code = "0" + code;
  }
  else if (keynum >= 48 && keynum <= 57) {
    /* The number row, NOT the numpad. */
    if (ev.shiftKey) {
      code = numShifts[keynum - 48].toString(16);
    }
    else {
      code = keynum.toString(16);
    }
  }
  else if (keynum in keyHexCodes) {
    if (ev.shiftKey)
      code = keyHexCodes[keynum][1];
    else
      code = keyHexCodes[keynum][0];
  }
  else if (keynum >= 16 && keynum <= 20) {
    /* Shift, Cntl, Alt, CAPSLOCK */
    return;
  }
  else {
    debug(1, "Ignoring keycode " + keynum);
    return;
  }
  ev.preventDefault();
  if (session.sock) {
    session.sock.send(JSON.stringify({"t": "d", "d": code}));
  }
  /* Otherwise it is disconnected */
  return;
}

var charshifts = { '-': "5f", '=': "2b", '[': "7b", ']': "7d", '\\': "7c",
  ';': "3a", '\'': "22", ',': "3c", '.': "3e", '/': "3f", '`': "7e"
}

var kpkeys = { "KP1": "1b4f46", "KP2": "1b4f42", "KP3": "1b5b367e", 
               "KP4": "1b4f44", "KP5": "1b5b45", "KP6": "1b4f43", 
               "KP7": "1b4f48", "KP8": "1b4f41", "KP9": "1b5b357e" };

function vkey(c) {
  if (!session.playing)
    return;
  var keystr;
  if (c.match(/^[a-z]$/)) {
    if (termemu.ctrlp()) {
      var n = c.charCodeAt(0) - 96;
      keystr = n.toString(16);
      if (keystr.length < 2)
        keystr = "0" + keystr;
    }
    else if (termemu.shiftp())
      keystr = c.toUpperCase().charCodeAt(0).toString(16);
    else
      keystr = c.charCodeAt(0).toString(16);
  }
  else if (c.match(/^[0-9]$/)) {
    if (termemu.shiftp())
      keystr = numShifts[c.charCodeAt(0) - 48].toString(16);
    else
      keystr = c.charCodeAt(0).toString(16);
  }
  else if (c == '\n')
    keystr = "0a";
  else if (c == '\t')
    keystr = "09";
  else if (c == '\b')
    keystr = "08";
  else if (c == ' ')
    keystr = "20";
  else if (c in charshifts) {
    if (termemu.shiftp())
      keystr = charshifts[c];
    else
      keystr = c.charCodeAt(0).toString(16);
  }
  else if (c in kpkeys) {
    keystr = kpkeys[c];
  }
  else
    return;
  if (session.sock) {
    session.sock.send(JSON.stringify({"t": "d", "d": keystr}));
  }
  return;
}

function setup() {
  keyHexCodes.init();
  termemu.init("termwrap", 24, 80);
  /* Is someone already logged in? */
  if ("lcred" in sessionStorage) {
    setmode("choose");
    message("You are logged in as " + sessionStorage.getItem("lname") + ".");
  }
  else
    setmode("login");
  /* Set up the text size. */
  var cssSize = termemu.view.style.fontSize;
  var match = cssSize.match(/\d*/);
  if (!match) {
    return;
  }
  var csize = Number(match[0]);
  var allscreen = document.getElementById("termwrap");
  while (csize > 9 && csize < 48) {
    if (allscreen.scrollWidth * 1.2 > window.innerWidth) {
      csize = textsize(false);
    }
    else if (allscreen.scrollWidth * 2 < window.innerWidth) {
      csize = textsize(true);
    }
    else
      break;
  }
  if (!window.WebSocket) {
    message("Your browser does not support WebSockets. " +
            "This Web app will not work.", "warn");
  }
  return;
}

function toggleshift() {
  termemu.toggleshift();
  keydiv = document.getElementById("shiftkey");
  if (termemu.shiftp())
    keydiv.className = "keysel";
  else
    keydiv.className = "key";
  return;
}

function togglectrl() {
  termemu.togglectrl();
  keydiv = document.getElementById("ctrlkey");
  if (termemu.ctrlp())
    keydiv.className = "keysel";
  else
    keydiv.className = "key";
  return;
}

function formlogin(ev) {
  ev.preventDefault();
  /* What to do if logged in already? */
  var loginmsg = {};
  loginmsg["name"] = document.getElementById("input_name").value;
  loginmsg["pw"] = document.getElementById("input_pw").value;
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply = JSON.parse(req.responseText);
    if (reply.t == 'l') {
      /* Success */
      sessionStorage.setItem("lcred", reply.k);
      sessionStorage.setItem("lname", reply.u);
      message("You are now logged in as " + reply.u + ".");
      setmode("choose");
    }
    else if (reply.t == 'E') {
      var failmsg = "Logging in failed. ";
      if (reply.c == 2)
        failmsg += reply.s.match(/Invalid data: (.*)/)[1];
      else if (reply.c == 3)
        failmsg += "The username or password was incorrect.";
      else if (reply.c == 6)
        failmsg += "The server is shutting down.";
      message(failmsg, "warn");
      document.getElementById("input_name").value = "";
      document.getElementById("input_pw").value = "";
    }
  };
  req.open('POST', '/login', true);
  req.send(JSON.stringify(loginmsg));
  return;
}

function tableCurrent(gamelist) {
  var gamediv = document.getElementById("gametable");
  while (gamediv.children.length > 2)
    gamediv.removeChild(gamediv.children[2]);
  if (gamelist.length === 0) {
    gamediv.style.display = "none";
    document.getElementById("nogames").style.display = "block";
  }
  else {
    gamediv.style.display = "table";
    document.getElementById("nogames").style.display = "none";
  }
  for (var i = 0; i < gamelist.length; i++) {
    var row = document.createElement("div");
    var cell1 = document.createElement("div");
    var cell2 = document.createElement("div");
    var cell3 = document.createElement("div");
    var cell4 = document.createElement("div");
    cell1.appendChild(document.createTextNode(gamelist[i].p));
    var uname = gamelist[i].g;
    if (uname in games)
      cell2.appendChild(document.createTextNode(games[uname].name));
    else {
      continue;
    }
    cell3.appendChild(document.createTextNode(idlestr(gamelist[i].i)));
    var button = document.createElement("span");
    button.appendChild(document.createTextNode("Watch"));
    button.onclick = makeWatcher(uname + "/" + gamelist[i].p);
    button.className = "ibutton";
    cell4.appendChild(button);
    row.appendChild(cell1);
    row.appendChild(cell2);
    row.appendChild(cell3);
    row.appendChild(cell4);
    gamediv.appendChild(row);
  }
}

/* Handles the status socket, opening and closing it when necessary. */
function wsCurrent() {
  if (!window.WebSocket)
    return;
  if (session.connect) {
    /* Don't bother with status if already playing/watching. */
    if (statsock) {
      statsock.close();
      statsock = null;
    }
    return;
  }
  if ("lcred" in sessionStorage) {
    /* When starting the socket, the choices list might not be initialized. */
    getchoices();
  }
  if (statsock)
    return; 
  statsock = new WebSocket("ws://" + window.location.host + "/status");
  statsock.onmessage = function (ev) {
    var msg;
    try {
      msg = JSON.parse(ev.data);
    } catch (e) {
      if (e instanceof SyntaxError)
        return;
    }
    if (msg.t == "t") {
      currentList = msg.g;
      currentTS = new Date();
      tableCurrent(currentList);
    }
    else if (msg.t == "p") {
      var now = new Date();
      var idletimes = {};
      for (var i = 0; i < msg.g.length; i++) {
        var tag = msg.g[i].g + "/" + msg.g[i].p;
        idletimes[tag] = msg.g[i].i;
      }
      for (var i = 0; i < currentList.length; i++) {
        var tag = currentList[i].g + "/" + currentList[i].p;
        if (tag in idletimes) {
          currentList[i].i = idletimes[tag];
        }
        else {
          currentList[i].i += now - currentTS;
        }
      }
      currentTS = now;
      tableCurrent(currentList);
    }
    else if (msg.t == "b") {
      var justbegun = {};
      justbegun.g = msg.g;
      justbegun.p = msg.p;
      justbegun.i = 0;
      currentList.push(justbegun);
      tableCurrent(currentList);
      if (msg.p == sessionStorage.getItem("lname")) {
        getchoices();
      }
    }
    else if (msg.t == "e") {
      var i = 0;
      while (i < currentList.length) {
        if (currentList[i].g == msg.g && currentList[i].p == msg.p)
          break;
        i++;
      }
      if (i < currentList.length) {
        currentList.splice(i, 1);
      }
      tableCurrent(currentList);
      if (msg.p == sessionStorage.getItem("lname")) {
        getchoices();
      }
    }
  };
  statsock.onclose = function (ev) {
    statsock = null;
  }
}

/* FIXME gamelist API has changed */
function getcurrent(clear) {
  if (window.WebSocket) {
    return;
  }
  if (session.connect || clear) {
    if (statInterval) {
      window.clearInterval(statInterval);
      statInterval = null;
    }
    return;
  }
  if (!statInterval) {
    statInterval = window.setInterval(getcurrent, statDelta);
  }
  if ("lcred" in sessionStorage)
    getchoices();
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply;
    try {
      reply = JSON.parse(req.responseText);
    } catch (e) {
      if (e instanceof SyntaxError)
        return;
    }
    if (!reply.s) {
      return;
    }
    tableCurrent(reply.g);
  };
  req.open('GET', '/status', true);
  req.send();
  return;
}

function getchoices() {
  if (session.connect || !("lcred" in sessionStorage))
    return;
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply;
    try {
      reply = JSON.parse(req.responseText);
    } catch (e) {
      if (e instanceof SyntaxError)
        return;
    }
    if (!("name" in reply) || reply["name"] != sessionStorage.getItem("lname") 
          || !("stat" in reply))
      return;
    var optdiv = document.getElementById("opttable");
    /* Don't remove the first child, it's the header. */
    while (optdiv.childNodes.length > 1)
      optdiv.removeChild(optdiv.childNodes[1]);
    for (var gname in games) {
      if (!(gname in reply.stat))
        continue;
      var acttext;
      if (reply.stat[gname] == "s")
        acttext = "Resume your game";
      else if (reply.stat[gname] == "0")
        acttext = "Start a game";
      else if (reply.stat[gname] == "p" || reply.stat[gname] == "d")
        acttext = "Force save";
      else
        continue;
      var button = document.createElement("span");
      button.appendChild(document.createTextNode(acttext));
      if ("s0".indexOf(reply.stat[gname]) >= 0) {
        button.onclick = makeStarter(gname);
        button.className = "ibutton";
      }
      else {
        button.onclick = makeStopper(gname);
        button.className = "ibutton";
      }
      var actdiv = document.createElement("div");
      actdiv.appendChild(button);
      var gamediv = document.createElement("div");
      gamediv.appendChild(document.createTextNode(games[gname].name));
      var rowdiv = document.createElement("div");
      rowdiv.appendChild(gamediv);
      rowdiv.appendChild(actdiv);
      optdiv.appendChild(rowdiv);
    }
  };
  req.open('GET', '/pstatus/' + sessionStorage.getItem("lname"), true);
  req.send();
  return;
}

/* This can't be in the loop in getchoices(), or the closure's scope will
 * get overwritten on the next iteration, and then all the games end up 
 * being Super-Rogue.
 */
function makeStarter(gname) {
  if (!(gname in games))
    return null;
  var game = games[gname];
  function starter(ev) {
    startgame(game);
  }
  return starter;
}

function makeStopper(gname) {
  if (!(gname in games))
    return null;
  var game = games[gname];
  function stopper(ev) {
    stopgame(game);
  }
  return stopper;
}

function stopgame(game) {
  if (!("lcred" in sessionStorage))
    return;
  var stopmsg = {"key": sessionStorage.getItem("lcred"), "g": game.uname};
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply = JSON.parse(req.responseText);
    if (reply.t == 'E') {
      if (reply.c == 7)
        message("That game has already stopped.");
      else if (reply.c == 1) {
        logout();
        message("The server forgot about you, please log in again.", "warn");
      }
      else {
        message("That game could not be stopped because: " + reply.s + 
                "This might be a bug.", "warn");
      }
    }
  }
  req.open('POST', '/quit', true);
  req.send(JSON.stringify(stopmsg));
  return;
}

function startgame(game) {
  if (!("lcred" in sessionStorage) || session.connect)
    return;
  if (!window.WebSocket) {
    return;
  }
  var sockurl = "ws://" + window.location.host + "/play/" + game.uname;
  sockurl += "?key=" + sessionStorage.getItem("lcred") + "&w=80&h=24";
  ws = new WebSocket(sockurl);
  ws.onopen = function (event) {
    session.connect = true;
    session.playing = true;
    session.sock = ws;
    setmode("play");
  };
  ws.onmessage = function (event) {
    var msgObject = JSON.parse(event.data);
    if (msgObject.t == 's') {
      termemu.resize(msgObject.h, msgObject.w);
      message("You are now playing " + games[msgObject.g].name + ".");
    }
    else if (msgObject.t == 'd') {
      writeData(msgObject.d);
    }
  };
  ws.onclose = function (event) {
    session.sock = null;
    gameover();
  };
}

function makeWatcher(t) {
  function watcher(ev) {
    startwatching(t);
  }
  return watcher;
}

function startwatching(tag) {
  if (session.connect)
    return;
  var sockurl = "ws://" + window.location.host + "/watch/" + tag;
  var ws = new WebSocket(sockurl);
  ws.onopen = function (event) {
    session.connect = true;
    session.sock = ws;
    setmode("watch");
  };
  ws.onmessage = function (event) {
    var msgObject = JSON.parse(event.data);
    if (msgObject.t == 'w') {
      termemu.resize(msgObject.h, msgObject.w);
      termemu.reset();
      termemu.toAltBuf();
      var pname = msgObject.p;
      var gname = games[msgObject.g].name;
      message("You are now watching " + pname + " play " + gname + ".");
    }
    else if (msgObject.t == 'd') {
      writeData(msgObject.d);
    }
  };
  ws.onclose = function (event) {
    session.sock = null;
    gameover();
  };
}

function formreg(ev) {
  ev.preventDefault();
  /* This ought to check for being logged in instead. */
  if (session.connect)
    return;
  var regmsg = {};
  regmsg["name"] = document.getElementById("regin_name").value;
  regmsg["pw"] = document.getElementById("regin_pw").value;
  regmsg["email"] = document.getElementById("regin_email").value;
  var req = new XMLHttpRequest();
  req.onerror = errHandler;
  req.onreadystatechange = function () {
    if (req.readyState != 4 || req.status != 200) 
      return;
    var reply = JSON.parse(req.responseText);
    if (reply.t == 'r') {
      /* Success */
      message("Welcome " + reply.u + ", you are now registered.");
      sessionStorage.setItem("lcred", reply.k);
      sessionStorage.setItem("lname", reply.u);
      message("You are now logged in as " + reply.u + ".");
      setmode("choose");
    }
    else if (reply.t == 'E') {
      var failmsg = "Registration failed.";
      if (reply.c == 2) {
        var errdesc = reply.s.match(/Invalid data: (.*)/)[1];
        if (errdesc.match(/No name/))
          failmsg += " You need to choose a name.";
        else if (errdesc.match(/No password/))
          failmsg += " You need to choose a password.";
        else if (errdesc.match(/Invalid/)) {
          failmsg += " Names must be letters and numbers. E-mail addresses " +
                     "can also contain these characters: @.-_";
        }
        else if (errdesc.match(/Username/))
          failmsg += " Someone else is already using that name.";
        else
          failmsg += " This is probably a bug.";
      }
      else
        failmsg += " This is probably a bug.";
      message(failmsg, "warn");
      document.getElementById("regin_name").value = "";
      document.getElementById("regin_pw").value = "";
      document.getElementById("regin_email").value = "";
    }
  };
  req.open('POST', '/addacct', true);
  req.send(JSON.stringify(regmsg));
  return;
}

function gameover() {
  if (!session.connect)
    return;
  /* TODO IFACE2 If the end was unexpected, tell player the game was saved. */
  if (session.playing)
    message("Finished playing.");
  else
    message("Finished watching.");
  session.connect = false;
  session.playing = false;
  termemu.toNormBuf();
  if ("lcred" in sessionStorage)
    setmode("choose");
  else
    setmode("login");
  return;
}

function logout() {
  sessionStorage.removeItem("lcred");
  sessionStorage.removeItem("lname");
  setmode("login");
}

/* TODO determine whether this is needed */
function stop() {
  if (!session.connect)
    return;
  if (session.sock) {
    session.sock.close();
    return;
  }
}

function setmode(mode, ev) {
  if (ev)
    ev.preventDefault();
  if (mode == "play") {
    document.getElementById("keyboard").style.display = "block";
    document.getElementById("playctl").style.display = "block";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "none";
  }
  else if (mode == "watch") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "block";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "none";
  }
  else if (mode == "choose") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "none";
    document.getElementById("startgame").style.display = "block";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "block";
    getcurrent();
  }
  else if (mode == "login") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "none";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "block";
    document.getElementById("register").style.display = "none";
    document.getElementById("current").style.display = "block";
    getcurrent();
  }
  else if (mode == "register") {
    document.getElementById("keyboard").style.display = "none";
    document.getElementById("playctl").style.display = "none";
    document.getElementById("startgame").style.display = "none";
    document.getElementById("login").style.display = "none";
    document.getElementById("register").style.display = "block";
    document.getElementById("current").style.display = "none";
  }
  wsCurrent();
}

function toggleBlock(id) {
  var element = document.getElementById(id);
  if (!element)
    return;
  if (element.style.display != "block")
    element.style.display = "block";
  else
    element.style.display = "none";
}

function message(msg, mtype) {
  var msgdiv = document.createElement("div");
  var msgtext = document.createTextNode(msg);
  msgdiv.appendChild(msgtext);
  if (mtype) {
    msgdiv.className = mtype;
  }
  var msgcontainer = document.getElementById("messages");
  msgcontainer.insertBefore(msgdiv, msgcontainer.firstChild);
}

function debug(level, msg) {
  if (level < debugSuppress)
    return;
  var msgdiv = document.createElement("div");
  var msgtext = document.createTextNode(msg);
  msgdiv.appendChild(msgtext);
  document.getElementById("debug").appendChild(msgdiv);