summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--designs.js21
-rw-r--r--public/altcha.min.js8
-rw-r--r--public/common/client.css19
-rw-r--r--public/common/client.js83
-rw-r--r--public/common/replay.js33
-rw-r--r--public/common/util.js2
-rw-r--r--public/docs/tournaments.html2
-rw-r--r--public/robots.txt46
-rw-r--r--public/style.css5
-rw-r--r--schema.sql280
-rw-r--r--server.js624
-rw-r--r--tools/elo.js2
-rwxr-xr-xtools/import-game.js90
-rw-r--r--tools/lift-bans.sh30
-rwxr-xr-xtools/patchgame.js78
-rw-r--r--tools/purge.sql6
-rw-r--r--views/create-index.pug2
-rw-r--r--views/create.pug25
-rw-r--r--views/forgot_password.pug2
-rw-r--r--views/games_active.pug5
-rw-r--r--views/games_finished.pug5
-rw-r--r--views/games_public.pug2
-rw-r--r--views/head.pug38
-rw-r--r--views/login.pug2
-rw-r--r--views/profile.pug13
-rw-r--r--views/signup.pug8
-rw-r--r--views/tm_active.pug4
-rw-r--r--views/tm_finished.pug5
-rw-r--r--views/tm_list.pug4
-rw-r--r--views/tm_pool.pug30
-rw-r--r--views/tm_seed.pug21
-rw-r--r--views/user.pug32
32 files changed, 1109 insertions, 418 deletions
diff --git a/designs.js b/designs.js
index c755afc..4df0b9b 100644
--- a/designs.js
+++ b/designs.js
@@ -197,6 +197,7 @@ designs.resolvable_bibd = function (v, k) {
switch (k) {
case 3:
switch (v) {
+ case 6: return designs.social_golfer_6_3_1
case 9: return designs.resolvable_bibd_9_3_1
case 12: return designs.social_golfer_12_3_1
case 15: return designs.resolvable_bibd_15_3_1
@@ -233,6 +234,7 @@ designs.youden_square = function (v, k) {
case 3: return designs.youden_square_3_3_3
case 4: return designs.youden_square_4_3_2
case 7: return designs.youden_square_7_3_1
+ case 13: return designs.bibd_13_3_1 // sit twice
}
break
case 4:
@@ -240,6 +242,7 @@ designs.youden_square = function (v, k) {
case 4: return designs.youden_square_4_4_4
case 5: return designs.youden_square_5_4_3
case 7: return designs.youden_square_7_4_2
+ case 9: return designs.bibd_9_4_3 // sit twice
case 13: return designs.youden_square_13_4_1
}
break
@@ -711,6 +714,13 @@ designs.youden_square_15_7_3 = [
// Other designs.
+// sit 1x - meet 1x and 2x - missed pairings (0 and 3 never meet)
+designs.social_golfer_6_3_1 = [
+ [[0,1,2],[3,4,5]],
+ [[5,0,1],[2,3,4]],
+ [[4,5,0],[1,2,3]],
+]
+
// sit 1x - meet 1x - missed pairings
designs.social_golfer_12_3_1 = [
[[2,0,1],[5,4,3],[6,8,7],[11,9,10]],
@@ -725,6 +735,17 @@ designs.social_golfer_8_4_2 = [
[[0,1,2,7],[4,5,6,3]],
[[7,4,1,6],[3,0,5,2]],
[[6,3,0,1],[2,7,4,5]],
+ // meet 3x / 1x if extra round
+ [[1,5,2,6],[3,7,4,0]],
+]
+
+// https://faculty.mercer.edu/schultz_sr/social_golfer/social_golfer.html#434
+// sit 1x - meet 1x (missed 7 pairings, max 4 pairings)
+designs.social_golfer_12_4_1 = [
+ [[8,7,5,3],[9,2,10,4],[11,1,6,0]],
+ [[10,11,8,7],[2,6,3,1],[0,4,9,5]],
+ [[4,3,11,9],[7,8,0,2],[5,10,1,6]],
+ [[3,0,4,10],[6,5,2,11],[1,9,7,8]]
]
// sit 2x - meet 1x
diff --git a/public/altcha.min.js b/public/altcha.min.js
new file mode 100644
index 0000000..c73e371
--- /dev/null
+++ b/public/altcha.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.37.0.
+ * Original file: /gh/altcha-org/altcha@main/dist/altcha.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+var vi=Object.defineProperty,Pr=e=>{throw TypeError(e)},gi=(e,t,n)=>t in e?vi(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,ne=(e,t,n)=>gi(e,"symbol"!=typeof t?t+"":t,n),zr=(e,t,n)=>t.has(e)||Pr("Cannot "+n),M=(e,t,n)=>(zr(e,t,"read from private field"),n?n.call(e):t.get(e)),Yt=(e,t,n)=>t.has(e)?Pr("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),Ft=(e,t,n,r)=>(zr(e,t,"write to private field"),r?r.call(e,n):t.set(e,n),n);const Hr="KGZ1bmN0aW9uKCl7InVzZSBzdHJpY3QiO2NvbnN0IGQ9bmV3IFRleHRFbmNvZGVyO2Z1bmN0aW9uIHAoZSl7cmV0dXJuWy4uLm5ldyBVaW50OEFycmF5KGUpXS5tYXAodD0+dC50b1N0cmluZygxNikucGFkU3RhcnQoMiwiMCIpKS5qb2luKCIiKX1hc3luYyBmdW5jdGlvbiBiKGUsdCxyKXtpZih0eXBlb2YgY3J5cHRvPiJ1Inx8ISgic3VidGxlImluIGNyeXB0byl8fCEoImRpZ2VzdCJpbiBjcnlwdG8uc3VidGxlKSl0aHJvdyBuZXcgRXJyb3IoIldlYiBDcnlwdG8gaXMgbm90IGF2YWlsYWJsZS4gU2VjdXJlIGNvbnRleHQgaXMgcmVxdWlyZWQgKGh0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTL2RvY3MvV2ViL1NlY3VyaXR5L1NlY3VyZV9Db250ZXh0cykuIik7cmV0dXJuIHAoYXdhaXQgY3J5cHRvLnN1YnRsZS5kaWdlc3Qoci50b1VwcGVyQ2FzZSgpLGQuZW5jb2RlKGUrdCkpKX1mdW5jdGlvbiB3KGUsdCxyPSJTSEEtMjU2IixuPTFlNixzPTApe2NvbnN0IG89bmV3IEFib3J0Q29udHJvbGxlcixhPURhdGUubm93KCk7cmV0dXJue3Byb21pc2U6KGFzeW5jKCk9Pntmb3IobGV0IGM9cztjPD1uO2MrPTEpe2lmKG8uc2lnbmFsLmFib3J0ZWQpcmV0dXJuIG51bGw7aWYoYXdhaXQgYih0LGMscik9PT1lKXJldHVybntudW1iZXI6Yyx0b29rOkRhdGUubm93KCktYX19cmV0dXJuIG51bGx9KSgpLGNvbnRyb2xsZXI6b319ZnVuY3Rpb24gaChlKXtjb25zdCB0PWF0b2IoZSkscj1uZXcgVWludDhBcnJheSh0Lmxlbmd0aCk7Zm9yKGxldCBuPTA7bjx0Lmxlbmd0aDtuKyspcltuXT10LmNoYXJDb2RlQXQobik7cmV0dXJuIHJ9ZnVuY3Rpb24gZyhlLHQ9MTIpe2NvbnN0IHI9bmV3IFVpbnQ4QXJyYXkodCk7Zm9yKGxldCBuPTA7bjx0O24rKylyW25dPWUlMjU2LGU9TWF0aC5mbG9vcihlLzI1Nik7cmV0dXJuIHJ9YXN5bmMgZnVuY3Rpb24gbShlLHQ9IiIscj0xZTYsbj0wKXtjb25zdCBzPSJBRVMtR0NNIixvPW5ldyBBYm9ydENvbnRyb2xsZXIsYT1EYXRlLm5vdygpLGw9YXN5bmMoKT0+e2ZvcihsZXQgdT1uO3U8PXI7dSs9MSl7aWYoby5zaWduYWwuYWJvcnRlZHx8IWN8fCF5KXJldHVybiBudWxsO3RyeXtjb25zdCBmPWF3YWl0IGNyeXB0by5zdWJ0bGUuZGVjcnlwdCh7bmFtZTpzLGl2OmcodSl9LGMseSk7aWYoZilyZXR1cm57Y2xlYXJUZXh0Om5ldyBUZXh0RGVjb2RlcigpLmRlY29kZShmKSx0b29rOkRhdGUubm93KCktYX19Y2F0Y2h7fX1yZXR1cm4gbnVsbH07bGV0IGM9bnVsbCx5PW51bGw7dHJ5e3k9aChlKTtjb25zdCB1PWF3YWl0IGNyeXB0by5zdWJ0bGUuZGlnZXN0KCJTSEEtMjU2IixkLmVuY29kZSh0KSk7Yz1hd2FpdCBjcnlwdG8uc3VidGxlLmltcG9ydEtleSgicmF3Iix1LHMsITEsWyJkZWNyeXB0Il0pfWNhdGNoe3JldHVybntwcm9taXNlOlByb21pc2UucmVqZWN0KCksY29udHJvbGxlcjpvfX1yZXR1cm57cHJvbWlzZTpsKCksY29udHJvbGxlcjpvfX1sZXQgaTtvbm1lc3NhZ2U9YXN5bmMgZT0+e2NvbnN0e3R5cGU6dCxwYXlsb2FkOnIsc3RhcnQ6bixtYXg6c309ZS5kYXRhO2xldCBvPW51bGw7aWYodD09PSJhYm9ydCIpaT09bnVsbHx8aS5hYm9ydCgpLGk9dm9pZCAwO2Vsc2UgaWYodD09PSJ3b3JrIil7aWYoIm9iZnVzY2F0ZWQiaW4gcil7Y29uc3R7a2V5OmEsb2JmdXNjYXRlZDpsfT1yfHx7fTtvPWF3YWl0IG0obCxhLHMsbil9ZWxzZXtjb25zdHthbGdvcml0aG06YSxjaGFsbGVuZ2U6bCxzYWx0OmN9PXJ8fHt9O289dyhsLGMsYSxzLG4pfWk9by5jb250cm9sbGVyLG8ucHJvbWlzZS50aGVuKGE9PntzZWxmLnBvc3RNZXNzYWdlKGEmJnsuLi5hLHdvcmtlcjohMH0pfSl9fX0pKCk7Cg==",_i=e=>Uint8Array.from(atob(e),(e=>e.charCodeAt(0))),Gr=typeof self<"u"&&self.Blob&&new Blob([_i(Hr)],{type:"text/javascript;charset=utf-8"});function mi(e){let t;try{if(t=Gr&&(self.URL||self.webkitURL).createObjectURL(Gr),!t)throw"";const n=new Worker(t,{name:null==e?void 0:e.name});return n.addEventListener("error",(()=>{(self.URL||self.webkitURL).revokeObjectURL(t)})),n}catch{return new Worker("data:text/javascript;base64,"+Hr,{name:null==e?void 0:e.name})}finally{t&&(self.URL||self.webkitURL).revokeObjectURL(t)}}const bi="5";typeof window<"u"&&(window.__svelte||(window.__svelte={v:new Set})).v.add(bi);const yi=1,pi=4,wi=8,Ei=16,xi=1,ki=2,Jr="[",Kr="[!",qr="]",Me={},j=Symbol(),Qr=!1;var en=Array.isArray,Ci=Array.from,kt=Object.keys,Ct=Object.defineProperty,Ae=Object.getOwnPropertyDescriptor,Ri=Object.getOwnPropertyDescriptors,Ii=Object.prototype,$i=Array.prototype,Xt=Object.getPrototypeOf;function tn(e){for(var t=0;t<e.length;t++)e[t]()}const ae=2,rn=4,ht=8,Jt=16,oe=32,vt=64,Zt=128,Ve=256,Rt=512,B=1024,Ce=2048,gt=4096,He=8192,qe=16384,Si=32768,Kt=65536,Ni=1<<19,nn=1<<20,ut=Symbol("$state"),ln=Symbol("legacy props"),Li=Symbol("");function an(e){return e===this.v}function Ai(e,t){return e!=e?t==t:e!==t||null!==e&&"object"==typeof e||"function"==typeof e}function on(e){return!Ai(e,this.v)}function Ti(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function Vi(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function Pi(e){throw new Error("https://svelte.dev/e/effect_orphan")}function zi(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function Gi(){throw new Error("https://svelte.dev/e/hydration_failed")}function Di(e){throw new Error("https://svelte.dev/e/props_invalid_value")}function Yi(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function Fi(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function Oi(){throw new Error("https://svelte.dev/e/state_unsafe_local_read")}function Xi(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}let sn=!1;function ce(e){return{f:0,v:e,reactions:null,equals:an,version:0}}function Fe(e){return Zi(ce(e))}function un(e,t=!1){const n=ce(e);return t||(n.equals=on),n}function Zi(e){return null!==R&&2&R.f&&(null===de?nl([e]):de.push(e)),e}function V(e,t){return null!==R&&ll()&&18&R.f&&(null===de||!de.includes(e))&&Xi(),Wi(e,t)}function Wi(e,t){return e.equals(t)||(e.v=t,e.version=$n(),fn(e,Ce),null!==y&&y.f&B&&!(32&y.f)&&(null!==O&&O.includes(e)?(ve(y,Ce),Vt(y)):null===xe?il([e]):xe.push(e))),t}function fn(e,t){var n=e.reactions;if(null!==n)for(var r=n.length,o=0;o<r;o++){var l=n[o],i=l.f;i&Ce||(ve(l,t),1280&i&&(2&i?fn(l,gt):Vt(l)))}}function Nt(e){console.warn("https://svelte.dev/e/hydration_mismatch")}let P,z=!1;function We(e){z=e}function Re(e){if(null===e)throw Nt(),Me;return P=e}function Je(){return Re($e(P))}function H(e){if(z){if(null!==$e(P))throw Nt(),Me;P=e}}function Ui(){for(var e=0,t=P;;){if(8===t.nodeType){var n=t.data;if(n===qr){if(0===e)return t;e-=1}else(n===Jr||n===Kr)&&(e+=1)}var r=$e(t);t.remove(),t=r}}function le(e,t=null,n){if("object"!=typeof e||null===e||ut in e)return e;const r=Xt(e);if(r!==Ii&&r!==$i)return e;var o,l=new Map,i=en(e),a=ce(0);return i&&l.set("length",ce(e.length)),new Proxy(e,{defineProperty(e,t,n){(!("value"in n)||!1===n.configurable||!1===n.enumerable||!1===n.writable)&&Yi();var r=l.get(t);return void 0===r?(r=ce(n.value),l.set(t,r)):V(r,le(n.value,o)),!0},deleteProperty(e,t){var n=l.get(t);if(void 0===n)t in e&&l.set(t,ce(j));else{if(i&&"string"==typeof t){var r=l.get("length"),o=Number(t);Number.isInteger(o)&&o<r.v&&V(r,o)}V(n,j),Dr(a)}return!0},get(t,n,r){var i;if(n===ut)return e;var a=l.get(n),s=n in t;if(void 0===a&&(!s||null!=(i=Ae(t,n))&&i.writable)&&(a=ce(le(s?t[n]:j,o)),l.set(n,a)),void 0!==a){var c=h(a);return c===j?void 0:c}return Reflect.get(t,n,r)},getOwnPropertyDescriptor(e,t){var n=Reflect.getOwnPropertyDescriptor(e,t);if(n&&"value"in n){var r=l.get(t);r&&(n.value=h(r))}else if(void 0===n){var o=l.get(t),i=null==o?void 0:o.v;if(void 0!==o&&i!==j)return{enumerable:!0,configurable:!0,value:i,writable:!0}}return n},has(e,t){var n;if(t===ut)return!0;var r=l.get(t),i=void 0!==r&&r.v!==j||Reflect.has(e,t);if((void 0!==r||null!==y&&(!i||null!=(n=Ae(e,t))&&n.writable))&&(void 0===r&&(r=ce(i?le(e[t],o):j),l.set(t,r)),h(r)===j))return!1;return i},set(e,t,n,r){var s,c=l.get(t),u=t in e;if(i&&"length"===t)for(var d=n;d<c.v;d+=1){var f=l.get(d+"");void 0!==f?V(f,j):d in e&&(f=ce(j),l.set(d+"",f))}void 0===c?(!u||null!=(s=Ae(e,t))&&s.writable)&&(V(c=ce(void 0),le(n,o)),l.set(t,c)):(u=c.v!==j,V(c,le(n,o)));var h=Reflect.getOwnPropertyDescriptor(e,t);if(null!=h&&h.set&&h.set.call(r,n),!u){if(i&&"string"==typeof t){var v=l.get("length"),p=Number(t);Number.isInteger(p)&&p>=v.v&&V(v,p+1)}Dr(a)}return!0},ownKeys(e){h(a);var t=Reflect.ownKeys(e).filter((e=>{var t=l.get(e);return void 0===t||t.v!==j}));for(var[n,r]of l)r.v!==j&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Fi()}})}function Dr(e,t=1){V(e,e.v+t)}var Yr,cn,dn;function Wt(){if(void 0===Yr){Yr=window;var e=Element.prototype,t=Node.prototype;cn=Ae(t,"firstChild").get,dn=Ae(t,"nextSibling").get,e.__click=void 0,e.__className="",e.__attributes=null,e.__styles=null,e.__e=void 0,Text.prototype.__t=void 0}}function Lt(e=""){return document.createTextNode(e)}function Te(e){return cn.call(e)}function $e(e){return dn.call(e)}function J(e,t){if(!z)return Te(e);var n=Te(P);return null===n&&(n=P.appendChild(Lt())),Re(n),n}function Ot(e,t){if(!z){var n=Te(e);return n instanceof Comment&&""===n.data?$e(n):n}return P}function me(e,t=1,n=!1){let r=z?P:e;for(var o;t--;)o=r,r=$e(r);if(!z)return r;var l=null==r?void 0:r.nodeType;if(n&&3!==l){var i=Lt();return null===r?null==o||o.after(i):r.before(i),Re(i),i}return Re(r),r}function Mi(e){e.textContent=""}function ot(e){var t=2050;null===y?t|=Ve:y.f|=nn;var n=null!==R&&2&R.f?R:null;const r={children:null,ctx:X,deps:null,equals:an,f:t,fn:e,reactions:null,v:null,version:0,parent:n??y};return null!==n&&(n.children??(n.children=[])).push(r),r}function hn(e){var t=e.children;if(null!==t){e.children=null;for(var n=0;n<t.length;n+=1){var r=t[n];2&r.f?qt(r):Pe(r)}}}function ji(e){for(var t=e.parent;null!==t;){if(!(2&t.f))return t;t=t.parent}return null}function vn(e){var t,n=y;he(ji(e));try{hn(e),t=Sn(e)}finally{he(n)}return t}function gn(e){var t=vn(e);ve(e,(Ue||e.f&Ve)&&null!==e.deps?gt:B),e.equals(t)||(e.v=t,e.version=$n())}function qt(e){hn(e),dt(e,0),ve(e,qe),e.v=e.children=e.deps=e.ctx=e.reactions=null}function Bi(e){null===y&&null===R&&Pi(),null!==R&&R.f&Ve&&Vi(),nr&&Ti()}function Hi(e,t){var n=t.last;null===n?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function Qe(e,t,n,r=!0){var o=!!(64&e),l=y,i={ctx:X,deps:null,deriveds:null,nodes_start:null,nodes_end:null,f:e|Ce,first:null,fn:t,last:null,next:null,parent:o?null:l,prev:null,teardown:null,transitions:null,version:0};if(n){var a=je;try{Xr(!0),Tt(i),i.f|=Si}catch(e){throw Pe(i),e}finally{Xr(a)}}else null!==t&&Vt(i);if(!(n&&null===i.deps&&null===i.first&&null===i.nodes_start&&null===i.teardown&&!(i.f&nn))&&!o&&r&&(null!==l&&Hi(i,l),null!==R&&2&R.f)){var s=R;(s.children??(s.children=[])).push(i)}return i}function Ji(e){const t=Qe(8,null,!1);return ve(t,B),t.teardown=e,t}function Ut(e){if(Bi(),!(null!==y&&!!(32&y.f)&&null!==X&&!X.m))return Qt(e);var t=X;(t.e??(t.e=[])).push({fn:e,effect:y,reaction:R})}function _n(e){const t=Qe(64,e,!0);return()=>{Pe(t)}}function Qt(e){return Qe(4,e,!1)}function er(e){return Qe(8,e,!0)}function Oe(e){return tr(e)}function tr(e,t=0){return Qe(24|t,e,!0)}function It(e,t=!0){return Qe(40,e,!0,t)}function mn(e){var t=e.teardown;if(null!==t){const e=nr,n=R;Zr(!0),Ie(null);try{t.call(null)}finally{Zr(e),Ie(n)}}}function bn(e){var t=e.deriveds;if(null!==t){e.deriveds=null;for(var n=0;n<t.length;n+=1)qt(t[n])}}function yn(e,t=!1){var n=e.first;for(e.first=e.last=null;null!==n;){var r=n.next;Pe(n,t),n=r}}function Ki(e){for(var t=e.first;null!==t;){var n=t.next;32&t.f||Pe(t),t=n}}function Pe(e,t=!0){var n=!1;if((t||e.f&Ni)&&null!==e.nodes_start){for(var r=e.nodes_start,o=e.nodes_end;null!==r;){var l=r===o?null:$e(r);r.remove(),r=l}n=!0}yn(e,t&&!n),bn(e),dt(e,0),ve(e,qe);var i=e.transitions;if(null!==i)for(const e of i)e.stop();mn(e);var a=e.parent;null!==a&&null!==a.first&&pn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes_start=e.nodes_end=null}function pn(e){var t=e.parent,n=e.prev,r=e.next;null!==n&&(n.next=r),null!==r&&(r.prev=n),null!==t&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Fr(e,t){var n=[];wn(e,n,!0),qi(n,(()=>{Pe(e),t&&t()}))}function qi(e,t){var n=e.length;if(n>0){var r=()=>--n||t();for(var o of e)o.out(r)}else t()}function wn(e,t,n){if(!(e.f&He)){if(e.f^=He,null!==e.transitions)for(const r of e.transitions)(r.is_global||n)&&t.push(r);for(var r=e.first;null!==r;){var o=r.next;wn(r,t,!!(!!(r.f&Kt)||!!(32&r.f))&&n),r=o}}}function Or(e){En(e,!0)}function En(e,t){if(e.f&He){_t(e)&&Tt(e),e.f^=He;for(var n=e.first;null!==n;){var r=n.next;En(n,!!(!!(n.f&Kt)||!!(32&n.f))&&t),n=r}if(null!==e.transitions)for(const n of e.transitions)(n.is_global||t)&&n.in()}}const Qi=typeof requestIdleCallback>"u"?e=>setTimeout(e,1):requestIdleCallback;let $t=!1,St=!1,Mt=[],jt=[];function xn(){$t=!1;const e=Mt.slice();Mt=[],tn(e)}function kn(){St=!1;const e=jt.slice();jt=[],tn(e)}function rr(e){$t||($t=!0,queueMicrotask(xn)),Mt.push(e)}function el(e){St||(St=!0,Qi(kn)),jt.push(e)}function tl(){$t&&xn(),St&&kn()}function Cn(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}const Rn=0,rl=1;let wt=!1,Et=Rn,ft=!1,ct=null,je=!1,nr=!1;function Xr(e){je=e}function Zr(e){nr=e}let Le=[],Be=0,R=null;function Ie(e){R=e}let y=null;function he(e){y=e}let de=null;function nl(e){de=e}let O=null,q=0,xe=null;function il(e){xe=e}let In=0,Ue=!1,X=null;function $n(){return++In}function ll(){return!sn}function _t(e){var t,n,r=e.f;if(r&Ce)return!0;if(r&gt){var o=e.deps,l=!!(r&Ve);if(null!==o){var i;if(r&Rt){for(i=0;i<o.length;i++)((t=o[i]).reactions??(t.reactions=[])).push(e);e.f^=Rt}for(i=0;i<o.length;i++){var a=o[i];if(_t(a)&&gn(a),l&&null!==y&&!Ue&&!(null!=(n=null==a?void 0:a.reactions)&&n.includes(e))&&(a.reactions??(a.reactions=[])).push(e),a.version>e.version)return!0}}l||ve(e,B)}return!1}function al(e,t){for(var n=t;null!==n;){if(n.f&Zt)try{return void n.fn(e)}catch{n.f^=Zt}n=n.parent}throw wt=!1,e}function ol(e){return!(e.f&qe||null!==e.parent&&e.parent.f&Zt)}function At(e,t,n,r){if(wt){if(null===n&&(wt=!1),ol(t))throw e}else null!==n&&(wt=!0),al(e,t)}function Sn(e){var t,n=O,r=q,o=xe,l=R,i=Ue,a=de,s=X,c=e.f;O=null,q=0,xe=null,R=96&c?null:e,Ue=!je&&!!(c&Ve),de=null,X=e.ctx;try{var u=(0,e.fn)(),d=e.deps;if(null!==O){var f;if(dt(e,q),null!==d&&q>0)for(d.length=q+O.length,f=0;f<O.length;f++)d[q+f]=O[f];else e.deps=d=O;if(!Ue)for(f=q;f<d.length;f++)((t=d[f]).reactions??(t.reactions=[])).push(e)}else null!==d&&q<d.length&&(dt(e,q),d.length=q);return u}finally{O=n,q=r,xe=o,R=l,Ue=i,de=a,X=s}}function sl(e,t){let n=t.reactions;if(null!==n){var r=n.indexOf(e);if(-1!==r){var o=n.length-1;0===o?n=t.reactions=null:(n[r]=n[o],n.pop())}}null===n&&2&t.f&&(null===O||!O.includes(t))&&(ve(t,gt),768&t.f||(t.f^=Rt),dt(t,0))}function dt(e,t){var n=e.deps;if(null!==n)for(var r=t;r<n.length;r++)sl(e,n[r])}function Tt(e){var t=e.f;if(!(t&qe)){ve(e,B);var n=y,r=X;y=e;try{16&t?Ki(e):yn(e),bn(e),mn(e);var o=Sn(e);e.teardown="function"==typeof o?o:null,e.version=In}catch(t){At(t,e,n,r||e.ctx)}finally{y=n}}}function Nn(){if(Be>1e3){Be=0;try{zi()}catch(e){if(null===ct)throw e;At(e,ct,null)}}Be++}function Ln(e){var t=e.length;if(0!==t){Nn();var n=je;je=!0;try{for(var r=0;r<t;r++){var o=e[r];o.f&B||(o.f^=B);var l=[];An(o,l),ul(l)}}finally{je=n}}}function ul(e){var t=e.length;if(0!==t)for(var n=0;n<t;n++){var r=e[n];if(!(24576&r.f))try{_t(r)&&(Tt(r),null===r.deps&&null===r.first&&null===r.nodes_start&&(null===r.teardown?pn(r):r.fn=null))}catch(e){At(e,r,null,r.ctx)}}}function fl(){if(ft=!1,Be>1001)return;const e=Le;Le=[],Ln(e),ft||(Be=0,ct=null)}function Vt(e){Et===Rn&&(ft||(ft=!0,queueMicrotask(fl))),ct=e;for(var t=e;null!==t.parent;){var n=(t=t.parent).f;if(96&n){if(!(n&B))return;t.f^=B}}Le.push(t)}function An(e,t){var n=e.first,r=[];e:for(;null!==n;){var o=n.f,l=!!(32&o),i=l&&!!(o&B),a=n.next;if(!(i||o&He))if(8&o){if(l)n.f^=B;else try{_t(n)&&Tt(n)}catch(e){At(e,n,null,n.ctx)}var s=n.first;if(null!==s){n=s;continue}}else 4&o&&r.push(n);if(null===a){let t=n.parent;for(;null!==t;){if(e===t)break e;var c=t.next;if(null!==c){n=c;continue e}t=t.parent}}n=a}for(var u=0;u<r.length;u++)s=r[u],t.push(s),An(s,t)}function k(e){var t=Et,n=Le;try{Nn();const t=[];Et=1,Le=t,ft=!1,Ln(n);var r=null==e?void 0:e();return tl(),(Le.length>0||t.length>0)&&k(),Be=0,ct=null,r}finally{Et=t,Le=n}}async function cl(){await Promise.resolve(),k()}function h(e){var t,n=e.f,r=!!(2&n);if(r&&n&qe){var o=vn(e);return qt(e),o}if(null!==R){null!==de&&de.includes(e)&&Oi();var l=R.deps;null===O&&null!==l&&l[q]===e?q++:null===O?O=[e]:O.push(e),null!==xe&&null!==y&&y.f&B&&!(32&y.f)&&xe.includes(e)&&(ve(y,Ce),Vt(y))}else if(r&&null===e.deps)for(var i=e,a=i.parent,s=i;null!==a;){if(!(2&a.f)){var c=a;null!=(t=c.deriveds)&&t.includes(s)||(c.deriveds??(c.deriveds=[])).push(s);break}s=a,a=a.parent}return r&&(_t(i=e)&&gn(i)),e.v}function Ke(e){const t=R;try{return R=null,e()}finally{R=t}}const dl=-7169;function ve(e,t){e.f=e.f&dl|t}function Tn(e,t=!1,n){X={p:X,c:null,e:null,m:!1,s:e,x:null,l:null}}function Vn(e){const t=X;if(null!==t){void 0!==e&&(t.x=e);const i=t.e;if(null!==i){var n=y,r=R;t.e=null;try{for(var o=0;o<i.length;o++){var l=i[o];he(l.effect),Ie(l.reaction),Qt(l.fn)}}finally{he(n),Ie(r)}}X=t.p,t.m=!0}return e||{}}let Wr=!1;function Pn(){Wr||(Wr=!0,document.addEventListener("reset",(e=>{Promise.resolve().then((()=>{var t;if(!e.defaultPrevented)for(const n of e.target.elements)null==(t=n.__on_r)||t.call(n)}))}),{capture:!0}))}function zn(e){var t=R,n=y;Ie(null),he(null);try{return e()}finally{Ie(t),he(n)}}function hl(e,t,n,r=n){e.addEventListener(t,(()=>zn(n)));const o=e.__on_r;e.__on_r=o?()=>{o(),r(!0)}:()=>r(!0),Pn()}const Gn=new Set,Bt=new Set;function vl(e,t,n,r){function o(e){if(r.capture||st.call(t,e),!e.cancelBubble)return zn((()=>n.call(this,e)))}return e.startsWith("pointer")||e.startsWith("touch")||"wheel"===e?rr((()=>{t.addEventListener(e,o,r)})):t.addEventListener(e,o,r),o}function gl(e,t,n,r,o){var l={capture:r,passive:o},i=vl(e,t,n,l);(t===document.body||t===window||t===document)&&Ji((()=>{t.removeEventListener(e,i,l)}))}function _l(e){for(var t=0;t<e.length;t++)Gn.add(e[t]);for(var n of Bt)n(e)}function st(e){var t,n=this,r=n.ownerDocument,o=e.type,l=(null==(t=e.composedPath)?void 0:t.call(e))||[],i=l[0]||e.target,a=0,s=e.__root;if(s){var c=l.indexOf(s);if(-1!==c&&(n===document||n===window))return void(e.__root=n);var u=l.indexOf(n);if(-1===u)return;c<=u&&(a=c)}if((i=l[a]||e.target)!==n){Ct(e,"currentTarget",{configurable:!0,get:()=>i||r});var d=R,f=y;Ie(null),he(null);try{for(var h,v=[];null!==i;){var p=i.assignedSlot||i.parentNode||i.host||null;try{var g=i["__"+o];if(void 0!==g&&!i.disabled)if(en(g)){var[m,...b]=g;m.apply(i,[e,...b])}else g.call(i,e)}catch(e){h?v.push(e):h=e}if(e.cancelBubble||p===n||null===p)break;i=p}if(h){for(let e of v)queueMicrotask((()=>{throw e}));throw h}}finally{e.__root=n,delete e.currentTarget,Ie(d),he(f)}}}function Dn(e){var t=document.createElement("template");return t.innerHTML=e,t.content}function ke(e,t){var n=y;null===n.nodes_start&&(n.nodes_start=e,n.nodes_end=t)}function se(e,t){var n,r=!!(1&t),o=!!(2&t),l=!e.startsWith("<!>");return()=>{if(z)return ke(P,null),P;void 0===n&&(n=Dn(l?e:"<!>"+e),r||(n=Te(n)));var t=o?document.importNode(n,!0):n.cloneNode(!0);r?ke(Te(t),t.lastChild):ke(t,t);return t}}function ml(){if(z)return ke(P,null),P;var e=document.createDocumentFragment(),t=document.createComment(""),n=Lt();return e.append(t,n),ke(t,n),e}function K(e,t){if(z)return y.nodes_end=P,void Je();null!==e&&e.before(t)}const bl=["touchstart","touchmove"];function yl(e){return bl.includes(e)}function Yn(e,t){return Fn(e,t)}function pl(e,t){Wt(),t.intro=t.intro??!1;const n=t.target,r=z,o=P;try{for(var l=Te(n);l&&(8!==l.nodeType||l.data!==Jr);)l=$e(l);if(!l)throw Me;We(!0),Re(l),Je();const r=Fn(e,{...t,anchor:l});if(null===P||8!==P.nodeType||P.data!==qr)throw Nt(),Me;return We(!1),r}catch(r){if(r===Me)return!1===t.recover&&Gi(),Wt(),Mi(n),We(!1),Yn(e,t);throw r}finally{We(r),Re(o)}}const Xe=new Map;function Fn(e,{target:t,anchor:n,props:r={},events:o,context:l,intro:i=!0}){Wt();var a=new Set,s=e=>{for(var n=0;n<e.length;n++){var r=e[n];if(!a.has(r)){a.add(r);var o=yl(r);t.addEventListener(r,st,{passive:o});var l=Xe.get(r);void 0===l?(document.addEventListener(r,st,{passive:o}),Xe.set(r,1)):Xe.set(r,l+1)}}};s(Ci(Gn)),Bt.add(s);var c=void 0,u=_n((()=>{var i=n??t.appendChild(Lt());return It((()=>{l&&(Tn({}),X.c=l);o&&(r.$$events=o),z&&ke(i,null),c=e(i,r)||{},z&&(y.nodes_end=P),l&&Vn()})),()=>{var e;for(var r of a){t.removeEventListener(r,st);var o=Xe.get(r);0==--o?(document.removeEventListener(r,st),Xe.delete(r)):Xe.set(r,o)}Bt.delete(s),Ht.delete(c),i!==n&&(null==(e=i.parentNode)||e.removeChild(i))}}));return Ht.set(c,u),c}let Ht=new WeakMap;function wl(e){const t=Ht.get(e);t&&t()}function Ee(e,t,n=!1){z&&Je();var r=e,o=null,l=null,i=j,a=!1;const s=(e,t=!0)=>{a=!0,c(t,e)},c=(e,t)=>{if(i===(i=e))return;let n=!1;if(z){const e=r.data===Kr;!!i===e&&(Re(r=Ui()),We(!1),n=!0)}i?(o?Or(o):t&&(o=It((()=>t(r)))),l&&Fr(l,(()=>{l=null}))):(l?Or(l):t&&(l=It((()=>t(r)))),o&&Fr(o,(()=>{o=null}))),n&&We(!0)};tr((()=>{a=!1,t(s),a||c(null,null)}),n?Kt:0),z&&(r=P)}function Ze(e,t,n,r,o){var l,i=e,a="";tr((()=>{a!==(a=t()??"")?(void 0!==l&&(Pe(l),l=void 0),""!==a&&(l=It((()=>{if(z){P.data;for(var e=Je(),t=e;null!==e&&(8!==e.nodeType||""!==e.data);)t=e,e=$e(e);if(null===e)throw Nt(),Me;return ke(P,t),void(i=Re(e))}var n=Dn(a+"");ke(Te(n),n.lastChild),i.before(n)})))):z&&Je()}))}function El(e,t,n,r,o){var l;z&&Je();var i=null==(l=t.$$slots)?void 0:l[n],a=!1;!0===i&&(i=t.children,a=!0),void 0===i||i(e,a?()=>r:r)}function xl(e,t){rr((()=>{var n=e.getRootNode(),r=n.host?n:n.head??n.ownerDocument.head;if(!r.querySelector("#"+t.hash)){const e=document.createElement("style");e.id=t.hash,e.textContent=t.code,r.appendChild(e)}}))}function Ur(e){if(z){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute("value")){var n=e.value;ie(e,"value",null),e.value=n}if(e.hasAttribute("checked")){var r=e.checked;ie(e,"checked",null),e.checked=r}}};e.__on_r=n,el(n),Pn()}}function kl(e,t){var n=e.__attributes??(e.__attributes={});n.value===(n.value=t??void 0)||e.value===t&&(0!==t||"PROGRESS"!==e.nodeName)||(e.value=t)}function ie(e,t,n,r){var o=e.__attributes??(e.__attributes={});z&&(o[t]=e.getAttribute(t),"src"===t||"srcset"===t||"href"===t&&"LINK"===e.nodeName)||o[t]!==(o[t]=n)&&("style"===t&&"__styles"in e&&(e.__styles={}),"loading"===t&&(e[Li]=n),null==n?e.removeAttribute(t):"string"!=typeof n&&Cl(e).includes(t)?e[t]=n:e.setAttribute(t,n))}var Mr=new Map;function Cl(e){var t=Mr.get(e.nodeName);if(t)return t;Mr.set(e.nodeName,t=[]);for(var n,r=Xt(e),o=Element.prototype;o!==r;){for(var l in n=Ri(r))n[l].set&&t.push(l);r=Xt(r)}return t}function Rl(e,t,n){if(n){if(e.classList.contains(t))return;e.classList.add(t)}else{if(!e.classList.contains(t))return;e.classList.remove(t)}}function Il(e,t,n=t){hl(e,"change",(t=>{var r=t?e.defaultChecked:e.checked;n(r)})),(z&&e.defaultChecked!==e.checked||null==Ke(t))&&n(e.checked),er((()=>{var n=t();e.checked=!!n}))}function jr(e,t){return e===t||(null==e?void 0:e[ut])===t}function Br(e={},t,n,r){return Qt((()=>{var r,o;return er((()=>{r=o,o=[],Ke((()=>{e!==n(...o)&&(t(e,...o),r&&jr(n(...r),e)&&t(null,...r))}))})),()=>{rr((()=>{o&&jr(n(...o),e)&&t(null,...o)}))}})),e}function On(e){null===X&&Cn(),Ut((()=>{const t=Ke(e);if("function"==typeof t)return t}))}function $l(e){null===X&&Cn(),On((()=>()=>Ke(e)))}let pt=!1;function Sl(e){var t=pt;try{return pt=!1,[e(),pt]}finally{pt=t}}function Nl(e){for(var t=y,n=y;null!==t&&!(96&t.f);)t=t.parent;try{return he(t),e()}finally{he(n)}}function N(e,t,n,r){var o,l,i=!!(1&n),a=!sn,s=!!(8&n),c=!!(16&n),u=!1;s?[l,u]=Sl((()=>e[t])):l=e[t];var d,f=ut in e||ln in e,v=(null==(o=Ae(e,t))?void 0:o.set)??(f&&s&&t in e?n=>e[t]=n:void 0),p=r,g=!0,m=!1,b=()=>(m=!0,g&&(g=!1,p=c?Ke(r):r),p);if(void 0===l&&void 0!==r&&(v&&a&&Di(),l=b(),v&&v(l)),d=()=>{var n=e[t];return void 0===n?b():(g=!0,m=!1,n)},!(4&n))return d;if(v){var y=e.$$legacy;return function(e,t){return arguments.length>0?((!t||y||u)&&v(t?d():e),e):d()}}var w=!1,x=un(l),$=Nl((()=>ot((()=>{var e=d(),t=h(x);return w?(w=!1,t):x.v=e}))));return i||($.equals=on),function(e,t){if(arguments.length>0){const n=t?h($):s?le(e):e;return $.equals(n)||(w=!0,V(x,n),m&&void 0!==p&&(p=n),Ke((()=>h($)))),e}return h($)}}function Ll(e){return new Al(e)}var be,te;class Al{constructor(e){var t;Yt(this,be),Yt(this,te);var n=new Map,r=(e,t)=>{var r=un(t);return n.set(e,r),r};const o=new Proxy({...e.props||{},$$events:{}},{get:(e,t)=>h(n.get(t)??r(t,Reflect.get(e,t))),has:(e,t)=>t===ln||(h(n.get(t)??r(t,Reflect.get(e,t))),Reflect.has(e,t)),set:(e,t,o)=>(V(n.get(t)??r(t,o),o),Reflect.set(e,t,o))});Ft(this,te,(e.hydrate?pl:Yn)(e.component,{target:e.target,anchor:e.anchor,props:o,context:e.context,intro:e.intro??!1,recover:e.recover})),(!(null!=(t=null==e?void 0:e.props)&&t.$$host)||!1===e.sync)&&k(),Ft(this,be,o.$$events);for(const e of Object.keys(M(this,te)))"$set"===e||"$destroy"===e||"$on"===e||Ct(this,e,{get(){return M(this,te)[e]},set(t){M(this,te)[e]=t},enumerable:!0});M(this,te).$set=e=>{Object.assign(o,e)},M(this,te).$destroy=()=>{wl(M(this,te))}}$set(e){M(this,te).$set(e)}$on(e,t){M(this,be)[e]=M(this,be)[e]||[];const n=(...e)=>t.call(this,...e);return M(this,be)[e].push(n),()=>{M(this,be)[e]=M(this,be)[e].filter((e=>e!==n))}}$destroy(){M(this,te).$destroy()}}let Xn;function xt(e,t,n,r){var o;const l=null==(o=n[e])?void 0:o.type;if(t="Boolean"===l&&"boolean"!=typeof t?null!=t:t,!r||!n[e])return t;if("toAttribute"===r)switch(l){case"Object":case"Array":return null==t?null:JSON.stringify(t);case"Boolean":return t?"":null;case"Number":return t??null;default:return t}else switch(l){case"Object":case"Array":return t&&JSON.parse(t);case"Boolean":default:return t;case"Number":return null!=t?+t:t}}function Tl(e){const t={};return e.childNodes.forEach((e=>{t[e.slot||"default"]=!0})),t}function Vl(e,t,n,r,o,l){let i=class extends Xn{constructor(){super(e,n,o),this.$$p_d=t}static get observedAttributes(){return kt(t).map((e=>(t[e].attribute||e).toLowerCase()))}};return kt(t).forEach((e=>{Ct(i.prototype,e,{get(){return this.$$c&&e in this.$$c?this.$$c[e]:this.$$d[e]},set(n){var r;n=xt(e,n,t),this.$$d[e]=n;var o=this.$$c;o&&((null==(r=Ae(o,e))?void 0:r.get)?o[e]=n:o.$set({[e]:n}))}})})),r.forEach((e=>{Ct(i.prototype,e,{get(){var t;return null==(t=this.$$c)?void 0:t[e]}})})),e.element=i,i}be=new WeakMap,te=new WeakMap,"function"==typeof HTMLElement&&(Xn=class extends HTMLElement{constructor(e,t,n){super(),ne(this,"$$ctor"),ne(this,"$$s"),ne(this,"$$c"),ne(this,"$$cn",!1),ne(this,"$$d",{}),ne(this,"$$r",!1),ne(this,"$$p_d",{}),ne(this,"$$l",{}),ne(this,"$$l_u",new Map),ne(this,"$$me"),this.$$ctor=e,this.$$s=t,n&&this.attachShadow({mode:"open"})}addEventListener(e,t,n){if(this.$$l[e]=this.$$l[e]||[],this.$$l[e].push(t),this.$$c){const n=this.$$c.$on(e,t);this.$$l_u.set(t,n)}super.addEventListener(e,t,n)}removeEventListener(e,t,n){if(super.removeEventListener(e,t,n),this.$$c){const e=this.$$l_u.get(t);e&&(e(),this.$$l_u.delete(t))}}async connectedCallback(){if(this.$$cn=!0,!this.$$c){let e=function(e){return t=>{const n=document.createElement("slot");"default"!==e&&(n.name=e),K(t,n)}};if(await Promise.resolve(),!this.$$cn||this.$$c)return;const t={},n=Tl(this);for(const r of this.$$s)r in n&&("default"!==r||this.$$d.children?t[r]=e(r):(this.$$d.children=e(r),t.default=!0));for(const e of this.attributes){const t=this.$$g_p(e.name);t in this.$$d||(this.$$d[t]=xt(t,e.value,this.$$p_d,"toProp"))}for(const e in this.$$p_d)!(e in this.$$d)&&void 0!==this[e]&&(this.$$d[e]=this[e],delete this[e]);this.$$c=Ll({component:this.$$ctor,target:this.shadowRoot||this,props:{...this.$$d,$$slots:t,$$host:this}}),this.$$me=_n((()=>{er((()=>{var e;this.$$r=!0;for(const t of kt(this.$$c)){if(null==(e=this.$$p_d[t])||!e.reflect)continue;this.$$d[t]=this.$$c[t];const n=xt(t,this.$$d[t],this.$$p_d,"toAttribute");null==n?this.removeAttribute(this.$$p_d[t].attribute||t):this.setAttribute(this.$$p_d[t].attribute||t,n)}this.$$r=!1}))}));for(const e in this.$$l)for(const t of this.$$l[e]){const n=this.$$c.$on(e,t);this.$$l_u.set(t,n)}this.$$l={}}}attributeChangedCallback(e,t,n){var r;this.$$r||(e=this.$$g_p(e),this.$$d[e]=xt(e,n,this.$$p_d,"toProp"),null==(r=this.$$c)||r.$set({[e]:this.$$d[e]}))}disconnectedCallback(){this.$$cn=!1,Promise.resolve().then((()=>{!this.$$cn&&this.$$c&&(this.$$c.$destroy(),this.$$me(),this.$$c=void 0)}))}$$g_p(e){return kt(this.$$p_d).find((t=>this.$$p_d[t].attribute===e||!this.$$p_d[t].attribute&&t.toLowerCase()===e))||e}});const Zn=new TextEncoder;function Pl(e){return[...new Uint8Array(e)].map((e=>e.toString(16).padStart(2,"0"))).join("")}async function zl(e,t="SHA-256",n=1e5){const r=Date.now().toString(16);e||(e=Math.round(Math.random()*n));return{algorithm:t,challenge:await Wn(r,e,t),salt:r,signature:""}}async function Wn(e,t,n){if(typeof crypto>"u"||!("subtle"in crypto)||!("digest"in crypto.subtle))throw new Error("Web Crypto is not available. Secure context is required (https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).");return Pl(await crypto.subtle.digest(n.toUpperCase(),Zn.encode(e+t)))}function Gl(e,t,n="SHA-256",r=1e6,o=0){const l=new AbortController,i=Date.now();return{promise:(async()=>{for(let a=o;a<=r;a+=1){if(l.signal.aborted)return null;if(await Wn(t,a,n)===e)return{number:a,took:Date.now()-i}}return null})(),controller:l}}function Dl(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{}}function Yl(e){const t=atob(e),n=new Uint8Array(t.length);for(let e=0;e<t.length;e++)n[e]=t.charCodeAt(e);return n}function Fl(e,t=12){const n=new Uint8Array(t);for(let r=0;r<t;r++)n[r]=e%256,e=Math.floor(e/256);return n}async function Ol(e,t="",n=1e6,r=0){const o="AES-GCM",l=new AbortController,i=Date.now();let a=null,s=null;try{s=Yl(e);const n=await crypto.subtle.digest("SHA-256",Zn.encode(t));a=await crypto.subtle.importKey("raw",n,o,!1,["decrypt"])}catch{return{promise:Promise.reject(),controller:l}}return{promise:(async()=>{for(let e=r;e<=n;e+=1){if(l.signal.aborted||!a||!s)return null;try{const t=await crypto.subtle.decrypt({name:o,iv:Fl(e)},a,s);if(t)return{clearText:(new TextDecoder).decode(t),took:Date.now()-i}}catch{}}return null})(),controller:l}}var x=(e=>(e.ERROR="error",e.VERIFIED="verified",e.VERIFYING="verifying",e.UNVERIFIED="unverified",e.EXPIRED="expired",e))(x||{}),Xl=se('<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="svelte-ddsc3z"><path d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" fill="currentColor" opacity=".25" class="svelte-ddsc3z"></path><path d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z" fill="currentColor" class="altcha-spinner svelte-ddsc3z"></path></svg>'),Zl=se('<span role="status" aria-live="polite" class="svelte-ddsc3z"><!></span> <input type="hidden" class="svelte-ddsc3z">',1),Wl=se('<span role="status" aria-live="polite" class="svelte-ddsc3z"><!></span>'),Ul=se('<label class="svelte-ddsc3z"><!></label>'),Ml=se('<div class="svelte-ddsc3z"><a target="_blank" class="altcha-logo svelte-ddsc3z"><svg width="22" height="22" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="svelte-ddsc3z"><path d="M2.33955 16.4279C5.88954 20.6586 12.1971 21.2105 16.4279 17.6604C18.4699 15.947 19.6548 13.5911 19.9352 11.1365L17.9886 10.4279C17.8738 12.5624 16.909 14.6459 15.1423 16.1284C11.7577 18.9684 6.71167 18.5269 3.87164 15.1423C1.03163 11.7577 1.4731 6.71166 4.8577 3.87164C8.24231 1.03162 13.2883 1.4731 16.1284 4.8577C16.9767 5.86872 17.5322 7.02798 17.804 8.2324L19.9522 9.01429C19.7622 7.07737 19.0059 5.17558 17.6604 3.57212C14.1104 -0.658624 7.80283 -1.21043 3.57212 2.33956C-0.658625 5.88958 -1.21046 12.1971 2.33955 16.4279Z" fill="currentColor" class="svelte-ddsc3z"></path><path d="M3.57212 2.33956C1.65755 3.94607 0.496389 6.11731 0.12782 8.40523L2.04639 9.13961C2.26047 7.15832 3.21057 5.25375 4.8577 3.87164C8.24231 1.03162 13.2883 1.4731 16.1284 4.8577L13.8302 6.78606L19.9633 9.13364C19.7929 7.15555 19.0335 5.20847 17.6604 3.57212C14.1104 -0.658624 7.80283 -1.21043 3.57212 2.33956Z" fill="currentColor" class="svelte-ddsc3z"></path><path d="M7 10H5C5 12.7614 7.23858 15 10 15C12.7614 15 15 12.7614 15 10H13C13 11.6569 11.6569 13 10 13C8.3431 13 7 11.6569 7 10Z" fill="currentColor" class="svelte-ddsc3z"></path></svg></a></div>'),jl=se('<div class="svelte-ddsc3z"><!></div>'),Bl=se('<div class="svelte-ddsc3z"><!></div>'),Hl=se('<div class="altcha-error svelte-ddsc3z"><svg width="14" height="14" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="svelte-ddsc3z"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" class="svelte-ddsc3z"></path></svg> <!></div>'),Jl=se('<div class="altcha-footer svelte-ddsc3z"><div class="svelte-ddsc3z"><!></div></div>'),Kl=se('<div class="altcha-anchor-arrow svelte-ddsc3z"></div>'),ql=se('<!> <div class="altcha svelte-ddsc3z"><div class="altcha-main svelte-ddsc3z"><!> <div class="altcha-checkbox svelte-ddsc3z"><input type="checkbox" class="svelte-ddsc3z"></div> <div class="altcha-label svelte-ddsc3z"><!></div> <!></div> <!> <!> <!></div>',1);const Ql={hash:"svelte-ddsc3z",code:'.altcha.svelte-ddsc3z {background:var(--altcha-color-base, transparent);border:var(--altcha-border-width, 1px) solid var(--altcha-color-border, #a0a0a0);border-radius:var(--altcha-border-radius, 3px);color:var(--altcha-color-text, currentColor);display:flex;flex-direction:column;max-width:var(--altcha-max-width, 260px);position:relative;text-align:left;}.altcha.svelte-ddsc3z:focus-within {border-color:var(--altcha-color-border-focus, currentColor);}.altcha[data-floating].svelte-ddsc3z {background:var(--altcha-color-base, white);display:none;filter:drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.2));left:-100%;position:fixed;top:-100%;width:var(--altcha-max-width, 260px);z-index:999999;}.altcha[data-floating=top].svelte-ddsc3z .altcha-anchor-arrow:where(.svelte-ddsc3z) {border-bottom-color:transparent;border-top-color:var(--altcha-color-border, #a0a0a0);bottom:-12px;top:auto;}.altcha[data-floating=bottom].svelte-ddsc3z:focus-within::after {border-bottom-color:var(--altcha-color-border-focus, currentColor);}.altcha[data-floating=top].svelte-ddsc3z:focus-within::after {border-top-color:var(--altcha-color-border-focus, currentColor);}.altcha[data-floating].svelte-ddsc3z:not([data-state=unverified]) {display:block;}.altcha-anchor-arrow.svelte-ddsc3z {border:6px solid transparent;border-bottom-color:var(--altcha-color-border, #a0a0a0);content:"";height:0;left:12px;position:absolute;top:-12px;width:0;}.altcha-main.svelte-ddsc3z {align-items:center;display:flex;gap:0.4rem;padding:0.7rem;}.altcha-label.svelte-ddsc3z {flex-grow:1;}.altcha-label.svelte-ddsc3z label:where(.svelte-ddsc3z) {cursor:pointer;}.altcha-logo.svelte-ddsc3z {color:currentColor;opacity:0.3;}.altcha-logo.svelte-ddsc3z:hover {opacity:1;}.altcha-error.svelte-ddsc3z {color:var(--altcha-color-error-text, #f23939);display:flex;font-size:0.85rem;gap:0.3rem;padding:0 0.7rem 0.7rem;}.altcha-footer.svelte-ddsc3z {align-items:center;background-color:var(--altcha-color-footer-bg, transparent);display:flex;font-size:0.75rem;opacity:0.4;padding:0.2rem 0.7rem;text-align:right;}.altcha-footer.svelte-ddsc3z:hover {opacity:1;}.altcha-footer.svelte-ddsc3z > :where(.svelte-ddsc3z):first-child {flex-grow:1;}.altcha-footer.svelte-ddsc3z a {color:currentColor;}.altcha-checkbox.svelte-ddsc3z {display:flex;align-items:center;height:24px;width:24px;}.altcha-checkbox.svelte-ddsc3z input:where(.svelte-ddsc3z) {width:18px;height:18px;margin:0;}.altcha-hidden.svelte-ddsc3z {display:none;}.altcha-spinner.svelte-ddsc3z {\n animation: svelte-ddsc3z-altcha-spinner 0.75s infinite linear;transform-origin:center;}\n\n@keyframes svelte-ddsc3z-altcha-spinner {\n 100% {\n transform: rotate(360deg);\n }\n}'};function ea(e,t){var n,r;Tn(t,!0),xl(e,Ql);let o=N(t,"auto",7,void 0),l=N(t,"blockspam",7,void 0),i=N(t,"challengeurl",7,void 0),a=N(t,"challengejson",7,void 0),s=N(t,"customfetch",7,void 0),c=N(t,"debug",7,!1),u=N(t,"delay",7,0),d=N(t,"expire",7,void 0),f=N(t,"floating",7,void 0),v=N(t,"floatinganchor",7,void 0),p=N(t,"floatingoffset",7,void 0),g=N(t,"hidefooter",7,!1),m=N(t,"hidelogo",7,!1),b=N(t,"name",7,"altcha"),y=N(t,"maxnumber",7,1e6),w=N(t,"mockerror",7,!1),$=N(t,"obfuscated",7,void 0),E=N(t,"plugins",7,void 0),R=N(t,"refetchonexpire",7,!0),C=N(t,"spamfilter",7,!1),I=N(t,"strings",7,void 0),_=N(t,"test",7,!1),L=N(t,"verifyurl",7,void 0),z=N(t,"workers",23,(()=>Math.min(16,navigator.hardwareConcurrency||8))),X=N(t,"workerurl",7,void 0);const P=["SHA-256","SHA-384","SHA-512"],S="Visit Altcha.org",O="https://altcha.org/",j=(e,n)=>{t.$$host.dispatchEvent(new CustomEvent(e,{detail:n}))},A=null==(r=null==(n=document.documentElement.lang)?void 0:n.split("-"))?void 0:r[0],G=ot((()=>{var e;return i()&&new URL(i(),location.origin).host.endsWith(".altcha.org")&&!(null==(e=i())||!e.includes("apiKey=ckey_"))})),Z=ot((()=>a()?ve(a()):void 0)),W=ot((()=>I()?ve(I()):{})),M=ot((()=>{var e;return{ariaLinkLabel:S,error:"Verification failed. Try again later.",expired:"Verification expired. Try again.",footer:`Protected by <a href="${O}" target="_blank" aria-label="${(null==(e=h(W))?void 0:e.ariaLinkLabel)||S}">ALTCHA</a>`,label:"I'm not a robot",verified:"Verified",verifying:"Verifying...",waitAlert:"Verifying... please wait.",...h(W)}}));let B=Fe(!1),Y=Fe(le(x.UNVERIFIED)),F=Fe(void 0),T=Fe(null),U=null,D=null,q=Fe(null),Q=null,ee=[],te=Fe(null);function ne(e,t){return btoa(JSON.stringify({algorithm:e.algorithm,challenge:e.challenge,number:t.number,salt:e.salt,signature:e.signature,test:!!_()||void 0,took:t.took}))}function re(){i()&&R()&&h(Y)===x.VERIFIED?Le():Ne(x.EXPIRED,h(M).expired)}function oe(...e){(c()||e.some((e=>e instanceof Error)))&&console[e[0]instanceof Error?"error":"log"]("ALTCHA",`[name=${b()}]`,...e)}function ae(e){const t=e.target;f()&&t&&!h(F).contains(t)&&(h(Y)===x.VERIFIED||"off"===o()&&h(Y)===x.UNVERIFIED)&&(h(F).style.display="none")}function se(){f()&&h(Y)!==x.UNVERIFIED&&pe()}function ce(e){h(Y)===x.UNVERIFIED&&Le()}function ue(e){D&&"onsubmit"===o()?h(Y)===x.UNVERIFIED?(e.preventDefault(),e.stopPropagation(),Le().then((()=>{null==D||D.requestSubmit()}))):h(Y)!==x.VERIFIED&&(e.preventDefault(),e.stopPropagation(),h(Y)===x.VERIFYING&&fe()):D&&f()&&"off"===o()&&h(Y)===x.UNVERIFIED&&(e.preventDefault(),e.stopPropagation(),h(F).style.display="block",pe())}function de(){Ne()}function fe(){h(Y)===x.VERIFYING&&h(M).waitAlert&&alert(h(M).waitAlert)}function he(){f()&&pe()}function ve(e){return JSON.parse(e)}function pe(e=20){if(h(F))if(U||(U=(v()?document.querySelector(v()):null==D?void 0:D.querySelector('input[type="submit"], button[type="submit"], button:not([type="button"]):not([type="reset"])'))||D),U){const t=parseInt(p(),10)||12,n=U.getBoundingClientRect(),r=h(F).getBoundingClientRect(),o=document.documentElement.clientHeight,l=document.documentElement.clientWidth,i="auto"===f()?n.bottom+r.height+t+e>o:"top"===f(),a=Math.max(e,Math.min(l-e-r.width,n.left+n.width/2-r.width/2));if(h(F).style.top=i?n.top-(r.height+t)+"px":`${n.bottom+t}px`,h(F).style.left=`${a}px`,h(F).setAttribute("data-floating",i?"top":"bottom"),h(T)){const e=h(T).getBoundingClientRect();h(T).style.left=n.left-a+n.width/2-e.width/2+"px"}}else oe("unable to find floating anchor element")}async function ge(e){if(!L())throw new Error("Attribute verifyurl not set.");oe("requesting server verification from",L());const t={payload:e};if(!1!==C()){const{blockedCountries:e,classifier:n,disableRules:r,email:o,expectedLanguages:l,expectedCountries:i,fields:a,ipAddress:s,text:c,timeZone:u}="ipAddress"===C()?{blockedCountries:void 0,classifier:void 0,disableRules:void 0,email:!1,expectedCountries:void 0,expectedLanguages:void 0,fields:!1,ipAddress:void 0,text:void 0,timeZone:void 0}:"object"==typeof C()?C():{blockedCountries:void 0,classifier:void 0,disableRules:void 0,email:void 0,expectedCountries:void 0,expectedLanguages:void 0,fields:void 0,ipAddress:void 0,text:void 0,timeZone:void 0};t.blockedCountries=e,t.classifier=n,t.disableRules=r,t.email=!1===o?void 0:function(e){var t;const n=null==D?void 0:D.querySelector("string"==typeof e?`input[name="${e}"]`:'input[type="email"]:not([data-no-spamfilter])');return(null==(t=null==n?void 0:n.value)?void 0:t.slice(n.value.indexOf("@")))||void 0}(o),t.expectedCountries=i,t.expectedLanguages=l||(A?[A]:void 0),t.fields=!1===a?void 0:function(e){return[...(null==D?void 0:D.querySelectorAll(null!=e&&e.length?e.map((e=>`input[name="${e}"]`)).join(", "):'input[type="text"]:not([data-no-spamfilter]), textarea:not([data-no-spamfilter])'))||[]].reduce(((e,t)=>{const n=t.name,r=t.value;return n&&r&&(e[n]=/\n/.test(r)?r.replace(new RegExp("(?<!\\r)\\n","g"),"\r\n"):r),e}),{})}(a),t.ipAddress=!1===s?void 0:s||"auto",t.text=c,t.timeZone=!1===u?void 0:u||Dl()}const n=await fetch(L(),{body:JSON.stringify(t),headers:{"content-type":"application/json"},method:"POST"});if(200!==n.status)throw new Error(`Server responded with ${n.status}.`);const r=await n.json();if(null!=r&&r.payload&&V(te,le(r.payload)),j("serververification",r),l()&&"BAD"===r.classification)throw new Error("SpamFilter returned negative classification.")}function be(e){oe("expire",e),Q&&(clearTimeout(Q),Q=null),e<1?re():Q=setTimeout(re,e)}function ye(e){oe("floating",e),f()!==e&&(h(F).style.left="",h(F).style.top=""),f(!0===e||""===e?"auto":!1===e||"false"===e?void 0:f()),f()?(o()||o("onsubmit"),document.addEventListener("scroll",se),document.addEventListener("click",ae),window.addEventListener("resize",he)):"onsubmit"===o()&&o(void 0)}function we(e){if(!e.algorithm)throw new Error("Invalid challenge. Property algorithm is missing.");if(void 0===e.signature)throw new Error("Invalid challenge. Property signature is missing.");if(!P.includes(e.algorithm.toUpperCase()))throw new Error(`Unknown algorithm value. Allowed values: ${P.join(", ")}`);if(!e.challenge||e.challenge.length<40)throw new Error("Challenge is too short. Min. 40 chars.");if(!e.salt||e.salt.length<10)throw new Error("Salt is too short. Min. 10 chars.")}async function xe(e){let t=null;if("Worker"in window){try{t=await async function(e,t=("number"==typeof _()?_():y()),n=Math.ceil(z())){const r=[];n=Math.min(16,Math.max(1,n));for(let e=0;e<n;e++)r.push(altchaCreateWorker(X()));const o=Math.ceil(t/n),l=await Promise.all(r.map(((t,n)=>{const l=n*o;return new Promise((n=>{t.addEventListener("message",(e=>{if(e.data)for(const e of r)e!==t&&e.postMessage({type:"abort"});n(e.data)})),t.postMessage({payload:e,max:l+o,start:l,type:"work"})}))})));for(const e of r)e.terminate();return l.find((e=>!!e))||null}(e,e.maxnumber)}catch(e){oe(e)}if(void 0!==(null==t?void 0:t.number)||"obfuscated"in e)return{data:e,solution:t}}if("obfuscated"in e){const t=await Ol(e.obfuscated,e.key,e.maxnumber);return{data:e,solution:await t.promise}}return{data:e,solution:await Gl(e.challenge,e.salt,e.algorithm,e.maxnumber||y()).promise}}async function $e(){if(!$())return void Ve(x.ERROR);const e=ee.find((e=>"obfuscation"===e.constructor.pluginName));return e&&"clarify"in e?"clarify"in e&&"function"==typeof e.clarify?e.clarify():void 0:(Ve(x.ERROR),void oe("Plugin `obfuscation` not found. Import `altcha/plugins/obfuscation` to load it."))}function ke(e){void 0!==e.obfuscated&&$(e.obfuscated),void 0!==e.auto&&(o(e.auto),"onload"===o()&&($()?$e():Le())),void 0!==e.blockspam&&l(!!e.blockspam),void 0!==e.customfetch&&s(e.customfetch),void 0!==e.floatinganchor&&v(e.floatinganchor),void 0!==e.delay&&u(e.delay),void 0!==e.floatingoffset&&p(e.floatingoffset),void 0!==e.floating&&ye(e.floating),void 0!==e.expire&&(be(e.expire),d(e.expire)),e.challenge&&(a("string"==typeof e.challenge?e.challenge:JSON.stringify(e.challenge)),we(h(Z))),void 0!==e.challengeurl&&i(e.challengeurl),void 0!==e.debug&&c(!!e.debug),void 0!==e.hidefooter&&g(!!e.hidefooter),void 0!==e.hidelogo&&m(!!e.hidelogo),void 0!==e.maxnumber&&y(+e.maxnumber),void 0!==e.mockerror&&w(!!e.mockerror),void 0!==e.name&&b(e.name),void 0!==e.refetchonexpire&&R(!!e.refetchonexpire),void 0!==e.spamfilter&&C("object"==typeof e.spamfilter?e.spamfilter:!!e.spamfilter),e.strings&&I("string"==typeof e.strings?e.strings:JSON.stringify(e.strings)),void 0!==e.test&&_("number"==typeof e.test?e.test:!!e.test),void 0!==e.verifyurl&&L(e.verifyurl),void 0!==e.workers&&z(+e.workers),void 0!==e.workerurl&&X(e.workerurl)}function Re(){return{auto:o(),blockspam:l(),challengeurl:i(),debug:c(),delay:u(),expire:d(),floating:f(),floatinganchor:v(),floatingoffset:p(),hidefooter:g(),hidelogo:m(),name:b(),maxnumber:y(),mockerror:w(),obfuscated:$(),refetchonexpire:R(),spamfilter:C(),strings:h(M),test:_(),verifyurl:L(),workers:z(),workerurl:X()}}function Ce(){return U}function Ie(){return h(Y)}function Ne(e=x.UNVERIFIED,t=null){Q&&(clearTimeout(Q),Q=null),V(B,!1),V(te,null),Ve(e,t)}function _e(e){U=e}function Ve(e,t=null){V(Y,le(e)),V(q,le(t)),j("statechange",{payload:h(te),state:h(Y)})}async function Le(){return Ne(x.VERIFYING),await new Promise((e=>setTimeout(e,u()||0))),async function(){var e;if(w())throw oe("mocking error"),new Error("Mocked error.");if(h(Z))return oe("using provided json data"),h(Z);if(_())return oe("generating test challenge",{test:_()}),zl("boolean"!=typeof _()?+_():void 0);{if(!i()&&D){const e=D.getAttribute("action");null!=e&&e.includes("/form/")&&i(e+"/altcha")}if(!i())throw new Error("Attribute challengeurl not set.");oe("fetching challenge from",i());let t=null,n=null;if(s())if(oe("using customfetch"),"string"==typeof s()){if(t=globalThis[s()]||null,!t)throw new Error(`Custom fetch function not found: ${s()}`)}else t=s();const r={headers:!1!==C()?{"x-altcha-spam-filter":"1"}:{}};if(t){if(n=await t(i(),r),!(n&&n instanceof Response))throw new Error("Custom fetch function did not return a response.")}else n=await fetch(i(),r);if(200!==n.status)throw new Error(`Server responded with ${n.status}.`);const o=n.headers.get("Expires"),l=n.headers.get("X-Altcha-Config"),a=await n.json(),c=new URLSearchParams(null==(e=a.salt.split("?"))?void 0:e[1]),u=c.get("expires")||c.get("expire");if(u){const e=new Date(1e3*+u),t=isNaN(e.getTime())?0:e.getTime()-Date.now();t>0&&be(t)}if(l)try{const e=JSON.parse(l);e&&"object"==typeof e&&(e.verifyurl&&(e.verifyurl=new URL(e.verifyurl,new URL(i())).toString()),ke(e))}catch(e){oe("unable to configure from X-Altcha-Config",e)}if(!d()&&null!=o&&o.length){const e=Date.parse(o);if(e){const t=e-Date.now();t>0&&be(t)}}return a}}().then((e=>(we(e),oe("challenge",e),xe(e)))).then((({data:e,solution:t})=>{if(oe("solution",t),"challenge"in e&&t&&!("clearText"in t)){if(void 0===(null==t?void 0:t.number))throw oe("Unable to find a solution. Ensure that the 'maxnumber' attribute is greater than the randomly generated number."),new Error("Unexpected result returned.");if(L())return ge(ne(e,t));V(te,le(ne(e,t))),oe("payload",h(te))}})).then((()=>{Ve(x.VERIFIED),oe("verified"),cl().then((()=>{j("verified",{payload:h(te)})}))})).catch((e=>{oe(e),Ve(x.ERROR,e.message)}))}Ut((()=>{!function(){for(const e of ee)"function"==typeof e.onErrorChange&&e.onErrorChange(h(q))}(h(q))})),Ut((()=>{!function(){for(const e of ee)"function"==typeof e.onStateChange&&e.onStateChange(h(Y));f()&&h(Y)!==x.UNVERIFIED&&requestAnimationFrame((()=>{pe()})),V(B,h(Y)===x.VERIFIED)}(h(Y))})),$l((()=>{(function(){for(const e of ee)e.destroy()})(),D&&(D.removeEventListener("submit",ue),D.removeEventListener("reset",de),D.removeEventListener("focusin",ce),D=null),Q&&(clearTimeout(Q),Q=null),document.removeEventListener("click",ae),document.removeEventListener("scroll",se),window.removeEventListener("resize",he)})),On((()=>{var e;oe("mounted","1.1.0"),oe("workers",z()),function(){const e=void 0!==E()?E().split(","):void 0;for(const t of globalThis.altchaPlugins)(!e||e.includes(t.pluginName))&&ee.push(new t({el:h(F),clarify:$e,dispatch:j,getConfiguration:Re,getFloatingAnchor:Ce,getState:Ie,log:oe,reset:Ne,solve:xe,setState:Ve,setFloatingAnchor:_e,verify:Le}))}(),oe("plugins",ee.length?ee.map((e=>e.constructor.pluginName)).join(", "):"none"),_()&&oe("using test mode"),d()&&be(d()),void 0!==o()&&oe("auto",o()),void 0!==f()&&ye(f()),D=null==(e=h(F))?void 0:e.closest("form"),D&&(D.addEventListener("submit",ue,{capture:!0}),D.addEventListener("reset",de),"onfocus"===o()&&D.addEventListener("focusin",ce)),"onload"===o()&&($()?$e():Le()),h(G)&&(g()||m())&&oe("Attributes hidefooter and hidelogo ignored because usage with free API Keys requires attribution."),requestAnimationFrame((()=>{j("load")}))}));var ze=ql(),Xe=Ot(ze);El(Xe,t,"default",{});var Pe=me(Xe,2),Se=J(Pe),je=J(Se),Ae=e=>{K(e,Xl())};Ee(je,(e=>{h(Y)===x.VERIFYING&&e(Ae)}));var Ge=me(je,2),We=J(Ge);Ur(We),We.__change=function(){[x.UNVERIFIED,x.ERROR,x.EXPIRED].includes(h(Y))?!1!==C()&&!1===(null==D?void 0:D.reportValidity())?V(B,!1):$()?$e():Le():V(B,!0)},H(Ge);var Me=me(Ge,2),Je=J(Me),Be=e=>{var t=Zl(),n=Ot(t);Ze(J(n),(()=>h(M).verified)),H(n);var r=me(n,2);Ur(r),Oe((()=>{ie(r,"name",b()),kl(r,h(te))})),K(e,t)},Ye=e=>{var t=ml(),n=Ot(t),r=e=>{var t=Wl();Ze(J(t),(()=>h(M).verifying)),H(t),K(e,t)},o=e=>{var t=Ul();Ze(J(t),(()=>h(M).label)),H(t),Oe((()=>ie(t,"for",`${b()??""}_checkbox`))),K(e,t)};Ee(n,(e=>{h(Y)===x.VERIFYING?e(r):e(o,!1)}),!0),K(e,t)};Ee(Je,(e=>{h(Y)===x.VERIFIED?e(Be):e(Ye,!1)})),H(Me);var Te=me(Me,2),He=e=>{var t=Ml(),n=J(t);ie(n,"href",O),H(t),Oe((()=>ie(n,"aria-label",h(M).ariaLinkLabel))),K(e,t)};Ee(Te,(e=>{(!0!==m()||h(G))&&e(He)})),H(Se);var Ue=me(Se,2),Ke=e=>{var t=Hl(),n=me(J(t),2),r=e=>{var t=jl();Ze(J(t),(()=>h(M).expired)),H(t),Oe((()=>ie(t,"title",h(q)))),K(e,t)},o=e=>{var t=Bl();Ze(J(t),(()=>h(M).error)),H(t),Oe((()=>ie(t,"title",h(q)))),K(e,t)};Ee(n,(e=>{h(Y)===x.EXPIRED?e(r):e(o,!1)})),H(t),K(e,t)};Ee(Ue,(e=>{(h(q)||h(Y)===x.EXPIRED)&&e(Ke)}));var De=me(Ue,2),qe=e=>{var t=Jl(),n=J(t);Ze(J(n),(()=>h(M).footer)),H(n),H(t),K(e,t)};Ee(De,(e=>{h(M).footer&&(!0!==g()||h(G))&&e(qe)}));var Qe=me(De,2),et=e=>{var t=Kl();Br(t,(e=>V(T,e)),(()=>h(T))),K(e,t)};return Ee(Qe,(e=>{f()&&e(et)})),H(Pe),Br(Pe,(e=>V(F,e)),(()=>h(F))),Oe((()=>{ie(Pe,"data-state",h(Y)),ie(Pe,"data-floating",f()),Rl(Ge,"altcha-hidden",h(Y)===x.VERIFYING),ie(We,"id",`${b()??""}_checkbox`),We.required="onsubmit"!==o()&&(!f()||"off"!==o())})),gl("invalid",We,fe),Il(We,(()=>h(B)),(e=>V(B,e))),K(e,ze),Vn({clarify:$e,configure:ke,getConfiguration:Re,getFloatingAnchor:Ce,getPlugin:function(e){return ee.find((t=>t.constructor.pluginName===e))},getState:Ie,reset:Ne,setFloatingAnchor:_e,setState:Ve,verify:Le,get auto(){return o()},set auto(e=void 0){o(e),k()},get blockspam(){return l()},set blockspam(e=void 0){l(e),k()},get challengeurl(){return i()},set challengeurl(e=void 0){i(e),k()},get challengejson(){return a()},set challengejson(e=void 0){a(e),k()},get customfetch(){return s()},set customfetch(e=void 0){s(e),k()},get debug(){return c()},set debug(e=!1){c(e),k()},get delay(){return u()},set delay(e=0){u(e),k()},get expire(){return d()},set expire(e=void 0){d(e),k()},get floating(){return f()},set floating(e=void 0){f(e),k()},get floatinganchor(){return v()},set floatinganchor(e=void 0){v(e),k()},get floatingoffset(){return p()},set floatingoffset(e=void 0){p(e),k()},get hidefooter(){return g()},set hidefooter(e=!1){g(e),k()},get hidelogo(){return m()},set hidelogo(e=!1){m(e),k()},get name(){return b()},set name(e="altcha"){b(e),k()},get maxnumber(){return y()},set maxnumber(e=1e6){y(e),k()},get mockerror(){return w()},set mockerror(e=!1){w(e),k()},get obfuscated(){return $()},set obfuscated(e=void 0){$(e),k()},get plugins(){return E()},set plugins(e=void 0){E(e),k()},get refetchonexpire(){return R()},set refetchonexpire(e=!0){R(e),k()},get spamfilter(){return C()},set spamfilter(e=!1){C(e),k()},get strings(){return I()},set strings(e=void 0){I(e),k()},get test(){return _()},set test(e=!1){_(e),k()},get verifyurl(){return L()},set verifyurl(e=void 0){L(e),k()},get workers(){return z()},set workers(e=Math.min(16,navigator.hardwareConcurrency||8)){z(e),k()},get workerurl(){return X()},set workerurl(e=void 0){X(e),k()}})}_l(["change"]),customElements.define("altcha-widget",Vl(ea,{blockspam:{type:"Boolean"},debug:{type:"Boolean"},delay:{type:"Number"},expire:{type:"Number"},floatingoffset:{type:"Number"},hidefooter:{type:"Boolean"},hidelogo:{type:"Boolean"},maxnumber:{type:"Number"},mockerror:{type:"Boolean"},refetchonexpire:{type:"Boolean"},test:{type:"Boolean"},workers:{type:"Number"},auto:{},challengeurl:{},challengejson:{},customfetch:{},floating:{},floatinganchor:{},name:{},obfuscated:{},plugins:{},spamfilter:{},strings:{},verifyurl:{},workerurl:{}},["default"],["clarify","configure","getConfiguration","getFloatingAnchor","getPlugin","getState","reset","setFloatingAnchor","setState","verify"],!1)),globalThis.altchaCreateWorker=e=>e?new Worker(new URL(e)):new mi,globalThis.altchaPlugins=globalThis.altchaPlugins||[];export{ea as Altcha};
+//# sourceMappingURL=/sm/638df34d7a75e2016f9fd707bc2784bc7c8fc9aa9533684cd03dce4cc922471b.map \ No newline at end of file
diff --git a/public/common/client.css b/public/common/client.css
index 53b6a38..f328d12 100644
--- a/public/common/client.css
+++ b/public/common/client.css
@@ -12,7 +12,7 @@ html {
-webkit-tap-highlight-color: transparent; /* disable blue flashes when tapping on chrome mobile */
}
-summary::-webkit-details-marker {
+header summary::-webkit-details-marker {
display: none;
}
@@ -24,7 +24,7 @@ html {
user-select: text;
}
-summary img, button img, menu img {
+header summary img, button img, menu img {
pointer-events: none;
}
@@ -270,12 +270,12 @@ header.replay {
/* MENUS AND ICONS */
-details menu {
+header details menu {
display: block;
min-width: 140px;
}
-summary img, #toolbar button img {
+header summary img, #toolbar button img {
display: block;
height: 36px;
padding: 4px;
@@ -292,13 +292,13 @@ summary img, #toolbar button img {
margin: 0;
}
-details[open] > summary { background-color: #0004; }
+header details[open] > summary { background-color: #0004; }
@media (hover: hover) {
- summary:hover, #toolbar button:hover { background-color: #0004; }
+ header summary:hover, #toolbar button:hover { background-color: #0004; }
}
-summary:active, #toolbar button:active { background-color: #0008; }
+header summary:active, #toolbar button:active { background-color: #0008; }
-summary {
+header summary {
cursor: pointer;
list-style: none;
}
@@ -323,6 +323,9 @@ menu li {
cursor: pointer;
}
+menu li.checked::before { content: "\2714 " }
+menu li.unchecked::before { content: "\2714 "; color:transparent; }
+
menu li a {
display: block;
margin: -4px -8px;
diff --git a/public/common/client.js b/public/common/client.js
index 3bc8aff..f8a5e23 100644
--- a/public/common/client.js
+++ b/public/common/client.js
@@ -120,30 +120,15 @@ function drag_element_with_mouse(element_sel, grabber_sel) {
/* TITLE BLINKER */
-let blink_title = document.title
-let blink_timer = 0
-
-function start_blinker(message) {
- let tick = false
- if (blink_timer)
- stop_blinker()
- if (!document.hasFocus()) {
- document.title = message
- blink_timer = setInterval(function () {
- document.title = tick ? message : blink_title
- tick = !tick
- }, 1000)
- }
-}
+var game_title = document.title
-function stop_blinker() {
- document.title = blink_title
- clearInterval(blink_timer)
- blink_timer = 0
+function update_title() {
+ if (is_your_turn || (chat && chat.has_unread))
+ document.title = "\u2bc8 " + game_title
+ else
+ document.title = game_title
}
-window.addEventListener("focus", stop_blinker)
-
/* CHAT */
let chat = null
@@ -245,16 +230,22 @@ function fetch_chat() {
function update_chat_new() {
let button = document.getElementById("chat_button")
- start_blinker("NEW MESSAGE")
- if (chat && chat.is_visible)
+ if (chat && chat.is_visible) {
+ if (!document.hasFocus())
+ chat.has_unread = true
fetch_chat()
- else
+ } else {
+ chat.has_unread = true
button.classList.add("new")
+ }
+ update_title()
}
function update_chat_old() {
let button = document.getElementById("chat_button")
document.getElementById("chat_button").classList.remove("new")
+ chat.has_unread = false
+ update_title()
}
function show_chat() {
@@ -264,6 +255,10 @@ function show_chat() {
document.getElementById("chat_input").focus()
chat.is_visible = true
fetch_chat()
+ if (chat.has_unread) {
+ chat.has_unread = false
+ update_title()
+ }
}
}
@@ -282,6 +277,13 @@ function toggle_chat() {
show_chat()
}
+window.addEventListener("focus", function () {
+ if (chat && chat.is_visible && chat.has_unread) {
+ chat.has_unread = false
+ update_title()
+ }
+})
+
/* NOTEPAD */
let notepad = null
@@ -359,9 +361,9 @@ function toggle_notepad() {
show_notepad()
}
-/* REMATCH & REPLAY BUTTONS WHEN GAME OVER */
+/* REMATCH & REPLAY BUTTONS WHEN GAME IS FINISHED */
-function on_game_over() {
+function on_finished() {
remove_resign_menu()
add_icon_button(1, "replay_button", "sherlock-holmes-mirror",
@@ -545,8 +547,10 @@ function connect_play() {
if (typeof on_update === "function")
on_update()
on_update_log(view.log_start, game_log.length)
- if (view.game_over)
- on_game_over()
+ break
+
+ case "finished":
+ on_finished()
break
case "snapsize":
@@ -567,10 +571,6 @@ function connect_play() {
if (typeof on_reply === "function")
on_reply(arg[0], arg[1])
break
-
- case "save":
- window.localStorage[params.title_id + "/save"] = arg
- break
}
}
}
@@ -578,7 +578,6 @@ function connect_play() {
/* HEADER */
let is_your_turn = false
-let old_active = null
function on_update_header() {
if (typeof on_prompt === "function")
@@ -593,20 +592,20 @@ function on_update_header() {
document.querySelector("header").classList.remove("replay")
if (view.actions) {
document.querySelector("header").classList.add("your_turn")
- if (!is_your_turn || old_active !== view.active)
- start_blinker("YOUR TURN")
is_your_turn = true
} else {
document.querySelector("header").classList.remove("your_turn")
is_your_turn = false
}
- old_active = view.active
+ update_title()
}
function on_update_roles() {
if (view.active !== undefined)
for (let role in roles)
- roles[role].element.classList.toggle("active", view.active === role)
+ roles[role].element.classList.toggle("active",
+ view.active === role || view.active === "Both" || view.active.includes(role)
+ )
}
/* LOG */
@@ -742,14 +741,6 @@ function send_query(q, param) {
send_message("query", [ q, param ])
}
-function send_save() {
- send_message("save")
-}
-
-function send_restore() {
- send_message("restore", window.localStorage[params.title_id + "/save"])
-}
-
/* REPLAY */
function init_replay() {
@@ -766,7 +757,7 @@ function confirm_resign() {
}
function add_resign_menu() {
- if (Object.keys(roles).length > 1) {
+ if (Object.keys(roles).length === 2) {
let popup = document.querySelector("#toolbar details menu")
popup.insertAdjacentHTML("beforeend", '<li class="resign separator">')
popup.insertAdjacentHTML("beforeend", '<li class="resign" onclick="confirm_resign()">Resign')
diff --git a/public/common/replay.js b/public/common/replay.js
index b6c5e84..950b244 100644
--- a/public/common/replay.js
+++ b/public/common/replay.js
@@ -92,6 +92,20 @@ function snap_from_state(state) {
return snap
}
+function finish_game_state(state, result, message) {
+ if (typeof rules.finish === "function") {
+ state = rules.finish(state, result, message)
+ } else {
+ state.state = "game_over"
+ state.active = "None"
+ state.result = result
+ state.victory = message
+ state.log.push("")
+ state.log.push(message)
+ }
+ return state
+}
+
function eval_action(s, item, p) {
let [ item_role, item_action, item_arguments ] = item
switch (item_action) {
@@ -101,11 +115,15 @@ function eval_action(s, item, p) {
if (params.mode === "debug")
s.log.push([p, item_role.substring(0,2), item_action, null])
- s.state = "game_over"
- s.active = "None"
- s.victory = item_role + " resigned."
- s.log.push("")
- s.log.push(s.victory)
+ let result = "None"
+ if (roles.length === 2) {
+ for (let r of roles)
+ if (r !== item_role)
+ result = r
+ }
+
+ s = finish_game_state(s, result, role + " resigned.")
+
return s
default:
if (params.mode === "debug")
@@ -151,11 +169,6 @@ function update_replay_view() {
if (params.mode !== "debug")
view.actions = null
- if (viewpoint === "Observer")
- view.game_over = 1
- if (replay_state.state === "game_over")
- view.game_over = 1
-
if (replay.length > 0) {
if (document.body.classList.contains("shift")) {
view.prompt = `[${replay_this}/${replay.length}] ${replay_state.active} / ${replay_state.state}`
diff --git a/public/common/util.js b/public/common/util.js
index 7f5459e..b8f1b6c 100644
--- a/public/common/util.js
+++ b/public/common/util.js
@@ -1,4 +1,4 @@
-// === COMMON LIBRARY ===
+/* COMMON LIBRARY */
function clear_undo() {
if (game.undo) {
diff --git a/public/docs/tournaments.html b/public/docs/tournaments.html
index 6122194..e8f08f8 100644
--- a/public/docs/tournaments.html
+++ b/public/docs/tournaments.html
@@ -52,7 +52,7 @@ score is used to break ties.
<p>
Some tournaments may have multiple levels. If you win a tournament at one
-level, you will automatically be queued for the next level up.
+level, you may play in the next level (once for each victory).
<hr>
diff --git a/public/robots.txt b/public/robots.txt
index 05ad807..bf7a1cc 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1,3 +1,46 @@
+# https://lobste.rs/s/ybowdq/great_gpt_firewall
+User-agent: Ai2Bot-Dolma
+User-agent: Ai2bot
+User-agent: Amazonbot
+User-agent: Applebot
+User-agent: Applebot-Extended
+User-agent: Bytespider
+User-agent: CCBot
+User-agent: ChatGPT-User
+User-agent: Claude-Web
+User-agent: ClaudeBot
+User-agent: Cohere-ai
+User-agent: Diffbot
+User-agent: FacebookBot
+User-agent: GPTBot
+User-agent: Google-Extended
+User-agent: Meta-ExternalAgent
+User-agent: OAI-SearchBot
+User-agent: Omgili
+User-agent: Omgilibot
+User-agent: PanguBot
+User-agent: PerplexityBot
+User-agent: PetalBot
+User-agent: Timpibot
+User-agent: Webzio-Extended
+User-agent: YouBot
+User-agent: anthropic-ai
+Disallow: /
+
+# SEO/spam tools
+User-agent: AhrefsBot
+User-agent: BLEXBot
+User-agent: Clickagy
+User-agent: SemrushBot
+User-agent: SemrushBot-BA
+User-agent: SemrushBot-COUB
+User-agent: SemrushBot-CT
+User-agent: SemrushBot-SI
+User-agent: SemrushBot-SWA
+User-agent: SiteAuditBot
+User-agent: SplitSignalBot
+Disallow: /
+
User-agent: *
Disallow: /join
Disallow: /login
@@ -6,6 +49,3 @@ Disallow: /forum
Disallow: /user
Disallow: /games
Disallow: /*play*
-
-User-agent: Google-Extended
-Disallow: /
diff --git a/public/style.css b/public/style.css
index 0792dd1..486b309 100644
--- a/public/style.css
+++ b/public/style.css
@@ -87,7 +87,7 @@ input[type="checkbox"], input[type="radio"] {
accent-color: currentcolor;
}
-input[type="text"], input[type="password"], input[type="email"], textarea, select[size] {
+input[type="text"], input[type="password"], input[type="email"], input[type="number"], textarea, select[size] {
background-color: var(--color-text);
color: var(--color-black);
border: var(--thin-border);
@@ -328,7 +328,8 @@ div.body img {
margin: 16px 0;
}
-.tour_list table { margin: 0 }
+.tour_list table { margin: 0; }
+.tour_list table + table { margin-top: 24px; }
.game_item {
border: var(--thin-border);
diff --git a/schema.sql b/schema.sql
index e945916..2840934 100644
--- a/schema.sql
+++ b/schema.sql
@@ -38,24 +38,53 @@ create table if not exists users (
mail text unique collate nocase,
notify integer default 0,
is_verified boolean default 0,
- is_banned boolean default 0,
- ctime datetime default current_timestamp,
- password text,
- salt text,
- about text
+ is_banned boolean default 0
);
insert or ignore into
- users (user_id, name, mail, ctime)
- values (0, 'Deleted', 'deleted@nowhere', null)
+ users (user_id, name, mail)
+ values (0, 'Deleted', 'deleted@nowhere')
;
+create table if not exists user_password (
+ user_id integer primary key,
+ password text,
+ salt text
+);
+
+create table if not exists user_about (
+ user_id integer primary key,
+ about text
+);
+
+create table if not exists user_first_seen (
+ user_id integer primary key,
+ ctime datetime,
+ ip text
+);
+
create table if not exists user_last_seen (
user_id integer primary key,
atime datetime,
ip text
);
+create table if not exists user_timeout (
+ user_id integer,
+ game_id integer,
+ time datetime default current_timestamp,
+ primary key (user_id, game_id)
+);
+
+create index if not exists user_timeout_idx on user_timeout(user_id, time);
+
+create table if not exists user_move_hist (
+ user_id integer,
+ minutes integer,
+ frequency integer default 1,
+ primary key (user_id, minutes)
+) without rowid;
+
create table if not exists tokens (
user_id integer primary key,
token text,
@@ -84,15 +113,101 @@ create view user_login_view as
user_id, name, mail, notify, password, salt
from
users
+ left join user_password using(user_id)
+ ;
+
+drop view if exists user_move_iqr;
+create view user_move_iqr as
+ with
+ aa as (
+ select
+ user_id,
+ sum(frequency) as total
+ from user_move_hist
+ group by user_id
+ ),
+ bb as (
+ select
+ user_id,
+ minutes,
+ 4 * sum(frequency) over (partition by user_id order by minutes) / (total+1) as quartile
+ from aa join user_move_hist using(user_id)
+ ),
+ cc as (
+ select
+ user_id,
+ quartile,
+ last_value(minutes) over (partition by user_id order by quartile) as minutes
+ from bb
+ where quartile < 3
+ group by user_id, quartile
+ )
+ select
+ user_id,
+ sum(minutes) filter (where quartile = 0) as q1,
+ sum(minutes) filter (where quartile = 1) as q2,
+ sum(minutes) filter (where quartile = 2) as q3
+ from cc
+ group by user_id
;
drop view if exists user_profile_view;
create view user_profile_view as
- select
- user_id, name, mail, notify, ctime, atime, about, is_banned
- from
- users
- left join user_last_seen using(user_id)
+ with
+ timeout as (
+ select
+ user_id,
+ count(1) as timeout_total,
+ max(time) as timeout_last
+ from
+ user_timeout
+ group by
+ user_id
+ ),
+ user_move_mean as (
+ select
+ user_id,
+ sum(minutes * frequency) / sum(frequency) as move_time_mean
+ from
+ user_move_hist
+ group by
+ user_id
+ ),
+ profile as (
+ select
+ user_id, name, mail, notify, ctime, atime, about, is_banned,
+ move_time_mean,
+ coalesce(q1, q2, q3) as move_time_q1,
+ coalesce(q2, q3) as move_time_q2,
+ q3 as move_time_q3,
+ coalesce(timeout_total, 0) as timeout_total,
+ coalesce(timeout_last, 0) as timeout_last
+ from
+ users
+ left join user_first_seen using(user_id)
+ left join user_last_seen using(user_id)
+ left join user_about using(user_id)
+ left join timeout using(user_id)
+ left join user_move_mean using(user_id)
+ left join user_move_iqr using(user_id)
+ )
+ select
+ profile.*,
+ (
+ select
+ count(1)
+ from
+ players
+ join games using(game_id)
+ where
+ players.user_id = profile.user_id
+ and games.is_opposed
+ and games.status > 1
+ and games.result != 'None'
+ and games.mtime > timeout_last
+ ) as games_since_timeout
+ from
+ profile
;
drop view if exists user_dynamic_view;
@@ -119,16 +234,17 @@ create view user_dynamic_view as
join games using(game_id)
where
status = 1
- and user_count = player_count
+ and is_opposed
+ and is_active
and players.user_id = users.user_id
- and active in ( players.role, 'Both' )
) + (
select
count(*)
from
players
where
- players.user_id = users.user_id and players.is_invite
+ is_invite
+ and players.user_id = users.user_id
) + (
select
count(*)
@@ -157,6 +273,7 @@ create view rated_games_view as
and moves >= player_count * 3
and user_count = player_count
and player_count > 1
+ and result != 'None'
and not exists (
select 1 from players where players.game_id = games.game_id and user_id = 0
)
@@ -427,6 +544,8 @@ create table if not exists players (
role text,
user_id integer,
is_invite integer,
+ is_active boolean,
+ active_time real, -- julianday
clock real,
score integer,
primary key (game_id, role)
@@ -471,19 +590,11 @@ create view player_view as
name,
role,
is_invite,
+ is_active,
(
- case status
- when 0 then
- owner_id = user_id
- when 1 then
- active in ( 'Both', role )
- else
- 0
- end
- ) as is_active,
- (
- case when active in ( 'Both', role ) then
- clock - (julianday() - julianday(mtime))
+ case when is_active
+ then
+ clock - (julianday() - julianday(active_time))
else
clock
end
@@ -509,8 +620,8 @@ create view time_control_view as
join players using(game_id)
where
status = 1
- and active in ( 'Both', role )
- and clock - (julianday() - julianday(mtime)) < 0
+ and is_active
+ and clock - (julianday() - julianday(players.active_time)) < 0
;
-- Export game state as JSON
@@ -632,6 +743,17 @@ create table if not exists tm_winners (
create index if not exists tm_winners_pool_idx on tm_winners(pool_id);
+drop view if exists tm_queue_view;
+create view tm_queue_view as
+ select
+ tm_queue.*
+ from
+ tm_queue
+ join user_last_seen using(user_id)
+ where
+ julianday() - julianday(atime) < 3
+ ;
+
drop view if exists tm_pool_active_view;
create view tm_pool_active_view as
select
@@ -687,6 +809,7 @@ begin
set score = (
case
when new.result is null then null
+ when new.result = 'None' then null
when new.result = role then 2
when new.result = 'Draw' then 1
when instr(new.result, role) then 1
@@ -759,7 +882,7 @@ begin
with
tt as (
select
- round_count as threshold
+ (2 * round_count) / player_count as threshold
from
tm_seeds
where
@@ -834,15 +957,91 @@ begin
games.game_id = old.game_id;
end;
--- Trigger to track time spent!
+-- Triggers to track is_active and time spent!
-drop trigger if exists trigger_time_used_update;
-create trigger trigger_time_used_update before update of active on games
+drop trigger if exists trigger_game_started;
+create trigger trigger_game_started after update of status on games
+ when old.status = 0 and new.status = 1
begin
- update players
- set clock = clock - (julianday() - julianday(old.mtime))
+ update
+ players
+ set
+ clock = (
+ case old.pace
+ when 1 then 1
+ when 2 then 3
+ when 3 then 3
+ else 21
+ end
+ )
where
- players.game_id = old.game_id and players.role in ( 'Both', old.active );
+ players.game_id = old.game_id
+ ;
+end;
+
+drop trigger if exists trigger_active_changed;
+create trigger trigger_active_changed after update of active on games
+begin
+ update
+ players
+ set
+ is_active = ( new.active = 'Both' or instr(new.active, players.role) )
+ where
+ players.game_id = old.game_id
+ ;
+end;
+
+drop trigger if exists trigger_player_to_active;
+create trigger trigger_player_to_active after update of is_active on players
+ when old.is_active is not true and new.is_active
+begin
+ update
+ players
+ set
+ active_time = julianday()
+ where
+ players.game_id = old.game_id and players.role = old.role
+ ;
+end;
+
+drop trigger if exists trigger_player_to_inactive;
+create trigger trigger_player_to_inactive after update of is_active on players
+ when old.is_active and (not new.is_active)
+begin
+ update
+ players
+ set
+ active_time = null,
+ clock = (
+ case (select pace from games where games.game_id = players.game_id)
+ when 1 then min(clock - (julianday() - julianday(old.active_time)) + 4 / 24.0, 3)
+ when 2 then min(clock - (julianday() - julianday(old.active_time)) + 12 / 24.0, 5)
+ when 3 then min(clock - (julianday() - julianday(old.active_time)) + 36 / 24.0, 10)
+ else 21
+ end
+ )
+ where
+ players.game_id = old.game_id and players.role = old.role
+ ;
+ insert into user_move_hist (user_id, minutes)
+ select
+ old.user_id,
+ case
+ when minutes < 60 then ceil(minutes)
+ when minutes < 720 then round(minutes / 5) * 5
+ when minutes < 4320 then round(minutes / 15) * 15
+ when minutes < 7200 then round(minutes / 60) * 60
+ else round(minutes / 360) * 360
+ end as minutes
+ from (
+ select (julianday() - julianday(old.active_time)) * 1440 as minutes
+ )
+ where (
+ select is_opposed from games where games.game_id = old.game_id
+ )
+ on conflict do update
+ set frequency = frequency + 1
+ ;
end;
-- Trigger to remove game data when filing a game as archived
@@ -875,10 +1074,15 @@ end;
drop trigger if exists trigger_delete_on_users;
create trigger trigger_delete_on_users after delete on users
begin
+ delete from user_password where user_id = old.user_id;
+ delete from user_first_seen where user_id = old.user_id;
+ delete from user_last_seen where user_id = old.user_id;
+ delete from user_about where user_id = old.user_id;
+ delete from user_move_hist where user_id = old.user_id;
+ delete from user_timeout where user_id = old.user_id;
+ delete from webhooks where user_id = old.user_id;
delete from logins where user_id = old.user_id;
delete from tokens where user_id = old.user_id;
- delete from webhooks where user_id = old.user_id;
- delete from user_last_seen where user_id = old.user_id;
delete from read_threads where user_id = old.user_id;
delete from unread_chats where user_id = old.user_id;
delete from contacts where me = old.user_id or you = old.user_id;
diff --git a/server.js b/server.js
index f98ff22..5059bcc 100644
--- a/server.js
+++ b/server.js
@@ -12,7 +12,9 @@ const sqlite3 = require("better-sqlite3")
require("dotenv").config()
-const DEBUG = process.env.DEBUG || 0
+const DEBUG = process.env.DEBUG | 0
+const TIMEOUT = process.env.TIMEOUT | 0
+const ALTCHA = process.env.ALTCHA | 0
const HTTP_HOST = process.env.HTTP_HOST || "localhost"
const HTTP_PORT = process.env.HTTP_PORT || 8080
@@ -64,7 +66,13 @@ var game_cookies = {}
let db = new sqlite3(process.env.DATABASE || "./db")
db.pragma("synchronous = NORMAL")
-const SQL_BEGIN = db.prepare("begin")
+let ENABLE_ARCHIVE = process.env.ARCHIVE | 0
+if (ENABLE_ARCHIVE) {
+ console.log("Attached to archive database.")
+ db.exec("attach database 'archive.db' as archive")
+}
+
+const SQL_BEGIN = db.prepare("begin immediate")
const SQL_COMMIT = db.prepare("commit")
const SQL_ROLLBACK = db.prepare("rollback")
@@ -186,6 +194,7 @@ function set_static_headers(res, path) {
let app = express()
app.locals.DEBUG = DEBUG
+app.locals.ALTCHA = ALTCHA
app.locals.SITE_NAME = SITE_NAME
app.locals.SITE_NAME_P = SITE_NAME.endsWith("!") ? SITE_NAME : SITE_NAME + "."
@@ -197,6 +206,7 @@ app.locals.ENABLE_MAIL = !!mailer
app.locals.ENABLE_WEBHOOKS = !!WEBHOOKS
app.locals.ENABLE_FORUM = process.env.FORUM | 0
app.locals.ENABLE_TOURNAMENTS = process.env.TOURNAMENTS | 0
+app.locals.ENABLE_ARCHIVE = ENABLE_ARCHIVE
app.locals.EMOJI_PRIVATE = "\u{1F512}" // or 512
app.locals.EMOJI_MATCH = "\u{1f3c6}"
@@ -222,6 +232,9 @@ app.locals.PACE_TEXT = [
app.locals.human_date = human_date
app.locals.format_options = format_options
+app.locals.format_minutes = format_minutes
+
+app.locals.may_join_seed_level = may_join_seed_level
app.set("x-powered-by", false)
app.set("etag", false)
@@ -310,6 +323,15 @@ function human_date(date) {
return new Date(epoch_from_julianday(date)).toISOString().substring(0,10)
}
+function format_minutes(mins) {
+ if (mins > 59) {
+ var hh = mins / 60 | 0
+ var mm = mins % 60
+ return `${hh} hours ${mm} minutes`
+ }
+ return mins + " minutes"
+}
+
function is_valid_email(email) {
return REGEX_MAIL.test(email)
}
@@ -343,6 +365,72 @@ function hash_password(password, salt) {
}
/*
+ * ALTCHA ANTI-BOT SIGNUP
+ */
+
+const ALTCHA_HMAC_KEY = crypto.randomBytes(16).toString("hex")
+
+function sha2_hex(salty_secret) {
+ var hash = crypto.createHash("sha256")
+ hash.update(salty_secret)
+ return hash.digest("hex")
+}
+
+function hmac_sha2_hex(challenge, key) {
+ var hmac = crypto.createHmac("sha256", key)
+ hmac.update(challenge)
+ return hmac.digest("hex")
+}
+
+function altcha_create_challenge() {
+ var maxnumber = ALTCHA
+ var secret = crypto.randomInt(maxnumber)
+ var salt = crypto.randomBytes(16).toString("hex")
+ var challenge = sha2_hex(salt + secret)
+ var signature = hmac_sha2_hex(challenge, ALTCHA_HMAC_KEY)
+ return {
+ algorithm: "SHA-256",
+ challenge,
+ maxnumber,
+ salt,
+ signature
+ }
+}
+
+function altcha_verify_solution(payload) {
+ var data
+ if (!payload)
+ return "missing altcha payload"
+ try {
+ data = JSON.parse(atob(payload))
+ } catch (error) {
+ return "invalid altcha payload"
+ }
+ if (data.algorithm !== "SHA-256")
+ return "invalid altcha algorithm"
+ if (data.challenge !== sha2_hex(data.salt + data.number))
+ return "invalid altcha challenge"
+ if (data.signature !== hmac_sha2_hex(data.challenge, ALTCHA_HMAC_KEY))
+ return "invalid altcha signature"
+ return null
+}
+
+function must_pass_altcha(req, res, next) {
+ if (ALTCHA) {
+ var altcha_error = altcha_verify_solution(req.body.altcha)
+ if (altcha_error) {
+ setTimeout(() => res.status(500).send(altcha_error), 3000)
+ return
+ }
+ }
+ return next()
+}
+
+app.get("/altcha-challenge", function (_req, res) {
+ return res.json(altcha_create_challenge())
+})
+
+/*
* USER AUTHENTICATION
*/
@@ -352,14 +440,15 @@ const SQL_BLACKLIST_NAME = SQL("select exists ( select 1 from blacklist_name whe
const SQL_EXISTS_USER_NAME = SQL("SELECT EXISTS ( SELECT 1 FROM users WHERE name=? )").pluck()
const SQL_EXISTS_USER_MAIL = SQL("SELECT EXISTS ( SELECT 1 FROM users WHERE mail=? )").pluck()
-const SQL_INSERT_USER = SQL("INSERT INTO users (name,mail,password,salt,notify) VALUES (?,?,?,?,?) RETURNING user_id,name,mail,notify")
+const SQL_INSERT_USER = SQL("INSERT INTO users (name,mail) VALUES (?,?) RETURNING user_id,name,mail")
const SQL_DELETE_USER = SQL("DELETE FROM users WHERE user_id = ?")
const SQL_SELECT_LOGIN = SQL("SELECT * FROM user_login_view WHERE user_id=?")
-const SQL_SELECT_USER_VIEW = SQL("SELECT * FROM user_view WHERE user_id=?")
-const SQL_SELECT_USER_BY_NAME = SQL("SELECT * FROM user_view WHERE name=?")
const SQL_SELECT_LOGIN_BY_MAIL = SQL("SELECT * FROM user_login_view WHERE mail=?")
const SQL_SELECT_LOGIN_BY_NAME = SQL("SELECT * FROM user_login_view WHERE name=?")
+
+const SQL_SELECT_USER_VIEW = SQL("SELECT * FROM user_view WHERE user_id=?")
+const SQL_SELECT_USER_BY_NAME = SQL("SELECT * FROM user_view WHERE name=?")
const SQL_SELECT_USER_PROFILE = SQL("SELECT * FROM user_profile_view WHERE name=?")
const SQL_SELECT_USER_DYNAMIC = SQL("select * from user_dynamic_view where user_id=?")
const SQL_SELECT_USER_ID = SQL("SELECT user_id FROM users WHERE name=?").pluck()
@@ -371,9 +460,11 @@ const SQL_UPDATE_USER_NOTIFY = SQL("UPDATE users SET notify=? WHERE user_id=?")
const SQL_UPDATE_USER_NAME = SQL("UPDATE users SET name=? WHERE user_id=?")
const SQL_UPDATE_USER_MAIL = SQL("UPDATE users SET mail=? WHERE user_id=?")
const SQL_UPDATE_USER_VERIFIED = SQL("UPDATE users SET is_verified=? WHERE user_id=?")
-const SQL_UPDATE_USER_ABOUT = SQL("UPDATE users SET about=? WHERE user_id=?")
-const SQL_UPDATE_USER_PASSWORD = SQL("UPDATE users SET password=?, salt=? WHERE user_id=?")
-const SQL_UPDATE_USER_LAST_SEEN = SQL("INSERT OR REPLACE INTO user_last_seen (user_id,atime,ip) VALUES (?,datetime(),?)")
+
+const SQL_UPDATE_USER_ABOUT = SQL("insert or replace into user_about (user_id,about) values (?,?)")
+const SQL_UPDATE_USER_PASSWORD = SQL("insert or replace into user_password (user_id,password,salt) values (?,?,?)")
+const SQL_UPDATE_USER_FIRST_SEEN = SQL("insert or replace into user_first_seen (user_id,ctime,ip) values (?,datetime(),?)")
+const SQL_UPDATE_USER_LAST_SEEN = SQL("insert or replace into user_last_seen (user_id,atime,ip) values (?,datetime(),?)")
const SQL_UPDATE_USER_IS_BANNED = SQL("update users set is_banned=? where name=?")
const SQL_SELECT_WEBHOOK = SQL("SELECT * FROM webhooks WHERE user_id=?")
@@ -449,7 +540,7 @@ app.get("/login", function (req, res) {
res.render("login.pug", { redirect: req.query.redirect })
})
-app.post("/login", function (req, res) {
+app.post("/login", must_pass_altcha, function (req, res) {
let name_or_mail = req.body.username
let password = req.body.password
let redirect = req.body.redirect
@@ -470,14 +561,14 @@ app.get("/signup", function (req, res) {
res.render("signup.pug")
})
-app.post("/signup", function (req, res) {
+app.post("/signup", must_pass_altcha, function (req, res) {
function err(msg) {
res.render("signup.pug", { flash: msg })
}
+ let ip = req.headers["x-real-ip"] || req.ip || req.connection.remoteAddress || "0.0.0.0"
let name = req.body.username
let mail = req.body.mail
let password = req.body.password
- let notify = req.body.notify === "true"
name = clean_user_name(name)
if (!is_valid_user_name(name))
return err("Invalid user name!")
@@ -493,7 +584,9 @@ app.post("/signup", function (req, res) {
return err("Password is too long!")
let salt = crypto.randomBytes(32).toString("hex")
let hash = hash_password(password, salt)
- let user = SQL_INSERT_USER.get(name, mail, hash, salt, notify ? 1 : 0)
+ let user = SQL_INSERT_USER.get(name, mail)
+ SQL_UPDATE_USER_FIRST_SEEN.run(user.user_id, ip)
+ SQL_UPDATE_USER_PASSWORD.run(user.user_id, hash, salt)
login_insert(res, user.user_id)
res.redirect("/profile")
})
@@ -532,7 +625,7 @@ app.get("/forgot-password", function (req, res) {
res.render("forgot_password.pug")
})
-app.post("/forgot-password", function (req, res) {
+app.post("/forgot-password", must_pass_altcha, function (req, res) {
let mail = req.body.mail
let user = SQL_SELECT_LOGIN_BY_MAIL.get(mail)
if (user) {
@@ -585,7 +678,7 @@ app.post("/reset-password", function (req, res) {
return err("Invalid or expired token!")
let salt = crypto.randomBytes(32).toString("hex")
let hash = hash_password(password, salt)
- SQL_UPDATE_USER_PASSWORD.run(hash, salt, user.user_id)
+ SQL_UPDATE_USER_PASSWORD.run(user.user_id, hash, salt)
SQL_UPDATE_USER_VERIFIED.run(1, user.user_id)
login_insert(res, user.user_id)
return res.redirect("/profile")
@@ -609,7 +702,7 @@ app.post("/change-password", must_be_logged_in, function (req, res) {
return res.render("change_password.pug", { user: req.user, flash: "Wrong password!" })
let salt = crypto.randomBytes(32).toString("hex")
let hash = hash_password(newpass, salt)
- SQL_UPDATE_USER_PASSWORD.run(hash, salt, user.user_id)
+ SQL_UPDATE_USER_PASSWORD.run(user.user_id, hash, salt)
return res.redirect("/profile")
})
@@ -681,7 +774,6 @@ app.get("/unsubscribe", must_be_logged_in, function (req, res) {
})
app.get("/webhook", must_be_logged_in, function (req, res) {
- req.user.notify = SQL_SELECT_USER_NOTIFY.get(req.user.user_id)
let webhook = SQL_SELECT_WEBHOOK.get(req.user.user_id)
res.render("webhook.pug", { user: req.user, webhook: webhook })
})
@@ -738,7 +830,7 @@ app.get("/change-about", must_be_logged_in, function (req, res) {
})
app.post("/change-about", must_be_logged_in, function (req, res) {
- SQL_UPDATE_USER_ABOUT.run(req.body.about, req.user.user_id)
+ SQL_UPDATE_USER_ABOUT.run(req.user.user_id, req.body.about)
return res.redirect("/profile")
})
@@ -746,13 +838,22 @@ app.get("/user/:who_name", function (req, res) {
let who = SQL_SELECT_USER_PROFILE.get(req.params.who_name)
if (who) {
let games = QUERY_LIST_PUBLIC_GAMES_OF_USER.all({ user_id: who.user_id })
+ let ratings = SQL_USER_RATINGS.all(who.user_id)
annotate_games(games, 0, null)
let active_pools = TM_POOL_LIST_USER_ACTIVE.all(who.user_id)
let finished_pools = TM_POOL_LIST_USER_RECENT_FINISHED.all(who.user_id)
let relation = 0
if (req.user)
relation = SQL_SELECT_RELATION.get(req.user.user_id, who.user_id) | 0
- res.render("user.pug", { user: req.user, who, relation, games, active_pools, finished_pools })
+ res.render("user.pug", {
+ user: req.user,
+ who,
+ relation,
+ games,
+ active_pools,
+ finished_pools,
+ ratings,
+ })
} else {
return res.status(404).send("User not found.")
}
@@ -1203,10 +1304,11 @@ function format_options(options_json) {
function get_game_roles(title_id, scenario, options) {
let roles = RULES[title_id].roles
- if (typeof options === "string")
- options = parse_game_options(options)
- if (typeof roles === "function")
+ if (typeof roles === "function") {
+ if (typeof options === "string")
+ options = parse_game_options(options)
return roles(scenario, options)
+ }
return roles
}
@@ -1312,6 +1414,8 @@ const SQL_SELECT_REWIND = SQL("select snap_id, state->>'$.active' as active, sta
const SQL_UPDATE_GAME_ACTIVE = SQL("update games set active=?,mtime=datetime(),moves=moves+1 where game_id=?")
const SQL_UPDATE_GAME_SCENARIO = SQL("update games set scenario=? where game_id=?")
+const ARCHIVE_SELECT_GAME_STATE = ENABLE_ARCHIVE ? SQL("select state from archive.game_state where game_id=?").pluck() : null
+
const SQL_SELECT_GAME_STATE = SQL("select state from game_state where game_id=?").pluck()
const SQL_INSERT_GAME_STATE = SQL("insert or replace into game_state (game_id,state) values (?,?)")
@@ -1369,6 +1473,78 @@ const SQL_SELECT_REPLAY = SQL(`
where game_id = ?
`).pluck()
+const ARCHIVE_SELECT_REPLAY = ENABLE_ARCHIVE ? SQL(`
+ select
+ json_object(
+ 'players',
+ (select json_group_array(
+ json_object('role', role, 'name', name)
+ )
+ from players
+ left join users using(user_id)
+ where game_id = outer.game_id
+ ),
+ 'state',
+ (select json(state)
+ from archive.game_state
+ where game_id = outer.game_id
+ ),
+ 'replay',
+ (select json_group_array(
+ case when arguments is null then
+ json_array(role, action)
+ else
+ json_array(role, action, json(arguments))
+ end
+ )
+ from archive.game_replay
+ where game_id = outer.game_id
+ )
+ ) as export
+ from games as outer
+ where game_id = ?
+`).pluck() : null
+
+const ARCHIVE_SELECT_EXPORT = ENABLE_ARCHIVE ? SQL(`
+ select
+ game_id,
+ json_object(
+ 'setup', json_object(
+ 'game_id', game_id,
+ 'title_id', title_id,
+ 'scenario', scenario,
+ 'options', json(options),
+ 'player_count', player_count,
+ 'notice', notice
+ ),
+ 'players',
+ (select json_group_array(
+ json_object('role', role, 'name', name)
+ )
+ from players
+ left join users using(user_id)
+ where game_id = outer.game_id
+ ),
+ 'state',
+ (select json(state)
+ from archive.game_state
+ where game_id = outer.game_id
+ ),
+ 'replay',
+ (select json_group_array(
+ case when arguments is null then
+ json_array(role, action)
+ else
+ json_array(role, action, json(arguments))
+ end
+ )
+ from archive.game_replay
+ where game_id = outer.game_id
+ )
+ ) as export
+ from games as outer
+`).pluck() : null
+
const SQL_SELECT_EXPORT = SQL("select export from game_export_view where game_id=?").pluck()
const SQL_SELECT_GAME = SQL("SELECT * FROM games WHERE game_id=?")
@@ -1464,8 +1640,10 @@ const QUERY_NEXT_GAME_OF_USER = SQL(`
join players using(game_id)
where
status = ${STATUS_ACTIVE}
- and active in (role, 'Both')
+ -- and active in (role, 'Both')
+ and ( active = 'Both' or instr(active, role) > 0 )
and user_id = ?
+ and is_opposed
order by mtime
limit 1
`)
@@ -1477,7 +1655,7 @@ const QUERY_LIST_PUBLIC_GAMES_OF_USER = SQL(`
and
( status <= ${STATUS_FINISHED} )
and
- ( not is_private or status = ${STATUS_ACTIVE} )
+ ( not is_private or status >= ${STATUS_ACTIVE} )
order by status asc, mtime desc
`)
@@ -1733,7 +1911,7 @@ app.get("/create/:title_id", function (req, res) {
user: req.user,
title: title,
limit: req.user ? check_create_game_limit(req.user) : null,
- scenarios: RULES[title_id].scenarios,
+ rules: RULES[title_id],
})
})
@@ -1919,6 +2097,11 @@ app.get("/join/:game_id", function (req, res) {
if (!game)
return res.status(404).send("Invalid game ID.")
+ if (ENABLE_ARCHIVE) {
+ if (game.status === STATUS_ARCHIVED && game.moves >= game.player_count * 3)
+ game.status = STATUS_FINISHED
+ }
+
let roles = get_game_roles(game.title_id, game.scenario, game.options)
let players = SQL_SELECT_PLAYER_VIEW.all(game_id)
@@ -2085,7 +2268,7 @@ function assign_random_roles(game, options, players) {
app.post("/api/start/:game_id", must_be_logged_in, function (req, res) {
let game_id = req.params.game_id | 0
let game = SQL_SELECT_GAME.get(game_id)
- if (game.owner_id !== req.user.user_id)
+ if (req.user.user_id !== game.owner_id && req.user.user_id !== 1)
return res.send("Not authorized to start that game ID.")
if (game.status !== STATUS_OPEN)
return res.send("The game is already started.")
@@ -2121,13 +2304,11 @@ function start_game(game) {
state = RULES[game.title_id].setup(seed, game.scenario, options)
- SQL_START_GAME.run(state.active, game.game_id)
+ SQL_START_GAME.run(String(state.active), game.game_id)
let replay_id = put_replay(game.game_id, null, ".setup", [ seed, game.scenario, options ])
put_snap(game.game_id, replay_id, state)
SQL_INSERT_GAME_STATE.run(game.game_id, JSON.stringify(state))
- SQL_UPDATE_PLAYERS_INIT_TIME.run(game.game_id)
-
SQL_COMMIT.run()
} finally {
if (db.inTransaction)
@@ -2146,6 +2327,10 @@ app.get("/api/replay/:game_id", function (req, res) {
return res.status(404).send("Invalid game ID.")
if (game.status < STATUS_FINISHED && (!req.user || req.user.user_id !== 1))
return res.status(401).send("Not authorized to debug.")
+ if (ENABLE_ARCHIVE) {
+ if (game.status === STATUS_ARCHIVED)
+ return res.type("application/json").send(ARCHIVE_SELECT_REPLAY.get(game_id))
+ }
return res.type("application/json").send(SQL_SELECT_REPLAY.get(game_id))
})
@@ -2156,6 +2341,10 @@ app.get("/api/export/:game_id", function (req, res) {
return res.status(404).send("Invalid game ID.")
if (game.status < STATUS_FINISHED && (!req.user || req.user.user_id !== 1))
return res.status(401).send("Not authorized to debug.")
+ if (ENABLE_ARCHIVE) {
+ if (game.status === STATUS_ARCHIVED)
+ return res.type("application/json").send(ARCHIVE_SELECT_EXPORT.get(game_id))
+ }
return res.type("application/json").send(SQL_SELECT_EXPORT.get(game_id))
})
@@ -2172,7 +2361,7 @@ function rewind_game_to_snap(game_id, snap_id) {
SQL_DELETE_GAME_REPLAY.run(game_id, snap.replay_id)
SQL_INSERT_GAME_STATE.run(game_id, JSON.stringify(snap_state))
- SQL_REWIND_GAME.run(snap_id - 1, snap_state.active, game_id)
+ SQL_REWIND_GAME.run(snap_id - 1, String(snap_state.active), game_id)
SQL_REWIND_GAME_CLOCK.run(game_id)
update_join_clients(game_id)
@@ -2224,7 +2413,7 @@ const SQL_CLONE_1 = SQL(`
`).pluck()
const SQL_CLONE_2 = [
- SQL(`insert into players(game_id,role,user_id) select $new_game_id,role,user_id from players where game_id=$old_game_id`),
+ SQL(`insert into players(game_id,role,user_id,is_active) select $new_game_id,role,user_id,is_active from players where game_id=$old_game_id`),
SQL(`insert into game_state(game_id,state) select $new_game_id,state from game_state where game_id=$old_game_id`),
SQL(`insert into game_replay(game_id,replay_id,role,action,arguments) select $new_game_id,replay_id,role,action,arguments from game_replay where game_id=$old_game_id`),
SQL(`insert into game_snap(game_id,snap_id,replay_id,state) select $new_game_id,snap_id,replay_id,state from game_snap where game_id=$old_game_id`),
@@ -2268,8 +2457,8 @@ function is_winner(role, result) {
return (result === "Draw" || result === role || result.includes(role))
}
-function elo_k(a) {
- return a.count < 10 ? 60 : 30
+function elo_k(_) {
+ return 30
}
function elo_ev(a, players) {
@@ -2424,6 +2613,10 @@ function message_link(msg_id) {
return SITE_URL + "/message/read/" + msg_id
}
+function tour_pool_link(pool_id) {
+ return SITE_URL + "/tm/pool/" + pool_id
+}
+
function send_notification(user, link, message) {
if (WEBHOOKS) {
let webhook = SQL_SELECT_WEBHOOK_SEND.get(user.user_id)
@@ -2458,6 +2651,10 @@ function send_play_notification(user, game_id, message) {
send_notification(user, game_play_link(game_id, title_id, user), `${title_name} #${game_id} (${user.role}) - ${message}`)
}
+function send_tour_notification(user, pool_name, message) {
+ send_notification(user, tour_pool_link(pool_name), `${pool_name} - ${message}`)
+}
+
function send_chat_activity_notification(game_id, p) {
send_play_notification(p, game_id, "Chat activity")
}
@@ -2465,7 +2662,7 @@ function send_chat_activity_notification(game_id, p) {
function send_game_started_notification(game_id, active) {
let players = SQL_SELECT_PLAYERS.all(game_id)
for (let p of players) {
- let p_is_active = active === p.role || active === "Both"
+ let p_is_active = is_role_active(active, p.role)
if (p_is_active)
send_play_notification(p, game_id, "Started - Your turn")
else
@@ -2473,15 +2670,15 @@ function send_game_started_notification(game_id, active) {
}
}
-function send_your_turn_notification_to_offline_users(game_id, old_active, active) {
+function send_your_turn_notification_to_offline_users(game_id, old_active, new_active) {
// Only send notifications when the active player changes.
- if (old_active === active)
+ if (!is_changed_active(old_active, new_active))
return
let players = SQL_SELECT_PLAYERS.all(game_id)
for (let p of players) {
- let p_was_active = old_active === p.role || old_active === "Both"
- let p_is_active = active === p.role || active === "Both"
+ let p_was_active = is_role_active(old_active, p.role)
+ let p_is_active = is_role_active(new_active, p.role)
if (!p_was_active && p_is_active) {
if (!is_player_online(game_id, p.user_id))
send_play_notification(p, game_id, "Your turn")
@@ -2593,6 +2790,7 @@ function purge_game_ticker() {
QUERY_PURGE_ACTIVE_GAMES.run()
QUERY_PURGE_FINISHED_GAMES.run()
QUERY_PURGE_MESSAGES.run()
+ TM_DELETE_QUEUE_INACTIVE.run()
}
// Purge abandoned games every 31 minutes.
@@ -2603,43 +2801,20 @@ setTimeout(purge_game_ticker, 89 * 1000)
* TIME CONTROL
*/
-const SQL_UPDATE_PLAYERS_INIT_TIME = SQL(`
- update players
- set clock = (
- case (select pace from games where games.game_id = players.game_id)
- when 1 then 1
- when 2 then 3
- when 3 then 3
- else 21
- end
- )
- where
- players.game_id = ?
-`)
-
-const SQL_UPDATE_PLAYERS_ADD_TIME = SQL(`
- update players
- set clock = (
- case (select pace from games where games.game_id = players.game_id)
- when 1 then min(clock + ${4 / 24}, 3)
- when 2 then min(clock + ${12 / 24}, 5)
- when 3 then min(clock + ${36 / 24}, 10)
- else 21
- end
- )
- where
- players.game_id = ? and players.role = ?
-`)
-
+// SQL_UPDATE_PLAYERS_INIT_TIME is handled by trigger
+// SQL_UPDATE_PLAYERS_ADD_TIME is handled by trigger
// SQL_UPDATE_PLAYERS_USE_TIME is handled by trigger
const SQL_SELECT_TIME_CONTROL = SQL("select * from time_control_view")
+const SQL_INSERT_TIMEOUT = SQL("insert into user_timeout (user_id, game_id) values (?, ?)")
+
function time_control_ticker() {
for (let item of SQL_SELECT_TIME_CONTROL.all()) {
if (item.is_opposed) {
console.log("TIMED OUT GAME:", item.game_id, item.role)
- do_resign(item.game_id, item.role, "timed out")
+ do_timeout(item.game_id, item.role, item.role + " timed out.")
+ SQL_INSERT_TIMEOUT.run(item.user_id, item.game_id)
if (item.is_match) {
console.log("BANNED FROM TOURNAMENTS:", item.user_id)
TM_INSERT_BANNED.run(item.user_id)
@@ -2653,8 +2828,10 @@ function time_control_ticker() {
}
// Run time control checks every 13 minutes.
-setInterval(time_control_ticker, 13 * 60 * 1000)
-setTimeout(time_control_ticker, 13 * 1000)
+if (TIMEOUT) {
+ setInterval(time_control_ticker, 13 * 60 * 1000)
+ setTimeout(time_control_ticker, 13 * 1000)
+}
/*
* TOURNAMENTS
@@ -2662,9 +2839,20 @@ setTimeout(time_control_ticker, 13 * 1000)
const designs = require("./designs.js")
+const TM_SELECT_BANNED = SQL("select exists ( select 1 from tm_banned where user_id=? )").pluck()
const TM_INSERT_BANNED = SQL("insert or ignore into tm_banned (user_id, time) values (?, datetime())")
+
const TM_DELETE_QUEUE_ALL = SQL("delete from tm_queue where user_id=?")
+const TM_DELETE_QUEUE_INACTIVE = SQL(`
+ delete from tm_queue where exists (
+ select 1
+ from user_last_seen
+ where user_last_seen.user_id = tm_queue.user_id
+ and julianday() - julianday(atime) > 14
+ )
+`)
+
const TM_MAY_JOIN_ANY_SEED = SQL(`
select ( select notify and is_verified from users where user_id=@user_id )
or ( select exists ( select 1 from webhooks where user_id=@user_id and error is null ) )
@@ -2673,16 +2861,56 @@ const TM_MAY_JOIN_ANY_SEED = SQL(`
`).pluck()
const TM_MAY_JOIN_SEED = SQL(`
- select ( select not exists ( select 1 from tm_banned where user_id=@user_id ) )
- and ( select coalesce(is_open, 0) as may_join from tm_seeds where seed_id=@seed_id )
+ select is_open or exists ( select 1 from tm_queue_view where tm_queue_view.seed_id = tm_seeds.seed_id )
+ from tm_seeds
+ where seed_id=?
`).pluck()
+const TM_MAY_JOIN_SEED_LEVEL = SQL(`
+ with
+ win_cte as (
+ select
+ count(1) as n_win
+ from
+ tm_winners
+ join tm_pools using(pool_id)
+ where
+ level = @level - 1 and user_id = @user_id and seed_id = @seed_id
+ ),
+ play_cte as (
+ select
+ count(distinct pool_id) as n_play
+ from
+ tm_rounds
+ join tm_pools using(pool_id)
+ join players using(game_id)
+ where
+ level = @level and user_id = @user_id and seed_id = @seed_id
+ )
+ select
+ coalesce(n_win, 0) > coalesce(n_play, 0) as may_join
+ from
+ win_cte, play_cte
+`).pluck()
+
+function is_banned_from_tournaments(user_id) {
+ return TM_SELECT_BANNED.get(user_id)
+}
+
function may_join_any_seed(user_id) {
return DEBUG || TM_MAY_JOIN_ANY_SEED.get({user_id})
}
-function may_join_seed(user_id, seed_id) {
- return TM_MAY_JOIN_SEED.get({user_id,seed_id})
+function may_join_seed(seed_id) {
+ return TM_MAY_JOIN_SEED.get(seed_id)
+}
+
+function may_join_seed_level(user_id, seed_id, level) {
+ if (level === 1)
+ return true
+ if (level >= 2)
+ return TM_MAY_JOIN_SEED_LEVEL.get({ level, user_id, seed_id })
+ return false
}
const TM_SEED_LIST_ALL = SQL(`
@@ -2690,8 +2918,9 @@ const TM_SEED_LIST_ALL = SQL(`
tm_seeds.*,
sum(level is 1) as queue_size,
sum(user_id is ?) as is_queued
- from tm_seeds left join tm_queue using(seed_id)
- where is_open
+ from tm_seeds left join tm_queue_view using(seed_id)
+ where
+ is_open or exists ( select 1 from tm_queue_view where tm_queue_view.seed_id = tm_seeds.seed_id )
group by seed_id
order by seed_name
`)
@@ -2701,8 +2930,10 @@ const TM_SEED_LIST_TITLE = SQL(`
tm_seeds.*,
sum(level is 1) as queue_size,
sum(user_id is ?) as is_queued
- from tm_seeds left join tm_queue using(seed_id)
- where title_id = ? and is_open
+ from tm_seeds left join tm_queue_view using(seed_id)
+ where title_id = ? and (
+ is_open or exists ( select 1 from tm_queue_view where tm_queue_view.seed_id = tm_seeds.seed_id )
+ )
group by seed_id
order by seed_name
`)
@@ -2712,14 +2943,12 @@ const TM_SEED_LIST_USER = SQL(`
tm_seeds.*,
sum(level is 1) as queue_size,
sum(user_id is ?) as is_queued
- from tm_seeds left join tm_queue using(seed_id)
+ from tm_seeds left join tm_queue_view using(seed_id)
group by seed_id
having is_queued
order by seed_name
`)
-const TM_POOL_LIST_ACTIVE = SQL("select * from tm_pool_active_view")
-
const TM_POOL_LIST_USER_ACTIVE = SQL(`
select * from tm_pool_active_view
where not is_finished and pool_id in (
@@ -2768,16 +2997,18 @@ const TM_POOL_LIST_SEED_FINISHED = SQL("select * from tm_pool_finished_view wher
const TM_SELECT_QUEUE_BLACKLIST = SQL(`
with qq as (
- select user_id from tm_queue where seed_id=? and level=?
+ select user_id from tm_queue_view where seed_id=? and level=?
)
- select me, you
+ select me, you, u_me.name as me_name, u_you.name as you_name
from contacts
join qq on qq.user_id = me
+ join users u_me on u_me.user_id=me
+ join users u_you on u_you.user_id=you
where relation < 0 and exists (select 1 from qq where user_id = you)
`)
-const TM_SELECT_QUEUE_NAMES = SQL("select user_id, name, level from tm_queue join users using(user_id) where seed_id=? and level=? order by time")
-const TM_SELECT_QUEUE = SQL("select user_id from tm_queue where seed_id=? and level=? order by time desc").pluck()
+const TM_SELECT_QUEUE_NAMES = SQL("select user_id, name, level from tm_queue_view join users using(user_id) where seed_id=? and level=? order by time")
+const TM_SELECT_QUEUE = SQL("select user_id from tm_queue_view where seed_id=? and level=? order by time desc").pluck()
const TM_DELETE_QUEUE = SQL("delete from tm_queue where user_id=? and seed_id=? and level=?")
const TM_INSERT_QUEUE = SQL("insert or ignore into tm_queue (user_id, seed_id, level) values (?,?,?)")
@@ -2798,6 +3029,7 @@ const TM_SELECT_GAMES = SQL(`
tm_rounds.*,
games.status,
games.moves,
+ games.status > 1 and games.result = 'None' as is_abandoned,
json_group_object(role, coalesce(name, 'null')) as role_names,
json_group_object(role, score) as role_scores
from
@@ -2811,7 +3043,16 @@ const TM_SELECT_GAMES = SQL(`
game_id
`)
-const TM_SELECT_WINNERS = SQL("select user_id from tm_winners where pool_id = ?").pluck()
+const TM_SELECT_PLAYERS_IN_POOL = SQL(`
+ select
+ user_view.*
+ from
+ tm_rounds
+ join players using(game_id)
+ join user_view using(user_id)
+ group by
+ user_id
+`)
const TM_SELECT_PLAYERS_2P = SQL(`
with
@@ -2930,10 +3171,9 @@ const TM_FIND_NEXT_GAME_TO_START = SQL(`
const TM_SELECT_ENDED_POOLS = SQL(`
select
- pool_id, seed_id, level, pool_name, level_count
+ pool_id, pool_name
from
tm_pools
- join tm_seeds using(seed_id)
join tm_rounds using(pool_id)
join games using(game_id)
where
@@ -2949,9 +3189,9 @@ const TM_SELECT_SEED_READY_MINI_CUP = SQL(`
seed_id, level
from
tm_seeds
- join tm_queue using(seed_id)
+ join tm_queue_view using(seed_id)
where
- is_open and seed_name like 'mc.%'
+ seed_name like 'mc.%'
and julianday(time) < julianday('now', '-30 seconds')
group by
seed_id, level
@@ -2961,10 +3201,7 @@ const TM_SELECT_SEED_READY_MINI_CUP = SQL(`
app.get("/tm/list", function (req, res) {
let seeds = TM_SEED_LIST_ALL.all(req.user ? req.user.user_id : 0)
- let seeds_by_title = object_group_by(seeds, "title_id")
- let active_pools = TM_POOL_LIST_ACTIVE.all()
- let pools_by_seed = object_group_by(active_pools, "seed_name")
- res.render("tm_list.pug", { user: req.user, seeds, seeds_by_title, active_pools, pools_by_seed })
+ res.render("tm_list.pug", { user: req.user, seeds })
})
app.get("/tm/seed/:seed_name", function (req, res) {
@@ -2982,11 +3219,13 @@ app.get("/tm/seed/:seed_name", function (req, res) {
let error = null
let may_register = false
- if (req.user && seed.is_open) {
- if (!may_join_any_seed(req.user.user_id))
+ if (req.user) {
+ if (is_banned_from_tournaments(req.user.user_id))
+ error = "You may not join any tournaments."
+ else if (!may_join_any_seed(req.user.user_id))
error = "Please verify your mail address and enable notifications to join tournaments."
- else if (!may_join_seed(req.user.user_id, seed_id))
- error = "You may not register for this tournament."
+ else if (!may_join_seed(seed_id))
+ error = "This tournament is closed."
else
may_register = true
}
@@ -3009,17 +3248,22 @@ app.get("/tm/pool/:pool_name", function (req, res) {
players = TM_SELECT_PLAYERS_MP.all(pool_id)
let games = TM_SELECT_GAMES.all(pool_id)
let games_by_round = object_group_by(games, "round")
- res.render("tm_pool.pug", { user: req.user, seed, pool, roles, players, games_by_round })
+ res.render("tm_pool.pug", { user: req.user, seed, pool, roles, players, games, games_by_round })
})
-app.post("/api/tm/register/:seed_id", must_be_logged_in, function (req, res) {
+app.post("/api/tm/register/:seed_id/:level", must_be_logged_in, function (req, res) {
let seed_id = req.params.seed_id | 0
+ let level = req.params.level | 0
let user_id = req.user.user_id
+ if (is_banned_from_tournaments(req.user.user_id))
+ return res.status(401).send("You may not join any tournaments.")
if (!may_join_any_seed(user_id))
return res.status(401).send("You may not join any tournaments right now.")
- if (!may_join_seed(user_id, seed_id))
+ if (!may_join_seed(seed_id))
+ return res.status(401).send("This tournament is closed.")
+ if (!may_join_seed_level(req.user.user_id, seed_id, level))
return res.status(401).send("You may not join this tournament.")
- TM_INSERT_QUEUE.run(user_id, seed_id, 1)
+ TM_INSERT_QUEUE.run(user_id, seed_id, level)
return res.redirect(req.headers.referer)
})
@@ -3165,7 +3409,7 @@ function make_concurrent_rounds(v, k, n) {
let rbibd = designs.resolvable_bibd(v, k)
if (rbibd)
- return rbibd.slice(0, n).flat()
+ return [ rbibd.slice(0, n).flat() ]
throw new Error("cannot create rounds for this configuration")
}
@@ -3278,6 +3522,7 @@ function start_tournament_seed_mc(seed_id, level) {
let blacklist = TM_SELECT_QUEUE_BLACKLIST.all(seed_id, level)
console.log("TM SPAWN SEED (MC)", seed.seed_name, level, queue.length)
+ console.log("TM BLACKLIST", blacklist)
let players = filter_queue_through_blacklist(queue, seed.pool_size, blacklist)
if (!players) {
@@ -3320,23 +3565,15 @@ function start_tournament_seed(seed_id, level) {
}
function tm_reap_pools() {
- // reap pools that are finished (and promote winners)
+ // reap pools that are finished (and notify players)
let ended = TM_SELECT_ENDED_POOLS.all()
for (let item of ended) {
- console.log("TM POOL - END", item.pool_name)
- SQL_BEGIN.run()
- try {
- TM_UPDATE_POOL_FINISHED.run(item.pool_id)
- if (item.level < item.level_count) {
- let winners = TM_SELECT_WINNERS.all(item.pool_id)
- for (let user_id of winners)
- TM_INSERT_QUEUE.run(user_id, item.seed_id, item.level + 1)
- }
- SQL_COMMIT.run()
- } finally {
- if (db.inTransaction)
- SQL_ROLLBACK.run()
- }
+ console.log("TM POOL FINISHED", item.pool_name)
+ TM_UPDATE_POOL_FINISHED.run(item.pool_id)
+
+ let players = TM_SELECT_PLAYERS_IN_POOL.all(item.pool_id)
+ for (let user of players)
+ send_tour_notification(user, item.pool_name, "Finished")
}
}
@@ -3376,6 +3613,26 @@ if (app.locals.ENABLE_TOURNAMENTS) {
* GAME SERVER
*/
+function is_role_active(active, role) {
+ return active === role || active === "Both" || active.includes(role)
+}
+
+function is_nobody_active(active) {
+ return !active || active === "None"
+}
+
+function is_multi_active(active) {
+ if (!active)
+ return false
+ if (Array.isArray(active))
+ return true
+ return active === "Both" || active.includes(",")
+}
+
+function is_changed_active(old_active, new_active) {
+ return String(old_active) !== String(new_active)
+}
+
function is_player_online(game_id, user_id) {
if (game_clients[game_id])
for (let other of game_clients[game_id])
@@ -3397,13 +3654,14 @@ function send_state(socket, state) {
view.log_start = view.log.length
socket.seen = view.log.length
view.log = view.log.slice(view.log_start)
- if (state.state === "game_over")
- view.game_over = 1
let this_view = JSON.stringify(view)
if (view.actions || socket.last_view !== this_view) {
socket.send('["state",' + this_view + "," + game_cookies[socket.game_id] + "]")
socket.last_view = this_view
}
+ if (is_nobody_active(state.active)) {
+ socket.send('["finished"]')
+ }
} catch (err) {
console.log(err)
return send_message(socket, "error", err.toString())
@@ -3412,6 +3670,10 @@ function send_state(socket, state) {
function get_game_state(game_id) {
let game_state = SQL_SELECT_GAME_STATE.get(game_id)
+ if (ENABLE_ARCHIVE) {
+ if (!game_state)
+ game_state = ARCHIVE_SELECT_GAME_STATE.get(game_id)
+ }
if (!game_state)
throw new Error("No game with that ID")
return JSON.parse(game_state)
@@ -3442,6 +3704,18 @@ function put_replay(game_id, role, action, args) {
return SQL_INSERT_REPLAY.get(game_id, game_id, role, action, args)
}
+function dont_snap(rules, state, old_active) {
+ if (is_nobody_active(state.active))
+ return true
+ if (is_multi_active(old_active) && is_multi_active(state.active))
+ return true
+ if (!is_changed_active(old_active, state.active))
+ return true
+ if (rules.dont_snap && rules.dont_snap(state))
+ return true
+ return false
+}
+
function put_snap(game_id, replay_id, state) {
let snap_id = SQL_INSERT_SNAP.get(game_id, game_id, replay_id, snap_from_state(state))
if (game_clients[game_id])
@@ -3449,42 +3723,38 @@ function put_snap(game_id, replay_id, state) {
send_message(other, "snapsize", snap_id)
}
-function put_game_state(game_id, state, old_active, current_role) {
+function put_game_state(game_id, state, old_active) {
// TODO: separate state, undo, and log entries (and reuse "snap" json stringifaction?)
-
SQL_INSERT_GAME_STATE.run(game_id, JSON.stringify(state))
- if (state.active !== old_active) {
- SQL_UPDATE_GAME_ACTIVE.run(state.active, game_id)
+ if (is_changed_active(old_active, state.active))
+ SQL_UPDATE_GAME_ACTIVE.run(String(state.active), game_id)
- // add time for the player who took the current action
- SQL_UPDATE_PLAYERS_ADD_TIME.run(game_id, current_role)
- }
-
- if (state.state === "game_over") {
+ if (is_nobody_active(state.active)) {
SQL_FINISH_GAME.run(state.result, game_id)
if (state.result && state.result !== "None")
update_elo_ratings(game_id)
}
}
-function put_new_state(game_id, state, old_active, role, action, args) {
+function put_new_state(title_id, game_id, state, old_active, role, action, args) {
SQL_BEGIN.run()
try {
let replay_id = put_replay(game_id, role, action, args)
- if (state.active !== old_active)
+ if (!dont_snap(RULES[title_id], state, old_active))
put_snap(game_id, replay_id, state)
- put_game_state(game_id, state, old_active, role)
+ put_game_state(game_id, state, old_active)
- if (state.active !== old_active)
+ if (is_changed_active(old_active, state.active))
update_join_clients(game_id)
+
if (game_clients[game_id])
for (let other of game_clients[game_id])
send_state(other, state)
- if (state.state === "game_over")
+ if (is_nobody_active(state.active))
send_game_finished_notification_to_offline_users(game_id, state.result)
else
send_your_turn_notification_to_offline_users(game_id, old_active, state.active)
@@ -3510,15 +3780,15 @@ function on_action(socket, action, args, cookie) {
try {
let state = get_game_state(socket.game_id)
- let old_active = state.active
+ let old_active = String(state.active)
// Don't update cookie during simultaneous turns, as it results
// in many in-flight collisions.
- if (old_active !== "Both")
+ if (!is_multi_active(old_active))
game_cookies[socket.game_id] ++
state = RULES[socket.title_id].action(state, socket.role, action, args)
- put_new_state(socket.game_id, state, old_active, socket.role, action, args)
+ put_new_state(socket.title_id, socket.game_id, state, old_active, socket.role, action, args)
} catch (err) {
console.log(err)
return send_message(socket, "error", err.toString())
@@ -3528,73 +3798,51 @@ function on_action(socket, action, args, cookie) {
function on_resign(socket) {
SLOG(socket, "RESIGN")
try {
- do_resign(socket.game_id, socket.role, "resigned")
+ do_resign(socket.game_id, socket.role)
} catch (err) {
console.log(err)
return send_message(socket, "error", err.toString())
}
}
-function do_resign(game_id, role, how) {
+function do_timeout(game_id, role) {
let game = SQL_SELECT_GAME.get(game_id)
let state = get_game_state(game_id)
- let old_active = state.active
+ let old_active = String(state.active)
+ state = finish_game_state(game.title_id, state, "None", role + " timed out.")
+ put_new_state(game.title_id, game_id, state, old_active, role, ".timeout", null)
+}
- let result = "None"
+function do_resign(game_id, role) {
+ let game = SQL_SELECT_GAME.get(game_id)
+ let state = get_game_state(game_id)
+ let old_active = String(state.active)
- let roles = get_game_roles(game.title_id, game.scenario, game.options)
+ let result = "None"
if (game.player_count === 2) {
+ let roles = get_game_roles(game.title_id, game.scenario, game.options)
for (let r of roles)
if (r !== role)
result = r
- } else {
- result = roles.filter(r => r !== role).join(", ")
}
- state.state = "game_over"
- state.active = "None"
- state.result = result
- state.victory = role + " " + how + "."
- state.log.push("")
- state.log.push(state.victory)
-
- put_new_state(game_id, state, old_active, role, ".resign", null)
-}
-
-function on_restore(socket, state_text) {
- if (!DEBUG)
- send_message(socket, "error", "Debugging is not enabled on this server.")
- SLOG(socket, "RESTORE")
- try {
- let state = JSON.parse(state_text)
-
- // reseed!
- state.seed = random_seed()
-
- // resend full log!
- for (let other of game_clients[socket.game_id])
- other.seen = 0
+ state = finish_game_state(game.title_id, state, result, role + " resigned.")
- put_new_state(socket.game_id, state, null, null, "$restore", state)
- } catch (err) {
- console.log(err)
- return send_message(socket, "error", err.toString())
- }
+ put_new_state(game.title_id, game_id, state, old_active, role, ".resign", result)
}
-function on_save(socket) {
- if (!DEBUG)
- send_message(socket, "error", "Debugging is not enabled on this server.")
- SLOG(socket, "SAVE")
- try {
- let game_state = SQL_SELECT_GAME_STATE.get(socket.game_id)
- if (!game_state)
- return send_message(socket, "error", "No game with that ID.")
- send_message(socket, "save", game_state)
- } catch (err) {
- console.log(err)
- return send_message(socket, "error", err.toString())
+function finish_game_state(title_id, state, result, message) {
+ if (typeof RULES[title_id].finish === "function") {
+ state = RULES[title_id].finish(state, result, message)
+ } else {
+ state.state = "game_over"
+ state.active = "None"
+ state.result = result
+ state.victory = message
+ state.log.push("")
+ state.log.push(message)
}
+ return state
}
function on_query(socket, q, params) {
@@ -3755,12 +4003,6 @@ function handle_player_message(socket, cmd, arg) {
case "querysnap":
on_query_snap(socket, arg[0], arg[1], arg[2])
break
- case "save":
- on_save(socket)
- break
- case "restore":
- on_restore(socket, arg)
- break
default:
send_message(socket, "error", "Invalid server command: " + cmd)
break
@@ -3974,12 +4216,12 @@ const SQL_USER_STATS = SQL(`
`)
const SQL_USER_RATINGS = SQL(`
- select title_name, rating, count, date(last) as last
+ select title_id, title_name, rating, count, date(last) as last
from ratings
join titles using(title_id)
where user_id = ?
- and count >= 5
- order by rating desc
+ and count >= 3
+ order by count desc
`)
const SQL_GAME_RATINGS = SQL(`
diff --git a/tools/elo.js b/tools/elo.js
index ad0dbad..f6064f1 100644
--- a/tools/elo.js
+++ b/tools/elo.js
@@ -14,7 +14,7 @@ function is_winner(role, result) {
}
function elo_k(n) {
- return n < 10 ? 60 : 30
+ return 30
}
function elo_ev(a, players) {
diff --git a/tools/import-game.js b/tools/import-game.js
index 87b93ab..42391e1 100755
--- a/tools/import-game.js
+++ b/tools/import-game.js
@@ -4,7 +4,7 @@ const fs = require("fs")
const sqlite3 = require("better-sqlite3")
var options = {}
-var input = null
+var input = []
for (let i = 2; i < process.argv.length; ++i) {
let opt = process.argv[i]
@@ -12,65 +12,69 @@ for (let i = 2; i < process.argv.length; ++i) {
let [key, val] = opt.split("=", 2)
options[key] = val
} else {
- input = opt
+ input.push(opt)
}
}
-if (!input) {
+if (input.length < 1) {
console.error("usage: node tools/import-game.js [title_id=value] [notice=value] game.json")
process.exit(1)
}
-var game = JSON.parse(fs.readFileSync(input, "utf8"))
+for (let file of input) {
+ var game = JSON.parse(fs.readFileSync(file, "utf8"))
-if (options.title_id)
- game.setup.title_id = options.title_id
-if (options.notice)
- game.setup.notice = options.notice
+ if (options.title_id)
+ game.setup.title_id = options.title_id
+ if (options.notice)
+ game.setup.notice = options.notice
-if (game.setup.notice === undefined)
- game.setup.notice = ""
-if (game.setup.options === undefined)
- game.setup.options = "{}"
+ if (game.setup.notice === undefined)
+ game.setup.notice = ""
+ if (game.setup.options === undefined)
+ game.setup.options = "{}"
-game.setup.active = game.state.active
-game.setup.moves = game.snaps && game.snaps.length > 0 ? game.snaps.length - 1 : 0
+ game.setup.active = game.state.active
+ game.setup.moves = game.snaps && game.snaps.length > 0 ? game.snaps.length - 1 : 0
-let db = new sqlite3("db")
+ let db = new sqlite3("db")
-let insert_game = db.prepare("insert into games(status,owner_id,title_id,scenario,options,player_count,active,moves,notice) values (1,1,:title_id,:scenario,:options,:player_count,:active,:moves,:notice) returning game_id").pluck()
-let insert_player = db.prepare("insert into players(game_id,role,user_id) values (?,?,?)")
-let insert_state = db.prepare("insert into game_state(game_id,state) values (?,?)")
+ let insert_game = db.prepare("insert into games(status,owner_id,title_id,scenario,options,player_count,active,moves,notice) values (1,1,:title_id,:scenario,:options,:player_count,:active,:moves,:notice) returning game_id").pluck()
+ let insert_player = db.prepare("insert into players(game_id,role,user_id,clock) values (?,?,?,21)")
+ let insert_state = db.prepare("insert into game_state(game_id,state) values (?,?)")
+ let update_active_trigger = db.prepare("update games set active=active where game_id=?")
-let select_user = db.prepare("select user_id from users where name=?").pluck()
+ let select_user = db.prepare("select user_id from users where name=?").pluck()
-db.exec("begin")
+ db.exec("begin")
-game.setup.options = JSON.stringify(game.setup.options)
+ game.setup.options = JSON.stringify(game.setup.options)
-function find_user(name) {
- return select_user.get(name) || 1
-}
-
-let game_id = insert_game.get(game.setup)
-for (let p of game.players)
- insert_player.run(game_id, p.role, find_user(p.name))
-insert_state.run(game_id, JSON.stringify(game.state))
+ function find_user(name) {
+ return select_user.get(name) || 1
+ }
-if (game.replay) {
- let insert_replay = db.prepare("insert into game_replay(game_id,replay_id,role,action,arguments) values (?,?,?,?,?)")
- game.replay.forEach(([role, action, args], i) => {
- insert_replay.run(game_id, i+1, role, action, JSON.stringify(args))
- })
-}
+ let game_id = insert_game.get(game.setup)
+ for (let p of game.players)
+ insert_player.run(game_id, p.role, find_user(p.name))
+ insert_state.run(game_id, JSON.stringify(game.state))
+ update_active_trigger.run(game_id)
+
+ if (game.replay) {
+ let insert_replay = db.prepare("insert into game_replay(game_id,replay_id,role,action,arguments) values (?,?,?,?,?)")
+ game.replay.forEach(([role, action, args], i) => {
+ insert_replay.run(game_id, i+1, role, action, JSON.stringify(args))
+ })
+ }
-if (game.snaps) {
- let insert_snap = db.prepare("insert into game_snap(game_id,snap_id,replay_id,state) values (?,?,?,?)")
- game.snaps.forEach(([replay_id, state], i) => {
- insert_snap.run(game_id, i+1, replay_id, JSON.stringify(state))
- })
-}
+ if (game.snaps) {
+ let insert_snap = db.prepare("insert into game_snap(game_id,snap_id,replay_id,state) values (?,?,?,?)")
+ game.snaps.forEach(([replay_id, state], i) => {
+ insert_snap.run(game_id, i+1, replay_id, JSON.stringify(state))
+ })
+ }
-console.log(game_id)
+ console.log(game_id)
-db.exec("commit")
+ db.exec("commit")
+}
diff --git a/tools/lift-bans.sh b/tools/lift-bans.sh
new file mode 100644
index 0000000..e76f621
--- /dev/null
+++ b/tools/lift-bans.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+sqlite3 db <<EOF
+
+begin immediate;
+
+.mode column
+
+create temporary view tm_lift_ban_view as
+ select
+ user_id,
+ name,
+ date(timeout_last),
+ timeout_total,
+ games_since_timeout,
+ (games_since_timeout > timeout_total) and (julianday() > julianday(timeout_last)+14) as lift_ban
+ from
+ user_profile_view
+ where
+ user_id in (select user_id from tm_banned)
+ order by lift_ban desc, timeout_last asc
+;
+
+select * from tm_lift_ban_view;
+
+delete from tm_banned where user_id in (select user_id from tm_lift_ban_view where lift_ban) returning user_id;
+
+commit;
+
+EOF
diff --git a/tools/patchgame.js b/tools/patchgame.js
index c2b0802..de8721e 100755
--- a/tools/patchgame.js
+++ b/tools/patchgame.js
@@ -71,24 +71,82 @@ function snapshot(state) {
return snap
}
+function is_role_active(active, role) {
+ return active === role || active === "Both" || active.includes(role)
+}
+
+function is_nobody_active(active) {
+ return !active || active === "None"
+}
+
+function is_multi_active(active) {
+ if (!active)
+ return false
+ if (Array.isArray(active))
+ return true
+ return active === "Both" || active.includes(",")
+}
+
+function is_changed_active(old_active, new_active) {
+ return String(old_active) !== String(new_active)
+}
+
function is_valid_action(rules, state, role, action, arg) {
if (action === "undo") // for jc, hots, r3, and cr compatibility
return true
- if (state.active !== role && state.active !== "Both")
+ if (!is_role_active(state.active, role))
return false
let view = rules.view(state, role)
let va = view.actions[action]
if (va) {
- if (Array.isArray(arg))
- arg = arg[0]
if (Array.isArray(va) && va.includes(arg))
return true
- if (arg === undefined || arg === null || arg === 1)
+ if (arg === undefined || arg === null || arg === 1 || Array.isArray(arg))
return (va === 1 || va === true || typeof va === "string")
}
return false
}
+function dont_snap(rules, state, old_active) {
+ if (is_nobody_active(state.active))
+ return true
+ if (is_multi_active(old_active) && is_multi_active(state.active))
+ return true
+ if (!is_changed_active(old_active, state.active))
+ return true
+ if (rules.dont_snap && rules.dont_snap(state))
+ return true
+ return false
+}
+
+function get_game_roles(rules, scenario, options) {
+ let roles = rules.roles
+ if (typeof roles === "function") {
+ if (typeof options === "string")
+ options = JSON.parse(options)
+ return roles(scenario, options)
+ }
+ return roles
+}
+
+function get_resign_result(roles, role) {
+ return roles.filter(r => r !== role).join(", ")
+}
+
+function finish_game(rules, state, result, message) {
+ if (typeof rules.finish === "function") {
+ state = RULES[title_id].finish(state, result, message)
+ } else {
+ state.state = "game_over"
+ state.active = "None"
+ state.result = result
+ state.victory = message
+ state.log.push("")
+ state.log.push(message)
+ }
+ return state
+}
+
function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_undo=false, delete_invalid=false}, verbose) {
let game = select_game.get(game_id)
if (!game) {
@@ -98,6 +156,7 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und
let title_id = game.title_id
let rules = require("../public/" + title_id + "/rules.js")
+ let roles = get_game_roles(rules, game.scenario, game.options)
let replay = select_replay.all(game_id)
if (replay.length === 0)
@@ -121,8 +180,13 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und
case ".setup":
state = rules.setup(...args)
break
+ case ".timeout":
+ finish_game(rules, state, "None", item.role + " timed out.")
+ break
+ case ".abandon":
+ finish_game(rules, state, "None", item.role + " abandoned the game.")
case ".resign":
- state = rules.resign(state, item.role)
+ finish_game(rules, state, get_resign_result(roles, item.role), item.role + " resigned.")
break
default:
if (validate_actions) {
@@ -142,7 +206,7 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und
item.state = snapshot(state)
item.checksum = crc32c(item.state)
- if (old_active !== state.active)
+ if (!dont_snap(rules, state, old_active))
item.save = 1
old_active = state.active
@@ -177,7 +241,7 @@ function patch_game(game_id, {validate_actions=true, save_snaps=true, delete_und
insert_snap.run(game_id, ++snap_id, item.replay_id, item.state)
}
- update_active.run(state.active, game_id)
+ update_active.run(String(state.active), game_id)
update_state.run(JSON.stringify(state), game_id)
if (state.state === "game_over")
diff --git a/tools/purge.sql b/tools/purge.sql
index 3e0c898..2a95f01 100644
--- a/tools/purge.sql
+++ b/tools/purge.sql
@@ -2,6 +2,8 @@
attach database 'db' as live;
+pragma live.busy_timeout=10000;
+
create temporary view prune_snap_list as
select
distinct game_id
@@ -26,12 +28,8 @@ create temporary view prune_all_list as
)
;
-begin;
-
select 'PURGE SNAPS FROM ' || count(1) from prune_snap_list;
delete from live.game_snap where game_id in (select game_id from prune_snap_list);
select 'PURGE ALL FROM ' || count(1) from prune_all_list;
update live.games set status = 3 where game_id in (select game_id from prune_all_list);
-
-commit;
diff --git a/views/create-index.pug b/views/create-index.pug
index d038810..c508be7 100644
--- a/views/create-index.pug
+++ b/views/create-index.pug
@@ -3,7 +3,7 @@ doctype html
html
head
include head
- title= SITE_NAME
+ title Create game
body
include header
article
diff --git a/views/create.pug b/views/create.pug
index d45903e..c4c5c2a 100644
--- a/views/create.pug
+++ b/views/create.pug
@@ -19,27 +19,36 @@ html
p.error You are not logged in!
form(method="post" action="/create/"+title.title_id)
- if Array.isArray(scenarios)
- if scenarios.length > 1
+ if Array.isArray(rules.scenarios)
+ if rules.scenarios.length > 1
p Scenario:
br
select(name="scenario")
- each scenario in scenarios
- option(value=scenario)= scenario
+ each scenario in rules.scenarios
+ if scenario === rules.default_scenario
+ option(value=scenario selected)= scenario
+ else
+ option(value=scenario)= scenario
else
- input(type="hidden" name="scenario" value=scenarios[0])
+ input(type="hidden" name="scenario" value=rules.scenarios[0])
else
p Scenario:
br
select(name="scenario")
- each list, name in scenarios
+ each list, name in rules.scenarios
if name === ""
each scenario in list
- option(value=scenario)= scenario
+ if scenario === rules.default_scenario
+ option(value=scenario selected)= scenario
+ else
+ option(value=scenario)= scenario
else
optgroup(label=name)
each scenario in list
- option(value=scenario)= scenario
+ if scenario === rules.default_scenario
+ option(value=scenario selected)= scenario
+ else
+ option(value=scenario)= scenario
| !{ title.create_html }
diff --git a/views/forgot_password.pug b/views/forgot_password.pug
index 113610c..935cae1 100644
--- a/views/forgot_password.pug
+++ b/views/forgot_password.pug
@@ -4,6 +4,7 @@ html
head
include head
title Forgot password
+ +altcha_script()
body
include header
article
@@ -19,5 +20,6 @@ html
label Mail:
br
input(type="email" name="mail" required)
+ +altcha_widget()
p
button(type="submit") Forgot password
diff --git a/views/games_active.pug b/views/games_active.pug
index 6046b9b..bac7c67 100644
--- a/views/games_active.pug
+++ b/views/games_active.pug
@@ -11,7 +11,10 @@ doctype html
html
head
include head
- title= SITE_NAME
+ if user.waiting > 0
+ title= "Games (" + user.waiting + ")"
+ else
+ title Games
if active_games.length > 0
meta(http-equiv="refresh" content=600)
script window.onunload=function(){}
diff --git a/views/games_finished.pug b/views/games_finished.pug
index 8d71717..90ec9a2 100644
--- a/views/games_finished.pug
+++ b/views/games_finished.pug
@@ -3,7 +3,10 @@ doctype html
html
head
include head
- title= SITE_NAME
+ if user && user.user_id === who.user_id
+ title Your finished games
+ else
+ title #{who.name}&rsquo;s finished games
body
include header
article.wide
diff --git a/views/games_public.pug b/views/games_public.pug
index 54591cd..8cfd541 100644
--- a/views/games_public.pug
+++ b/views/games_public.pug
@@ -3,7 +3,7 @@ doctype html
html
head
include head
- title= SITE_NAME
+ title Public room
if user
meta(http-equiv="refresh" content=900)
body
diff --git a/views/head.pug b/views/head.pug
index 3792429..75e1135 100644
--- a/views/head.pug
+++ b/views/head.pug
@@ -8,6 +8,14 @@ link(rel="stylesheet" href="/style.css")
if SITE_THEME
link(rel="stylesheet" href="/themes/"+SITE_THEME)
+mixin altcha_script()
+ if ALTCHA
+ script(async defer type="module" src="/altcha.min.js")
+
+mixin altcha_widget()
+ if ALTCHA
+ altcha-widget(challengeurl="/altcha-challenge" hidelogo hidefooter auto="onsubmit" style="--altcha-border-radius:0")
+
mixin social(title,description,game)
meta(property="og:title" content=title)
meta(property="og:type" content="website")
@@ -78,7 +86,10 @@ mixin gamelist(list,hide_title=0)
case item.status
when 0
- a(class="command" href=`/join/${item.game_id}`) Join
+ if item.is_match
+ a(class="command" href="/join/"+item.game_id) Wait
+ else
+ a(class="command" href="/join/"+item.game_id) Join
when 1
if !item.is_ready
a(class="command" href="/join/"+item.game_id) Join
@@ -98,7 +109,16 @@ mixin gamelist(list,hide_title=0)
else
a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}`) Review
when 3
- | Archived
+ if ENABLE_ARCHIVE
+ if item.is_yours
+ if item.your_role
+ a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=${encodeURIComponent(item.your_role)}`) Archived
+ else
+ a(class="command" href="/join/"+item.game_id) Archived
+ else
+ a(class="command" href=`/${item.title_id}/play.html?game=${item.game_id}&role=Observer`) Archived
+ else
+ | Archived
div.game_main
div.game_info
@@ -185,12 +205,8 @@ mixin tourlist(seeds, pools, fin)
if (seeds && seeds.length > 0) || (pools && pools.length > 0) || (fin && fin.length > 0)
h2 Tournaments
div.tour_list
- if seeds && seeds.length > 0
- div
- +seedlist(seeds, "Registrations")
- if pools && pools.length > 0
- div
- +poollist(pools, "Active", TM_ICON_ACTIVE)
- if fin && fin.length > 0
- div
- +poollist(fin, "Finished", TM_ICON_FINISHED)
+ div
+ +seedlist(seeds, "Registrations")
+ +poollist(pools, "Active", TM_ICON_ACTIVE)
+ div
+ +poollist(fin, "Finished", TM_ICON_FINISHED)
diff --git a/views/login.pug b/views/login.pug
index a470b59..c6e5c21 100644
--- a/views/login.pug
+++ b/views/login.pug
@@ -4,6 +4,7 @@ html
head
include head
title Login
+ +altcha_script()
body
include header
article
@@ -28,6 +29,7 @@ html
label Password:
br
input(type="password" name="password" required)
+ +altcha_widget()
p
button(type="submit") Login
p
diff --git a/views/profile.pug b/views/profile.pug
index 2437eb2..4ecf289 100644
--- a/views/profile.pug
+++ b/views/profile.pug
@@ -3,23 +3,24 @@ doctype html
html
head
include head
- title= SITE_NAME
+ title Profile
body
include header
article
h1= SITE_NAME
- p Welcome, #{user.name}!
+ p Welcome, <a class="black" href="/user/#{user.name}">#{user.name}</a>!
p Your mail address is #{user.mail}
if ENABLE_MAIL
if !user.is_verified
- p &#x26a0; <a href="/verify-mail">Verify your mail address</a>
-
+ p &#x26a0; <a href="/verify-mail">Verify your mail address!</a>
+ p You must verify your mail address before you can enable notifications.
+ else
+ if !user.notify
+ p <a href="/subscribe">Enable mail notifications</a>
if user.notify
p <a href="/unsubscribe">Disable mail notifications</a>
- else
- p <a href="/subscribe">Enable mail notifications</a>
p
| <a href="/change-password">Change password</a>
diff --git a/views/signup.pug b/views/signup.pug
index 8ce41a2..63b9ab3 100644
--- a/views/signup.pug
+++ b/views/signup.pug
@@ -4,6 +4,7 @@ html
head
include head
title Signup
+ +altcha_script()
body
include header
article
@@ -36,11 +37,6 @@ html
label Password:
br
input(type="password" name="password" required)
- div
- label
- input(type="checkbox" name="notify" value="true")
- | Enable mail notifications
- div(style="margin-left:2rem")
- i (when it is your turn, your games are ready to start, etc.)
+ +altcha_widget()
p
button(type="submit") Create account
diff --git a/views/tm_active.pug b/views/tm_active.pug
index cc821b2..625eac2 100644
--- a/views/tm_active.pug
+++ b/views/tm_active.pug
@@ -3,11 +3,11 @@ doctype html
html
head
include head
- title= SITE_NAME
+ title Your tournaments
body
include header
article.wide
- h1 Your Tournaments
+ h1 Your tournaments
if seeds && seeds.length > 0
+seedlist(seeds, "Registrations")
diff --git a/views/tm_finished.pug b/views/tm_finished.pug
index efe193d..5d077c6 100644
--- a/views/tm_finished.pug
+++ b/views/tm_finished.pug
@@ -3,7 +3,10 @@ doctype html
html
head
include head
- title= SITE_NAME
+ if user && user.user_id === who.user_id
+ title Your finished tournaments
+ else
+ title #{who.name}&rsquo;s finished tournaments
body
include header
article.wide
diff --git a/views/tm_list.pug b/views/tm_list.pug
index bc7fd83..f5efa26 100644
--- a/views/tm_list.pug
+++ b/views/tm_list.pug
@@ -12,7 +12,3 @@ html
p See <a href="/docs/tournaments.html">tournament information</a>.
+seedlist(seeds, "Mini Cup")
-
- h2 Active
- each pools, seed_name in pools_by_seed
- +poollist(pools, `<a href="/tm/seed/${seed_name}">${seed_name}</a>`)
diff --git a/views/tm_pool.pug b/views/tm_pool.pug
index 1690535..e6c7715 100644
--- a/views/tm_pool.pug
+++ b/views/tm_pool.pug
@@ -49,9 +49,9 @@ html
tr
td Started
td= human_date(pool.start_date)
- tr
- td Finished
- if pool.finish_date
+ if pool.finish_date
+ tr
+ td Finished
td= human_date(pool.finish_date)
if seed.player_count === 2
@@ -84,7 +84,10 @@ html
if ix > 0
| &nbsp;
if gs[1] === null
- a.black(href="/join/" + gs[0]) &minus;
+ if games.find(game => game.game_id === gs[0]).is_abandoned
+ a.black(href="/join/" + gs[0]) &#xd7;
+ else
+ a.black(href="/join/" + gs[0]) &minus;
else
a.black(href="/join/" + gs[0])= gs[1]
td.r= row.points
@@ -116,7 +119,10 @@ html
each gs in result
td.c
if gs[1] === null
- a.black(href="/join/" + gs[0]) &minus;
+ if games.find(game => game.game_id === gs[0]).is_abandoned
+ a.black(href="/join/" + gs[0]) &#xd7;
+ else
+ a.black(href="/join/" + gs[0]) &minus;
else
a.black(href="/join/" + gs[0])= gs[1]
td.r= row.points
@@ -150,10 +156,16 @@ html
if game.status > 1
td.w.r
- each role, ix in roles
- if ix > 0
- | &nbsp;:&nbsp;
- | #{role_scores[role]}
+ if game.is_abandoned
+ | None
+ else
+ each role, ix in roles
+ if ix > 0
+ | &nbsp;:&nbsp;
+ if role_scores[role] === null
+ | &#xd7;
+ else
+ | #{role_scores[role]}
else
td.r
if game.status > 0
diff --git a/views/tm_seed.pug b/views/tm_seed.pug
index 81574ca..ea31a41 100644
--- a/views/tm_seed.pug
+++ b/views/tm_seed.pug
@@ -48,7 +48,7 @@ html
else
td #{seed.round_count} sequential
- if seed.is_open
+ if seed.is_open || queues.some(q => q.length > 0)
each queue,ix in queues
table.half
thead
@@ -66,21 +66,18 @@ html
td Nobody
if user
- if ix === 0
- if may_register
- if !queue.find(p => p.user_id === user.user_id)
- form(method="post" action="/api/tm/register/" + seed.seed_id)
- button(type="submit") Register
- button(disabled) Withdraw
- else
- div
- button(disabled) Register
- button(disabled) Withdraw
-
if queue.find(p => p.user_id === user.user_id)
form(method="post" action="/api/tm/withdraw/" + seed.seed_id + "/" + (ix+1))
button(disabled) Register
button(type="submit") Withdraw
+ else if may_register && may_join_seed_level(user.user_id, seed.seed_id, ix+1)
+ form(method="post" action="/api/tm/register/" + seed.seed_id + "/" + (ix+1))
+ button(type="submit") Register
+ button(disabled) Withdraw
+ else
+ div
+ button(disabled) Register
+ button(disabled) Withdraw
if user.user_id === 1
if queue.length >= seed.pool_size
diff --git a/views/user.pug b/views/user.pug
index fc55b42..c3e8925 100644
--- a/views/user.pug
+++ b/views/user.pug
@@ -42,6 +42,38 @@ html
br
a(href="/contacts/add-enemy/"+who.name) Add to blacklist
+ if (who.move_time_mean !== null)
+ h3 Response time
+ div Average response time: #{format_minutes(who.move_time_mean)}
+ if (who.move_time_q2 !== null)
+ div Median response time: #{format_minutes(who.move_time_q2)}
+ if (who.move_time_q1 !== null && who.move_time_q2 !== null)
+ div Middle half of response times: #{format_minutes(who.move_time_q1)} to #{format_minutes(who.move_time_q3)}
+
+ h3 Timeouts
+ div Total number of timeouts: #{who.timeout_total}
+ div Games completed since last timeout: #{who.games_since_timeout}
+
+ if ratings.length > 0
+ h3 Most played games
+ table
+ thead
+ tr
+ th Title
+ th Count
+ th Last played
+ if user && user.user_id === 1
+ th Elo
+ tbody
+ each row in ratings
+ tr
+ td
+ a.black(href="/" + row.title_id)= row.title_name
+ td.r= row.count
+ td.r= row.last
+ if user && user.user_id === 1
+ td.r= row.rating
+
+tourlist(null, active_pools, finished_pools)
if open_games.length > 0