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