view rlgterm.js @ 111:f56fdfeed01a

Replace taking over games with forced saves. Instead of reusing the id, just SIGHUP the game process. This works whether it is using polling, WebSockets, or dgamelaunch.
author John "Elwin" Edwards <elwin@sdf.org>
date Sun, 15 Jul 2012 22:33:44 -0700
parents 67b393f10c2b
children 4f2b89e6fde2
line wrap: on
line source

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

// A state machine that keeps track of polling the server.
var ajaxstate = {
  state: 0,
  timerID: null,
  clear: function () {
    if (this.timerID != null) {
      window.clearTimeout(this.timerID);
      this.timerID = null;
    }
  },
  set: function (ms) {
    this.clear();
    this.timerID = window.setTimeout(getData, ms);
  },
  gotdata: function () {
    this.set(1000);
    this.state = 1;
  },
  gotnothing: function () {
    if (this.state == 0) {
      this.set(1000);
      this.state = 1;
    }
    else if (this.state < 4) {
      this.set(4000);
      this.state++;
    }
    else if (session.playing) {
      if (this.state < 8) {
        this.set(15000);
        this.state++;
      }
      else {
        /* It's been over a minute.  Stop polling. */
        this.clear();
      }
    }
    else {
      /* If watching, it can't stop polling entirely, because there
       * are no POST events to start it up again. */
      this.set(10000);
    }
  },
  posted: function (wasdata) {
    if (wasdata) {
      this.set(1000);
      this.state = 1;
    }
    else {
      this.set(200);
      this.state = 0;
    }
  }
};

/* 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"
  }
};

var session = {
  /* The session id assigned by the server. */
  id: null,
  /* Login name and key */
  lname: null,
  lcred: null,
  /* 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;

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;
}

/* State for sending and receiving messages. */
var nsend = 0; // The number of the next packet to send.
var nrecv = 0; // The next packet expected.
var msgQ = []; // Queue for out-of-order messages.

/* Processes a message from the server, returning true or false if it was a 
 * data message with or without data, null if not data.
 * All non-special responseTexts should be handed directly to this function.
 */
function processMsg(msg) {
  var msgDicts;
  var havedata = null; // eventual return value
  try {
    msgDicts = JSON.parse(msg);
  } catch (e) {
    if (e instanceof SyntaxError)
      return null;
  }
  if (msgDicts.length === 0)
    return false;
  for (var j = 0; j < msgDicts.length; j++) {
    if (!msgDicts[j].t)
      continue;
    else if (msgDicts[j].t == "E") {
      if (msgDicts[j].c == 1 || msgDicts[j].c == 6 || msgDicts[j].c == 7) {
        gameover();
        if (msgDicts[j].c == 1) {
          logout();
        }
      }
      debug(1, "Server error: " + msgDicts[j].s);
    }
    // A data message
    else if (msgDicts[j].t == "d") {
      if (msgDicts[j].n === nrecv) {
        writeData(msgDicts[j].d);
        nrecv++;
        /* Process anything in the queue that's now ready. */
        var next;
        while ((next = msgQ.shift()) !== undefined) {
          writeData(next.d);
          nrecv++;
        }
      }
      else if (msgDicts[j].n > nrecv) {
        /* The current message comes after one still missing.  Queue this one
         * for later use. */
        debug(1, "Got packet " + msgDicts[j].n + ", expected " + nrecv);
        msgQ[msgDicts[j].n - nrecv - 1] = msgDicts[j];
      }
      else {
        /* This message's number was encountered previously. */
        debug(1, "Discarding packet " + msgDicts[j].n + ", expected " + nrecv);
      }
      havedata = true;
    }
    else if (msgDicts[j].t == "T") {
      setTitle(msgDicts[j].d);
    }
    else if (msgDicts[j].t == "q") {
      gameover();
    }
    else {
      debug(1, "Unrecognized server message " + msg);
    }
  }
  return havedata;
}

function getData() {
  if (session.id == null)
    return;
  var datareq = new XMLHttpRequest();
  var msg = JSON.stringify({"id": session.id, "t": "n"});
  datareq.onerror = errHandler;
  datareq.onreadystatechange = function () {
    if (datareq.readyState == 4 && datareq.status == 200) {
      var wasdata = processMsg(datareq.responseText);
      if (wasdata != null) {
        if (wasdata)
          ajaxstate.gotdata();
        else
          ajaxstate.gotnothing();
      }
      return;
    }
  };
  datareq.open('POST', '/feed', true);
  datareq.send(msg);
  return;
}

function postResponseHandler() {
  if (this.readyState == 4 && this.status == 200) {
    // We might want to do something with wasdata someday.
    var wasdata = processMsg(this.responseText);
    ajaxstate.posted();
    return;
  }
}

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

