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