summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTor Andersson <tor@ccxvii.net>2022-11-18 00:58:36 +0100
committerTor Andersson <tor@ccxvii.net>2023-05-24 21:06:17 +0200
commit0c673db43e6a6872ffc3ecf4db42b168ad375c70 (patch)
treed726282ba180fcfac01a35c5f6ec265d75433af0
parent9b9c9311ff8d6ebf8665e8ae97610e0db13413e7 (diff)
downloadred-flag-over-paris-0c673db43e6a6872ffc3ecf4db42b168ad375c70.tar.gz
Add initial implementation of UI and rules.
-rw-r--r--data.js97
-rw-r--r--play.html398
-rw-r--r--play.js366
-rw-r--r--rules.js583
4 files changed, 1444 insertions, 0 deletions
diff --git a/data.js b/data.js
new file mode 100644
index 0000000..499b190
--- /dev/null
+++ b/data.js
@@ -0,0 +1,97 @@
+"use strict"
+
+const data = {
+
+ cards: [
+ null,
+ { side: "Versailles", ops: 1, name: "Jules Decatel" },
+ { side: "Versailles", ops: 1, name: "The Murder of Vincenzini" },
+ { side: "Versailles", ops: 1, name: "Brassardiers" },
+ { side: "Versailles", ops: 1, name: "Jules Ferry" },
+ { side: "Versailles", ops: 1, name: "Le Figaro" },
+ { side: "Versailles", ops: 1, name: "Général Louis Valentin" },
+ { side: "Versailles", ops: 1, name: "Général Espivent" },
+ { side: "Versailles", ops: 1, name: "Les Amis de l'Ordre" },
+ { side: "Versailles", ops: 2, name: "Socialist Newspaper Ban" },
+ { side: "Versailles", ops: 2, name: "Fortification of Mont-Valérien" },
+ { side: "Versailles", ops: 2, name: "Adolphe Thiers" },
+ { side: "Versailles", ops: 2, name: "Otto von Bismarck" },
+ { side: "Versailles", ops: 2, name: "Général Ernest de Cissey" },
+ { side: "Versailles", ops: 2, name: "Colonel de Lochner" },
+ { side: "Versailles", ops: 2, name: "Jules Favre" },
+ { side: "Versailles", ops: 2, name: "Hostage Decree" },
+ { side: "Versailles", ops: 4, name: "Maréchal Macmahon" },
+ { side: "Commune", ops: 1, name: "Paule Minck" },
+ { side: "Commune", ops: 1, name: "Walery Wroblewski" },
+ { side: "Commune", ops: 1, name: "Banque de France" },
+ { side: "Commune", ops: 1, name: "Le Réveil" },
+ { side: "Commune", ops: 1, name: "Execution of Generals" },
+ { side: "Commune", ops: 1, name: "Les Cantinières" },
+ { side: "Commune", ops: 1, name: "Eugène Protot" },
+ { side: "Commune", ops: 1, name: "Paul Cluseret" },
+ { side: "Commune", ops: 2, name: "Gaston Crémieux" },
+ { side: "Commune", ops: 2, name: "Luise Michel" },
+ { side: "Commune", ops: 2, name: "Jaroslav Dombrowski" },
+ { side: "Commune", ops: 2, name: "Raoul Rigault" },
+ { side: "Commune", ops: 2, name: "Karl Marx" },
+ { side: "Commune", ops: 2, name: "Blanquists" },
+ { side: "Commune", ops: 2, name: "Général Lullier" },
+ { side: "Commune", ops: 2, name: "Jules Vallès" },
+ { side: "Commune", ops: 4, name: "Charles Delescluze" },
+ { side: "Neutral", ops: 3, name: "Conciliation" },
+ { side: "Neutral", ops: 3, name: "Georges Clemenceau" },
+ { side: "Neutral", ops: 3, name: "Archbishop Georges Darboy" },
+ { side: "Neutral", ops: 3, name: "Victor Hugo" },
+ { side: "Neutral", ops: 3, name: "Léon Gambetta" },
+ { side: "Neutral", ops: 3, name: "Elihu Washburne" },
+ { side: "Neutral", ops: 3, name: "Freemason Parade" },
+ { side: "Objective", ops: 0, name: "Paris Cannons", region: "Butte Montmartre" },
+ { side: "Objective", ops: 0, name: "Aux Barricades!", region: "Buttes-Aux-Cailles" },
+ { side: "Objective", ops: 0, name: "Commune's Stronghold", region: "Père Lachaise" },
+ { side: "Objective", ops: 0, name: "Fighting in Issy Village", region: "Fort d'Issy" },
+ { side: "Objective", ops: 0, name: "Battle of Mont-Valérien", region: "Mont-Valérien" },
+ { side: "Objective", ops: 0, name: "Raid on Château de Vincennes", region: "Château de Vincennes" },
+ { side: "Objective", ops: 0, name: "Revolution in the Press", region: "Press" },
+ { side: "Objective", ops: 0, name: "Pius IX", region: "Catholic Church" },
+ { side: "Objective", ops: 0, name: "Socialist International", region: "Social Movements" },
+ { side: "Objective", ops: 0, name: "Royalists Dissension", region: "Royalists" },
+ { side: "Objective", ops: 0, name: "Rise of Republicanism", region: "Republicans" },
+ { side: "Objective", ops: 0, name: "Legitimacy", region: "National Assembly" },
+ ],
+
+ space_names: [
+ "Red Cube Pool",
+ "Red Crisis Track Start",
+ "Red Crisis Track Escalation",
+ "Red Crisis Track Tension",
+ "Red Crisis Track Final Crisis",
+ "Red Bonus Cubes 1",
+ "Red Bonus Cubes 2",
+ "Red Bonus Cubes 3",
+ "Blue Cube Pool",
+ "Blue Crisis Track Start",
+ "Blue Crisis Track Escalation",
+ "Blue Crisis Track Tension",
+ "Blue Crisis Track Final Crisis",
+ "Blue Bonus Cubes 1",
+ "Blue Bonus Cubes 2",
+ "Blue Bonus Cubes 3",
+ "Royalists",
+ "National Assembly",
+ "Republicans",
+ "Catholic Church",
+ "Press",
+ "Social Movements",
+ "Mont-Valérien",
+ "Butte Montmartre",
+ "Fort d'Issy",
+ "Butte-Aux-Cailles",
+ "Père Lachaise",
+ "Château de Vincennes",
+ "Prussian Occupied Territory",
+ "Versailles HQ",
+ ],
+}
+
+if (typeof module !== 'undefined')
+ module.exports = data
diff --git a/play.html b/play.html
new file mode 100644
index 0000000..fce9c2c
--- /dev/null
+++ b/play.html
@@ -0,0 +1,398 @@
+<!DOCTYPE html>
+<!-- vim:set nowrap: -->
+<html>
+<head>
+<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1">
+<meta charset="UTF-8">
+<title>RED FLAG OVER PARIS</title>
+<link rel="icon" href="favicon.svg">
+<link rel="stylesheet" href="/fonts/fonts.css">
+<link rel="stylesheet" href="/common/play.css">
+<script defer src="/common/play.js"></script>
+<script defer src="data.js"></script>
+<script defer src="play.js"></script>
+<style>
+
+main { background-color: dimgray; }
+header { background-color: gainsboro; }
+
+body.Commune header.your_turn { background-color: salmon; }
+body.Versailles header.your_turn { background-color: skyblue; }
+#role_Commune { background-color: salmon; }
+#role_Versailles { background-color: skyblue; }
+
+#log { background-color: ivory; }
+#log .h1 { background-color: tan; font-weight: bold; padding-top:2px; padding-bottom:2px; text-align: center; }
+#log .h2 { background-color: wheat; padding-top:2px; padding-bottom:2px; text-align: center; }
+#log .commune { background-color: lightpink }
+#log .versailles { background-color: lightblue }
+#log > .i { padding-left: 20px; }
+#log .tip { font-style: italic }
+#log .tip:hover { cursor: pointer; text-decoration: underline; }
+
+.role_extra {
+ float: right;
+}
+
+.action {
+ cursor: pointer;
+}
+
+#hand {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ margin: 20px;
+ gap: 20px;
+}
+
+.card {
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-color: ivory;
+ width: 250px;
+ height: 350px;
+ border-radius: 16px;
+ box-shadow: 1px 1px 5px rgba(0,0,0,0.5);
+}
+
+.card_info {
+ padding: 12px 0;
+ border-bottom: 1px solid black;
+ background-color: gray;
+}
+
+.card_info .card {
+ margin: 0 auto;
+ width: 125px;
+ height: 175px;
+ border-radius: 8px;
+}
+
+#tooltip {
+ position: fixed;
+ z-index: 100;
+ right: 240px;
+ top: 60px;
+}
+
+/* MAP */
+
+#mapwrap {
+ box-shadow: 0px 0px 15px rgba(0,0,0,0.8);
+ width: 1500px;
+ height: 1050px;
+}
+
+#map {
+ display: block;
+ width: 1500px;
+ height: 1050px;
+ background-image: url(map75.jpg);
+ background-size: 1500px 1050px;
+}
+
+@media (min-resolution: 97dpi) {
+ #map {
+ background-image: url(map150.jpg);
+ }
+}
+
+.space {
+ position: absolute;
+ border: 8px solid transparent;
+}
+
+.space.action {
+ border-color: white;
+}
+
+.piece {
+ position: absolute;
+ pointer-events: none;
+ background-size: cover;
+ background-repeat: no-repeat;
+ filter: drop-shadow(0px 2px 3px #0008);
+ transition-property: top, left;
+ transition-duration: 1s;
+ transition-timing-function: ease;
+}
+
+.piece.action {
+ filter:
+ drop-shadow(0 -2px 0 white)
+ drop-shadow(0 2px 0 white)
+ drop-shadow(-2px 0 0 white)
+ drop-shadow(2px 0 0 white)
+}
+
+.card.action {
+ box-shadow: 0 0 0 3px white;
+}
+
+/* original size (72dpi?) */
+.piece.cube { width: 28px; height: 30px; }
+.piece.disc { width: 40px; height: 32px; }
+.piece.cylinder { width: 40px; height: 40px; }
+.piece.pawn { width: 28px; height: 48px; }
+
+/* 125% */
+.piece.cube { width: 35px; height: 38px; }
+.piece.disc { width: 50px; height: 40px; }
+.piece.cylinder { width: 50px; height: 50px; }
+.piece.pawn { width: 35px; height: 60px; }
+
+.piece.cube.red { background-image:url(pieces/red_cube.svg) }
+.piece.cube.blue { background-image:url(pieces/blue_cube.svg) }
+.piece.disc.red { background-image:url(pieces/red_disc.svg) }
+.piece.disc.blue { background-image:url(pieces/blue_disc.svg) }
+.piece.cylinder.red { background-image:url(pieces/red_cylinder.svg) }
+.piece.cylinder.blue { background-image:url(pieces/blue_cylinder.svg) }
+.piece.cylinder.orange { background-image:url(pieces/orange_cylinder.svg) }
+.piece.cylinder.purple { background-image:url(pieces/purple_cylinder.svg) }
+.piece.pawn { background-image:url(pieces/pawn.svg) }
+
+#round_marker { top: 965px; }
+#round_marker.round1 { left: 623px; }
+#round_marker.round2 { left: 709px; }
+#round_marker.round3 { left: 805px; }
+#round_marker.round4 { left: 897px; }
+
+#blue_momentum { top: 278px; }
+#blue_momentum.m0 { top: 300px; left: 712px; }
+#blue_momentum.m1 { left: 630px; }
+#blue_momentum.m2 { left: 510px; }
+#blue_momentum.m3 { left: 360px; }
+
+#red_momentum { top: 278px; }
+#red_momentum.m0 { top: 273px; left: 742px; }
+#red_momentum.m1 { left: 820px; }
+#red_momentum.m2 { left: 950px; }
+#red_momentum.m3 { left: 1095px; }
+
+#political_vp { top: 175px; }
+#military_vp { top: 225px; }
+.vp0 { left: 465px; }
+.vp1 { left: 517px; }
+.vp2 { left: 570px; }
+.vp3 { left: 622px; }
+.vp4 { left: 674px; }
+.vp5 { left: 726px; }
+.vp6 { left: 778px; }
+.vp7 { left: 830px; }
+.vp8 { left: 882px; }
+.vp9 { left: 934px; }
+.vp10 { left: 987px; }
+
+/* CARD ACTION POPUP MENU */
+
+#popup {
+ position: fixed;
+ user-select: none;
+ background-color: gainsboro;
+ left: 10px;
+ top: 100px;
+ box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.3);
+ z-index: 200;
+ min-width: 20ex;
+ white-space: nowrap;
+ display: none;
+}
+#popup div { padding: 3pt 8pt; color: gray; display: none; }
+#popup div.enabled { color: black; display: block; }
+#popup div.enabled:hover { background-color: teal; color: white; }
+#popup div.always { display: block; }
+
+/* CARD IMAGES */
+
+.card_strategy_back{background-image:url(cards.1x/card_strategy_back.jpg)}
+.card_objective_back{background-image:url(cards.1x/card_objective_back.jpg)}
+.card_1{background-image:url(cards.1x/card_1.jpg)}
+.card_2{background-image:url(cards.1x/card_2.jpg)}
+.card_3{background-image:url(cards.1x/card_3.jpg)}
+.card_4{background-image:url(cards.1x/card_4.jpg)}
+.card_5{background-image:url(cards.1x/card_5.jpg)}
+.card_6{background-image:url(cards.1x/card_6.jpg)}
+.card_7{background-image:url(cards.1x/card_7.jpg)}
+.card_8{background-image:url(cards.1x/card_8.jpg)}
+.card_9{background-image:url(cards.1x/card_9.jpg)}
+.card_10{background-image:url(cards.1x/card_10.jpg)}
+.card_11{background-image:url(cards.1x/card_11.jpg)}
+.card_12{background-image:url(cards.1x/card_12.jpg)}
+.card_13{background-image:url(cards.1x/card_13.jpg)}
+.card_14{background-image:url(cards.1x/card_14.jpg)}
+.card_15{background-image:url(cards.1x/card_15.jpg)}
+.card_16{background-image:url(cards.1x/card_16.jpg)}
+.card_17{background-image:url(cards.1x/card_17.jpg)}
+.card_18{background-image:url(cards.1x/card_18.jpg)}
+.card_19{background-image:url(cards.1x/card_19.jpg)}
+.card_20{background-image:url(cards.1x/card_20.jpg)}
+.card_21{background-image:url(cards.1x/card_21.jpg)}
+.card_22{background-image:url(cards.1x/card_22.jpg)}
+.card_23{background-image:url(cards.1x/card_23.jpg)}
+.card_24{background-image:url(cards.1x/card_24.jpg)}
+.card_25{background-image:url(cards.1x/card_25.jpg)}
+.card_26{background-image:url(cards.1x/card_26.jpg)}
+.card_27{background-image:url(cards.1x/card_27.jpg)}
+.card_28{background-image:url(cards.1x/card_28.jpg)}
+.card_29{background-image:url(cards.1x/card_29.jpg)}
+.card_30{background-image:url(cards.1x/card_30.jpg)}
+.card_31{background-image:url(cards.1x/card_31.jpg)}
+.card_32{background-image:url(cards.1x/card_32.jpg)}
+.card_33{background-image:url(cards.1x/card_33.jpg)}
+.card_34{background-image:url(cards.1x/card_34.jpg)}
+.card_35{background-image:url(cards.1x/card_35.jpg)}
+.card_36{background-image:url(cards.1x/card_36.jpg)}
+.card_37{background-image:url(cards.1x/card_37.jpg)}
+.card_38{background-image:url(cards.1x/card_38.jpg)}
+.card_39{background-image:url(cards.1x/card_39.jpg)}
+.card_40{background-image:url(cards.1x/card_40.jpg)}
+.card_41{background-image:url(cards.1x/card_41.jpg)}
+.card_42{background-image:url(cards.1x/card_42.jpg)}
+.card_43{background-image:url(cards.1x/card_43.jpg)}
+.card_44{background-image:url(cards.1x/card_44.jpg)}
+.card_45{background-image:url(cards.1x/card_45.jpg)}
+.card_46{background-image:url(cards.1x/card_46.jpg)}
+.card_47{background-image:url(cards.1x/card_47.jpg)}
+.card_48{background-image:url(cards.1x/card_48.jpg)}
+.card_49{background-image:url(cards.1x/card_49.jpg)}
+.card_50{background-image:url(cards.1x/card_50.jpg)}
+.card_51{background-image:url(cards.1x/card_51.jpg)}
+.card_52{background-image:url(cards.1x/card_52.jpg)}
+.card_53{background-image:url(cards.1x/card_53.jpg)}
+.card_54{background-image:url(cards.1x/card_54.jpg)}
+@media (min-resolution:97dpi) {
+.card_strategy_back{background-image:url(cards.2x/card_strategy_back.jpg)}
+.card_objective_back{background-image:url(cards.2x/card_objective_back.jpg)}
+.card_1{background-image:url(cards.2x/card_1.jpg)}
+.card_2{background-image:url(cards.2x/card_2.jpg)}
+.card_3{background-image:url(cards.2x/card_3.jpg)}
+.card_4{background-image:url(cards.2x/card_4.jpg)}
+.card_5{background-image:url(cards.2x/card_5.jpg)}
+.card_6{background-image:url(cards.2x/card_6.jpg)}
+.card_7{background-image:url(cards.2x/card_7.jpg)}
+.card_8{background-image:url(cards.2x/card_8.jpg)}
+.card_9{background-image:url(cards.2x/card_9.jpg)}
+.card_10{background-image:url(cards.2x/card_10.jpg)}
+.card_11{background-image:url(cards.2x/card_11.jpg)}
+.card_12{background-image:url(cards.2x/card_12.jpg)}
+.card_13{background-image:url(cards.2x/card_13.jpg)}
+.card_14{background-image:url(cards.2x/card_14.jpg)}
+.card_15{background-image:url(cards.2x/card_15.jpg)}
+.card_16{background-image:url(cards.2x/card_16.jpg)}
+.card_17{background-image:url(cards.2x/card_17.jpg)}
+.card_18{background-image:url(cards.2x/card_18.jpg)}
+.card_19{background-image:url(cards.2x/card_19.jpg)}
+.card_20{background-image:url(cards.2x/card_20.jpg)}
+.card_21{background-image:url(cards.2x/card_21.jpg)}
+.card_22{background-image:url(cards.2x/card_22.jpg)}
+.card_23{background-image:url(cards.2x/card_23.jpg)}
+.card_24{background-image:url(cards.2x/card_24.jpg)}
+.card_25{background-image:url(cards.2x/card_25.jpg)}
+.card_26{background-image:url(cards.2x/card_26.jpg)}
+.card_27{background-image:url(cards.2x/card_27.jpg)}
+.card_28{background-image:url(cards.2x/card_28.jpg)}
+.card_29{background-image:url(cards.2x/card_29.jpg)}
+.card_30{background-image:url(cards.2x/card_30.jpg)}
+.card_31{background-image:url(cards.2x/card_31.jpg)}
+.card_32{background-image:url(cards.2x/card_32.jpg)}
+.card_33{background-image:url(cards.2x/card_33.jpg)}
+.card_34{background-image:url(cards.2x/card_34.jpg)}
+.card_35{background-image:url(cards.2x/card_35.jpg)}
+.card_36{background-image:url(cards.2x/card_36.jpg)}
+.card_37{background-image:url(cards.2x/card_37.jpg)}
+.card_38{background-image:url(cards.2x/card_38.jpg)}
+.card_39{background-image:url(cards.2x/card_39.jpg)}
+.card_40{background-image:url(cards.2x/card_40.jpg)}
+.card_41{background-image:url(cards.2x/card_41.jpg)}
+.card_42{background-image:url(cards.2x/card_42.jpg)}
+.card_43{background-image:url(cards.2x/card_43.jpg)}
+.card_44{background-image:url(cards.2x/card_44.jpg)}
+.card_45{background-image:url(cards.2x/card_45.jpg)}
+.card_46{background-image:url(cards.2x/card_46.jpg)}
+.card_47{background-image:url(cards.2x/card_47.jpg)}
+.card_48{background-image:url(cards.2x/card_48.jpg)}
+.card_49{background-image:url(cards.2x/card_49.jpg)}
+.card_50{background-image:url(cards.2x/card_50.jpg)}
+.card_51{background-image:url(cards.2x/card_51.jpg)}
+.card_52{background-image:url(cards.2x/card_52.jpg)}
+.card_53{background-image:url(cards.2x/card_53.jpg)}
+.card_54{background-image:url(cards.2x/card_54.jpg)}
+}
+
+</style>
+</head>
+<body>
+
+<div id="tooltip" class="card hide"></div>
+
+<div id="popup" onmouseleave="hide_popup_menu()">
+<div id="menu_card_event" class="always" onclick="on_card_event()">Play event</div>
+<div id="menu_card_ops" class="always" onclick="on_card_ops()">Operations</div>
+<div id="menu_card_use_discarded" class="always" onclick="on_card_use_discarded()">Use discarded event</div>
+<div id="menu_card_advance_momentum" class="always" onclick="on_card_advance_momentum()">Advance momentum</div>
+</div>
+
+<header>
+ <div id="toolbar">
+ <div class="menu">
+ <div class="menu_title"><img src="/images/cog.svg"></div>
+ <div class="menu_popup">
+ <a class="menu_item" href="info/rules.html" target="_blank">Rules</a>
+ <a class="menu_item" href="info/playbook.html" target="_blank">Playbook</a>
+ <a class="menu_item" href="info/pac.html" target="_blank">Player Aids</a>
+ <a class="menu_item" href="info/cards.html" target="_blank">Cards</a>
+ <div class="resign menu_separator"></div>
+ <div class="resign menu_item" onclick="confirm_resign()">Resign</div>
+ </div>
+ </div>
+ <div class="icon_button" onclick="toggle_zoom()"><img src="/images/magnifying-glass.svg"></div>
+ <div class="icon_button" onclick="toggle_log()"><img src="/images/scroll-quill.svg"></div>
+ </div>
+ <div id="prompt"></div>
+ <div id="actions"></div>
+</header>
+
+<aside>
+ <div id="roles">
+ <div class="role" id="role_Commune">
+ <div class="role_name">
+ Commune
+ <div class="role_extra" id="commune_info"></div>
+ <div class="role_user">-</div>
+ </div>
+ </div>
+ <div class="role" id="role_Versailles">
+ <div class="role_name">
+ Versailles
+ <div class="role_extra" id="versailles_info"></div>
+ <div class="role_user">-</div>
+ </div>
+ </div>
+ <div class="card_info"><div id="active_card" class="card card_strategy_back"></div></div>
+ </div>
+ <div id="log"></div>
+</aside>
+
+<main>
+
+<div id="mapwrap" class="fit">
+<div id="map">
+<div id="spaces"></div>
+<div id="pieces">
+<div id="round_marker" class="piece pawn round1"></div>
+<div id="political_vp" class="piece cylinder orange vp5"></div>
+<div id="military_vp" class="piece cylinder purple vp5"></div>
+<div id="red_momentum" class="piece cylinder red m0"></div>
+<div id="blue_momentum" class="piece cylinder blue m0"></div>
+</div>
+</div>
+</div>
+
+<div id="hand"></div>
+
+</main>
+
+<footer id="status"></footer>
+
+</body>
diff --git a/play.js b/play.js
new file mode 100644
index 0000000..c3abff4
--- /dev/null
+++ b/play.js
@@ -0,0 +1,366 @@
+"use strict"
+
+const space_count = data.space_names.length
+
+let layout = []
+let space_layout_cube = []
+let space_layout_disc = []
+
+let ui = {
+ cards: [ null ],
+ cubes: [],
+ discs: [],
+ spaces: [],
+ red_momentum: document.getElementById("red_momentum"),
+ blue_momentum: document.getElementById("blue_momentum"),
+ political_vp: document.getElementById("political_vp"),
+ military_vp: document.getElementById("military_vp"),
+ round_marker: document.getElementById("round_marker"),
+}
+
+// :r !python3 tools/genboxes.py
+const boxes = {
+ "Royalists": [318,1711,506,505],
+ "Republicans": [1960,1712,504,504],
+ "Catholic Church": [318,3146,505,505],
+ "Social Movements": [1960,3146,506,505],
+ "Fort d'Issy": [3374,3172,506,506],
+ "Butte-Aux-Cailles": [4153,2642,505,505],
+ "Père Lachaise": [4824,2364,506,505],
+ "Château de Vincennes": [5369,2599,504,507],
+ "Press": [1176,2908,447,448],
+ "National Assembly": [1168,1984,447,447],
+ "Butte Montmartre": [4208,1842,447,444],
+ "Mont-Valérien": [2868,2028,447,448],
+ "Versailles HQ": [2646,3398,404,380],
+ "Prussian Occupied Territory": [5190,1413,718,346],
+ "Red Crisis Track Start": [3928,244,361,448],
+ "Red Crisis Track Escalation": [4289,244,334,448],
+ "Red Crisis Track Tension": [4623,244,332,448],
+ "Red Crisis Track Final Crisis": [4955,244,332,448],
+ "Blue Crisis Track Start": [1728,244,354,446],
+ "Blue Crisis Track Escalation": [1394,244,334,446],
+ "Blue Crisis Track Tension": [1061,244,333,446],
+ "Blue Crisis Track Final Crisis": [728,244,333,446],
+ "Blue Objective Card": [479,3983,1088,218],
+ "Red Objective Card": [4454,3984,1088,218],
+ "Red Cube Pool": [4498,790,791,219],
+ "Blue Cube Pool": [720,790,791,219],
+ "Red Bonus Cubes 1": [4289,89,334,155],
+ "Red Bonus Cubes 2": [4623,89,332,155],
+ "Red Bonus Cubes 3": [4955,89,332,155],
+ "Blue Bonus Cubes 1": [1394,89,334,155],
+ "Blue Bonus Cubes 2": [1061,89,333,155],
+ "Blue Bonus Cubes 3": [728,89,333,155],
+}
+
+function is_card_action(action, card) {
+ if (view.actions && view.actions[action] && view.actions[action].includes(card))
+ return true
+ return false
+}
+
+function is_cube_action(i) {
+ if (view.actions && view.actions.cube && view.actions.cube.includes(i))
+ return true
+ return false
+}
+
+function is_disc_action(i) {
+ if (view.actions && view.actions.disc && view.actions.disc.includes(i))
+ return true
+ return false
+}
+
+function is_space_action(i) {
+ if (view.actions && view.actions.space && view.actions.space.includes(i))
+ return true
+ return false
+}
+
+function on_blur(evt) {
+ document.getElementById("status").textContent = ""
+}
+
+function on_focus_space(evt) {
+ document.getElementById("status").textContent = evt.target.my_name
+}
+
+function on_click_space(evt) {
+ if (evt.button === 0) {
+ if (send_action('space', evt.target.my_space))
+ evt.stopPropagation()
+ }
+}
+
+function on_click_cube(evt) {
+ if (evt.button === 0) {
+ if (send_action('cube', evt.target.my_cube))
+ evt.stopPropagation()
+ }
+}
+
+function on_click_disc(evt) {
+ if (evt.button === 0) {
+ if (send_action('disc', evt.target.my_disc))
+ evt.stopPropagation()
+ }
+}
+
+function build_user_interface() {
+ let elt
+
+ for (let c = 1; c <= 41 + 12; ++c) {
+ elt = ui.cards[c] = document.createElement("div")
+ elt.className = `card card_${c}`
+ elt.my_card = c
+ elt.addEventListener("click", on_click_card)
+ }
+
+ for (let i = 0; i < 36; ++i) {
+ elt = ui.cubes[i] = document.createElement("div")
+ if (i < 18)
+ elt.className = "piece cube red"
+ else
+ elt.className = "piece cube blue"
+ elt.my_cube = i
+ elt.addEventListener("mousedown", on_click_cube)
+ document.getElementById("pieces").appendChild(elt)
+ }
+
+ for (let i = 0; i < 4; ++i) {
+ elt = ui.discs[i] = document.createElement("div")
+ if (i < 2)
+ elt.className = "piece disc red"
+ else
+ elt.className = "piece disc blue"
+ elt.my_disc = i
+ elt.addEventListener("mousedown", on_click_disc)
+ }
+
+ for (let i = 0; i < space_count; ++i) {
+ let name = data.space_names[i]
+ let r = boxes[name]
+ elt = ui.spaces[i] = document.createElement("div")
+ elt.className = "space"
+ elt.my_space = i
+ elt.my_name = name
+ elt.addEventListener("mousedown", on_click_space)
+ elt.addEventListener("mouseenter", on_focus_space)
+ elt.addEventListener("mouseleave", on_blur)
+ let bw = 8
+ let x = Math.round(r[0] / 4)
+ let y = Math.round(r[1] / 4)
+ let w = Math.round(r[2] / 4)
+ let h = Math.round(r[3] / 4)
+ elt.style.top = y + "px"
+ elt.style.left = x + "px"
+ elt.style.width = (w - bw * 2) + "px"
+ elt.style.height = (h - bw * 2) + "px"
+ space_layout_cube[i] = { x: x + Math.round(w/2), y: y + Math.round(h*1/2) }
+ space_layout_disc[i] = { x: x + w, y: y + h }
+ document.getElementById("spaces").appendChild(elt)
+ }
+}
+
+function layout_cubes(list, xorig, yorig) {
+ const dx = 20
+ const dy = 11
+ if (list.length > 0) {
+ let ncol = Math.round(Math.sqrt(list.length))
+ let nrow = Math.ceil(list.length / ncol)
+ function place_cube(row, col, e, z) {
+ let x = xorig - (row * dx - col * dx) - 18 + (nrow-ncol) * 6
+ let y = yorig - (row * dy + col * dy) - 28 + (nrow-1) * 8
+ e.style.left = x + "px"
+ e.style.top = y + "px"
+ e.style.zIndex = z
+ }
+ let z = 50
+ let i = 0
+ for (let row = 0; row < nrow; ++row)
+ for (let col = 0; col < ncol && i < list.length; ++col)
+ place_cube(row, col, list[list.length-(++i)], z--)
+ }
+}
+
+function layout_disc(s, disc) {
+ if (s > 0)
+ disc.classList.remove("hide")
+ else
+ disc.classList.add("hide")
+ disc.style.left = (space_layout_disc[s].x - 50 - 12) + "px"
+ disc.style.top = (space_layout_disc[s].y - 20 - 8) + "px"
+}
+
+function on_focus_card_tip(card_number) {
+ document.getElementById("tooltip").className = "card card_" + card_number
+}
+
+function on_blur_card_tip() {
+ document.getElementById("tooltip").classList = "card hide"
+}
+
+function sub_card_name(match, p1, offset, string) {
+ let c = p1 | 0
+ let n = data.cards[c].name
+ return `<span class="tip" onmouseenter="on_focus_card_tip(${c})" onmouseleave="on_blur_card_tip()">${n}</span>`
+}
+
+function on_log(text) {
+ let p = document.createElement("div")
+
+ if (text.match(/^>/)) {
+ text = text.substring(1)
+ p.className = 'i'
+ }
+
+ text = text.replace(/&/g, "&amp;")
+ text = text.replace(/</g, "&lt;")
+ text = text.replace(/>/g, "&gt;")
+ text = text.replace(/#(\d+)/g, sub_card_name)
+
+ if (text.match(/^\.h1/)) {
+ text = text.substring(4)
+ p.className = 'h1'
+ }
+
+ if (text.match(/^\.h2/)) {
+ text = text.substring(4)
+ if (text === 'Commune')
+ p.className = 'h2 commune'
+ else if (text === 'Versailles')
+ p.className = 'h2 versailles'
+ else
+ p.className = 'h2'
+ }
+
+ p.innerHTML = text
+ return p
+}
+
+function on_update() {
+ if (view.active_card)
+ document.getElementById("active_card").className = `card card_${view.active_card}`
+ else
+ document.getElementById("active_card").className = `card card_strategy_back`
+
+ if (view.initiative === "Commune")
+ document.getElementById("commune_info").textContent = "\u2756"
+ else
+ document.getElementById("commune_info").textContent = ""
+ if (view.initiative === "Versailles")
+ document.getElementById("versailles_info").textContent = "\u2756"
+ else
+ document.getElementById("versailles_info").textContent = ""
+
+ ui.round_marker.className = `piece pawn round${view.round}`
+ ui.red_momentum.className = `piece cylinder red m${view.red_momentum}`
+ ui.blue_momentum.className = `piece cylinder blue m${view.blue_momentum}`
+ ui.military_vp.className = `piece cylinder purple vp${5+view.military_vp}`
+ ui.political_vp.className = `piece cylinder orange vp${5+view.political_vp}`
+
+ document.getElementById("hand").replaceChildren()
+ if (view.objective)
+ document.getElementById("hand").appendChild(ui.cards[view.objective])
+ if (view.hand)
+ for (let c of view.hand)
+ document.getElementById("hand").appendChild(ui.cards[c])
+
+ for (let i = 0; i < space_count; ++i)
+ layout[i] = []
+ for (let i = 0; i < 36; ++i) {
+ layout[view.cubes[i]].push(ui.cubes[i])
+ ui.cubes[i].classList.toggle("action", is_cube_action(i))
+ }
+ for (let i = 0; i < space_count; ++i) {
+ layout_cubes(layout[i], space_layout_cube[i].x, space_layout_cube[i].y)
+ ui.spaces[i].classList.toggle("action", is_space_action(i))
+ }
+ for (let i = 0; i < 4; ++i) {
+ layout_disc(view.discs[i], ui.discs[i])
+ ui.discs[i].classList.toggle("action", is_cube_action(i))
+ }
+
+ for (let i = 1; i < ui.cards.length; ++i) {
+ ui.cards[i].classList.toggle("action", is_card_action('card', i))
+ }
+
+ action_button("commune", "Commune")
+ action_button("versailles", "Versailles")
+ action_button("undo", "Undo")
+}
+
+/* CARD ACTION MENU */
+
+let current_popup_card = 0
+
+function show_popup_menu(evt, list) {
+ document.querySelectorAll("#popup div").forEach(e => e.classList.remove('enabled'))
+ for (let item of list) {
+ let e = document.getElementById("menu_" + item)
+ e.classList.add('enabled')
+ }
+ let popup = document.getElementById("popup")
+ popup.style.display = 'block'
+ popup.style.left = (evt.clientX-50) + "px"
+ popup.style.top = (evt.clientY-12) + "px"
+ ui.cards[current_popup_card].classList.add("selected")
+}
+
+function hide_popup_menu() {
+ let popup = document.getElementById("popup")
+ popup.style.display = 'none'
+ if (current_popup_card) {
+ ui.cards[current_popup_card].classList.remove("selected")
+ current_popup_card = 0
+ }
+}
+
+function on_card_event() {
+ if (send_action('card_event', current_popup_card))
+ hide_popup_menu()
+}
+
+function on_card_ops() {
+ if (send_action('card_ops', current_popup_card))
+ hide_popup_menu()
+}
+
+function on_card_use_discarded() {
+ if (send_action('card_use_discarded', current_popup_card))
+ hide_popup_menu()
+}
+
+function on_card_advance_momentum() {
+ if (send_action('card_advance_momentum', current_popup_card))
+ hide_popup_menu()
+}
+
+function on_click_card(evt) {
+ if (evt.button === 0) {
+ if (view.actions) {
+ let card = evt.target.my_card
+ if (is_card_action('card', card)) {
+ send_action('card', card)
+ } else {
+ let menu = []
+ if (is_card_action('card_event', card))
+ menu.push('card_event')
+ if (is_card_action('card_ops', card))
+ menu.push('card_ops')
+ if (is_card_action('card_use_discarded', card))
+ menu.push('card_use_discarded')
+ if (is_card_action('card_advance_momentum', card))
+ menu.push('card_advance_momentum')
+ if (menu.length > 0) {
+ current_popup_card = card
+ show_popup_menu(evt, menu)
+ }
+ }
+ }
+ }
+}
+
+build_user_interface()
+scroll_with_middle_mouse("main")
diff --git a/rules.js b/rules.js
new file mode 100644
index 0000000..51d2753
--- /dev/null
+++ b/rules.js
@@ -0,0 +1,583 @@
+"use strict"
+
+const data = require("./data")
+
+const RED_CUBE_POOL = 0
+const RED_CRISIS_TRACK = [1, 2, 3, 4]
+const RED_BONUS_CUBES = [ 5, 6, 7 ]
+const BLUE_CUBE_POOL = 8
+const BLUE_CRISIS_TRACK = [9, 10, 11, 12]
+const BLUE_BONUS_CUBES = [ 13, 14, 15 ]
+const S_ROYALISTS = 16
+const S_NATIONAL_ASSEMBLY = 17
+const S_REPUBLICANS = 18
+const S_CATHOLIC_CHURCH = 19
+const S_PRESS = 20
+const S_SOCIAL_MOVEMENTS = 21
+const S_MONT_VALERIEN = 22
+const S_BUTTE_MONTMARTRE = 23
+const S_FORT_D_ISSY = 24
+const S_BUTTE_AUX_CAILLES = 25
+const S_PERE_LACHAISE = 26
+const S_CHATEAU_DE_VINCENNES = 27
+const S_PRUSSIAN_OCCUPIED_TERRITORY = 28
+const S_VERSAILLES_HQ = 29
+
+var game, view
+
+var states = {}
+
+exports.scenarios = [ "Standard" ]
+
+exports.roles = [ "Commune", "Versailles" ]
+
+exports.action = function (state, current, action, arg) {
+ game = state
+ if (action in states[game.state]) {
+ states[game.state][action](arg, current)
+ } else {
+ if (action === "undo" && game.undo && game.undo.length > 0)
+ pop_undo()
+ else
+ throw new Error("Invalid action: " + action)
+ }
+ return game
+}
+
+exports.resign = function (state, current) {
+ game = state
+ if (game.state !== "game_over") {
+ log_br()
+ log(`${current} resigned.`)
+ game.state = "game_over"
+ game.active = null
+ game.state = "game_over"
+ game.result = (current === "Commune" ? "Versailles" : "Commune")
+ game.victory = current + " resigned."
+ }
+ return game
+}
+
+exports.is_checkpoint = function (a, b) {
+ return a.round !== b.round
+}
+
+exports.view = function(state, current) {
+ game = state
+
+ view = {
+ log: game.log,
+ prompt: null,
+ actions: null,
+
+ round: game.round,
+ initiative: game.initiative,
+ political_vp: game.political_vp,
+ military_vp: game.military_vp,
+
+ red_hand: game.red_hand.length,
+ blue_hand: game.blue_hand.length,
+ red_momentum: game.red_momentum,
+ blue_momentum: game.blue_momentum,
+
+ discs: game.discs,
+ cubes: game.cubes,
+
+ active_card: game.active_card,
+ hand: 0,
+ objective: 0
+ }
+
+ if (current === "Commune") {
+ view.hand = game.red_hand
+ view.objective = game.red_objective
+ }
+ if (current === "Versailles") {
+ view.hand = game.blue_hand
+ view.objective = game.blue_objective
+ }
+
+ if (game.state === "game_over") {
+ view.prompt = game.victory
+ } else if (current === "Observer" || (game.active !== current && game.active !== "Both")) {
+ if (states[game.state]) {
+ let inactive = states[game.state].inactive || game.state
+ view.prompt = `Waiting for ${game.active} to ${inactive}...`
+ } else {
+ view.prompt = "Unknown state: " + game.state
+ }
+ } else {
+ view.actions = {}
+ if (states[game.state])
+ states[game.state].prompt(current)
+ else
+ view.prompt = "Unknown state: " + game.state
+ if (view.actions.undo === undefined) {
+ if (game.undo && game.undo.length > 0)
+ view.actions.undo = 1
+ else
+ view.actions.undo = 0
+ }
+ }
+
+ return view
+}
+
+exports.setup = function (seed, scenario, options) {
+ game = {
+ seed: seed,
+ log: [],
+ undo: [],
+ active: "Both",
+ state: "choose_objective_card",
+
+ round: 1,
+ initiative: null,
+ political_vp: 0,
+ military_vp: 0,
+ red_momentum: 0,
+ blue_momentum: 0,
+
+ strategy_deck: [],
+ objective_deck: [],
+
+ red_hand: [ 34 ],
+ red_objective: 0,
+ blue_hand: [ 17 ],
+ blue_objective: 0,
+
+ cubes: [
+ // red cubes
+ RED_CRISIS_TRACK[0],
+ RED_CRISIS_TRACK[0],
+ RED_CRISIS_TRACK[0],
+ RED_CRISIS_TRACK[1],
+ RED_CRISIS_TRACK[1],
+ RED_CRISIS_TRACK[2],
+ RED_CRISIS_TRACK[2],
+ RED_CRISIS_TRACK[3],
+ RED_CRISIS_TRACK[3],
+ RED_BONUS_CUBES[0],
+ RED_BONUS_CUBES[0],
+ RED_BONUS_CUBES[1],
+ RED_BONUS_CUBES[1],
+ RED_BONUS_CUBES[2],
+ RED_BONUS_CUBES[2],
+ S_PRESS,
+ S_SOCIAL_MOVEMENTS,
+ S_PERE_LACHAISE,
+
+ // blue cubes
+ BLUE_CRISIS_TRACK[0],
+ BLUE_CRISIS_TRACK[1],
+ BLUE_CRISIS_TRACK[1],
+ BLUE_CRISIS_TRACK[2],
+ BLUE_CRISIS_TRACK[3],
+ BLUE_CRISIS_TRACK[3],
+ BLUE_BONUS_CUBES[0],
+ BLUE_BONUS_CUBES[1],
+ BLUE_BONUS_CUBES[2],
+ BLUE_BONUS_CUBES[2],
+ BLUE_CUBE_POOL,
+ BLUE_CUBE_POOL,
+ BLUE_CUBE_POOL,
+ BLUE_CUBE_POOL,
+ BLUE_CUBE_POOL,
+ BLUE_CUBE_POOL,
+ S_ROYALISTS,
+ S_PRESS,
+ ],
+
+ discs: [ 0, 0, 0, 0 ],
+
+ active_card: 0,
+ count: 0,
+ }
+
+ log_h1("Red Flag Over Paris")
+ log_h1("Round 1")
+
+ for (let i = 1; i <= 41; ++i)
+ if (i !== 17 && i !== 34)
+ game.strategy_deck.push(i)
+
+ for (let i = 42; i <= 53; ++i)
+ game.objective_deck.push(i)
+
+ shuffle(game.strategy_deck)
+ shuffle(game.objective_deck)
+
+ for (let i = 0; i < 4; ++i) {
+ game.red_hand.push(game.strategy_deck.pop())
+ game.blue_hand.push(game.strategy_deck.pop())
+ }
+
+ for (let i = 0; i < 2; ++i) {
+ game.red_hand.push(game.objective_deck.pop())
+ game.blue_hand.push(game.objective_deck.pop())
+ }
+
+ return game
+}
+
+// === GAME STATES ===
+
+function is_objective_card(c) {
+ return c >= 42 && c <= 53
+}
+
+function is_strategy_card(c) {
+ return !is_objective_card(c) && c !== 17 && c !== 34
+}
+
+function enemy_player() {
+ if (game.active === "Commune")
+ return "Versailles"
+ return "Commune"
+}
+
+function player_hand(current) {
+ if (current === "Commune")
+ return game.red_hand
+ return game.blue_hand
+}
+
+function can_play_event(c) {
+ let side = data.cards[c].side
+ if (side === game.active || side === "Neutral")
+ return true
+ return false
+}
+
+function can_advance_momentum() {
+ if (game.active === "Commune")
+ return game.red_momentum < 3
+ return game.blue_momentum < 3
+}
+
+states.choose_objective_card = {
+ inactive: "choose an objective card",
+ prompt(current) {
+ view.prompt = "Choose an Objective card."
+ for (let c of player_hand(current)) {
+ if (is_objective_card(c)) {
+ gen_action("card", c)
+ }
+ }
+ },
+ card(c, current) {
+ if (current === "Commune") {
+ game.red_objective = c
+ game.red_hand = game.red_hand.filter(c => !is_objective_card(c))
+ } else {
+ game.blue_objective = c
+ game.blue_hand = game.blue_hand.filter(c => !is_objective_card(c))
+ }
+ if (game.red_objective > 0 && game.blue_objective > 0)
+ goto_initiative_phase()
+ else if (game.red_objective > 0)
+ game.active = "Versailles"
+ else if (game.blue_objective > 0)
+ game.active = "Commune"
+ else
+ game.active = "Both"
+ },
+}
+
+function goto_initiative_phase() {
+ game.active = "Commune"
+ game.state = "initiative_phase"
+}
+
+states.initiative_phase = {
+ inactive: "decide player order",
+ prompt() {
+ view.prompt = "Decide player order."
+ view.actions.commune = 1
+ view.actions.versailles = 1
+ },
+ commune() {
+ log("Initiative: Commune")
+ game.initiative = "Commune"
+ game.active = game.initiative
+ goto_strategy_phase()
+ },
+ versailles() {
+ log("Initiative: Versailles")
+ game.initiative = "Versailles"
+ game.active = game.initiative
+ goto_strategy_phase()
+ },
+}
+
+function goto_strategy_phase() {
+ clear_undo()
+ log_h2(game.active)
+ game.state = "strategy_phase"
+}
+
+function resume_strategy_phase() {
+ game.active = enemy_player()
+ goto_strategy_phase()
+}
+
+states.strategy_phase = {
+ inactive: "play a card",
+ prompt() {
+ view.prompt = "Play a card."
+ for (let c of player_hand(game.active)) {
+ console.log(game.active, "hand", c)
+ if (is_strategy_card(c)) {
+ if (can_play_event(c))
+ gen_action("card_event", c)
+ gen_action("card_ops", c)
+ if (game.active_card > 0 && can_play_event(game.active_card))
+ gen_action("card_use_discarded", c)
+ if (can_advance_momentum())
+ gen_action("card_advance_momentum", c)
+ }
+ }
+ },
+ card_event(c) {
+ push_undo()
+ log(`Played #${c} for event.`)
+ array_remove_item(player_hand(game.active), c)
+ game.active_card = c
+ goto_play_event()
+ },
+ card_ops(c) {
+ push_undo()
+ log(`Played #${c} for ${data.cards[c].ops} ops.`)
+ array_remove_item(player_hand(game.active), c)
+ game.active_card = c
+ game.count = data.cards[c].ops
+ game.state = "operations"
+ },
+ card_advance_momentum(c) {
+ push_undo()
+ log(`Played #${c} to advance momentum.`)
+ array_remove_item(player_hand(game.active), c)
+ game.active_card = c
+ if (game.active === "Commune")
+ game.red_momentum += 1
+ else
+ game.blue_momentum += 1
+ // TODO: momentum trigger
+ resume_strategy_phase()
+ },
+ card_use_discarded(c) {
+ push_undo()
+ log(`Discarded #${c} to play #${game.active_card}.`)
+ let old_c = game.active_card
+ array_remove_item(player_hand(game.active), c)
+ game.active_card = c
+ goto_play_event(old_c)
+ },
+}
+
+function goto_play_event(c) {
+ switch (c) {
+ // TODO
+ }
+ resume_strategy_phase()
+}
+
+// === COMMON LIBRARY ===
+
+function gen_action(action, argument) {
+ if (argument !== undefined) {
+ if (!(action in view.actions))
+ view.actions[action] = []
+ set_add(view.actions[action], argument)
+ } else {
+ view.actions[action] = 1
+ }
+}
+
+function random(range) {
+ // An MLCG using integer arithmetic with doubles.
+ // https://www.ams.org/journals/mcom/1999-68-225/S0025-5718-99-00996-5/S0025-5718-99-00996-5.pdf
+ // m = 2**35 − 31
+ return (game.seed = game.seed * 200105 % 34359738337) % range
+}
+
+function shuffle(list) {
+ for (let i = list.length - 1; i > 0; --i) {
+ let j = random(i + 1)
+ let tmp = list[j]
+ list[j] = list[i]
+ list[i] = tmp
+ }
+}
+
+// remove item at index (faster than splice)
+function array_remove(array, index) {
+ let n = array.length
+ for (let i = index + 1; i < n; ++i)
+ array[i - 1] = array[i]
+ array.length = n - 1
+ return array
+}
+
+// insert item at index (faster than splice)
+function array_insert(array, index, item) {
+ for (let i = array.length; i > index; --i)
+ array[i] = array[i - 1]
+ array[index] = item
+ return array
+}
+
+function array_remove_item(array, item) {
+ let i = array.indexOf(item)
+ if (i >= 0)
+ array_remove(array, i)
+}
+
+function set_clear(set) {
+ set.length = 0
+}
+
+function set_has(set, item) {
+ let a = 0
+ let b = set.length - 1
+ while (a <= b) {
+ let m = (a + b) >> 1
+ let x = set[m]
+ if (item < x)
+ b = m - 1
+ else if (item > x)
+ a = m + 1
+ else
+ return true
+ }
+ return false
+}
+
+function set_add(set, item) {
+ let a = 0
+ let b = set.length - 1
+ while (a <= b) {
+ let m = (a + b) >> 1
+ let x = set[m]
+ if (item < x)
+ b = m - 1
+ else if (item > x)
+ a = m + 1
+ else
+ return set
+ }
+ return array_insert(set, a, item)
+}
+
+function set_delete(set, item) {
+ let a = 0
+ let b = set.length - 1
+ while (a <= b) {
+ let m = (a + b) >> 1
+ let x = set[m]
+ if (item < x)
+ b = m - 1
+ else if (item > x)
+ a = m + 1
+ else
+ return array_remove(set, m)
+ }
+ return set
+}
+
+function set_toggle(set, item) {
+ let a = 0
+ let b = set.length - 1
+ while (a <= b) {
+ let m = (a + b) >> 1
+ let x = set[m]
+ if (item < x)
+ b = m - 1
+ else if (item > x)
+ a = m + 1
+ else
+ return array_remove(set, m)
+ }
+ return array_insert(set, a, item)
+}
+
+
+// Fast deep copy for objects without cycles
+function object_copy(original) {
+ if (Array.isArray(original)) {
+ let n = original.length
+ let copy = new Array(n)
+ for (let i = 0; i < n; ++i) {
+ let v = original[i]
+ if (typeof v === "object" && v !== null)
+ copy[i] = object_copy(v)
+ else
+ copy[i] = v
+ }
+ return copy
+ } else {
+ let copy = {}
+ for (let i in original) {
+ let v = original[i]
+ if (typeof v === "object" && v !== null)
+ copy[i] = object_copy(v)
+ else
+ copy[i] = v
+ }
+ return copy
+ }
+}
+
+function clear_undo() {
+ if (game.undo.length > 0)
+ game.undo = []
+}
+
+function push_undo() {
+ let copy = {}
+ for (let k in game) {
+ let v = game[k]
+ if (k === "undo")
+ continue
+ else if (k === "log")
+ v = v.length
+ else if (typeof v === "object" && v !== null)
+ v = object_copy(v)
+ copy[k] = v
+ }
+ game.undo.push(copy)
+}
+
+function pop_undo() {
+ let save_log = game.log
+ let save_undo = game.undo
+ game = save_undo.pop()
+ save_log.length = game.log
+ game.log = save_log
+ game.undo = save_undo
+}
+
+function log(msg) {
+ game.log.push(msg)
+}
+
+function log_br() {
+ if (game.log.length > 0 && game.log[game.log.length-1] !== "")
+ game.log.push("")
+}
+
+function logi(msg) {
+ game.log.push(">" + msg)
+}
+
+function log_h1(msg) {
+ log_br()
+ log(".h1 " + msg)
+ log_br()
+}
+
+function log_h2(msg) {
+ log_br()
+ log(".h2 " + msg)
+ log_br()
+}