/* GEMINI INK DEMO SERVER See the README for more details This code was made by DistractedMOSFET of mosfetarium.com. But consider it as public domain as physically possible :) */ var Story = require('inkjs').Story; var fs = require('fs'); var tls = require('tls'); var hostAddr = process.env.GEMINI_INK_ADDR || 'localhost'; var port = process.env.GEMINI_INK_POST || 1965; // load the ink file, I took this from the inkjs github so I don't know what the story of the replace is. var inkFile = fs.readFileSync(process.env.GEMINI_INK_FILE || 'intercept.ink.json', 'UTF-8').replace(/^\uFEFF/, ''); // Options for the TLS listen server var tlsOptions = { // most notable thing, these paths define where we load the key and cert from. key: fs.readFileSync('server-key.pem'), cert: fs.readFileSync('server-cert.pem'), // we need to request a cert from the connecting client because we use client TLS certs for game sessions. requestCert: true, // we accept certless connections anyway so we can send the normal gemini reject message rejectUnauthorized: false }; // we remember everyone's games as a map of client tls SHA256 fingerprint -> (lastActionUnixTime, inkStory) // we evict stale games every so often // also, i'm not entirely sure at this point in time of the differences between js Maps vs Objects // and if I should be using this or just an object, but oh well. var activeGames = new Map(); // listen... tls.createServer(tlsOptions, function(sock) { // this function handles incoming data // so this is where we need to do all the gemini stuff! sock.on('data', function(data) { // a gemini request is just: // slice in here to remove the CRLF var schemeSplit = data.toString().slice(0, -2).split('://'); // they don't need to say gemini:// at the start, so 1 is valid length if (schemeSplit.length == 2) { if (schemeSplit[0] != 'gemini') { sock.write('50 Unsupported Protocol\r\n'); sock.pipe(sock); return; } } else if (schemeSplit.length != 1) { sock.write('50 Bad URI?\r\n'); sock.pipe(sock); return; } var path = schemeSplit.pop(); // if the URI isn't for us, uhhhh? if (!path.startsWith(hostAddr)) { sock.write('50 Wrong audience?\r\n'); sock.pipe(sock); return; } path = path.slice(hostAddr.length); if (path == '' || path == '/') { // show the landing page // i mean, I could like try to load a markdown file or something? but I don't care. sock.write('20 text/gemini\r\n'); sock.write('# Gemini Ink Demo!\r\n'); sock.write('Welcome to the Gemini-ink demo! This demo shows playing a game using inkle\'s ink engine. The demo game is called the Intercept. You will need a client certificate active in your gemini client to be able to play, if your inactive for more than 15 minutes, your game session will be lost. The game is short though!\r\n'); sock.write('=> gemini://' + hostAddr + '/play Start!'); sock.pipe(sock); } else if (path.startsWith('/play')) { // GAME PLAY!!!! // So we need them to have a certifcate so that we can track their session // Seemingly, you can't just do like, == {} in js (ugh) and so to check for an empty cert // (ie, no cert) I have to do this stupid check. var clientCert = sock.getPeerCertificate(); if (Object.keys(clientCert).length == 0) { sock.write('60 You need an active gemini certificate in order to play.\r\n'); sock.pipe(sock); return; } var sessionId = clientCert.fingerprint256; // make a game, if needed. but we do have a cap just to make the server not cry if (!activeGames.has(sessionId)) { if (activeGames.size > 49) { sock.write('40 Too many concurrent games at the moment! Try again later!\r\n'); sock.pipe(sock); return; } console.log('Creating new game for: ' + sock.remoteAddress + ':' + sock.remotePort); activeGames.set(sessionId, [Date.now(), new Story(inkFile)]); } else { // update the active time so that I don't evict the game. activeGames.get(sessionId)[0] = Date.now(); } var game = activeGames.get(sessionId)[1]; // check if this is a URI for picking a game option var option = path.slice('/play/'.length); if (option != '') { if (isNaN(option)) { sock.write('50 Bad choice option?\r\n'); sock.pipe(sock); return; } option = parseInt(option); if (option < 0 || option >= game.currentChoices.length) { sock.write('50 Bad choice option?\r\n'); sock.pipe(sock); return; } game.ChooseChoiceIndex(option); } // get as much text as possible var text = game.ContinueMaximally(); // make links for choices for (var choiceIndex = 0; choiceIndex < game.currentChoices.length; choiceIndex += 1) { text += '\r\n=> gemini://' + hostAddr + '/play/' + choiceIndex + ' ' + game.currentChoices[choiceIndex].text; } // game has ended. send an end message and clean up. if (!game.canContinue && game.currentChoices.length === 0) { text += '\r\n\r\nThank you trying out Gemini Ink! That\'s the end.\r\n'; text += '=> https://mosfetarium.com/ Please check out my blog, follow and give feedback (not on gemini yet, sorry!)\r\n'; text += '=> gemini://' + hostAddr +'/play/ Restart'; console.log('Someone finished! ' + sock.remoteAddress + ':' + sock.remotePort); activeGames.delete(sessionId); } //Send the text! sock.write('20 text/gemini\r\n'); sock.write(text); sock.pipe(sock); } else { sock.write('50 There\'s nothing here.\r\n'); sock.pipe(sock); return; } sock.end(); }); }).listen(port, hostAddr); function cleanUpOldGames() { var now = Date.now(); for (var [key, entry] of activeGames.entries()) { if ((now-entry[0]) > 1000*60*15) { // 15 minute life console.log('Cleaning up game for... :' + key); activeGames.delete(key); } } // queue check for ten minutes from now setTimeout(cleanUpOldGames, 1000*60*10); } cleanUpOldGames();