function sendback(str) {
  /* For responding to terminal queries. */
  var msgDict = {"id": session.id, "t": "d", "n": nsend++, "d": str};
  var datareq = new XMLHttpRequest();
  datareq.onerror = errHandler;
  datareq.onreadystatechange = postResponseHandler;
  datareq.open('POST', '/feed', true);
  datareq.send(JSON.stringify(msgDict));
  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}));
  }
  else {
    var datareq = new XMLHttpRequest();
    var msgDict = {"id": session.id, "t": "d", "n": nsend++, "d": code};
    datareq.onerror = errHandler;
    datareq.onreadystatechange = postResponseHandler;
    datareq.open('POST', '/feed', true);
    datareq.send(JSON.stringify(msgDict));
  }
  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}));
  }
  else {
    var datareq = new XMLHttpRequest();
    var msgDict = {"id": session.id, "t": "d", "n": nsend++, "d": keystr};
    datareq.onerror = errHandler;
    datareq.onreadystatechange = postResponseHandler;
    datareq.open('POST', '/feed', true);
    datareq.send(JSON.stringify(msgDict));
  }
  return;
}

function setup() {
  keyHexCodes.init();
  termemu.init("termwrap", 24, 80);
  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 (!WebSocket) {
    message("Your browser does not support WebSockets. You can still play, " +
            "but it will be slower, and may not work in the future.", "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();
  if (session.id != null)
    return;
  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 */
      session.lcred = reply.k;
      session.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(gamelist[i].n);
    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 (!WebSocket)
    return;
  if (session.id) {
    /* Don't bother with status if already playing/watching. */
    if (statsock) {
      statsock.close();
      statsock = null;
    }
    return;
  }
  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") {
      return;
    }
    tableCurrent(msg.g);
  };
  statsock.onclose = function (ev) {
    statsock = null;
  }
}

function getcurrent(clear) {
  if (session.id || clear) {
    if (statInterval) {
      window.clearInterval(statInterval);
      statInterval = null;
    }
    return;
  }
  if (!statInterval) {
    statInterval = window.setInterval(getcurrent, statDelta);
  }
  if (session.lcred)
    getchoices();
  if (WebSocket) {
    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 (!reply.s) {
      return;
    }
    tableCurrent(reply.g);
  };
  req.open('GET', '/status', true);
  req.send();
  return;
}

function getchoices() {
  if (session.id != null || !session.lcred)
    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"] != session.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/' + session.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 startgame(game) {
  if (session.id != null || !session.lcred)
    return;
  if (WebSocket) {
    wsStart(game);
    return;
  }
  var smsg = {};
  smsg["key"] = session.lcred;
  smsg["game"] = game.uname;
  smsg["h"] = 24;
  smsg["w"] = 80;
  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 == 's') {
      /* Success */
      session.id = reply.id;
      session.playing = true;
      termemu.resize(reply.h, reply.w);
      message("You are now playing " + game.name + ".");
      setmode("play");
      getData();
    }
    else if (reply.t == 'E') {
      if (reply.c == 1) {
        logout();
        message("The server forgot about you, please log in again.", "warn");
      }
      else if (reply.c == 4) {
        if (reply.s == "dgamelaunch") {
          message("You are already playing " + game.name + " over SSH.", 
                  "warn");
        }
        else {
          message("You are already playing " + game.name + 
                  " in another browser window.", "warn");
        }
      }
      else if (reply.c == 7) {
        message("The game is being saved, try again in a few seconds.");
      }
      else {
        message("The server says it can't start your game because \"" + 
                reply.s + "\". This is probably a bug.", "warn");
      }
    }
  };
  req.open('POST', '/play', true);
  req.send(JSON.stringify(smsg));
  return;
}

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

function stopgame(game) {
  if (!session.lcred)
    return;
  var stopmsg = {"key": session.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 wsStart(game) {
  var sockurl = "ws://" + window.location.host + "/play/" + game.uname;
  sockurl += "?key=" + session.lcred + "&w=80&h=24";
  ws = new WebSocket(sockurl);
  ws.onopen = function (event) {
    session.id = 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 " + msgObject.g + ".");
    }
    else if (msgObject.t == 'd') {
      writeData(msgObject.d);
    }
  };
  ws.onclose = function (event) {
    session.sock = null;
    gameover();
  };
}

function startwatching(gamenumber) {
  if (session.id != null)
    return;
  if (WebSocket) {
    wsWatch(gamenumber);
    return;
  }
  var wmsg = {"n": Number(gamenumber)};
  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 == 'w') {
      /* Success */
      session.id = reply.id;
      session.playing = false;
      termemu.resize(reply.h, reply.w);
      termemu.reset();
      termemu.toAltBuf();
      var pname = reply.p;
      var gname = games[reply.g].name;
      message("You are now watching " + pname + " play " + gname + ".");
      setmode("watch");
      getData();
    }
    else if (reply.t == 'E') {
      message("The game could not be watched: " + reply.s, "warn");
      getcurrent();
    }
  };
  req.open('POST', '/watch', true);
  req.send(JSON.stringify(wmsg));
  return;
}

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

function wsWatch(gamenumber) {
  var sockurl = "ws://" + window.location.host + "/watch/" + String(gamenumber);
  var ws = new WebSocket(sockurl);
  ws.onopen = function (event) {
    session.id = 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;