home · contact · privacy
Spawn hats from BottleDeposit, add HatRemixer.
[plomrogue2] / rogue_chat.html
1 <!DOCTYPE html>
2 <html><head>
3 <style>
4   pre {
5       display: inline-block;
6   }
7   pre a {
8       color: white;
9   }
10 </style>
11 </head><body>
12 <div>
13 terminal rows: <input id="n_rows" type="number" step=4 min=24 value=24 />
14 / terminal columns: <input id="n_cols" type="number" step=4 min=80 value=80 />
15 / <a href="https://plomlompom.com/repos/?p=plomrogue2;a=summary">source code</a> (includes proper terminal/ncurses client)
16 </div>
17 <pre id="terminal"></pre>
18 <textarea id="input" style="opacity: 0; width: 0px;"></textarea>
19 <div>
20 keyboard input/control: <span id="keyboard_control"></span>
21 </div>
22 <h3>button controls for mouse players</h3>
23 <table id="move_table" style="float: left">
24   <tr>
25     <td style="text-align: right"><button id="hex_move_upleft"></button></td>
26     <td style="text-align: center"><button id="square_move_up"></button></td>
27     <td><button id="hex_move_upright"></button></td>
28   </tr>
29   <tr>
30     <td style="text-align: right;"><button id="square_move_left"></button><button id="hex_move_left">left</button></td>
31     <td stlye="text-align: center;">move</td>
32     <td><button id="square_move_right"></button><button id="hex_move_right"></button></td>
33   </tr>
34   <tr>
35     <td><button id="hex_move_downleft"></button></td>
36     <td style="text-align: center"><button id="square_move_down"></button></td>
37     <td><button id="hex_move_downright"></button></td>
38   </tr>
39 </table>
40 <table>
41   <tr>
42     <td><button id="help"></button></td>
43   </tr>
44   <tr>
45     <td><button id="switch_to_chat"></button><br /></td>
46   </tr>
47   <tr>
48     <td><button id="switch_to_study"></button></td>
49     <td><button id="toggle_map_mode"></button>
50   </tr>
51   <tr>
52     <td><button id="switch_to_play"></button></td>
53     <td>
54       <button id="switch_to_take_thing"></button>
55       <button id="drop_thing"></button>
56       <button id="door"></button>
57       <button id="consume"></button>
58       <button id="switch_to_command_thing"></button>
59       <button id="teleport"></button>
60       <button id="install"></button>
61       <button id="wear"></button>
62     </td>
63   </tr>
64   <tr>
65     <td><button id="switch_to_edit"></button></td>
66     <td>
67       <button id="switch_to_write"></button>
68       <button id="flatten"></button>
69       <button id="switch_to_annotate"></button>
70       <button id="switch_to_portal"></button>
71       <button id="switch_to_name_thing"></button>
72       <button id="switch_to_password"></button>
73       <button id="switch_to_enter_face"></button>
74     </td>
75   </tr>
76   <tr>
77     <td><button id="switch_to_admin_enter"></button></td>
78     <td>
79       <button id="switch_to_control_pw_type"></button>
80       <button id="switch_to_control_tile_type"></button>
81       <button id="switch_to_admin_thing_protect"></button>
82       <button id="toggle_tile_draw"></button>
83     </td>
84   <tr>
85   </tr>
86 </table>
87 <h3>edit keybindings</h3> (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values">here</a> for non-obvious available values):<br />
88 <ul>
89 <li>move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)
90 <li>move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)
91 <li>move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)
92 <li>move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)
93 <li>move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" />
94 <li>move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" />
95 <li>move right (hex grid): <input id="key_hex_move_right" type="text" value="d" />
96 <li>move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" />
97 <li>move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" />
98 <li>move left (hex grid): <input id="key_hex_move_left" type="text" value="a" />
99 <li>help: <input id="key_help" type="text" value="h" />
100 <li>flatten surroundings: <input id="key_flatten" type="text" value="F" />
101 <li>teleport: <input id="key_teleport" type="text" value="p" />
102 <li>drop thing: <input id="key_drop_thing" type="text" value="u" />
103 <li>open/close: <input id="key_door" type="text" value="D" />
104 <li>consume: <input id="key_consume" type="text" value="C" />
105 <li>install: <input id="key_install" type="text" value="I" />
106 <li>(un-)wear: <input id="key_wear" type="text" value="W" />
107 <li><input id="key_switch_to_enter_face" type="text" value="f" />
108 <li><input id="key_switch_to_take_thing" type="text" value="z" />
109 <li><input id="key_switch_to_chat" type="text" value="t" />
110 <li><input id="key_switch_to_play" type="text" value="p" />
111 <li><input id="key_switch_to_study" type="text" value="?" />
112 <li><input id="key_switch_to_edit" type="text" value="E" />
113 <li><input id="key_switch_to_write" type="text" value="m" />
114 <li><input id="key_switch_to_name_thing" type="text" value="N" />
115 <li><input id="key_switch_to_command_thing" type="text" value="O" />
116 <li><input id="key_switch_to_password" type="text" value="P" />
117 <li><input id="key_switch_to_admin_enter" type="text" value="A" />
118 <li><input id="key_switch_to_control_pw_type" type="text" value="C" />
119 <li><input id="key_switch_to_control_tile_type" type="text" value="Q" />
120 <li><input id="key_switch_to_admin_thing_protect" type="text" value="T" />
121 <li><input id="key_switch_to_annotate" type="text" value="M" />
122 <li><input id="key_switch_to_portal" type="text" value="T" />
123 <li>toggle map view: <input id="key_toggle_map_mode" type="text" value="L" />
124 <li>toggle protection character drawing: <input id="key_toggle_tile_draw" type="text" value="m" />
125 </ul>
126 </div>
127 <script>
128 "use strict";
129 let websocket_location = "wss://plomlompom.com/rogue_chat/";
130 //let websocket_location = "ws://localhost:8001/";
131
132 let mode_helps = {
133     'play': {
134         'short': 'play',
135         'intro': '',
136         'long': 'This mode allows you to interact with the map in various ways.'
137     },
138     'study': {
139         'short': 'study',
140         'intro': '',
141         'long': 'This mode allows you to study the map and its tiles in detail.  Move the question mark over a tile, and the right half of the screen will show detailed information on it.  Toggle the map view to show or hide different information layers.'},
142     'edit': {
143         'short': 'world edit',
144         'intro': '',
145         'long': 'This mode allows you to change the game world in various ways.  Individual map tiles can be protected by "protection characters", which you can see by toggling into the protections map view.  You can edit a tile if you set the world edit password that matches its protection character.  The character "." marks the absence of protection:  Such tiles can always be edited.'
146     },
147     'name_thing': {
148         'short': 'name thing',
149         'intro': '',
150         'long': 'Give name to/change name of thing here.'
151     },
152     'command_thing': {
153         'short': 'command thing',
154         'intro': '',
155         'long': 'Enter a command to the thing you carry.  Enter nothing to return to play mode.'
156     },
157     'take_thing': {
158         'short': 'take thing',
159         'intro': 'Pick up a thing in reach by entering its index number.  Enter nothing to abort.',
160         'long': 'You see a list of things which you could pick up.  Enter the target thing\'s index, or, to leave, nothing.'
161     },
162     'admin_thing_protect': {
163         'short': 'change thing protection',
164         'intro': '@ enter thing protection character:',
165         'long': 'Change protection character for thing here.'
166     },
167     'enter_face': {
168         'short': 'enter your face',
169         'intro': '@ enter face line (enter nothing to abort):',
170         'long': 'Draw your face as ASCII art.  The string you enter must be 9 characters long, and will be divided on display into three lines of three characters each, from top to bottom..'
171     },
172     'write': {
173         'short': 'change terrain',
174         'intro': '',
175         'long': 'This mode allows you to change the map tile you currently stand on (if your world editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.'
176     },
177     'control_pw_type': {
178         'short': 'change protection character password',
179         'intro': '@ enter protection character for which you want to change the password:',
180         'long': 'This mode is the first of two steps to change the password for a protection character.  First enter the protection character for which you want to change the password.'
181     },
182     'control_pw_pw': {
183         'short': 'change protection character password',
184         'intro': '',
185         'long': 'This mode is the second of two steps to change the password for a protection character.  Enter the new password for the protection character you chose.'
186     },
187     'control_tile_type': {
188         'short': 'change tiles protection',
189         'intro': '@ enter protection character which you want to draw:',
190         'long': 'This mode is the first of two steps to change tile protection areas on the map.  First enter the tile protection character you want to write.'
191     },
192     'control_tile_draw': {
193         'short': 'change tiles protection',
194         'intro': '',
195         'long': 'This mode is the second of two steps to change tile protection areas on the map.  Toggle tile protection drawing on/off and move the ?? cursor around the map to draw the selected protection character.'
196     },
197     'annotate': {
198         'short': 'annotate tile',
199         'intro': '',
200         'long': 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your world editing password authorizes you so).  Hit Return to leave.'
201     },
202     'portal': {
203         'short': 'edit portal',
204         'intro': '',
205         'long': 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your world editing password authorizes you so).  Enter or edit a URL to imprint a teleportation target; enter emptiness to remove a pre-existing teleportation target.  Hit Return to leave.'
206     },
207     'chat': {
208         'short': 'chat',
209         'intro': '',
210         'long': 'This mode allows you to engage in chit-chat with other users.  Any line you enter into the input prompt that does not start with a "/" will be sent out to nearby players – but barriers and distance will reduce what they can read, so stand close to them to ensure they get your message.  Lines that start with a "/" are used for commands like:\n\n/nick NAME – re-name yourself to NAME'
211     },
212     'login': {
213         'short': 'login',
214         'intro': '',
215         'long': 'Enter your player name.'
216     },
217     'waiting_for_server': {
218         'short': 'waiting for server response',
219         'intro': '@ waiting for server …',
220         'long': 'Waiting for a server response.'
221     },
222     'post_login_wait': {
223         'short': 'waiting for server response',
224         'intro': '',
225         'long': 'Waiting for a server response.'
226     },
227     'password': {
228         'short': 'set world edit password',
229         'intro': '',
230         'long': 'This mode allows you to change the password that you send to authorize yourself for editing password-protected elements of the world.  Hit return to confirm and leave.'
231     },
232     'admin_enter': {
233         'short': 'become admin',
234         'intro': '@ enter admin password:',
235         'long': 'This mode allows you to become admin if you know an admin password.'
236     },
237     'admin': {
238         'short': 'admin',
239         'intro': '',
240         'long': 'This mode allows you access to actions limited to administrators.'
241     }
242 }
243 let key_descriptions = {
244     'help': 'help',
245     'flatten': 'flatten surroundings',
246     'teleport': 'teleport',
247     'drop_thing': 'drop thing',
248     'door': 'open/close',
249     'consume': 'consume',
250     'install': '(un-)install',
251     'wear': '(un-)wear',
252     'toggle_map_mode': 'toggle map view',
253     'toggle_tile_draw': 'toggle protection character drawing',
254     'hex_move_upleft': 'up-left',
255     'hex_move_upright': 'up-right',
256     'hex_move_right': 'right',
257     'hex_move_left': 'left',
258     'hex_move_downleft': 'down-left',
259     'hex_move_downright': 'down-right',
260     'square_move_up': 'up',
261     'square_move_left': 'left',
262     'square_move_down': 'down',
263     'square_move_right': 'right',
264 };
265 for (const mode_name of Object.keys(mode_helps)) {
266     key_descriptions['switch_to_' + mode_name] = mode_helps[mode_name].short;
267 };
268
269 let rows_selector = document.getElementById("n_rows");
270 let cols_selector = document.getElementById("n_cols");
271 let key_selectors = document.querySelectorAll('[id^="key_"]');
272
273 for (const key_switch_selector of document.querySelectorAll('[id^="key_switch_to_"]')) {
274     const action = key_switch_selector.id.slice("key_switch_to_".length);
275     key_switch_selector.parentNode.prepend(mode_helps[action].short + ': ');
276 }
277
278 function restore_selector_value(selector) {
279     let stored_selection = window.localStorage.getItem(selector.id);
280     if (stored_selection) {
281         selector.value = stored_selection;
282     }
283 }
284 restore_selector_value(rows_selector);
285 restore_selector_value(cols_selector);
286 for (let key_selector of key_selectors) {
287     restore_selector_value(key_selector);
288 }
289
290 function escapeHTML(str) {
291     return str.
292         replace(/&/g, '&amp;').
293         replace(/</g, '&lt;').
294         replace(/>/g, '&gt;').
295         replace(/'/g, '&apos;').
296         replace(/"/g, '&quot;');
297 };
298
299 let terminal = {
300   initialize: function() {
301     this.rows = rows_selector.value;
302     this.cols = cols_selector.value;
303     this.pre_el = document.getElementById("terminal");
304     this.set_default_colors();
305     this.apply_colors();
306     this.content = [];
307       let line = []
308     for (let y = 0, x = 0; y <= this.rows; x++) {
309         if (x == this.cols) {
310             x = 0;
311             y += 1;
312             this.content.push(line);
313             line = [];
314             if (y == this.rows) {
315                 break;
316             }
317         }
318         line.push(' ');
319     }
320   },
321   apply_colors: function() {
322     this.pre_el.style.color = this.foreground;
323     this.pre_el.style.backgroundColor = this.background;
324   },
325   set_default_colors: function() {
326       this.foreground = 'white';
327       this.background = 'black';
328       this.apply_colors();
329   },
330   set_random_colors: function() {
331       function rand(offset) {
332           return Math.floor(offset + Math.random() * 96).toString(16).padStart(2, '0');
333       }
334       this.foreground = '#' + rand(159) + rand(159) + rand(159);
335       this.background = '#' + rand(0) + rand(0) + rand(0);
336       this.apply_colors();
337   },
338   blink_screen: function() {
339       this.pre_el.style.color = this.background;
340       this.pre_el.style.backgroundColor = this.foreground;
341       setTimeout(() => {
342           this.pre_el.style.color = this.foreground;
343           this.pre_el.style.backgroundColor = this.background;
344       }, 100);
345   },
346   refresh: function() {
347       let pre_content = '';
348       for (let y = 0; y < this.rows; y++) {
349           let line = this.content[y].join('');
350           let chunks = [];
351           if (y in tui.links) {
352               let start_x = 0;
353               for (let span of tui.links[y]) {
354                   chunks.push(escapeHTML(line.slice(start_x, span[0])));
355                   chunks.push('<a target="_blank" href="');
356                   chunks.push(escapeHTML(span[2]));
357                   chunks.push('">');
358                   chunks.push(escapeHTML(line.slice(span[0], span[1])));
359                   chunks.push('</a>');
360                   start_x = span[1];
361               }
362               chunks.push(escapeHTML(line.slice(start_x)));
363           } else {
364               chunks = [escapeHTML(line)];
365           }
366           for (const chunk of chunks) {
367               pre_content += chunk;
368           }
369           pre_content += '\n';
370       }
371       this.pre_el.innerHTML = pre_content;
372   },
373   write: function(start_y, start_x, msg) {
374       for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
375           this.content[start_y][x] = msg[i];
376       }
377   },
378   drawBox: function(start_y, start_x, height, width) {
379     let end_y = start_y + height;
380     let end_x = start_x + width;
381     for (let y = start_y, x = start_x; y < this.rows; x++) {
382       if (x == end_x) {
383         x = start_x;
384         y += 1;
385         if (y == end_y) {
386             break;
387         }
388       };
389       this.content[y][x] = ' ';
390     }
391   },
392 }
393 terminal.initialize();
394
395 let parser = {
396   tokenize: function(str) {
397     let tokens = [];
398     let token = ''
399     let quoted = false;
400     let escaped = false;
401     for (let i = 0; i < str.length; i++) {
402       let c = str[i];
403       if (quoted) {
404         if (escaped) {
405           token += c;
406           escaped = false;
407         } else if (c == '\\') {
408           escaped = true;
409         } else if (c == '"') {
410           quoted = false
411         } else {
412           token += c;
413         }
414       } else if (c == '"') {
415         quoted = true
416       } else if (c === ' ') {
417         if (token.length > 0) {
418           tokens.push(token);
419           token = '';
420         }
421       } else {
422         token += c;
423       }
424     }
425     if (token.length > 0) {
426       tokens.push(token);
427     }
428     return tokens;
429   },
430   parse_yx: function(position_string) {
431     let coordinate_strings = position_string.split(',')
432     let position = [0, 0];
433     position[0] = parseInt(coordinate_strings[0].slice(2));
434     position[1] = parseInt(coordinate_strings[1].slice(2));
435     return position;
436   },
437 }
438
439 class Thing {
440     constructor(yx) {
441         this.position = yx;
442     }
443 }
444
445 let server = {
446     init: function(url) {
447         this.url = url;
448         this.websocket = new WebSocket(this.url);
449         this.websocket.onopen = function(event) {
450             server.connected = true;
451             game.thing_types = {};
452             game.terrains = {};
453             server.send(['TASKS']);
454             server.send(['TERRAINS']);
455             server.send(['THING_TYPES']);
456             tui.log_msg("@ server connected! :)");
457             tui.switch_mode('login');
458         };
459         this.websocket.onclose = function(event) {
460             server.connected = false;
461             tui.switch_mode('waiting_for_server');
462             tui.log_msg("@ server disconnected :(");
463         };
464             this.websocket.onmessage = this.handle_event;
465         },
466     reconnect_to: function(url) {
467         this.websocket.close();
468         this.init(url);
469     },
470     send: function(tokens) {
471         this.websocket.send(unparser.untokenize(tokens));
472     },
473     handle_event: function(event) {
474         let tokens = parser.tokenize(event.data);
475         if (tokens[0] === 'TURN') {
476             game.turn_complete = false;
477             explorer.empty_annotations();
478             game.things = {};
479             game.portals = {};
480             game.fov = '';
481             game.turn = parseInt(tokens[1]);
482         } else if (tokens[0] === 'THING') {
483             let t = game.get_thing(tokens[4], true);
484             t.position = parser.parse_yx(tokens[1]);
485             t.type_ = tokens[2];
486             t.protection = tokens[3];
487             t.portable = parseInt(tokens[5]);
488         } else if (tokens[0] === 'THING_NAME') {
489             let t = game.get_thing(tokens[1], false);
490             t.name_ = tokens[2];
491         } else if (tokens[0] === 'THING_FACE') {
492             let t = game.get_thing(tokens[1], false);
493             t.face = tokens[2];
494         } else if (tokens[0] === 'THING_HAT') {
495             let t = game.get_thing(tokens[1], false);
496             t.hat = tokens[2];
497         } else if (tokens[0] === 'THING_CHAR') {
498             let t = game.get_thing(tokens[1], false);
499             t.thing_char = tokens[2];
500         } else if (tokens[0] === 'TASKS') {
501             game.tasks = tokens[1].split(',');
502             tui.mode_write.legal = game.tasks.includes('WRITE');
503             tui.mode_command_thing.legal = game.tasks.includes('WRITE');
504             tui.mode_take_thing.legal = game.tasks.includes('PICK_UP');
505         } else if (tokens[0] === 'THING_TYPE') {
506             game.thing_types[tokens[1]] = tokens[2]
507         } else if (tokens[0] === 'THING_CARRYING') {
508             let t = game.get_thing(tokens[1], false);
509             t.carrying = true;
510         } else if (tokens[0] === 'THING_INSTALLED') {
511             let t = game.get_thing(tokens[1], false);
512             t.installed = true;
513         } else if (tokens[0] === 'TERRAIN') {
514             game.terrains[tokens[1]] = tokens[2]
515         } else if (tokens[0] === 'MAP') {
516             game.map_geometry = tokens[1];
517             tui.init_keys();
518             game.map_size = parser.parse_yx(tokens[2]);
519             game.map = tokens[3]
520         } else if (tokens[0] === 'FOV') {
521             game.fov = tokens[1]
522         } else if (tokens[0] === 'MAP_CONTROL') {
523             game.map_control = tokens[1]
524         } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
525             game.turn_complete = true;
526             if (tui.mode.name == 'post_login_wait') {
527                 tui.switch_mode('play');
528             }
529             explorer.info_cached = false;
530             tui.full_refresh();
531         } else if (tokens[0] === 'CHAT') {
532              tui.log_msg('# ' + tokens[1], 1);
533         } else if (tokens[0] === 'REPLY') {
534              tui.log_msg('#MUSICPLAYER: ' + tokens[1], 1);
535         } else if (tokens[0] === 'PLAYER_ID') {
536             game.player_id = parseInt(tokens[1]);
537         } else if (tokens[0] === 'LOGIN_OK') {
538             this.send(['GET_GAMESTATE']);
539             tui.switch_mode('post_login_wait');
540         } else if (tokens[0] === 'DEFAULT_COLORS') {
541             terminal.set_default_colors();
542         } else if (tokens[0] === 'RANDOM_COLORS') {
543             terminal.set_random_colors();
544         } else if (tokens[0] === 'ADMIN_OK') {
545             tui.is_admin = true;
546             tui.log_msg('@ you now have admin rights');
547             tui.switch_mode('admin');
548         } else if (tokens[0] === 'PORTAL') {
549             let position = parser.parse_yx(tokens[1]);
550             game.portals[position] = tokens[2];
551         } else if (tokens[0] === 'ANNOTATION') {
552             let position = parser.parse_yx(tokens[1]);
553             explorer.update_annotations(position, tokens[2]);
554             tui.full_refresh();
555         } else if (tokens[0] === 'UNHANDLED_INPUT') {
556             tui.log_msg('? unknown command');
557         } else if (tokens[0] === 'PLAY_ERROR') {
558             tui.log_msg('? ' + tokens[1]);
559             terminal.blink_screen();
560         } else if (tokens[0] === 'ARGUMENT_ERROR') {
561             tui.log_msg('? syntax error: ' + tokens[1]);
562         } else if (tokens[0] === 'GAME_ERROR') {
563             tui.log_msg('? game error: ' + tokens[1]);
564         } else if (tokens[0] === 'PONG') {
565             ;
566         } else {
567             tui.log_msg('? unhandled input: ' + event.data);
568         }
569     }
570 }
571
572 let unparser = {
573     quote: function(str) {
574         let quoted = ['"'];
575         for (let i = 0; i < str.length; i++) {
576             let c = str[i];
577             if (['"', '\\'].includes(c)) {
578                 quoted.push('\\');
579             };
580             quoted.push(c);
581         }
582         quoted.push('"');
583         return quoted.join('');
584     },
585     to_yx: function(yx_coordinate) {
586         return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
587     },
588     untokenize: function(tokens) {
589         let quoted_tokens = [];
590         for (let token of tokens) {
591             quoted_tokens.push(this.quote(token));
592         }
593         return quoted_tokens.join(" ");
594     }
595 }
596
597 class Mode {
598     constructor(name, has_input_prompt=false, shows_info=false,
599                 is_intro=false, is_single_char_entry=false) {
600         this.name = name;
601         this.short_desc = mode_helps[name].short;
602         this.available_modes = [];
603         this.available_actions = [];
604         this.has_input_prompt = has_input_prompt;
605         this.shows_info= shows_info;
606         this.is_intro = is_intro;
607         this.help_intro = mode_helps[name].long;
608         this.intro_msg = mode_helps[name].intro;
609         this.is_single_char_entry = is_single_char_entry;
610         this.legal = true;
611     }
612     *iter_available_modes() {
613         for (let mode_name of this.available_modes) {
614             let mode = tui['mode_' + mode_name];
615             if (!mode.legal) {
616                 continue;
617             }
618             let key = tui.keys['switch_to_' + mode.name];
619             yield [mode, key]
620         }
621     }
622     list_available_modes() {
623         let msg = ''
624         if (this.available_modes.length > 0) {
625             msg += 'Other modes available from here:\n';
626             for (let [mode, key] of this.iter_available_modes()) {
627                 msg += '[' + key + '] – ' + mode.short_desc + '\n';
628             }
629         }
630         return msg;
631     }
632     mode_switch_on_key(key_event) {
633         for (let [mode, key] of this.iter_available_modes()) {
634             if (key_event.key == key) {
635                 event.preventDefault();
636                 tui.switch_mode(mode.name);
637                 return true;
638             };
639         }
640         return false;
641     }
642 }
643 let tui = {
644   links: {},
645   log: [],
646   input_prompt: '> ',
647   input_lines: [],
648   window_width: terminal.cols / 2,
649   height_turn_line: 1,
650   height_mode_line: 1,
651   height_input: 1,
652   password: 'foo',
653   show_help: false,
654   is_admin: false,
655   tile_draw: false,
656   mode_waiting_for_server: new Mode('waiting_for_server',
657                                      false, false, true),
658   mode_login: new Mode('login', true, false, true),
659   mode_post_login_wait: new Mode('post_login_wait'),
660   mode_chat: new Mode('chat', true),
661   mode_annotate: new Mode('annotate', true, true),
662   mode_play: new Mode('play'),
663   mode_study: new Mode('study', false, true),
664   mode_write: new Mode('write', false, false, false, true),
665   mode_edit: new Mode('edit'),
666   mode_control_pw_type: new Mode('control_pw_type', true),
667   mode_admin_thing_protect: new Mode('admin_thing_protect', true),
668   mode_portal: new Mode('portal', true, true),
669   mode_password: new Mode('password', true),
670   mode_name_thing: new Mode('name_thing', true, true),
671   mode_command_thing: new Mode('command_thing', true),
672   mode_take_thing: new Mode('take_thing', true),
673   mode_enter_face: new Mode('enter_face', true),
674   mode_admin_enter: new Mode('admin_enter', true),
675   mode_admin: new Mode('admin'),
676   mode_control_pw_pw: new Mode('control_pw_pw', true),
677   mode_control_tile_type: new Mode('control_tile_type', true),
678   mode_control_tile_draw: new Mode('control_tile_draw'),
679   action_tasks: {
680       'flatten': 'FLATTEN_SURROUNDINGS',
681       'take_thing': 'PICK_UP',
682       'drop_thing': 'DROP',
683       'move': 'MOVE',
684       'door': 'DOOR',
685       'install': 'INSTALL',
686       'wear': 'WEAR',
687       'command': 'COMMAND',
688       'consume': 'INTOXICATE',
689   },
690   offset: [0,0],
691   map_lines: [],
692   selectables: [],
693   init: function() {
694       this.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
695                                         "command_thing", "take_thing"]
696       this.mode_play.available_actions = ["move", "drop_thing", "teleport",
697                                           "door", "consume", "install", "wear"];
698       this.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
699       this.mode_study.available_actions = ["toggle_map_mode", "move_explorer"];
700       this.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
701                                          "control_tile_type", "chat",
702                                          "study", "play", "edit"]
703       this.mode_admin.available_actions = ["move"];
704       this.mode_control_tile_draw.available_modes = ["admin_enter"]
705       this.mode_control_tile_draw.available_actions = ["toggle_tile_draw"];
706       this.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
707                                         "password", "chat", "study", "play",
708                                         "admin_enter", "enter_face"]
709       this.mode_edit.available_actions = ["move", "flatten", "toggle_map_mode"]
710       this.inputEl = document.getElementById("input");
711       this.inputEl.focus();
712       this.switch_mode('waiting_for_server');
713       this.recalc_input_lines();
714       this.height_header = this.height_turn_line + this.height_mode_line;
715       this.init_keys();
716   },
717   init_keys: function() {
718     document.getElementById("move_table").hidden = true;
719     this.keys = {};
720     for (let key_selector of key_selectors) {
721         this.keys[key_selector.id.slice(4)] = key_selector.value;
722     }
723     this.movement_keys = {};
724     let geometry_prefix = 'undefinedMapGeometry_';
725     if (game.map_geometry) {
726         geometry_prefix = game.map_geometry.toLowerCase() + '_';
727     }
728     for (const key_name of Object.keys(key_descriptions)) {
729         if (key_name.startsWith(geometry_prefix)) {
730             let direction = key_name.split('_')[2].toUpperCase();
731             let key = this.keys[key_name];
732             this.movement_keys[key] = direction;
733         }
734     };
735     for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
736         if (move_button.id.startsWith('key_')) {
737             continue;
738         }
739         move_button.hidden = true;
740     };
741     for (const move_button of document.querySelectorAll('[id^="' + geometry_prefix + 'move_"]')) {
742         document.getElementById("move_table").hidden = false;
743         move_button.hidden = false;
744     };
745     for (let el of document.getElementsByTagName("button")) {
746       let action_desc = key_descriptions[el.id];
747       let action_key = '[' + this.keys[el.id] + ']';
748       el.innerHTML = escapeHTML(action_desc) + '<br /><span class="keyboard_controlled">' + escapeHTML(action_key) + '</span>';
749     }
750   },
751   task_action_on: function(action) {
752       return game.tasks.includes(this.action_tasks[action]);
753   },
754   switch_mode: function(mode_name) {
755     if (this.mode && this.mode.name == 'control_tile_draw') {
756         tui.log_msg('@ finished tile protection drawing.')
757     }
758     this.tile_draw = false;
759     if (mode_name == 'admin_enter' && this.is_admin) {
760         mode_name = 'admin';
761     } else if (['name_thing', 'admin_thing_protect'].includes(mode_name)) {
762         let player_position = game.things[game.player_id].position;
763         let thing_id = null;
764         for (let t_id in game.things) {
765             if (t_id == game.player_id) {
766                 continue;
767             }
768             let t = game.things[t_id];
769             if (player_position[0] == t.position[0] && player_position[1] == t.position[1]) {
770                 thing_id = t_id;
771                 break;
772             }
773         }
774         if (!thing_id) {
775             terminal.blink_screen();
776             this.log_msg('? not standing over thing');
777             return;
778         } else {
779             this.selected_thing_id = thing_id;
780         }
781     };
782     this.mode = this['mode_' + mode_name];
783     if (["control_tile_draw", "control_tile_type", "control_pw_type"].includes(this.mode.name)) {
784         this.map_mode = 'protections';
785     } else if (this.mode.name != "edit") {
786         this.map_mode = 'terrain + things';
787     };
788     if (this.mode.has_input_prompt || this.mode.is_single_char_entry) {
789         this.inputEl.focus();
790     }
791     if (game.player_id in game.things && (this.mode.shows_info || this.mode.name == 'control_tile_draw')) {
792         explorer.position = game.things[game.player_id].position;
793     }
794     this.inputEl.value = "";
795     this.restore_input_values();
796     for (let el of document.getElementsByTagName("button")) {
797         el.disabled = true;
798     }
799     document.getElementById("help").disabled = false;
800     for (const action of this.mode.available_actions) {
801         if (["move", "move_explorer"].includes(action)) {
802             for (const move_key of document.querySelectorAll('[id*="_move_"]')) {
803                 move_key.disabled = false;
804             }
805         } else if (Object.keys(this.action_tasks).includes(action)) {
806             if (this.task_action_on(action)) {
807                 document.getElementById(action).disabled = false;
808             }
809         } else {
810             document.getElementById(action).disabled = false;
811         };
812     }
813     for (const mode_name of this.mode.available_modes) {
814             document.getElementById('switch_to_' + mode_name).disabled = false;
815     }
816     if (this.mode.intro_msg.length > 0) {
817         this.log_msg(this.mode.intro_msg);
818     }
819     if (this.mode.name == 'login') {
820         if (this.login_name) {
821             server.send(['LOGIN', this.login_name]);
822         } else {
823             this.log_msg("? need login name");
824         }
825     } else if (this.mode.is_single_char_entry) {
826         this.show_help = true;
827     } else if (this.mode.name == 'take_thing') {
828         this.log_msg("Portable things in reach for pick-up:");
829         const player = game.things[game.player_id];
830         const y = player.position[0]
831         const x = player.position[1]
832         let select_range = [y.toString() + ':' + x.toString(),
833                             (y + 0).toString() + ':' + (x - 1).toString(),
834                             (y + 0).toString() + ':' + (x + 1).toString(),
835                             (y - 1).toString() + ':' + (x).toString(),
836                             (y + 1).toString() + ':' + (x).toString()];
837         if (game.map_geometry == 'Hex') {
838             if (y % 2) {
839                 select_range.push((y - 1).toString() + ':' + (x + 1).toString());
840                 select_range.push((y + 1).toString() + ':' + (x + 1).toString());
841             } else {
842                 select_range.push((y - 1).toString() + ':' + (x - 1).toString());
843                 select_range.push((y + 1).toString() + ':' + (x - 1).toString());
844             }
845         };
846         this.selectables = [];
847         for (const t_id in game.things) {
848             const t = game.things[t_id];
849             if (select_range.includes(t.position[0].toString()
850                                       + ':' + t.position[1].toString())
851                 && t.portable) {
852                 this.selectables.push([t_id, t]);
853             }
854         };
855         if (this.selectables.length == 0) {
856             this.log_msg('none')
857         } else {
858             for (let [i, t] of this.selectables.entries()) {
859                 this.log_msg(i + ': ' + explorer.get_thing_info(t[1]));
860             }
861         }
862     } else if (this.mode.name == 'command_thing') {
863         server.send(['TASK:COMMAND', 'HELP']);
864     } else if (this.mode.name == 'control_pw_pw') {
865         this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
866     } else if (this.mode.name == 'control_tile_draw') {
867         this.log_msg('@ can draw protection character "' + this.tile_control_char + '", turn drawing on/off with [' + this.keys.toggle_tile_draw + '], finish with [' +  this.keys.switch_to_admin_enter + '].')
868     }
869     this.full_refresh();
870   },
871   offset_links: function(offset, links) {
872       for (let y in links) {
873           let real_y = offset[0] + parseInt(y);
874           if (!this.links[real_y]) {
875               this.links[real_y] = [];
876           }
877           for (let link of links[y]) {
878               const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
879               this.links[real_y].push(offset_link);
880           }
881       }
882   },
883   restore_input_values: function() {
884       if (this.mode.name == 'annotate' && explorer.position in explorer.annotations) {
885           let info = explorer.annotations[explorer.position];
886           if (info != "(none)") {
887               this.inputEl.value = info;
888           }
889       } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
890           let portal = game.portals[explorer.position]
891           this.inputEl.value = portal;
892       } else if (this.mode.name == 'password') {
893           this.inputEl.value = this.password;
894       } else if (this.mode.name == 'name_thing') {
895           let t = game.get_thing(this.selected_thing_id);
896           if (t && t.name_) {
897               this.inputEl.value = t.name_;
898           }
899       } else if (this.mode.name == 'admin_thing_protect') {
900           let t = game.get_thing(this.selected_thing_id);
901           if (t && t.protection) {
902               this.inputEl.value = t.protection;
903           }
904       }
905   },
906   recalc_input_lines: function() {
907       if (this.mode.has_input_prompt) {
908           let _ = null;
909           [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value, this.window_width);
910       } else {
911           this.input_lines = [];
912       }
913       this.height_input = this.input_lines.length;
914   },
915   msg_into_lines_of_width: function(msg, width) {
916       function push_inner_link(y, end_x) {
917           if (!inner_links[y]) {
918               inner_links[y] = [];
919           };
920           inner_links[y].push([url_start_x, end_x, url]);
921       };
922       const matches = msg.matchAll(/https?:\/\/[^\s]+/g)
923       let link_data = {};
924       let url_ends = [];
925       for (const match of matches) {
926           const url = match[0];
927           const url_start = match.index;
928           const url_end = match.index + match[0].length;
929           link_data[url_start] = url;
930           url_ends.push(url_end);
931       }
932       let url_start_x = 0;
933       let url = '';
934       let inner_links = {};
935       let in_link = false;
936       let chunk = "";
937       let lines = [];
938       for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
939           if (x >= width || msg[i] == "\n") {
940               if (in_link) {
941                   push_inner_link(y, chunk.length);
942                   url_start_x = 0;
943                   if (url_ends[0] == i) {
944                       in_link = false;
945                       url_ends.shift();
946                   }
947               };
948               lines.push(chunk);
949               chunk = "";
950               x = 0;
951               if (msg[i] == "\n") {
952                   x -= 1;
953               };
954               y += 1;
955           };
956           if (msg[i] != "\n") {
957               chunk += msg[i];
958           };
959           if (i in link_data) {
960               url_start_x = x;
961               url = link_data[i];
962               in_link = true;
963           } else if (url_ends[0] == i) {
964               url_ends.shift();
965               push_inner_link(y, x);
966               in_link = false;
967           }
968       }
969       lines.push(chunk);
970       if (in_link) {
971           push_inner_link(lines.length - 1, chunk.length);
972       }
973       return [lines, inner_links];
974   },
975   log_msg: function(msg) {
976       this.log.push(msg);
977       while (this.log.length > 100) {
978         this.log.shift();
979       };
980       this.full_refresh();
981   },
982   draw_map: function() {
983     if (!game.turn_complete && this.map_lines.length == 0) {
984         return;
985     }
986     if (game.turn_complete) {
987         let map_lines_split = [];
988         let line = [];
989         for (let i = 0, j = 0; i < game.map.length; i++, j++) {
990             if (j == game.map_size[1]) {
991                 map_lines_split.push(line);
992                 line = [];
993                 j = 0;
994             };
995             if (this.map_mode == 'protections') {
996                 line.push(game.map_control[i] + ' ');
997             } else {
998                 line.push(game.map[i] + ' ');
999             }
1000         };
1001         map_lines_split.push(line);
1002         if (this.map_mode == 'terrain + annotations') {
1003             for (const coordinate of explorer.info_hints) {
1004                 map_lines_split[coordinate[0]][coordinate[1]] = 'A ';
1005             }
1006         } else if (this.map_mode == 'terrain + things') {
1007             for (const p in game.portals) {
1008                 let coordinate = p.split(',')
1009                 let original = map_lines_split[coordinate[0]][coordinate[1]];
1010                 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
1011             }
1012             let used_positions = [];
1013             function draw_thing(t, used_positions) {
1014                 let symbol = game.thing_types[t.type_];
1015                 let meta_char = ' ';
1016                 if (t.thing_char) {
1017                     meta_char = t.thing_char;
1018                 }
1019                 if (used_positions.includes(t.position.toString())) {
1020                     meta_char = '+';
1021                 };
1022                 if (t.carrying) {
1023                     meta_char = '$';
1024                 }
1025                 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
1026                 used_positions.push(t.position.toString());
1027             }
1028             for (const thing_id in game.things) {
1029                 let t = game.things[thing_id];
1030                 if (t.type_ != 'Player') {
1031                     draw_thing(t, used_positions);
1032                 }
1033             };
1034             for (const thing_id in game.things) {
1035                 let t = game.things[thing_id];
1036                 if (t.type_ == 'Player') {
1037                     draw_thing(t, used_positions);
1038                 }
1039             };
1040         }
1041         let player = game.things[game.player_id];
1042         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1043             map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
1044         } else if (tui.map_mode != 'terrain + things') {
1045             map_lines_split[player.position[0]][player.position[1]] = '??';
1046         }
1047         this.map_lines = []
1048         if (game.map_geometry == 'Square') {
1049             for (let line_split of map_lines_split) {
1050                 this.map_lines.push(line_split.join(''));
1051             };
1052         } else if (game.map_geometry == 'Hex') {
1053             let indent = 0
1054             for (let line_split of map_lines_split) {
1055                 this.map_lines.push(' '.repeat(indent) + line_split.join(''));
1056                 if (indent == 0) {
1057                     indent = 1;
1058                 } else {
1059                     indent = 0;
1060                 };
1061             };
1062         }
1063         let window_center = [terminal.rows / 2, this.window_width / 2];
1064         let center_position = [player.position[0], player.position[1]];
1065         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1066             center_position = [explorer.position[0], explorer.position[1]];
1067         }
1068         center_position[1] = center_position[1] * 2;
1069         this.offset = [center_position[0] - window_center[0],
1070                        center_position[1] - window_center[1]]
1071         if (game.map_geometry == 'Hex' && this.offset[0] % 2) {
1072             this.offset[1] += 1;
1073         };
1074     };
1075     let term_y = Math.max(0, -this.offset[0]);
1076     let term_x = Math.max(0, -this.offset[1]);
1077     let map_y = Math.max(0, this.offset[0]);
1078     let map_x = Math.max(0, this.offset[1]);
1079     for (; term_y < terminal.rows && map_y < this.map_lines.length; term_y++, map_y++) {
1080         let to_draw = this.map_lines[map_y].slice(map_x, this.window_width + this.offset[1]);
1081         terminal.write(term_y, term_x, to_draw);
1082     }
1083   },
1084   draw_mode_line: function() {
1085       let help = 'hit [' + this.keys.help + '] for help';
1086       if (this.mode.has_input_prompt) {
1087           help = 'enter /help for help';
1088       }
1089       terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
1090   },
1091   draw_turn_line: function(n) {
1092       if (game.turn_complete) {
1093           terminal.write(1, this.window_width, 'TURN: ' + game.turn);
1094       }
1095   },
1096   draw_history: function() {
1097       let log_display_lines = [];
1098       let log_links = {};
1099       let y_offset_in_log = 0;
1100       for (let line of this.log) {
1101           let [new_lines, link_data] = this.msg_into_lines_of_width(line,
1102                                                                     this.window_width)
1103           log_display_lines = log_display_lines.concat(new_lines);
1104           for (const y in link_data) {
1105               const rel_y = y_offset_in_log + parseInt(y);
1106               log_links[rel_y] = [];
1107               for (let link of link_data[y]) {
1108                   log_links[rel_y].push(link);
1109               }
1110           }
1111           y_offset_in_log += new_lines.length;
1112       };
1113       let i = log_display_lines.length - 1;
1114       for (let y = terminal.rows - 1 - this.height_input;
1115            y >= this.height_header && i >= 0;
1116            y--, i--) {
1117           terminal.write(y, this.window_width, log_display_lines[i]);
1118       }
1119       for (const key of Object.keys(log_links)) {
1120           if (parseInt(key) <= i) {
1121               delete log_links[key];
1122           }
1123       }
1124       let offset = [terminal.rows - this.height_input - log_display_lines.length,
1125                     this.window_width];
1126       this.offset_links(offset, log_links);
1127   },
1128   draw_info: function() {
1129       const info = "MAP VIEW: " + tui.map_mode + "\n" + explorer.get_info();
1130       let [lines, link_data] = this.msg_into_lines_of_width(info, this.window_width);
1131       let offset = [this.height_header, this.window_width];
1132       for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1133         terminal.write(y, offset[1], lines[i]);
1134       }
1135       this.offset_links(offset, link_data);
1136   },
1137   draw_input: function() {
1138     if (this.mode.has_input_prompt) {
1139         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
1140             terminal.write(y, this.window_width, this.input_lines[i]);
1141         }
1142     }
1143   },
1144   draw_help: function() {
1145       let movement_keys_desc = '';
1146       if (!this.mode.is_intro) {
1147           movement_keys_desc = Object.keys(this.movement_keys).join(',');
1148       }
1149       let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1150       if (this.mode.available_actions.length > 0) {
1151           content += "Available actions:\n";
1152           for (let action of this.mode.available_actions) {
1153               if (Object.keys(this.action_tasks).includes(action)) {
1154                   if (!this.task_action_on(action)) {
1155                       continue;
1156                   }
1157               }
1158               if (action == 'move_explorer') {
1159                   action = 'move';
1160               }
1161               if (action == 'move') {
1162                   content += "[" + movement_keys_desc + "] – move\n"
1163               } else {
1164                   content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1165               }
1166           }
1167           content += '\n';
1168       }
1169       content += this.mode.list_available_modes();
1170       let start_x = 0;
1171       if (!this.mode.has_input_prompt) {
1172           start_x = this.window_width
1173       }
1174       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1175       let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1176       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1177           terminal.write(y, start_x, lines[i]);
1178       }
1179   },
1180   toggle_tile_draw: function() {
1181       if (tui.tile_draw) {
1182           tui.tile_draw = false;
1183       } else {
1184           tui.tile_draw = true;
1185       }
1186   },
1187   toggle_map_mode: function() {
1188       if (tui.map_mode == 'terrain only') {
1189           tui.map_mode = 'terrain + annotations';
1190       } else if (tui.map_mode == 'terrain + annotations') {
1191           tui.map_mode = 'terrain + things';
1192       } else if (tui.map_mode == 'terrain + things') {
1193           tui.map_mode = 'protections';
1194       } else if (tui.map_mode == 'protections') {
1195           tui.map_mode = 'terrain only';
1196       }
1197   },
1198   full_refresh: function() {
1199     this.links = {};
1200     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1201     this.recalc_input_lines();
1202     if (this.mode.is_intro) {
1203         this.draw_history();
1204         this.draw_input();
1205     } else {
1206         this.draw_map();
1207         this.draw_turn_line();
1208         this.draw_mode_line();
1209         if (this.mode.shows_info) {
1210           this.draw_info();
1211         } else {
1212           this.draw_history();
1213         }
1214         this.draw_input();
1215     }
1216     if (this.show_help) {
1217         this.draw_help();
1218     }
1219     terminal.refresh();
1220   }
1221 }
1222
1223 let game = {
1224     init: function() {
1225         this.things = {};
1226         this.turn = -1;
1227         this.map = "";
1228         this.map_control = "";
1229         this.map_size = [0,0];
1230         this.player_id = -1;
1231         this.portals = {};
1232         this.tasks = {};
1233     },
1234     get_thing: function(id_, create_if_not_found=false) {
1235         if (id_ in game.things) {
1236             return game.things[id_];
1237         } else if (create_if_not_found) {
1238             let t = new Thing([0,0]);
1239             game.things[id_] = t;
1240             return t;
1241         };
1242     },
1243     move: function(start_position, direction) {
1244         let target = [start_position[0], start_position[1]];
1245         if (direction == 'LEFT') {
1246             target[1] -= 1;
1247         } else if (direction == 'RIGHT') {
1248             target[1] += 1;
1249         } else if (game.map_geometry == 'Square') {
1250             if (direction == 'UP') {
1251                 target[0] -= 1;
1252             } else if (direction == 'DOWN') {
1253                 target[0] += 1;
1254             };
1255         } else if (game.map_geometry == 'Hex') {
1256             let start_indented = start_position[0] % 2;
1257             if (direction == 'UPLEFT') {
1258                 target[0] -= 1;
1259                 if (!start_indented) {
1260                     target[1] -= 1;
1261                 }
1262             } else if (direction == 'UPRIGHT') {
1263                 target[0] -= 1;
1264                 if (start_indented) {
1265                     target[1] += 1;
1266                 }
1267             } else if (direction == 'DOWNLEFT') {
1268                 target[0] += 1;
1269                 if (!start_indented) {
1270                     target[1] -= 1;
1271                 }
1272             } else if (direction == 'DOWNRIGHT') {
1273                 target[0] += 1;
1274                 if (start_indented) {
1275                     target[1] += 1;
1276                 }
1277             };
1278         };
1279         if (target[0] < 0 || target[1] < 0 ||
1280             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1281             return null;
1282         };
1283         return target;
1284     },
1285     teleport: function() {
1286         let player = this.get_thing(game.player_id);
1287         if (player.position in this.portals) {
1288             server.reconnect_to(this.portals[player.position]);
1289         } else {
1290             terminal.blink_screen();
1291             tui.log_msg('? not standing on portal')
1292         }
1293     }
1294 }
1295
1296 game.init();
1297 tui.init();
1298 tui.full_refresh();
1299 server.init(websocket_location);
1300
1301 let explorer = {
1302     position: [0,0],
1303     annotations: {},
1304     info_cached: false,
1305     move: function(direction) {
1306         let target = game.move(this.position, direction);
1307         if (target) {
1308             this.position = target
1309             this.info_cached = false;
1310             if (tui.tile_draw) {
1311                 this.send_tile_control_command();
1312             }
1313         } else {
1314             terminal.blink_screen();
1315         };
1316     },
1317     update_annotations: function(yx, str) {
1318         this.annotations[yx] = str;
1319         if (tui.mode.name == 'study') {
1320             tui.full_refresh();
1321         }
1322     },
1323     empty_annotations: function() {
1324         this.annotations = {};
1325         if (tui.mode.name == 'study') {
1326             tui.full_refresh();
1327         }
1328     },
1329     get_info: function() {
1330         if (this.info_cached) {
1331             return this.info_cached;
1332         }
1333         let info_to_cache = '';
1334         let position_i = this.position[0] * game.map_size[1] + this.position[1];
1335         if (game.fov[position_i] != '.') {
1336             info_to_cache += 'outside field of view';
1337         } else {
1338             for (let t_id in game.things) {
1339                  let t = game.things[t_id];
1340                  if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1341                      info_to_cache += "THING: " + this.get_thing_info(t);
1342                      let protection = t.protection;
1343                      if (protection == '.') {
1344                          protection = 'none';
1345                      }
1346                      info_to_cache += " / protection: " + protection + "\n";
1347                      if (t.hat) {
1348                          info_to_cache += t.hat.slice(0, 3) + '\n';
1349                          info_to_cache += t.hat.slice(3, 6) + '\n';
1350                          info_to_cache += t.hat.slice(6, 9) + '\n';
1351                      }
1352                      if (t.face) {
1353                          info_to_cache += t.face.slice(0, 3) + '\n';
1354                          info_to_cache += t.face.slice(3, 6) + '\n';
1355                          info_to_cache += t.face.slice(6, 9) + '\n';
1356                      }
1357                  }
1358             }
1359             let terrain_char = game.map[position_i]
1360             let terrain_desc = '?'
1361             if (game.terrains[terrain_char]) {
1362                 terrain_desc = game.terrains[terrain_char];
1363             };
1364             info_to_cache += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1365             let protection = game.map_control[position_i];
1366             if (protection == '.') {
1367                 protection = 'unprotected';
1368             };
1369             info_to_cache += 'PROTECTION: ' + protection + '\n';
1370             if (this.position in game.portals) {
1371                 info_to_cache += "PORTAL: " + game.portals[this.position] + "\n";
1372             }
1373             if (this.position in this.annotations) {
1374                 info_to_cache += "ANNOTATION: " + this.annotations[this.position];
1375             }
1376         }
1377         this.info_cached = info_to_cache;
1378         return this.info_cached;
1379     },
1380     get_thing_info: function(t) {
1381         const symbol = game.thing_types[t.type_];
1382         let info = t.type_ + " / " + symbol;
1383         if (t.thing_char) {
1384             info += t.thing_char;
1385         };
1386         if (t.name_) {
1387             info += " (" + t.name_ + ")";
1388         }
1389         if (t.installed) {
1390             info += " / installed";
1391         }
1392         return info;
1393     },
1394     annotate: function(msg) {
1395         if (msg.length == 0) {
1396             msg = " ";  // triggers annotation deletion
1397         }
1398         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1399     },
1400     set_portal: function(msg) {
1401         if (msg.length == 0) {
1402             msg = " ";  // triggers portal deletion
1403         }
1404         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1405     },
1406     send_tile_control_command: function() {
1407         server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1408     }
1409 }
1410
1411 tui.inputEl.addEventListener('input', (event) => {
1412     if (tui.mode.has_input_prompt) {
1413         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1414         if (tui.inputEl.value.length > max_length) {
1415             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1416         };
1417     } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1418         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1419         tui.switch_mode('edit');
1420     }
1421     tui.full_refresh();
1422 }, false);
1423 document.onclick = function() {
1424     tui.show_help = false;
1425 };
1426 tui.inputEl.addEventListener('keydown', (event) => {
1427     tui.show_help = false;
1428     if (event.key == 'Enter') {
1429         event.preventDefault();
1430     }
1431     if (tui.mode.has_input_prompt && event.key == 'Enter'
1432         && tui.inputEl.value.length == 0
1433         && ['chat', 'command_thing', 'take_thing',
1434             'admin_enter'].includes(tui.mode.name)) {
1435         if (tui.mode.name != 'chat') {
1436             tui.log_msg('@ aborted');
1437         }
1438         tui.switch_mode('play');
1439     } else if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1440         tui.show_help = true;
1441         tui.inputEl.value = "";
1442         tui.restore_input_values();
1443     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1444                && !tui.mode.is_single_char_entry) {
1445         tui.show_help = true;
1446     } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1447         tui.login_name = tui.inputEl.value;
1448         server.send(['LOGIN', tui.inputEl.value]);
1449         tui.inputEl.value = "";
1450     } else if (tui.mode.name == 'enter_face' && event.key == 'Enter') {
1451         if (tui.inputEl.value.length != 9) {
1452             tui.log_msg('? wrong input length, aborting');
1453         } else {
1454             server.send(['PLAYER_FACE', tui.inputEl.value]);
1455         }
1456         tui.inputEl.value = "";
1457         tui.switch_mode('edit');
1458     } else if (tui.mode.name == 'command_thing' && event.key == 'Enter') {
1459         if (tui.task_action_on('command')) {
1460             server.send(['TASK:COMMAND', tui.inputEl.value]);
1461             tui.inputEl.value = "";
1462         }
1463     } else if (tui.mode.name == 'take_thing' && event.key == 'Enter') {
1464         const i = parseInt(tui.inputEl.value);
1465         if (isNaN(i) || i < 0 || i >= tui.selectables.length) {
1466             tui.log_msg('? invalid index, aborted');
1467         } else {
1468             server.send(['TASK:PICK_UP', tui.selectables[i][0]]);
1469         }
1470         tui.inputEl.value = "";
1471         tui.switch_mode('play');
1472     } else if (tui.mode.name == 'control_pw_pw' && event.key == 'Enter') {
1473         if (tui.inputEl.value.length == 0) {
1474             tui.log_msg('@ aborted');
1475         } else {
1476             server.send(['SET_MAP_CONTROL_PASSWORD',
1477                         tui.tile_control_char, tui.inputEl.value]);
1478             tui.log_msg('@ sent new password for protection character "' + tui.tile_control_char + '".');
1479         }
1480         tui.switch_mode('admin');
1481     } else if (tui.mode.name == 'portal' && event.key == 'Enter') {
1482         explorer.set_portal(tui.inputEl.value);
1483         tui.switch_mode('edit');
1484     } else if (tui.mode.name == 'name_thing' && event.key == 'Enter') {
1485         if (tui.inputEl.value.length == 0) {
1486             tui.inputEl.value = " ";
1487         }
1488         server.send(["THING_NAME", tui.selected_thing_id, tui.inputEl.value,
1489                      tui.password]);
1490         tui.switch_mode('edit');
1491     } else if (tui.mode.name == 'annotate' && event.key == 'Enter') {
1492         explorer.annotate(tui.inputEl.value);
1493         tui.switch_mode('edit');
1494     } else if (tui.mode.name == 'password' && event.key == 'Enter') {
1495         if (tui.inputEl.value.length == 0) {
1496             tui.inputEl.value = " ";
1497         }
1498         tui.password = tui.inputEl.value
1499         tui.switch_mode('edit');
1500     } else if (tui.mode.name == 'admin_enter' && event.key == 'Enter') {
1501         server.send(['BECOME_ADMIN', tui.inputEl.value]);
1502         tui.switch_mode('play');
1503     } else if (tui.mode.name == 'control_pw_type' && event.key == 'Enter') {
1504         if (tui.inputEl.value.length != 1) {
1505             tui.log_msg('@ entered non-single-char, therefore aborted');
1506             tui.switch_mode('admin');
1507         } else {
1508             tui.tile_control_char = tui.inputEl.value[0];
1509             tui.switch_mode('control_pw_pw');
1510         }
1511     } else if (tui.mode.name == 'control_tile_type' && event.key == 'Enter') {
1512         if (tui.inputEl.value.length != 1) {
1513             tui.log_msg('@ entered non-single-char, therefore aborted');
1514             tui.switch_mode('admin');
1515         } else {
1516             tui.tile_control_char = tui.inputEl.value[0];
1517             tui.switch_mode('control_tile_draw');
1518         }
1519     } else if (tui.mode.name == 'admin_thing_protect' && event.key == 'Enter') {
1520         if (tui.inputEl.value.length != 1) {
1521             tui.log_msg('@ entered non-single-char, therefore aborted');
1522         } else {
1523             server.send(['THING_PROTECTION', tui.selected_thing_id, tui.inputEl.value])
1524             tui.log_msg('@ sent new protection character for thing');
1525         }
1526         tui.switch_mode('admin');
1527     } else if (tui.mode.name == 'chat' && event.key == 'Enter') {
1528         let tokens = parser.tokenize(tui.inputEl.value);
1529         if (tokens.length > 0 && tokens[0].length > 0) {
1530             if (tui.inputEl.value[0][0] == '/') {
1531                 if (tokens[0].slice(1) == 'nick') {
1532                     if (tokens.length > 1) {
1533                         server.send(['NICK', tokens[1]]);
1534                     } else {
1535                         tui.log_msg('? need new name');
1536                     }
1537                 } else {
1538                     tui.log_msg('? unknown command');
1539                 }
1540             } else {
1541                     server.send(['ALL', tui.inputEl.value]);
1542             }
1543         } else if (tui.inputEl.valuelength > 0) {
1544                 server.send(['ALL', tui.inputEl.value]);
1545         }
1546         tui.inputEl.value = "";
1547     } else if (tui.mode.name == 'play') {
1548           if (tui.mode.mode_switch_on_key(event)) {
1549               null;
1550           } else if (event.key === tui.keys.drop_thing && tui.task_action_on('drop_thing')) {
1551               server.send(["TASK:DROP"]);
1552           } else if (event.key === tui.keys.consume && tui.task_action_on('consume')) {
1553               server.send(["TASK:INTOXICATE"]);
1554           } else if (event.key === tui.keys.door && tui.task_action_on('door')) {
1555               server.send(["TASK:DOOR"]);
1556           } else if (event.key === tui.keys.install && tui.task_action_on('install')) {
1557               server.send(["TASK:INSTALL"]);
1558           } else if (event.key === tui.keys.install && tui.task_action_on('wear')) {
1559               server.send(["TASK:WEAR"]);
1560           } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1561               server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1562           } else if (event.key === tui.keys.teleport) {
1563               game.teleport();
1564           };
1565     } else if (tui.mode.name == 'study') {
1566         if (tui.mode.mode_switch_on_key(event)) {
1567               null;
1568         } else if (event.key in tui.movement_keys) {
1569             explorer.move(tui.movement_keys[event.key]);
1570         } else if (event.key == tui.keys.toggle_map_mode) {
1571             tui.toggle_map_mode();
1572         };
1573     } else if (tui.mode.name == 'control_tile_draw') {
1574         if (tui.mode.mode_switch_on_key(event)) {
1575             null;
1576         } else if (event.key in tui.movement_keys) {
1577             explorer.move(tui.movement_keys[event.key]);
1578         } else if (event.key === tui.keys.toggle_tile_draw) {
1579             tui.toggle_tile_draw();
1580         };
1581     } else if (tui.mode.name == 'admin') {
1582         if (tui.mode.mode_switch_on_key(event)) {
1583               null;
1584         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1585             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1586         };
1587     } else if (tui.mode.name == 'edit') {
1588         if (tui.mode.mode_switch_on_key(event)) {
1589               null;
1590         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1591             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1592         } else if (event.key === tui.keys.flatten && tui.task_action_on('flatten')) {
1593             server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
1594         } else if (event.key == tui.keys.toggle_map_mode) {
1595             tui.toggle_map_mode();
1596         }
1597     }
1598     tui.full_refresh();
1599 }, false);
1600
1601 rows_selector.addEventListener('input', function() {
1602     if (rows_selector.value % 4 != 0 || rows_selector.value < 24) {
1603         return;
1604     }
1605     window.localStorage.setItem(rows_selector.id, rows_selector.value);
1606     terminal.initialize();
1607     tui.full_refresh();
1608 }, false);
1609 cols_selector.addEventListener('input', function() {
1610     if (cols_selector.value % 4 != 0 || cols_selector.value < 80) {
1611         return;
1612     }
1613     window.localStorage.setItem(cols_selector.id, cols_selector.value);
1614     terminal.initialize();
1615     tui.window_width = terminal.cols / 2,
1616     tui.full_refresh();
1617 }, false);
1618 for (let key_selector of key_selectors) {
1619     key_selector.addEventListener('input', function() {
1620         window.localStorage.setItem(key_selector.id, key_selector.value);
1621         tui.init_keys();
1622     }, false);
1623 }
1624 window.setInterval(function() {
1625     if (server.connected) {
1626         server.send(['PING']);
1627     } else {
1628         server.reconnect_to(server.url);
1629         tui.log_msg('@ attempting reconnect …')
1630     }
1631 }, 5000);
1632 window.setInterval(function() {
1633     let val = "?";
1634     let span_decoration = "none";
1635     if (document.activeElement == tui.inputEl) {
1636         val = "on (click outside terminal to change)";
1637     } else {
1638         val = "off (click into terminal to change)";
1639         span_decoration = "line-through";
1640     };
1641     document.getElementById("keyboard_control").textContent = val;
1642     for (const span of document.querySelectorAll('.keyboard_controlled')) {
1643         span.style.textDecoration = span_decoration;
1644     }
1645 }, 100);
1646 document.getElementById("terminal").onclick = function() {
1647     tui.inputEl.focus();
1648 };
1649 document.getElementById("help").onclick = function() {
1650     tui.show_help = true;
1651     tui.full_refresh();
1652 };
1653 for (const switchEl  of document.querySelectorAll('[id^="switch_to_"]')) {
1654     const mode = switchEl.id.slice("switch_to_".length);
1655     switchEl.onclick = function() {
1656         tui.switch_mode(mode);
1657         tui.full_refresh();
1658     }
1659 };
1660 document.getElementById("toggle_tile_draw").onclick = function() {
1661     tui.toggle_tile_draw();
1662 }
1663 document.getElementById("toggle_map_mode").onclick = function() {
1664     tui.toggle_map_mode();
1665     tui.full_refresh();
1666 };
1667 document.getElementById("drop_thing").onclick = function() {
1668     server.send(['TASK:DROP']);
1669 };
1670 document.getElementById("flatten").onclick = function() {
1671     server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1672 };
1673 document.getElementById("door").onclick = function() {
1674     server.send(['TASK:DOOR']);
1675 };
1676 document.getElementById("consume").onclick = function() {
1677     server.send(['TASK:INTOXICATE']);
1678 };
1679 document.getElementById("install").onclick = function() {
1680     server.send(['TASK:INSTALL']);
1681 };
1682 document.getElementById("wear").onclick = function() {
1683     server.send(['TASK:WEAR']);
1684 };
1685 document.getElementById("teleport").onclick = function() {
1686     game.teleport();
1687 };
1688 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
1689     let direction = move_button.id.split('_')[2].toUpperCase();
1690     move_button.onclick = function() {
1691         if (tui.mode.available_actions.includes("move")
1692             || tui.mode.available_actions.includes("move_explorer")) {
1693             server.send(['TASK:MOVE', direction]);
1694         } else {
1695             explorer.move(direction);
1696         };
1697     };
1698 };
1699 </script>
1700 </body></html>