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