home · contact · privacy
Only allow renaming and protection changing on thing player carries.
[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 select_range = [y.toString() + ':' + x.toString(),
865                             (y + 0).toString() + ':' + (x - 1).toString(),
866                             (y + 0).toString() + ':' + (x + 1).toString(),
867                             (y - 1).toString() + ':' + (x).toString(),
868                             (y + 1).toString() + ':' + (x).toString()];
869         if (game.map_geometry == 'Hex') {
870             if (y % 2) {
871                 select_range.push((y - 1).toString() + ':' + (x + 1).toString());
872                 select_range.push((y + 1).toString() + ':' + (x + 1).toString());
873             } else {
874                 select_range.push((y - 1).toString() + ':' + (x - 1).toString());
875                 select_range.push((y + 1).toString() + ':' + (x - 1).toString());
876             }
877         };
878         this.selectables = [];
879         for (const t_id in game.things) {
880             const t = game.things[t_id];
881             if (select_range.includes(t.position[0].toString()
882                                       + ':' + t.position[1].toString())
883                 && t.portable) {
884                 this.selectables.push(t_id);
885             }
886         };
887         if (this.selectables.length == 0) {
888             this.log_msg('none');
889             terminal.blink_screen();
890             this.switch_mode('play');
891             return;
892         } else {
893             for (let [i, t_id] of this.selectables.entries()) {
894                 const t = game.things[t_id];
895                 this.log_msg(i + ': ' + explorer.get_thing_info(t));
896             }
897         }
898     } else if (this.mode.name == 'drop_thing') {
899         this.log_msg('Direction to drop thing to:');
900         this.selectables = ['HERE'].concat(Object.values(this.movement_keys));
901         for (let [i, direction] of this.selectables.entries()) {
902             this.log_msg(i + ': ' + direction);
903         };
904     } else if (this.mode.name == 'enter_hat') {
905         this.log_msg('legal characters: ' + game.players_hat_chars);
906     } else if (this.mode.name == 'command_thing') {
907         server.send(['TASK:COMMAND', 'HELP']);
908     } else if (this.mode.name == 'control_pw_pw') {
909         this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
910     } else if (this.mode.name == 'control_tile_draw') {
911         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 + '].')
912     }
913     this.full_refresh();
914   },
915   offset_links: function(offset, links) {
916       for (let y in links) {
917           let real_y = offset[0] + parseInt(y);
918           if (!this.links[real_y]) {
919               this.links[real_y] = [];
920           }
921           for (let link of links[y]) {
922               const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
923               this.links[real_y].push(offset_link);
924           }
925       }
926   },
927   restore_input_values: function() {
928       if (this.mode.name == 'annotate' && explorer.position in explorer.annotations) {
929           let info = explorer.annotations[explorer.position];
930           if (info != "(none)") {
931               this.inputEl.value = info;
932           }
933       } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
934           let portal = game.portals[explorer.position]
935           this.inputEl.value = portal;
936       } else if (this.mode.name == 'password') {
937           this.inputEl.value = this.password;
938       } else if (this.mode.name == 'name_thing') {
939           if (game.player.carrying && game.player.carrying.name_) {
940               this.inputEl.value = game.player.carrying.name_;
941           }
942       } else if (this.mode.name == 'admin_thing_protect') {
943           if (game.player.carrying && game.player.carrying.protection) {
944               this.inputEl.value = game.player.carrying.protection;
945           }
946       } else if (['enter_face', 'enter_hat'].includes(this.mode.name)) {
947           const start = this.ascii_draw_stage * 6;
948           const end = (this.ascii_draw_stage + 1) * 6;
949           if (this.mode.name == 'enter_face') {
950               this.inputEl.value = game.player.face.slice(start, end);
951           } else if (this.mode.name == 'enter_hat') {
952               this.inputEl.value = game.player.hat.slice(start, end);
953           }
954       }
955   },
956   recalc_input_lines: function() {
957       if (this.mode.has_input_prompt) {
958           let _ = null;
959           [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value + '█', this.window_width);
960       } else {
961           this.input_lines = [];
962       }
963       this.height_input = this.input_lines.length;
964   },
965   msg_into_lines_of_width: function(msg, width) {
966       function push_inner_link(y, end_x) {
967           if (!inner_links[y]) {
968               inner_links[y] = [];
969           };
970           inner_links[y].push([url_start_x, end_x, url]);
971       };
972       const matches = msg.matchAll(/https?:\/\/[^\s]+/g)
973       let link_data = {};
974       let url_ends = [];
975       for (const match of matches) {
976           const url = match[0];
977           const url_start = match.index;
978           const url_end = match.index + match[0].length;
979           link_data[url_start] = url;
980           url_ends.push(url_end);
981       }
982       let url_start_x = 0;
983       let url = '';
984       let inner_links = {};
985       let in_link = false;
986       let chunk = "";
987       let lines = [];
988       for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
989           if (x >= width || msg[i] == "\n") {
990               if (in_link) {
991                   push_inner_link(y, chunk.length);
992                   url_start_x = 0;
993                   if (url_ends[0] == i) {
994                       in_link = false;
995                       url_ends.shift();
996                   }
997               };
998               lines.push(chunk);
999               chunk = "";
1000               x = 0;
1001               if (msg[i] == "\n") {
1002                   x -= 1;
1003               };
1004               y += 1;
1005           };
1006           if (msg[i] != "\n") {
1007               chunk += msg[i];
1008           };
1009           if (i in link_data) {
1010               url_start_x = x;
1011               url = link_data[i];
1012               in_link = true;
1013           } else if (url_ends[0] == i) {
1014               url_ends.shift();
1015               push_inner_link(y, x);
1016               in_link = false;
1017           }
1018       }
1019       lines.push(chunk);
1020       if (in_link) {
1021           push_inner_link(lines.length - 1, chunk.length);
1022       }
1023       return [lines, inner_links];
1024   },
1025   log_msg: function(msg) {
1026       this.log.push(msg);
1027       while (this.log.length > 100) {
1028         this.log.shift();
1029       };
1030       this.full_refresh();
1031   },
1032   pick_selectable: function(task_name) {
1033       const i = parseInt(this.inputEl.value);
1034       if (isNaN(this.inputEl.value) || i < 0 || i >= this.selectables.length) {
1035           tui.log_msg('? invalid index, aborted');
1036       } else {
1037           server.send(['TASK:' + task_name, tui.selectables[i]]);
1038       }
1039       this.inputEl.value = "";
1040       this.switch_mode('play');
1041   },
1042   enter_ascii_art: function(command) {
1043       if (this.inputEl.value.length != 6) {
1044           this.log_msg('? wrong input length, must be 6; try again');
1045           return;
1046       }
1047       this.log_msg('  ' + this.inputEl.value);
1048       this.full_ascii_draw += this.inputEl.value;
1049       this.ascii_draw_stage += 1;
1050       if (this.ascii_draw_stage < 3) {
1051           this.restore_input_values();
1052       } else {
1053           server.send([command, this.full_ascii_draw]);
1054           this.full_ascii_draw = '';
1055           this.ascii_draw_stage = 0;
1056           this.inputEl.value = '';
1057           this.switch_mode('edit');
1058       }
1059   },
1060   draw_map: function() {
1061     if (!game.turn_complete && this.map_lines.length == 0) {
1062         return;
1063     }
1064     if (game.turn_complete) {
1065         let map_lines_split = [];
1066         let line = [];
1067         for (let i = 0, j = 0; i < game.map.length; i++, j++) {
1068             if (j == game.map_size[1]) {
1069                 map_lines_split.push(line);
1070                 line = [];
1071                 j = 0;
1072             };
1073             if (this.map_mode == 'protections') {
1074                 line.push(game.map_control[i] + ' ');
1075             } else {
1076                 line.push(game.map[i] + ' ');
1077             }
1078         };
1079         map_lines_split.push(line);
1080         if (this.map_mode == 'terrain + annotations') {
1081             for (const [coordinate, _] of Object.entries(explorer.annotations)) {
1082                 const yx = coordinate.split(',')
1083                 map_lines_split[yx[0]][yx[1]] = 'A ';
1084             }
1085         } else if (this.map_mode == 'terrain + things') {
1086             for (const p in game.portals) {
1087                 let coordinate = p.split(',')
1088                 let original = map_lines_split[coordinate[0]][coordinate[1]];
1089                 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
1090             }
1091             let used_positions = [];
1092             function draw_thing(t, used_positions) {
1093                 let symbol = game.thing_types[t.type_];
1094                 let meta_char = ' ';
1095                 if (t.thing_char) {
1096                     meta_char = t.thing_char;
1097                 }
1098                 if (used_positions.includes(t.position.toString())) {
1099                     meta_char = '+';
1100                 };
1101                 if (t.carrying) {
1102                     meta_char = '$';
1103                 }
1104                 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
1105                 used_positions.push(t.position.toString());
1106             }
1107             for (const thing_id in game.things) {
1108                 let t = game.things[thing_id];
1109                 if (t.type_ != 'Player') {
1110                     draw_thing(t, used_positions);
1111                 }
1112             };
1113             for (const thing_id in game.things) {
1114                 let t = game.things[thing_id];
1115                 if (t.type_ == 'Player') {
1116                     draw_thing(t, used_positions);
1117                 }
1118             };
1119         }
1120         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1121             map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
1122         } else if (tui.map_mode != 'terrain + things') {
1123             map_lines_split[game.player.position[0]][game.player.position[1]] = '??';
1124         }
1125         this.map_lines = []
1126         if (game.map_geometry == 'Square') {
1127             for (let line_split of map_lines_split) {
1128                 this.map_lines.push(line_split.join(''));
1129             };
1130         } else if (game.map_geometry == 'Hex') {
1131             let indent = 0
1132             for (let line_split of map_lines_split) {
1133                 this.map_lines.push(' '.repeat(indent) + line_split.join(''));
1134                 if (indent == 0) {
1135                     indent = 1;
1136                 } else {
1137                     indent = 0;
1138                 };
1139             };
1140         }
1141         let window_center = [terminal.rows / 2, this.window_width / 2];
1142         let center_position = [game.player.position[0], game.player.position[1]];
1143         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1144             center_position = [explorer.position[0], explorer.position[1]];
1145         }
1146         center_position[1] = center_position[1] * 2;
1147         this.offset = [center_position[0] - window_center[0],
1148                        center_position[1] - window_center[1]]
1149         if (game.map_geometry == 'Hex' && this.offset[0] % 2) {
1150             this.offset[1] += 1;
1151         };
1152     };
1153     let term_y = Math.max(0, -this.offset[0]);
1154     let term_x = Math.max(0, -this.offset[1]);
1155     let map_y = Math.max(0, this.offset[0]);
1156     let map_x = Math.max(0, this.offset[1]);
1157     for (; term_y < terminal.rows && map_y < this.map_lines.length; term_y++, map_y++) {
1158         let to_draw = this.map_lines[map_y].slice(map_x, this.window_width + this.offset[1]);
1159         terminal.write(term_y, term_x, to_draw);
1160     }
1161   },
1162   draw_face_popup: function() {
1163       const t = game.things[this.draw_face];
1164       if (!t || !t.face) {
1165           this.draw_face = false;
1166           return;
1167       }
1168       const start_x = tui.window_width - 10;
1169       let t_char = ' ';
1170       if (t.thing_char) {
1171           t_char = t.thing_char;
1172       }
1173       function draw_body_part(body_part, end_y) {
1174           terminal.write(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ');
1175           terminal.write(end_y - 3, start_x, '|        |');
1176           terminal.write(end_y - 2, start_x, '| ' + body_part.slice(0, 6) + ' |');
1177           terminal.write(end_y - 1, start_x, '| ' + body_part.slice(6, 12) + ' |');
1178           terminal.write(end_y, start_x, '| ' + body_part.slice(12, 18) + ' |');
1179       }
1180       if (t.face) {
1181           draw_body_part(t.face, terminal.rows - 2);
1182       }
1183       if (t.hat) {
1184           draw_body_part(t.hat, terminal.rows - 5);
1185       }
1186       terminal.write(terminal.rows - 1, start_x, '|        |');
1187   },
1188   draw_mode_line: function() {
1189       let help = 'hit [' + this.keys.help + '] for help';
1190       if (this.mode.has_input_prompt) {
1191           help = 'enter /help for help';
1192       }
1193       terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
1194   },
1195   draw_turn_line: function(n) {
1196       if (game.turn_complete) {
1197           terminal.write(1, this.window_width, 'TURN: ' + game.turn);
1198       }
1199   },
1200   draw_history: function() {
1201       let log_display_lines = [];
1202       let log_links = {};
1203       let y_offset_in_log = 0;
1204       for (let line of this.log) {
1205           let [new_lines, link_data] = this.msg_into_lines_of_width(line,
1206                                                                     this.window_width)
1207           log_display_lines = log_display_lines.concat(new_lines);
1208           for (const y in link_data) {
1209               const rel_y = y_offset_in_log + parseInt(y);
1210               log_links[rel_y] = [];
1211               for (let link of link_data[y]) {
1212                   log_links[rel_y].push(link);
1213               }
1214           }
1215           y_offset_in_log += new_lines.length;
1216       };
1217       let i = log_display_lines.length - 1;
1218       for (let y = terminal.rows - 1 - this.height_input;
1219            y >= this.height_header && i >= 0;
1220            y--, i--) {
1221           terminal.write(y, this.window_width, log_display_lines[i]);
1222       }
1223       for (const key of Object.keys(log_links)) {
1224           if (parseInt(key) <= i) {
1225               delete log_links[key];
1226           }
1227       }
1228       let offset = [terminal.rows - this.height_input - log_display_lines.length,
1229                     this.window_width];
1230       this.offset_links(offset, log_links);
1231   },
1232   draw_info: function() {
1233       const info = "MAP VIEW: " + tui.map_mode + "\n" + explorer.get_info();
1234       let [lines, link_data] = this.msg_into_lines_of_width(info, this.window_width);
1235       let offset = [this.height_header, this.window_width];
1236       for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1237         terminal.write(y, offset[1], lines[i]);
1238       }
1239       this.offset_links(offset, link_data);
1240   },
1241   draw_input: function() {
1242     if (this.mode.has_input_prompt) {
1243         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
1244             terminal.write(y, this.window_width, this.input_lines[i]);
1245         }
1246     }
1247   },
1248   draw_help: function() {
1249       let movement_keys_desc = '';
1250       if (!this.mode.is_intro) {
1251           movement_keys_desc = Object.keys(this.movement_keys).join(',');
1252       }
1253       let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1254       if (this.mode.available_actions.length > 0) {
1255           content += "Available actions:\n";
1256           for (let action of this.mode.available_actions) {
1257               if (Object.keys(this.action_tasks).includes(action)) {
1258                   if (!this.task_action_on(action)) {
1259                       continue;
1260                   }
1261               }
1262               if (action == 'move_explorer') {
1263                   action = 'move';
1264               }
1265               if (action == 'move') {
1266                   content += "[" + movement_keys_desc + "] – move\n"
1267               } else {
1268                   content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1269               }
1270           }
1271           content += '\n';
1272       }
1273       content += this.mode.list_available_modes();
1274       let start_x = 0;
1275       if (!this.mode.has_input_prompt) {
1276           start_x = this.window_width;
1277           this.draw_links = false;
1278       }
1279       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1280       let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1281       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1282           terminal.write(y, start_x, lines[i]);
1283       }
1284   },
1285   toggle_tile_draw: function() {
1286       if (tui.tile_draw) {
1287           tui.tile_draw = false;
1288       } else {
1289           tui.tile_draw = true;
1290       }
1291   },
1292   toggle_map_mode: function() {
1293       if (tui.map_mode == 'terrain only') {
1294           tui.map_mode = 'terrain + annotations';
1295       } else if (tui.map_mode == 'terrain + annotations') {
1296           tui.map_mode = 'terrain + things';
1297       } else if (tui.map_mode == 'terrain + things') {
1298           tui.map_mode = 'protections';
1299       } else if (tui.map_mode == 'protections') {
1300           tui.map_mode = 'terrain only';
1301       }
1302   },
1303   full_refresh: function() {
1304     this.draw_links = true;
1305     this.links = {};
1306     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1307     this.recalc_input_lines();
1308     if (this.mode.is_intro) {
1309         this.draw_history();
1310         this.draw_input();
1311     } else {
1312         this.draw_map();
1313         this.draw_turn_line();
1314         this.draw_mode_line();
1315         if (this.mode.shows_info) {
1316           this.draw_info();
1317         } else {
1318           this.draw_history();
1319         }
1320         this.draw_input();
1321     }
1322     if (this.show_help) {
1323         this.draw_help();
1324     }
1325     if (this.draw_face && ['chat', 'play'].includes(this.mode.name)) {
1326         this.draw_face_popup();
1327     }
1328     if (!this.draw_links) {
1329         this.links = {};
1330     }
1331     terminal.refresh();
1332   }
1333 }
1334
1335 let game = {
1336     init: function() {
1337         this.turn = -1;
1338         this.player_id = -1;
1339         this.tasks = {};
1340         this.things = {};
1341         this.things_new = {};
1342         this.fov = "";
1343         this.fov_new = "";
1344         this.map = "";
1345         this.map_new = "";
1346         this.map_control = "";
1347         this.map_control_new = "";
1348         this.map_size = [0,0];
1349         this.map_size_new = [0,0];
1350         this.portals = {};
1351         this.portals_new = {};
1352         this.players_hat_chars = "";
1353     },
1354     get_thing_temp: function(id_, create_if_not_found=false) {
1355         if (id_ in game.things_new) {
1356             return game.things_new[id_];
1357         } else if (create_if_not_found) {
1358             let t = new Thing([0,0]);
1359             game.things_new[id_] = t;
1360             return t;
1361         };
1362     },
1363     get_thing: function(id_, create_if_not_found=false) {
1364         if (id_ in game.things) {
1365             return game.things[id_];
1366         };
1367     },
1368     move: function(start_position, direction) {
1369         let target = [start_position[0], start_position[1]];
1370         if (direction == 'LEFT') {
1371             target[1] -= 1;
1372         } else if (direction == 'RIGHT') {
1373             target[1] += 1;
1374         } else if (game.map_geometry == 'Square') {
1375             if (direction == 'UP') {
1376                 target[0] -= 1;
1377             } else if (direction == 'DOWN') {
1378                 target[0] += 1;
1379             };
1380         } else if (game.map_geometry == 'Hex') {
1381             let start_indented = start_position[0] % 2;
1382             if (direction == 'UPLEFT') {
1383                 target[0] -= 1;
1384                 if (!start_indented) {
1385                     target[1] -= 1;
1386                 }
1387             } else if (direction == 'UPRIGHT') {
1388                 target[0] -= 1;
1389                 if (start_indented) {
1390                     target[1] += 1;
1391                 }
1392             } else if (direction == 'DOWNLEFT') {
1393                 target[0] += 1;
1394                 if (!start_indented) {
1395                     target[1] -= 1;
1396                 }
1397             } else if (direction == 'DOWNRIGHT') {
1398                 target[0] += 1;
1399                 if (start_indented) {
1400                     target[1] += 1;
1401                 }
1402             };
1403         };
1404         if (target[0] < 0 || target[1] < 0 ||
1405             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1406             return null;
1407         };
1408         return target;
1409     },
1410     teleport: function() {
1411         if (game.player.position in this.portals) {
1412             server.reconnect_to(this.portals[game.player.position]);
1413         } else {
1414             terminal.blink_screen();
1415             tui.log_msg('? not standing on portal')
1416         }
1417     }
1418 }
1419
1420 game.init();
1421 tui.init();
1422 tui.full_refresh();
1423 server.init(websocket_location);
1424
1425 let explorer = {
1426     position: [0,0],
1427     annotations: {},
1428     annotations_new: {},
1429     info_cached: false,
1430     move: function(direction) {
1431         let target = game.move(this.position, direction);
1432         if (target) {
1433             this.position = target
1434             this.info_cached = false;
1435             if (tui.tile_draw) {
1436                 this.send_tile_control_command();
1437             }
1438         } else {
1439             terminal.blink_screen();
1440         };
1441     },
1442     get_info: function() {
1443         if (this.info_cached) {
1444             return this.info_cached;
1445         }
1446         let info_to_cache = '';
1447         let position_i = this.position[0] * game.map_size[1] + this.position[1];
1448         if (game.fov[position_i] != '.') {
1449             info_to_cache += 'outside field of view';
1450         } else {
1451             for (let t_id in game.things) {
1452                  let t = game.things[t_id];
1453                  if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1454                      info_to_cache += "THING: " + this.get_thing_info(t);
1455                      let protection = t.protection;
1456                      if (protection == '.') {
1457                          protection = 'none';
1458                      }
1459                      info_to_cache += " / protection: " + protection + "\n";
1460                      if (t.hat) {
1461                          info_to_cache += t.hat.slice(0, 6) + '\n';
1462                          info_to_cache += t.hat.slice(6, 12) + '\n';
1463                          info_to_cache += t.hat.slice(12, 18) + '\n';
1464                      }
1465                      if (t.face) {
1466                          info_to_cache += t.face.slice(0, 6) + '\n';
1467                          info_to_cache += t.face.slice(6, 12) + '\n';
1468                          info_to_cache += t.face.slice(12, 18) + '\n';
1469                      }
1470                  }
1471             }
1472             let terrain_char = game.map[position_i]
1473             let terrain_desc = '?'
1474             if (game.terrains[terrain_char]) {
1475                 terrain_desc = game.terrains[terrain_char];
1476             };
1477             info_to_cache += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1478             let protection = game.map_control[position_i];
1479             if (protection == '.') {
1480                 protection = 'unprotected';
1481             };
1482             info_to_cache += 'PROTECTION: ' + protection + '\n';
1483             if (this.position in game.portals) {
1484                 info_to_cache += "PORTAL: " + game.portals[this.position] + "\n";
1485             }
1486             if (this.position in this.annotations) {
1487                 info_to_cache += "ANNOTATION: " + this.annotations[this.position];
1488             }
1489         }
1490         this.info_cached = info_to_cache;
1491         return this.info_cached;
1492     },
1493     get_thing_info: function(t) {
1494         const symbol = game.thing_types[t.type_];
1495         let info = t.type_ + " / " + symbol;
1496         if (t.thing_char) {
1497             info += t.thing_char;
1498         };
1499         if (t.name_) {
1500             info += " (" + t.name_ + ")";
1501         }
1502         if (t.installed) {
1503             info += " / installed";
1504         }
1505         return info;
1506     },
1507     annotate: function(msg) {
1508         if (msg.length == 0) {
1509             msg = " ";  // triggers annotation deletion
1510         }
1511         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1512     },
1513     set_portal: function(msg) {
1514         if (msg.length == 0) {
1515             msg = " ";  // triggers portal deletion
1516         }
1517         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1518     },
1519     send_tile_control_command: function() {
1520         server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1521     }
1522 }
1523
1524 tui.inputEl.addEventListener('input', (event) => {
1525     if (tui.mode.has_input_prompt) {
1526         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1527         if (tui.inputEl.value.length > max_length) {
1528             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1529         };
1530     } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1531         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1532         tui.switch_mode('edit');
1533     }
1534     tui.full_refresh();
1535 }, false);
1536 document.onclick = function() {
1537     if (!tui.mode.is_single_char_entry) {
1538         tui.show_help = false;
1539     }
1540 };
1541 tui.inputEl.addEventListener('keydown', (event) => {
1542     tui.show_help = false;
1543     if (['Enter', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
1544         event.preventDefault();
1545     }
1546     if ((!tui.mode.is_intro && event.key == 'Escape')
1547         || (tui.mode.has_input_prompt && event.key == 'Enter'
1548             && tui.inputEl.value.length == 0
1549             && ['chat', 'command_thing', 'take_thing', 'drop_thing',
1550                 'admin_enter'].includes(tui.mode.name))) {
1551         if (!['chat', 'play', 'study', 'edit'].includes(tui.mode.name)) {
1552             tui.log_msg('@ aborted');
1553         }
1554         tui.switch_mode('play');
1555     } else if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1556         tui.show_help = true;
1557         tui.inputEl.value = "";
1558         tui.restore_input_values();
1559     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1560                && !tui.mode.is_single_char_entry) {
1561         tui.show_help = true;
1562     } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1563         tui.login_name = tui.inputEl.value;
1564         server.send(['LOGIN', tui.inputEl.value]);
1565         tui.inputEl.value = "";
1566     } else if (tui.mode.name == 'enter_face' && event.key == 'Enter') {
1567         tui.enter_ascii_art('PLAYER_FACE');
1568     } else if (tui.mode.name == 'enter_hat' && event.key == 'Enter') {
1569         tui.enter_ascii_art('PLAYER_HAT');
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.inputEl.value, tui.password]);
1594         tui.switch_mode('edit');
1595     } else if (tui.mode.name == 'annotate' && event.key == 'Enter') {
1596         explorer.annotate(tui.inputEl.value);
1597         tui.switch_mode('edit');
1598     } else if (tui.mode.name == 'password' && event.key == 'Enter') {
1599         if (tui.inputEl.value.length == 0) {
1600             tui.inputEl.value = " ";
1601         }
1602         tui.password = tui.inputEl.value
1603         tui.switch_mode('edit');
1604     } else if (tui.mode.name == 'admin_enter' && event.key == 'Enter') {
1605         server.send(['BECOME_ADMIN', tui.inputEl.value]);
1606         tui.switch_mode('play');
1607     } else if (tui.mode.name == 'control_pw_type' && event.key == 'Enter') {
1608         if (tui.inputEl.value.length != 1) {
1609             tui.log_msg('@ entered non-single-char, therefore aborted');
1610             tui.switch_mode('admin');
1611         } else {
1612             tui.tile_control_char = tui.inputEl.value[0];
1613             tui.switch_mode('control_pw_pw');
1614         }
1615     } else if (tui.mode.name == 'control_tile_type' && event.key == 'Enter') {
1616         if (tui.inputEl.value.length != 1) {
1617             tui.log_msg('@ entered non-single-char, therefore aborted');
1618             tui.switch_mode('admin');
1619         } else {
1620             tui.tile_control_char = tui.inputEl.value[0];
1621             tui.switch_mode('control_tile_draw');
1622         }
1623     } else if (tui.mode.name == 'admin_thing_protect' && event.key == 'Enter') {
1624         if (tui.inputEl.value.length != 1) {
1625             tui.log_msg('@ entered non-single-char, therefore aborted');
1626         } else {
1627             server.send(['THING_PROTECTION', tui.inputEl.value])
1628             tui.log_msg('@ sent new protection character for thing');
1629         }
1630         tui.switch_mode('admin');
1631     } else if (tui.mode.name == 'chat' && event.key == 'Enter') {
1632         let tokens = parser.tokenize(tui.inputEl.value);
1633         if (tokens.length > 0 && tokens[0].length > 0) {
1634             if (tui.inputEl.value[0][0] == '/') {
1635                 if (tokens[0].slice(1) == 'nick') {
1636                     if (tokens.length > 1) {
1637                         server.send(['NICK', tokens[1]]);
1638                     } else {
1639                         tui.log_msg('? need new name');
1640                     }
1641                 } else {
1642                     tui.log_msg('? unknown command');
1643                 }
1644             } else {
1645                     server.send(['ALL', tui.inputEl.value]);
1646             }
1647         } else if (tui.inputEl.valuelength > 0) {
1648                 server.send(['ALL', tui.inputEl.value]);
1649         }
1650         tui.inputEl.value = "";
1651     } else if (tui.mode.name == 'play') {
1652           if (tui.mode.mode_switch_on_key(event)) {
1653               null;
1654           } else if (event.key === tui.keys.consume && tui.task_action_on('consume')) {
1655               server.send(["TASK:INTOXICATE"]);
1656           } else if (event.key === tui.keys.door && tui.task_action_on('door')) {
1657               server.send(["TASK:DOOR"]);
1658           } else if (event.key === tui.keys.wear && tui.task_action_on('wear')) {
1659               server.send(["TASK:WEAR"]);
1660           } else if (event.key === tui.keys.spin && tui.task_action_on('spin')) {
1661               server.send(["TASK:SPIN"]);
1662           } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1663               server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1664           } else if (event.key === tui.keys.teleport) {
1665               game.teleport();
1666           };
1667     } else if (tui.mode.name == 'study') {
1668         if (tui.mode.mode_switch_on_key(event)) {
1669               null;
1670         } else if (event.key in tui.movement_keys) {
1671             explorer.move(tui.movement_keys[event.key]);
1672         } else if (event.key == tui.keys.toggle_map_mode) {
1673             tui.toggle_map_mode();
1674         };
1675     } else if (tui.mode.name == 'control_tile_draw') {
1676         if (tui.mode.mode_switch_on_key(event)) {
1677             null;
1678         } else if (event.key in tui.movement_keys) {
1679             explorer.move(tui.movement_keys[event.key]);
1680         } else if (event.key === tui.keys.toggle_tile_draw) {
1681             tui.toggle_tile_draw();
1682         };
1683     } else if (tui.mode.name == 'admin') {
1684         if (tui.mode.mode_switch_on_key(event)) {
1685               null;
1686         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1687             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1688         };
1689     } else if (tui.mode.name == 'edit') {
1690         if (tui.mode.mode_switch_on_key(event)) {
1691               null;
1692         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1693             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1694         } else if (event.key === tui.keys.flatten && tui.task_action_on('flatten')) {
1695             server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
1696           } else if (event.key === tui.keys.install && tui.task_action_on('install')) {
1697               server.send(["TASK:INSTALL", tui.password]);
1698         } else if (event.key == tui.keys.toggle_map_mode) {
1699             tui.toggle_map_mode();
1700         }
1701     }
1702     tui.full_refresh();
1703 }, false);
1704
1705 rows_selector.addEventListener('input', function() {
1706     if (rows_selector.value % 4 != 0 || rows_selector.value < 24) {
1707         return;
1708     }
1709     window.localStorage.setItem(rows_selector.id, rows_selector.value);
1710     terminal.initialize();
1711     tui.full_refresh();
1712 }, false);
1713 cols_selector.addEventListener('input', function() {
1714     if (cols_selector.value % 4 != 0 || cols_selector.value < 80) {
1715         return;
1716     }
1717     window.localStorage.setItem(cols_selector.id, cols_selector.value);
1718     terminal.initialize();
1719     tui.window_width = terminal.cols / 2,
1720     tui.full_refresh();
1721 }, false);
1722 for (let key_selector of key_selectors) {
1723     key_selector.addEventListener('input', function() {
1724         window.localStorage.setItem(key_selector.id, key_selector.value);
1725         tui.init_keys();
1726     }, false);
1727 }
1728 window.setInterval(function() {
1729     if (server.websocket.readyState == 1) {
1730         server.send(['PING']);
1731     } else if (server.websocket.readyState != 0) {
1732         server.reconnect_to(server.url);
1733         tui.log_msg('@ attempting reconnect …')
1734     }
1735 }, 1000);
1736 window.setInterval(function() {
1737     if (document.activeElement.tagName.toLowerCase() != 'input') {
1738         tui.inputEl.focus();
1739     };
1740 }, 100);
1741 document.getElementById("help").onclick = function() {
1742     tui.show_help = true;
1743     tui.full_refresh();
1744 };
1745 for (const switchEl  of document.querySelectorAll('[id^="switch_to_"]')) {
1746     const mode = switchEl.id.slice("switch_to_".length);
1747     switchEl.onclick = function() {
1748         tui.switch_mode(mode);
1749         tui.full_refresh();
1750     }
1751 };
1752 document.getElementById("toggle_tile_draw").onclick = function() {
1753     tui.toggle_tile_draw();
1754 }
1755 document.getElementById("toggle_map_mode").onclick = function() {
1756     tui.toggle_map_mode();
1757     tui.full_refresh();
1758 };
1759 document.getElementById("flatten").onclick = function() {
1760     server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1761 };
1762 document.getElementById("door").onclick = function() {
1763     server.send(['TASK:DOOR']);
1764 };
1765 document.getElementById("consume").onclick = function() {
1766     server.send(['TASK:INTOXICATE']);
1767 };
1768 document.getElementById("install").onclick = function() {
1769     server.send(['TASK:INSTALL', tui.password]);
1770 };
1771 document.getElementById("wear").onclick = function() {
1772     server.send(['TASK:WEAR']);
1773 };
1774 document.getElementById("spin").onclick = function() {
1775     server.send(['TASK:SPIN']);
1776 };
1777 document.getElementById("teleport").onclick = function() {
1778     game.teleport();
1779 };
1780 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
1781     if (move_button.id.startsWith('key_')) {  // not a move button
1782         continue;
1783     };
1784     let direction = move_button.id.split('_')[2].toUpperCase();
1785     let move_repeat;
1786     function move() {
1787         if (tui.mode.available_actions.includes("move")) {
1788             server.send(['TASK:MOVE', direction]);
1789         } else if (tui.mode.available_actions.includes("move_explorer")) {
1790             explorer.move(direction);
1791             tui.full_refresh();
1792         };
1793     }
1794     move_button.onmousedown = function() {
1795         move();
1796         move_repeat = window.setInterval(move, 100);
1797     };
1798     move_button.onmouseup = function() {
1799         window.clearInterval(move_repeat);
1800     }
1801 };
1802 </script>
1803 </body></html>