773bc1f191fc324aaf0aa4b1f26652e5b6c346e5
[vchess.git] / public / javascripts / components / game.js
1 // Game logic on a variant page
2 Vue.component('my-game', {
3 props: ["problem"],
4 data: function() {
5 return {
6 vr: null, //object to check moves, store them, FEN..
7 mycolor: "w",
8 possibleMoves: [], //filled after each valid click/dragstart
9 choices: [], //promotion pieces, or checkered captures... (as moves)
10 start: {}, //pixels coordinates + id of starting square (click or drag)
11 selectedPiece: null, //moving piece (or clicked piece)
12 conn: null, //socket connection
13 score: "*", //'*' means 'unfinished'
14 mode: "idle", //human, chat, friend, problem, computer or idle (if not playing)
15 myid: "", //our ID, always set
16 oppid: "", //opponent ID in case of HH game
17 gameId: "", //useful if opponent started other human games after we disconnected
18 myname: localStorage["username"] || "anonymous",
19 oppName: "anonymous", //opponent name, revealed after a game (if provided)
20 chats: [], //chat messages after human game
21 oppConnected: false,
22 seek: false,
23 fenStart: "",
24 incheck: [],
25 pgnTxt: "",
26 hints: (!localStorage["hints"] ? true : localStorage["hints"] === "1"),
27 color: localStorage["color"] || "lichess", //lichess, chesscom or chesstempo
28 // sound level: 0 = no sound, 1 = sound only on newgame, 2 = always
29 sound: parseInt(localStorage["sound"] || "2"),
30 // Web worker to play computer moves without freezing interface:
31 compWorker: new Worker('/javascripts/playCompMove.js'),
32 timeStart: undefined, //time when computer starts thinking
33 };
34 },
35 watch: {
36 problem: function(p) {
37 // 'problem' prop changed: update board state
38 this.newGame("problem", p.fen, V.ParseFen(p.fen).turn);
39 },
40 },
41 render(h) {
42 const [sizeX,sizeY] = [V.size.x,V.size.y];
43 const smallScreen = (window.innerWidth <= 420);
44 // Precompute hints squares to facilitate rendering
45 let hintSquares = doubleArray(sizeX, sizeY, false);
46 this.possibleMoves.forEach(m => { hintSquares[m.end.x][m.end.y] = true; });
47 // Also precompute in-check squares
48 let incheckSq = doubleArray(sizeX, sizeY, false);
49 this.incheck.forEach(sq => { incheckSq[sq[0]][sq[1]] = true; });
50 let elementArray = [];
51 let actionArray = [];
52 actionArray.push(
53 h('button',
54 {
55 on: { click: this.clickGameSeek },
56 attrs: { "aria-label": 'New online game' },
57 'class': {
58 "tooltip": true,
59 "bottom": true, //display below
60 "seek": this.seek,
61 "playing": this.mode == "human",
62 "small": smallScreen,
63 },
64 },
65 [h('i', { 'class': { "material-icons": true } }, "accessibility")])
66 );
67 if (["idle","chat","computer"].includes(this.mode))
68 {
69 actionArray.push(
70 h('button',
71 {
72 on: { click: this.clickComputerGame },
73 attrs: { "aria-label": 'New game VS computer' },
74 'class': {
75 "tooltip":true,
76 "bottom": true,
77 "playing": this.mode == "computer",
78 "small": smallScreen,
79 },
80 },
81 [h('i', { 'class': { "material-icons": true } }, "computer")])
82 );
83 }
84 if (["idle","chat","friend"].includes(this.mode))
85 {
86 actionArray.push(
87 h('button',
88 {
89 on: { click: this.clickFriendGame },
90 attrs: { "aria-label": 'New IRL game' },
91 'class': {
92 "tooltip":true,
93 "bottom": true,
94 "playing": this.mode == "friend",
95 "small": smallScreen,
96 },
97 },
98 [h('i', { 'class': { "material-icons": true } }, "people")])
99 );
100 }
101 if (!!this.vr)
102 {
103 const square00 = document.getElementById("sq-0-0");
104 const squareWidth = !!square00
105 ? parseFloat(window.getComputedStyle(square00).width.slice(0,-2))
106 : 0;
107 const settingsBtnElt = document.getElementById("settingsBtn");
108 const indicWidth = !!settingsBtnElt //-2 for border:
109 ? parseFloat(window.getComputedStyle(settingsBtnElt).height.slice(0,-2)) - 2
110 : (smallScreen ? 31 : 37);
111 if (["chat","human"].includes(this.mode))
112 {
113 const connectedIndic = h(
114 'div',
115 {
116 "class": {
117 "topindicator": true,
118 "indic-left": true,
119 "connected": this.oppConnected,
120 "disconnected": !this.oppConnected,
121 },
122 style: {
123 "width": indicWidth + "px",
124 "height": indicWidth + "px",
125 },
126 }
127 );
128 elementArray.push(connectedIndic);
129 }
130 if (this.mode == "chat")
131 {
132 const chatButton = h(
133 'button',
134 {
135 on: { click: this.startChat },
136 attrs: {
137 "aria-label": 'Start chat',
138 "id": "chatBtn",
139 },
140 'class': {
141 "tooltip": true,
142 "topindicator": true,
143 "indic-left": true,
144 "settings-btn": !smallScreen,
145 "settings-btn-small": smallScreen,
146 },
147 },
148 [h('i', { 'class': { "material-icons": true } }, "chat")]
149 );
150 elementArray.push(chatButton);
151 }
152 else if (this.mode == "computer")
153 {
154 const clearButton = h(
155 'button',
156 {
157 on: { click: this.clearComputerGame },
158 attrs: {
159 "aria-label": 'Clear computer game',
160 "id": "clearBtn",
161 },
162 'class': {
163 "tooltip": true,
164 "topindicator": true,
165 "indic-left": true,
166 "settings-btn": !smallScreen,
167 "settings-btn-small": smallScreen,
168 },
169 },
170 [h('i', { 'class': { "material-icons": true } }, "clear")]
171 );
172 elementArray.push(clearButton);
173 }
174 const turnIndic = h(
175 'div',
176 {
177 "class": {
178 "topindicator": true,
179 "indic-right": true,
180 "white-turn": this.vr.turn=="w",
181 "black-turn": this.vr.turn=="b",
182 },
183 style: {
184 "width": indicWidth + "px",
185 "height": indicWidth + "px",
186 },
187 }
188 );
189 elementArray.push(turnIndic);
190 const settingsBtn = h(
191 'button',
192 {
193 on: { click: this.showSettings },
194 attrs: {
195 "aria-label": 'Settings',
196 "id": "settingsBtn",
197 },
198 'class': {
199 "tooltip": true,
200 "topindicator": true,
201 "indic-right": true,
202 "settings-btn": !smallScreen,
203 "settings-btn-small": smallScreen,
204 },
205 },
206 [h('i', { 'class': { "material-icons": true } }, "settings")]
207 );
208 elementArray.push(settingsBtn);
209 if (this.mode == "problem")
210 {
211 // Show problem instructions
212 elementArray.push(
213 h('div',
214 {
215 attrs: { id: "instructions-div" },
216 "class": {
217 "clearer": true,
218 "section-content": true,
219 },
220 },
221 [
222 h('p',
223 {
224 attrs: { id: "problem-instructions" },
225 domProps: { innerHTML: this.problem.instructions }
226 }
227 )
228 ]
229 )
230 );
231 }
232 const choices = h('div',
233 {
234 attrs: { "id": "choices" },
235 'class': { 'row': true },
236 style: {
237 "display": this.choices.length>0?"block":"none",
238 "top": "-" + ((sizeY/2)*squareWidth+squareWidth/2) + "px",
239 "width": (this.choices.length * squareWidth) + "px",
240 "height": squareWidth + "px",
241 },
242 },
243 this.choices.map( m => { //a "choice" is a move
244 return h('div',
245 {
246 'class': {
247 'board': true,
248 ['board'+sizeY]: true,
249 },
250 style: {
251 'width': (100/this.choices.length) + "%",
252 'padding-bottom': (100/this.choices.length) + "%",
253 },
254 },
255 [h('img',
256 {
257 attrs: { "src": '/images/pieces/' +
258 VariantRules.getPpath(m.appear[0].c+m.appear[0].p) + '.svg' },
259 'class': { 'choice-piece': true },
260 on: {
261 "click": e => { this.play(m); this.choices=[]; },
262 // NOTE: add 'touchstart' event to fix a problem on smartphones
263 "touchstart": e => { this.play(m); this.choices=[]; },
264 },
265 })
266 ]
267 );
268 })
269 );
270 // Create board element (+ reserves if needed by variant or mode)
271 const lm = this.vr.lastMove;
272 const showLight = this.hints &&
273 (!["idle","chat"].includes(this.mode) || this.cursor==this.vr.moves.length);
274 const gameDiv = h('div',
275 {
276 'class': { 'game': true },
277 },
278 [_.range(sizeX).map(i => {
279 let ci = (this.mycolor=='w' ? i : sizeX-i-1);
280 return h(
281 'div',
282 {
283 'class': {
284 'row': true,
285 },
286 style: { 'opacity': this.choices.length>0?"0.5":"1" },
287 },
288 _.range(sizeY).map(j => {
289 let cj = (this.mycolor=='w' ? j : sizeY-j-1);
290 let elems = [];
291 if (this.vr.board[ci][cj] != VariantRules.EMPTY)
292 {
293 elems.push(
294 h(
295 'img',
296 {
297 'class': {
298 'piece': true,
299 'ghost': !!this.selectedPiece
300 && this.selectedPiece.parentNode.id == "sq-"+ci+"-"+cj,
301 },
302 attrs: {
303 src: "/images/pieces/" +
304 VariantRules.getPpath(this.vr.board[ci][cj]) + ".svg",
305 },
306 }
307 )
308 );
309 }
310 if (this.hints && hintSquares[ci][cj])
311 {
312 elems.push(
313 h(
314 'img',
315 {
316 'class': {
317 'mark-square': true,
318 },
319 attrs: {
320 src: "/images/mark.svg",
321 },
322 }
323 )
324 );
325 }
326 return h(
327 'div',
328 {
329 'class': {
330 'board': true,
331 ['board'+sizeY]: true,
332 'light-square': (i+j)%2==0,
333 'dark-square': (i+j)%2==1,
334 [this.color]: true,
335 'highlight': showLight && !!lm && _.isMatch(lm.end, {x:ci,y:cj}),
336 'incheck': showLight && incheckSq[ci][cj],
337 },
338 attrs: {
339 id: this.getSquareId({x:ci,y:cj}),
340 },
341 },
342 elems
343 );
344 })
345 );
346 }), choices]
347 );
348 if (!["idle","chat"].includes(this.mode))
349 {
350 actionArray.push(
351 h('button',
352 {
353 on: { click: this.resign },
354 attrs: { "aria-label": 'Resign' },
355 'class': {
356 "tooltip":true,
357 "bottom": true,
358 "small": smallScreen,
359 },
360 },
361 [h('i', { 'class': { "material-icons": true } }, "flag")])
362 );
363 }
364 else if (this.vr.moves.length > 0)
365 {
366 // A game finished, and another is not started yet: allow navigation
367 actionArray = actionArray.concat([
368 h('button',
369 {
370 on: { click: e => this.undo() },
371 attrs: { "aria-label": 'Undo' },
372 "class": {
373 "small": smallScreen,
374 "spaceleft": true,
375 },
376 },
377 [h('i', { 'class': { "material-icons": true } }, "fast_rewind")]),
378 h('button',
379 {
380 on: { click: e => this.play() },
381 attrs: { "aria-label": 'Play' },
382 "class": { "small": smallScreen },
383 },
384 [h('i', { 'class': { "material-icons": true } }, "fast_forward")]),
385 ]
386 );
387 }
388 if (["friend","problem"].includes(this.mode))
389 {
390 actionArray = actionArray.concat(
391 [
392 h('button',
393 {
394 on: { click: this.undoInGame },
395 attrs: { "aria-label": 'Undo' },
396 "class": {
397 "small": smallScreen,
398 "spaceleft": true,
399 },
400 },
401 [h('i', { 'class': { "material-icons": true } }, "undo")]
402 ),
403 h('button',
404 {
405 on: { click: () => { this.mycolor = this.vr.getOppCol(this.mycolor) } },
406 attrs: { "aria-label": 'Flip' },
407 "class": { "small": smallScreen },
408 },
409 [h('i', { 'class': { "material-icons": true } }, "cached")]
410 ),
411 ]);
412 }
413 elementArray.push(gameDiv);
414 if (!!this.vr.reserve)
415 {
416 const shiftIdx = (this.mycolor=="w" ? 0 : 1);
417 let myReservePiecesArray = [];
418 for (let i=0; i<VariantRules.RESERVE_PIECES.length; i++)
419 {
420 myReservePiecesArray.push(h('div',
421 {
422 'class': {'board':true, ['board'+sizeY]:true},
423 attrs: { id: this.getSquareId({x:sizeX+shiftIdx,y:i}) }
424 },
425 [
426 h('img',
427 {
428 'class': {"piece":true},
429 attrs: {
430 "src": "/images/pieces/" +
431 this.vr.getReservePpath(this.mycolor,i) + ".svg",
432 }
433 }),
434 h('sup',
435 {"class": { "reserve-count": true } },
436 [ this.vr.reserve[this.mycolor][VariantRules.RESERVE_PIECES[i]] ]
437 )
438 ]));
439 }
440 let oppReservePiecesArray = [];
441 const oppCol = this.vr.getOppCol(this.mycolor);
442 for (let i=0; i<VariantRules.RESERVE_PIECES.length; i++)
443 {
444 oppReservePiecesArray.push(h('div',
445 {
446 'class': {'board':true, ['board'+sizeY]:true},
447 attrs: { id: this.getSquareId({x:sizeX+(1-shiftIdx),y:i}) }
448 },
449 [
450 h('img',
451 {
452 'class': {"piece":true},
453 attrs: {
454 "src": "/images/pieces/" +
455 this.vr.getReservePpath(oppCol,i) + ".svg",
456 }
457 }),
458 h('sup',
459 {"class": { "reserve-count": true } },
460 [ this.vr.reserve[oppCol][VariantRules.RESERVE_PIECES[i]] ]
461 )
462 ]));
463 }
464 let reserves = h('div',
465 {
466 'class':{
467 'game': true,
468 "reserve-div": true,
469 },
470 },
471 [
472 h('div',
473 {
474 'class': {
475 'row': true,
476 "reserve-row-1": true,
477 },
478 },
479 myReservePiecesArray
480 ),
481 h('div',
482 { 'class': { 'row': true }},
483 oppReservePiecesArray
484 )
485 ]
486 );
487 elementArray.push(reserves);
488 }
489 const modalEog = [
490 h('input',
491 {
492 attrs: { "id": "modal-eog", type: "checkbox" },
493 "class": { "modal": true },
494 }),
495 h('div',
496 {
497 attrs: { "role": "dialog", "aria-labelledby": "eogMessage" },
498 },
499 [
500 h('div',
501 {
502 "class": { "card": true, "smallpad": true },
503 },
504 [
505 h('label',
506 {
507 attrs: { "for": "modal-eog" },
508 "class": { "modal-close": true },
509 }
510 ),
511 h('h3',
512 {
513 attrs: { "id": "eogMessage" },
514 "class": { "section": true },
515 domProps: { innerHTML: this.endgameMessage },
516 }
517 )
518 ]
519 )
520 ]
521 )
522 ];
523 elementArray = elementArray.concat(modalEog);
524 }
525 const modalFenEdit = [
526 h('input',
527 {
528 attrs: { "id": "modal-fenedit", type: "checkbox" },
529 "class": { "modal": true },
530 }),
531 h('div',
532 {
533 attrs: { "role": "dialog", "aria-labelledby": "titleFenedit" },
534 },
535 [
536 h('div',
537 {
538 "class": { "card": true, "smallpad": true },
539 },
540 [
541 h('label',
542 {
543 attrs: { "id": "close-fenedit", "for": "modal-fenedit" },
544 "class": { "modal-close": true },
545 }
546 ),
547 h('h3',
548 {
549 attrs: { "id": "titleFenedit" },
550 "class": { "section": true },
551 domProps: { innerHTML: "Position + flags (FEN):" },
552 }
553 ),
554 h('input',
555 {
556 attrs: {
557 "id": "input-fen",
558 type: "text",
559 value: VariantRules.GenRandInitFen(),
560 },
561 }
562 ),
563 h('button',
564 {
565 on: { click:
566 () => {
567 const fen = document.getElementById("input-fen").value;
568 document.getElementById("modal-fenedit").checked = false;
569 this.newGame("friend", fen);
570 }
571 },
572 domProps: { innerHTML: "Ok" },
573 }
574 ),
575 h('button',
576 {
577 on: { click:
578 () => {
579 document.getElementById("input-fen").value =
580 VariantRules.GenRandInitFen();
581 }
582 },
583 domProps: { innerHTML: "Random" },
584 }
585 ),
586 ]
587 )
588 ]
589 )
590 ];
591 elementArray = elementArray.concat(modalFenEdit);
592 const modalSettings = [
593 h('input',
594 {
595 attrs: { "id": "modal-settings", type: "checkbox" },
596 "class": { "modal": true },
597 }),
598 h('div',
599 {
600 attrs: { "role": "dialog", "aria-labelledby": "settingsTitle" },
601 },
602 [
603 h('div',
604 {
605 "class": { "card": true, "smallpad": true },
606 },
607 [
608 h('label',
609 {
610 attrs: { "id": "close-settings", "for": "modal-settings" },
611 "class": { "modal-close": true },
612 }
613 ),
614 h('h3',
615 {
616 attrs: { "id": "settingsTitle" },
617 "class": { "section": true },
618 domProps: { innerHTML: "Preferences" },
619 }
620 ),
621 h('fieldset',
622 { },
623 [
624 h('label',
625 {
626 attrs: { for: "nameSetter" },
627 domProps: { innerHTML: "My name is..." },
628 },
629 ),
630 h('input',
631 {
632 attrs: {
633 "id": "nameSetter",
634 type: "text",
635 value: this.myname,
636 },
637 on: { "change": this.setMyname },
638 }
639 ),
640 ]
641 ),
642 h('fieldset',
643 { },
644 [
645 h('label',
646 {
647 attrs: { for: "setHints" },
648 domProps: { innerHTML: "Show hints?" },
649 },
650 ),
651 h('input',
652 {
653 attrs: {
654 "id": "setHints",
655 type: "checkbox",
656 checked: this.hints,
657 },
658 on: { "change": this.toggleHints },
659 }
660 ),
661 ]
662 ),
663 h('fieldset',
664 { },
665 [
666 h('label',
667 {
668 attrs: { for: "selectColor" },
669 domProps: { innerHTML: "Board colors" },
670 },
671 ),
672 h("select",
673 {
674 attrs: { "id": "selectColor" },
675 on: { "change": this.setColor },
676 },
677 [
678 h("option",
679 {
680 domProps: {
681 "value": "lichess",
682 innerHTML: "brown"
683 },
684 attrs: { "selected": this.color=="lichess" },
685 }
686 ),
687 h("option",
688 {
689 domProps: {
690 "value": "chesscom",
691 innerHTML: "green"
692 },
693 attrs: { "selected": this.color=="chesscom" },
694 }
695 ),
696 h("option",
697 {
698 domProps: {
699 "value": "chesstempo",
700 innerHTML: "blue"
701 },
702 attrs: { "selected": this.color=="chesstempo" },
703 }
704 ),
705 ],
706 ),
707 ]
708 ),
709 h('fieldset',
710 { },
711 [
712 h('label',
713 {
714 attrs: { for: "selectSound" },
715 domProps: { innerHTML: "Play sounds?" },
716 },
717 ),
718 h("select",
719 {
720 attrs: { "id": "selectSound" },
721 on: { "change": this.setSound },
722 },
723 [
724 h("option",
725 {
726 domProps: {
727 "value": "0",
728 innerHTML: "None"
729 },
730 attrs: { "selected": this.sound==0 },
731 }
732 ),
733 h("option",
734 {
735 domProps: {
736 "value": "1",
737 innerHTML: "Newgame"
738 },
739 attrs: { "selected": this.sound==1 },
740 }
741 ),
742 h("option",
743 {
744 domProps: {
745 "value": "2",
746 innerHTML: "All"
747 },
748 attrs: { "selected": this.sound==2 },
749 }
750 ),
751 ],
752 ),
753 ]
754 ),
755 ]
756 )
757 ]
758 )
759 ];
760 elementArray = elementArray.concat(modalSettings);
761 let chatEltsArray =
762 [
763 h('label',
764 {
765 attrs: { "id": "close-chat", "for": "modal-chat" },
766 "class": { "modal-close": true },
767 }
768 ),
769 h('h3',
770 {
771 attrs: { "id": "titleChat" },
772 "class": { "section": true },
773 domProps: { innerHTML: "Chat with " + this.oppName },
774 }
775 )
776 ];
777 for (let chat of this.chats)
778 {
779 chatEltsArray.push(
780 h('p',
781 {
782 "class": {
783 "my-chatmsg": chat.author==this.myid,
784 "opp-chatmsg": chat.author==this.oppid,
785 },
786 domProps: { innerHTML: chat.msg }
787 }
788 )
789 );
790 }
791 chatEltsArray = chatEltsArray.concat([
792 h('input',
793 {
794 attrs: {
795 "id": "input-chat",
796 type: "text",
797 placeholder: "Type here",
798 },
799 on: { keyup: this.trySendChat }, //if key is 'enter'
800 }
801 ),
802 h('button',
803 {
804 on: { click: this.sendChat },
805 domProps: { innerHTML: "Send" },
806 }
807 )
808 ]);
809 const modalChat = [
810 h('input',
811 {
812 attrs: { "id": "modal-chat", type: "checkbox" },
813 "class": { "modal": true },
814 }),
815 h('div',
816 {
817 attrs: { "role": "dialog", "aria-labelledby": "titleChat" },
818 },
819 [
820 h('div',
821 {
822 "class": { "card": true, "smallpad": true },
823 },
824 chatEltsArray
825 )
826 ]
827 )
828 ];
829 elementArray = elementArray.concat(modalChat);
830 const actions = h('div',
831 {
832 attrs: { "id": "actions" },
833 'class': { 'text-center': true },
834 },
835 actionArray
836 );
837 elementArray.push(actions);
838 if (this.score != "*" && this.pgnTxt.length > 0)
839 {
840 elementArray.push(
841 h('div',
842 {
843 attrs: { id: "pgn-div" },
844 "class": { "section-content": true },
845 },
846 [
847 h('a',
848 {
849 attrs: {
850 id: "download",
851 href: "#",
852 }
853 }
854 ),
855 h('p',
856 {
857 attrs: { id: "pgn-game" },
858 domProps: { innerHTML: this.pgnTxt }
859 }
860 ),
861 h('button',
862 {
863 attrs: { "id": "downloadBtn" },
864 on: { click: this.download },
865 domProps: { innerHTML: "Download game" },
866 }
867 ),
868 ]
869 )
870 );
871 }
872 else if (this.mode != "idle")
873 {
874 if (this.mode == "problem")
875 {
876 // Show problem solution (on click)
877 elementArray.push(
878 h('div',
879 {
880 attrs: { id: "solution-div" },
881 "class": { "section-content": true },
882 },
883 [
884 h('h3',
885 {
886 domProps: { innerHTML: "Show solution" },
887 on: { click: this.toggleShowSolution },
888 }
889 ),
890 h('p',
891 {
892 attrs: { id: "problem-solution" },
893 domProps: { innerHTML: this.problem.solution }
894 }
895 )
896 ]
897 )
898 );
899 }
900 // Show current FEN
901 elementArray.push(
902 h('div',
903 {
904 attrs: { id: "fen-div" },
905 "class": { "section-content": true },
906 },
907 [
908 h('p',
909 {
910 attrs: { id: "fen-string" },
911 domProps: { innerHTML: this.vr.getBaseFen() },
912 "class": { "text-center": true },
913 }
914 )
915 ]
916 )
917 );
918 }
919 return h(
920 'div',
921 {
922 'class': {
923 "col-sm-12":true,
924 "col-md-8":true,
925 "col-md-offset-2":true,
926 "col-lg-6":true,
927 "col-lg-offset-3":true,
928 },
929 // NOTE: click = mousedown + mouseup
930 on: {
931 mousedown: this.mousedown,
932 mousemove: this.mousemove,
933 mouseup: this.mouseup,
934 touchstart: this.mousedown,
935 touchmove: this.mousemove,
936 touchend: this.mouseup,
937 },
938 },
939 elementArray
940 );
941 },
942 computed: {
943 endgameMessage: function() {
944 let eogMessage = "Unfinished";
945 switch (this.score)
946 {
947 case "1-0":
948 eogMessage = "White win";
949 break;
950 case "0-1":
951 eogMessage = "Black win";
952 break;
953 case "1/2":
954 eogMessage = "Draw";
955 break;
956 }
957 return eogMessage;
958 },
959 },
960 created: function() {
961 const url = socketUrl;
962 const humanContinuation = (localStorage.getItem("variant") === variant);
963 const computerContinuation = (localStorage.getItem("comp-variant") === variant);
964 this.myid = (humanContinuation ? localStorage.getItem("myid") : getRandString());
965 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
966 const socketOpenListener = () => {
967 if (humanContinuation) //game VS human has priority
968 this.continueGame("human");
969 else if (computerContinuation)
970 this.continueGame("computer");
971 };
972 const socketMessageListener = msg => {
973 const data = JSON.parse(msg.data);
974 const L = (!!this.vr ? this.vr.moves.length : 0);
975 switch (data.code)
976 {
977 case "oppname":
978 // Receive opponent's name
979 this.oppName = data.name;
980 break;
981 case "newchat":
982 // Receive new chat
983 this.chats.push({msg:data.msg, author:this.oppid});
984 break;
985 case "duplicate":
986 // We opened another tab on the same game
987 this.mode = "idle";
988 this.vr = null;
989 alert("Already playing a game in this variant on another tab!");
990 break;
991 case "newgame": //opponent found
992 // oppid: opponent socket ID
993 this.newGame("human", data.fen, data.color, data.oppid);
994 break;
995 case "newmove": //..he played!
996 this.play(data.move, "animate");
997 break;
998 case "pong": //received if we sent a ping (game still alive on our side)
999 if (this.gameId != data.gameId)
1000 break; //games IDs don't match: definitely over...
1001 this.oppConnected = true;
1002 // Send our "last state" informations to opponent
1003 this.conn.send(JSON.stringify({
1004 code: "lastate",
1005 oppid: this.oppid,
1006 gameId: this.gameId,
1007 lastMove: (L>0?this.vr.moves[L-1]:undefined),
1008 movesCount: L,
1009 }));
1010 break;
1011 case "lastate": //got opponent infos about last move
1012 if (this.gameId != data.gameId)
1013 break; //games IDs don't match: nothing we can do...
1014 // OK, opponent still in game (which might be over)
1015 if (this.mode != "human")
1016 {
1017 // We finished the game (any result possible)
1018 this.conn.send(JSON.stringify({
1019 code: "lastate",
1020 oppid: data.oppid,
1021 gameId: this.gameId,
1022 score: this.score,
1023 }));
1024 }
1025 else if (!!data.score) //opponent finished the game
1026 this.endGame(data.score);
1027 else if (data.movesCount < L)
1028 {
1029 // We must tell last move to opponent
1030 this.conn.send(JSON.stringify({
1031 code: "lastate",
1032 oppid: this.oppid,
1033 lastMove: this.vr.moves[L-1],
1034 movesCount: L,
1035 }));
1036 }
1037 else if (data.movesCount > L) //just got last move from him
1038 this.play(data.lastMove, "animate");
1039 break;
1040 case "resign": //..you won!
1041 this.endGame(this.mycolor=="w"?"1-0":"0-1");
1042 break;
1043 // TODO: also use (dis)connect info to count online players?
1044 case "connect":
1045 case "disconnect":
1046 if (["human","chat"].includes(this.mode) && this.oppid == data.id)
1047 this.oppConnected = (data.code == "connect");
1048 if (this.oppConnected && this.mode == "chat")
1049 {
1050 // Send our name to the opponent, in case of he hasn't it
1051 this.conn.send(JSON.stringify({
1052 code:"myname", name:this.myname, oppid: this.oppid}));
1053 }
1054 break;
1055 }
1056 };
1057 const socketCloseListener = () => {
1058 this.conn = new WebSocket(url + "/?sid=" + this.myid + "&page=" + variant);
1059 this.conn.addEventListener('open', socketOpenListener);
1060 this.conn.addEventListener('message', socketMessageListener);
1061 this.conn.addEventListener('close', socketCloseListener);
1062 };
1063 this.conn.onopen = socketOpenListener;
1064 this.conn.onmessage = socketMessageListener;
1065 this.conn.onclose = socketCloseListener;
1066 // Listen to keyboard left/right to navigate in game
1067 document.onkeydown = event => {
1068 if (["idle","chat"].includes(this.mode) &&
1069 !!this.vr && this.vr.moves.length > 0 && [37,39].includes(event.keyCode))
1070 {
1071 event.preventDefault();
1072 if (event.keyCode == 37) //Back
1073 this.undo();
1074 else //Forward (39)
1075 this.play();
1076 }
1077 };
1078 // Computer moves web worker logic:
1079 this.compWorker.postMessage(["scripts",variant]);
1080 const self = this;
1081 this.compWorker.onmessage = function(e) {
1082 let compMove = e.data;
1083 compMove.computer = true; //TODO: imperfect attempt to avoid ghost move
1084 // (first move) HACK: small delay to avoid selecting elements
1085 // before they appear on page:
1086 const delay = Math.max(500-(Date.now()-self.timeStart), 0);
1087 setTimeout(() => {
1088 if (self.mode == "computer") //warning: mode could have changed!
1089 self.play(compMove, "animate")
1090 }, delay);
1091 }
1092 },
1093 methods: {
1094 setMyname: function(e) {
1095 this.myname = e.target.value;
1096 localStorage["username"] = this.myname;
1097 },
1098 trySendChat: function(e) {
1099 if (e.keyCode == 13) //'enter' key
1100 this.sendChat();
1101 },
1102 sendChat: function() {
1103 let chatInput = document.getElementById("input-chat");
1104 const chatTxt = chatInput.value;
1105 chatInput.value = "";
1106 this.chats.push({msg:chatTxt, author:this.myid});
1107 this.conn.send(JSON.stringify({
1108 code:"newchat", oppid: this.oppid, msg: chatTxt}));
1109 },
1110 toggleShowSolution: function() {
1111 let problemSolution = document.getElementById("problem-solution");
1112 problemSolution.style.display =
1113 !problemSolution.style.display || problemSolution.style.display == "none"
1114 ? "block"
1115 : "none";
1116 },
1117 download: function() {
1118 let content = document.getElementById("pgn-game").innerHTML;
1119 content = content.replace(/<br>/g, "\n");
1120 // Prepare and trigger download link
1121 let downloadAnchor = document.getElementById("download");
1122 downloadAnchor.setAttribute("download", "game.pgn");
1123 downloadAnchor.href = "data:text/plain;charset=utf-8," +
1124 encodeURIComponent(content);
1125 downloadAnchor.click();
1126 },
1127 showScoreMsg: function() {
1128 let modalBox = document.getElementById("modal-eog");
1129 modalBox.checked = true;
1130 setTimeout(() => { modalBox.checked = false; }, 2000);
1131 },
1132 endGame: function(score) {
1133 this.score = score;
1134 if (["human","computer"].includes(this.mode))
1135 {
1136 const prefix = (this.mode=="computer" ? "comp-" : "");
1137 localStorage.setItem(prefix+"score", score);
1138 }
1139 this.showScoreMsg();
1140 // Variants may have special PGN structure (so next function isn't defined here)
1141 this.pgnTxt = this.vr.getPGN(this.mycolor, this.score, this.fenStart, this.mode);
1142 if (this.mode == "human" && this.oppConnected)
1143 {
1144 // Send our nickname to opponent
1145 this.conn.send(JSON.stringify({
1146 code:"myname", name:this.myname, oppid:this.oppid}));
1147 }
1148 this.mode = (this.mode=="human" ? "chat" : "idle");
1149 this.cursor = this.vr.moves.length; //to navigate in finished game
1150 },
1151 setStorage: function() {
1152 if (this.mode=="human")
1153 {
1154 localStorage.setItem("myid", this.myid);
1155 localStorage.setItem("oppid", this.oppid);
1156 localStorage.setItem("gameId", this.gameId);
1157 }
1158 // 'prefix' = "comp-" to resume games vs. computer
1159 const prefix = (this.mode=="computer" ? "comp-" : "");
1160 localStorage.setItem(prefix+"variant", variant);
1161 localStorage.setItem(prefix+"mycolor", this.mycolor);
1162 localStorage.setItem(prefix+"fenStart", this.fenStart);
1163 localStorage.setItem(prefix+"moves", JSON.stringify(this.vr.moves));
1164 localStorage.setItem(prefix+"fen", this.vr.getFen());
1165 localStorage.setItem(prefix+"score", "*");
1166 },
1167 updateStorage: function() {
1168 const prefix = (this.mode=="computer" ? "comp-" : "");
1169 localStorage.setItem(prefix+"moves", JSON.stringify(this.vr.moves));
1170 localStorage.setItem(prefix+"fen", this.vr.getFen());
1171 if (this.score != "*")
1172 localStorage.setItem(prefix+"score", this.score);
1173 },
1174 // "computer mode" clearing is done through the menu
1175 clearStorage: function() {
1176 if (["human","chat"].includes(this.mode))
1177 {
1178 delete localStorage["myid"];
1179 delete localStorage["oppid"];
1180 delete localStorage["gameId"];
1181 }
1182 const prefix = (this.mode=="computer" ? "comp-" : "");
1183 delete localStorage[prefix+"variant"];
1184 delete localStorage[prefix+"mycolor"];
1185 delete localStorage[prefix+"fenStart"];
1186 delete localStorage[prefix+"moves"];
1187 delete localStorage[prefix+"fen"];
1188 delete localStorage[prefix+"score"];
1189 },
1190 // HACK because mini-css tooltips are persistent after click...
1191 getRidOfTooltip: function(elt) {
1192 elt.style.visibility = "hidden";
1193 setTimeout(() => { elt.style.visibility="visible"; }, 100);
1194 },
1195 startChat: function(e) {
1196 this.getRidOfTooltip(e.currentTarget);
1197 document.getElementById("modal-chat").checked = true;
1198 },
1199 clearComputerGame: function(e) {
1200 this.getRidOfTooltip(e.currentTarget);
1201 this.clearStorage(); //this.mode=="computer" (already checked)
1202 location.reload(); //to see clearing effects
1203 },
1204 showSettings: function(e) {
1205 this.getRidOfTooltip(e.currentTarget);
1206 document.getElementById("modal-settings").checked = true;
1207 },
1208 toggleHints: function() {
1209 this.hints = !this.hints;
1210 localStorage["hints"] = (this.hints ? "1" : "0");
1211 },
1212 setColor: function(e) {
1213 this.color = e.target.options[e.target.selectedIndex].value;
1214 localStorage["color"] = this.color;
1215 },
1216 setSound: function(e) {
1217 this.sound = parseInt(e.target.options[e.target.selectedIndex].value);
1218 localStorage["sound"] = this.sound;
1219 },
1220 clickGameSeek: function(e) {
1221 this.getRidOfTooltip(e.currentTarget);
1222 if (this.mode == "human")
1223 return; //no newgame while playing
1224 if (this.seek)
1225 {
1226 this.conn.send(JSON.stringify({code:"cancelnewgame"}));
1227 this.seek = false;
1228 }
1229 else
1230 this.newGame("human");
1231 },
1232 clickComputerGame: function(e) {
1233 this.getRidOfTooltip(e.currentTarget);
1234 this.newGame("computer");
1235 },
1236 clickFriendGame: function(e) {
1237 this.getRidOfTooltip(e.currentTarget);
1238 document.getElementById("modal-fenedit").checked = true;
1239 },
1240 resign: function(e) {
1241 this.getRidOfTooltip(e.currentTarget);
1242 if (this.mode == "human" && this.oppConnected)
1243 {
1244 try {
1245 this.conn.send(JSON.stringify({code: "resign", oppid: this.oppid}));
1246 } catch (INVALID_STATE_ERR) {
1247 return; //socket is not ready (and not yet reconnected)
1248 }
1249 }
1250 this.endGame(this.mycolor=="w"?"0-1":"1-0");
1251 },
1252 newGame: function(mode, fenInit, color, oppId) {
1253 let fen = fenInit || VariantRules.GenRandInitFen();
1254 console.log(fen); //DEBUG
1255 if (mode=="human" && !oppId)
1256 {
1257 const storageVariant = localStorage.getItem("variant");
1258 if (!!storageVariant && storageVariant !== variant)
1259 return alert("Finish your " + storageVariant + " game first!");
1260 // Send game request and wait..
1261 try {
1262 this.conn.send(JSON.stringify({code:"newgame", fen:fen}));
1263 } catch (INVALID_STATE_ERR) {
1264 return; //nothing achieved
1265 }
1266 this.seek = true;
1267 let modalBox = document.getElementById("modal-newgame");
1268 modalBox.checked = true;
1269 setTimeout(() => { modalBox.checked = false; }, 2000);
1270 return;
1271 }
1272 if (["human","chat"].includes(this.mode))
1273 {
1274 // Start a new game vs. another human (or...) => forget about current one
1275 this.clearStorage();
1276 }
1277 if (mode == "computer")
1278 {
1279 const storageVariant = localStorage.getItem("comp-variant");
1280 if (!!storageVariant)
1281 {
1282 const score = localStorage.getItem("comp-score");
1283 if (storageVariant !== variant && score == "*")
1284 {
1285 if (!confirm("Unfinished " + storageVariant +
1286 " computer game will be erased"))
1287 {
1288 return;
1289 }
1290 }
1291 else if (score == "*")
1292 return this.continueGame("computer");
1293 }
1294 }
1295 this.vr = new VariantRules(fen, []);
1296 this.score = "*";
1297 this.pgnTxt = ""; //redundant with this.score = "*", but cleaner
1298 this.mode = mode;
1299 this.incheck = [];
1300 this.fenStart = V.ParseFen(fen).position; //this is enough
1301 if (mode=="human")
1302 {
1303 // Opponent found!
1304 this.gameId = getRandString();
1305 this.oppid = oppId;
1306 this.oppConnected = true;
1307 this.mycolor = color;
1308 this.seek = false;
1309 if (this.sound >= 1)
1310 new Audio("/sounds/newgame.mp3").play().catch(err => {});
1311 document.getElementById("modal-newgame").checked = false;
1312 this.setStorage(); //in case of interruptions
1313 }
1314 else if (mode == "computer")
1315 {
1316 this.compWorker.postMessage(["init",this.vr.getFen()]);
1317 this.mycolor = (Math.random() < 0.5 ? 'w' : 'b');
1318 this.setStorage(); //store game state
1319 if (this.mycolor != this.vr.turn)
1320 this.playComputerMove();
1321 }
1322 //else: against a (IRL) friend or problem solving: nothing more to do
1323 },
1324 continueGame: function(mode) {
1325 this.mode = mode;
1326 this.oppid = (mode=="human" ? localStorage.getItem("oppid") : undefined);
1327 const prefix = (mode=="computer" ? "comp-" : "");
1328 this.mycolor = localStorage.getItem(prefix+"mycolor");
1329 const moves = JSON.parse(localStorage.getItem(prefix+"moves"));
1330 const fen = localStorage.getItem(prefix+"fen");
1331 const score = localStorage.getItem(prefix+"score"); //set in "endGame()"
1332 this.fenStart = localStorage.getItem(prefix+"fenStart");
1333 if (mode == "human")
1334 {
1335 this.gameId = localStorage.getItem("gameId");
1336 // Send ping to server (answer pong if opponent is connected)
1337 this.conn.send(JSON.stringify({
1338 code:"ping",oppid:this.oppid,gameId:this.gameId}));
1339 }
1340 else
1341 this.compWorker.postMessage(["init",fen]);
1342 this.vr = new VariantRules(fen, moves);
1343 if (moves.length > 0)
1344 {
1345 const lastMove = moves[moves.length-1];
1346 this.vr.undo(lastMove);
1347 this.incheck = this.vr.getCheckSquares(lastMove);
1348 this.vr.play(lastMove, "ingame");
1349 }
1350 if (score != "*")
1351 {
1352 // Small delay required when continuation run faster than drawing page
1353 setTimeout(() => this.endGame(score), 100);
1354 }
1355 },
1356 playComputerMove: function() {
1357 this.timeStart = Date.now();
1358 this.compWorker.postMessage(["askmove"]);
1359 },
1360 // Get the identifier of a HTML table cell from its numeric coordinates o.x,o.y.
1361 getSquareId: function(o) {
1362 // NOTE: a separator is required to allow any size of board
1363 return "sq-" + o.x + "-" + o.y;
1364 },
1365 // Inverse function
1366 getSquareFromId: function(id) {
1367 let idParts = id.split('-');
1368 return [parseInt(idParts[1]), parseInt(idParts[2])];
1369 },
1370 mousedown: function(e) {
1371 e = e || window.event;
1372 let ingame = false;
1373 let elem = e.target;
1374 while (!ingame && elem !== null)
1375 {
1376 if (elem.classList.contains("game"))
1377 {
1378 ingame = true;
1379 break;
1380 }
1381 elem = elem.parentElement;
1382 }
1383 if (!ingame) //let default behavior (click on button...)
1384 return;
1385 e.preventDefault(); //disable native drag & drop
1386 if (!this.selectedPiece && e.target.classList.contains("piece"))
1387 {
1388 // Next few lines to center the piece on mouse cursor
1389 let rect = e.target.parentNode.getBoundingClientRect();
1390 this.start = {
1391 x: rect.x + rect.width/2,
1392 y: rect.y + rect.width/2,
1393 id: e.target.parentNode.id
1394 };
1395 this.selectedPiece = e.target.cloneNode();
1396 this.selectedPiece.style.position = "absolute";
1397 this.selectedPiece.style.top = 0;
1398 this.selectedPiece.style.display = "inline-block";
1399 this.selectedPiece.style.zIndex = 3000;
1400 const startSquare = this.getSquareFromId(e.target.parentNode.id);
1401 this.possibleMoves = [];
1402 if (!["idle","chat"].includes(this.mode))
1403 {
1404 const color = ["friend","problem"].includes(this.mode)
1405 ? this.vr.turn
1406 : this.mycolor;
1407 if (this.vr.canIplay(color,startSquare))
1408 this.possibleMoves = this.vr.getPossibleMovesFrom(startSquare);
1409 }
1410 // Next line add moving piece just after current image
1411 // (required for Crazyhouse reserve)
1412 e.target.parentNode.insertBefore(this.selectedPiece, e.target.nextSibling);
1413 }
1414 },
1415 mousemove: function(e) {
1416 if (!this.selectedPiece)
1417 return;
1418 e = e || window.event;
1419 // If there is an active element, move it around
1420 if (!!this.selectedPiece)
1421 {
1422 const [offsetX,offsetY] = !!e.clientX
1423 ? [e.clientX,e.clientY] //desktop browser
1424 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY]; //smartphone
1425 this.selectedPiece.style.left = (offsetX-this.start.x) + "px";
1426 this.selectedPiece.style.top = (offsetY-this.start.y) + "px";
1427 }
1428 },
1429 mouseup: function(e) {
1430 if (!this.selectedPiece)
1431 return;
1432 e = e || window.event;
1433 // Read drop target (or parentElement, parentNode... if type == "img")
1434 this.selectedPiece.style.zIndex = -3000; //HACK to find square from final coords
1435 const [offsetX,offsetY] = !!e.clientX
1436 ? [e.clientX,e.clientY]
1437 : [e.changedTouches[0].pageX, e.changedTouches[0].pageY];
1438 let landing = document.elementFromPoint(offsetX, offsetY);
1439 this.selectedPiece.style.zIndex = 3000;
1440 // Next condition: classList.contains(piece) fails because of marks
1441 while (landing.tagName == "IMG")
1442 landing = landing.parentNode;
1443 if (this.start.id == landing.id)
1444 {
1445 // A click: selectedPiece and possibleMoves are already filled
1446 return;
1447 }
1448 // OK: process move attempt
1449 let endSquare = this.getSquareFromId(landing.id);
1450 let moves = this.findMatchingMoves(endSquare);
1451 this.possibleMoves = [];
1452 if (moves.length > 1)
1453 this.choices = moves;
1454 else if (moves.length==1)
1455 this.play(moves[0]);
1456 // Else: impossible move
1457 this.selectedPiece.parentNode.removeChild(this.selectedPiece);
1458 delete this.selectedPiece;
1459 this.selectedPiece = null;
1460 },
1461 findMatchingMoves: function(endSquare) {
1462 // Run through moves list and return the matching set (if promotions...)
1463 let moves = [];
1464 this.possibleMoves.forEach(function(m) {
1465 if (endSquare[0] == m.end.x && endSquare[1] == m.end.y)
1466 moves.push(m);
1467 });
1468 return moves;
1469 },
1470 animateMove: function(move) {
1471 let startSquare = document.getElementById(this.getSquareId(move.start));
1472 let endSquare = document.getElementById(this.getSquareId(move.end));
1473 let rectStart = startSquare.getBoundingClientRect();
1474 let rectEnd = endSquare.getBoundingClientRect();
1475 let translation = {x:rectEnd.x-rectStart.x, y:rectEnd.y-rectStart.y};
1476 let movingPiece =
1477 document.querySelector("#" + this.getSquareId(move.start) + " > img.piece");
1478 // HACK for animation (with positive translate, image slides "under background")
1479 // Possible improvement: just alter squares on the piece's way...
1480 squares = document.getElementsByClassName("board");
1481 for (let i=0; i<squares.length; i++)
1482 {
1483 let square = squares.item(i);
1484 if (square.id != this.getSquareId(move.start))
1485 square.style.zIndex = "-1";
1486 }
1487 movingPiece.style.transform = "translate(" + translation.x + "px," +
1488 translation.y + "px)";
1489 movingPiece.style.transitionDuration = "0.2s";
1490 movingPiece.style.zIndex = "3000";
1491 setTimeout( () => {
1492 for (let i=0; i<squares.length; i++)
1493 squares.item(i).style.zIndex = "auto";
1494 movingPiece.style = {}; //required e.g. for 0-0 with KR swap
1495 this.play(move);
1496 }, 250);
1497 },
1498 play: function(move, programmatic) {
1499 if (!move)
1500 {
1501 // Navigate after game is over
1502 if (this.cursor >= this.vr.moves.length)
1503 return; //already at the end
1504 move = this.vr.moves[this.cursor++];
1505 }
1506 if (!!programmatic) //computer or human opponent
1507 {
1508 this.animateMove(move);
1509 return;
1510 }
1511 // Not programmatic, or animation is over
1512 if (this.mode == "human" && this.vr.turn == this.mycolor)
1513 this.conn.send(JSON.stringify({code:"newmove", move:move, oppid:this.oppid}));
1514 if (!["idle","chat"].includes(this.mode))
1515 {
1516 // Emergency check, if human game started "at the same time"
1517 // TODO: robustify this...
1518 if (this.mode == "human" && !!move.computer)
1519 return;
1520 this.incheck = this.vr.getCheckSquares(move); //is opponent in check?
1521 this.vr.play(move, "ingame");
1522 if (this.sound == 2)
1523 new Audio("/sounds/move.mp3").play().catch(err => {});
1524 if (this.mode == "computer")
1525 {
1526 // Send the move to web worker (TODO: including his own moves?!)
1527 this.compWorker.postMessage(["newmove",move]);
1528 }
1529 }
1530 else
1531 {
1532 VariantRules.PlayOnBoard(this.vr.board, move);
1533 this.$forceUpdate(); //TODO: ?!
1534 }
1535 if (!["idle","chat"].includes(this.mode))
1536 {
1537 const eog = this.vr.checkGameOver();
1538 if (eog != "*")
1539 {
1540 if (["human","computer"].includes(this.mode))
1541 this.endGame(eog);
1542 else
1543 {
1544 // Just show score on screen (allow undo)
1545 this.score = eog;
1546 this.showScoreMsg();
1547 }
1548 }
1549 }
1550 if (["human","computer"].includes(this.mode))
1551 this.updateStorage(); //after our moves and opponent moves
1552 if (this.mode == "computer" && this.vr.turn != this.mycolor && this.score == "*")
1553 this.playComputerMove();
1554 },
1555 undo: function() {
1556 // Navigate after game is over
1557 if (this.cursor == 0)
1558 return; //already at the beginning
1559 if (this.cursor == this.vr.moves.length)
1560 this.incheck = []; //in case of...
1561 const move = this.vr.moves[--this.cursor];
1562 VariantRules.UndoOnBoard(this.vr.board, move);
1563 this.$forceUpdate(); //TODO: ?!
1564 },
1565 undoInGame: function() {
1566 const lm = this.vr.lastMove;
1567 if (!!lm)
1568 {
1569 this.vr.undo(lm);
1570 if (this.sound == 2)
1571 new Audio("/sounds/undo.mp3").play().catch(err => {});
1572 const lmBefore = this.vr.lastMove;
1573 if (!!lmBefore)
1574 {
1575 this.vr.undo(lmBefore);
1576 this.incheck = this.vr.getCheckSquares(lmBefore);
1577 this.vr.play(lmBefore, "ingame");
1578 }
1579 else
1580 this.incheck = [];
1581 }
1582 },
1583 },
1584 })