From 9ea39363b861459dc2e295422ae22ce6c7dfbefd Mon Sep 17 00:00:00 2001 From: Neale Pickett Date: Wed, 13 Sep 2023 18:52:52 -0600 Subject: [PATCH] Mostly using new library, except scoreboard --- cmd/mothd/server.go | 1 - theme/background.mjs | 18 +- theme/basic.css | 91 ++++++++-- theme/common.mjs | 43 +++++ theme/index.html | 20 ++- theme/index.mjs | 158 +++++++++++++++++ theme/logout.html | 23 --- theme/moment.min.js | 1 - theme/moth.js | 196 --------------------- theme/moth.mjs | 130 ++++++++++---- theme/puzzle.html | 28 ++- theme/puzzle.js | 225 ------------------------ theme/puzzle.mjs | 83 +++++++-- theme/scoreboard.css | 91 ++++++++++ theme/scoreboard.html | 17 +- theme/{scoreboard.js => scoreboard.mjs} | 170 ++++++++++-------- theme/token.html | 50 ++---- theme/token.mjs | 48 +++++ 18 files changed, 732 insertions(+), 661 deletions(-) create mode 100644 theme/common.mjs create mode 100644 theme/index.mjs delete mode 100644 theme/logout.html delete mode 100644 theme/moment.min.js delete mode 100644 theme/moth.js delete mode 100644 theme/puzzle.js create mode 100644 theme/scoreboard.css rename theme/{scoreboard.js => scoreboard.mjs} (72%) create mode 100644 theme/token.mjs diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go index 7fe3849..5cf6582 100644 --- a/cmd/mothd/server.go +++ b/cmd/mothd/server.go @@ -169,7 +169,6 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time, // Register associates a team name with a team ID. func (mh *MothRequestHandler) Register(teamName string) error { - // BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success if teamName == "" { return fmt.Errorf("empty team name") } diff --git a/theme/background.mjs b/theme/background.mjs index 76b975d..61bd76e 100644 --- a/theme/background.mjs +++ b/theme/background.mjs @@ -2,9 +2,9 @@ function randint(max) { return Math.floor(Math.random() * max) } -const MILLISECOND = 1 -const SECOND = MILLISECOND * 1000 -const FRAMERATE = 24 / SECOND // Fast enough for this tomfoolery +const Millisecond = 1 +const Second = Millisecond * 1000 +const FrameRate = 24 / Second // Fast enough for this tomfoolery class Point { constructor(x, y) { @@ -88,7 +88,7 @@ class QixLine { * like the video game "qix" */ class QixBackground { - constructor(ctx, frameRate = 6/SECOND) { + constructor(ctx, frameRate = 6/Second) { this.ctx = ctx this.min = new Point(0, 0) this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height) @@ -110,7 +110,7 @@ class QixBackground { new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)), ) - this.frameInterval = MILLISECOND / frameRate + this.frameInterval = Millisecond / frameRate this.nextFrame = 0 } @@ -149,6 +149,12 @@ class QixBackground { } function init() { + // Don't like the background animation? You can disable it by setting a + // property in localStorage and reloading. + if (localStorage.disableBackgroundAnimation) { + return + } + let canvas = document.createElement("canvas") canvas.width = 640 canvas.height = 640 @@ -159,7 +165,7 @@ function init() { let qix = new QixBackground(ctx) // window.requestAnimationFrame is overkill for something this silly - setInterval(() => qix.Animate(), MILLISECOND/FRAMERATE) + setInterval(() => qix.Animate(), Millisecond/FrameRate) } if (document.readyState === "loading") { diff --git a/theme/basic.css b/theme/basic.css index ffc71b9..4b09947 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -29,6 +29,15 @@ a:any-link { background: red; color: white; } +.toast { + background: #333; + color: #eee; + box-shadow: 0px 0px 8px 0px #0b0; +} +.debug { + background: #ccc; + color: black; +} @media (prefers-color-scheme: light) { body { background: #b9cbd8; @@ -45,7 +54,8 @@ a:any-link { body { font-family: sans-serif; background-image: url("bg.png"); - background-size: contain; + background-size: cover; + background-position: center; background-blend-mode: soft-light; background-attachment: fixed; } @@ -62,7 +72,7 @@ canvas.wallpaper { } main { max-width: 40em; - margin: auto; + margin: 1em auto; padding: 1px 3px; border-radius: 5px; } @@ -85,19 +95,43 @@ input, select { padding: 0 1em; border-radius: 8px; } +.hidden { + display: none; +} +/** Puzzles list */ +.category { + margin: 5px 0; + background: #ccc4; +} +.category h2 { + margin: 0 0.2em; +} nav ul, .category ul { - padding: 1em; + margin: 0; + padding: 0.2em 1em; + display: flex; + flex-wrap: wrap; + gap: 8px 16px; } nav li, .category li { display: inline; - margin: 1em; } -iframe#body { - border: inherit; - width: 100%; +.mothball { + float: right; + text-decoration: none; + border-radius: 5px; + background: #ccc; + padding: 4px 8px; + margin: 5px; } -img { + +/** Puzzle content */ +#puzzle { + border-bottom: solid; + padding: 0 0.5em; +} +#puzzle img { max-width: 100%; } input:invalid { @@ -106,9 +140,8 @@ input:invalid { .answer_ok { cursor: help; } -#messages { - min-height: 3em; -} + +/** Scoreboard */ #rankings { width: 100%; position: relative; @@ -138,11 +171,17 @@ input:invalid { .cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} .cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} - -#devel { +.debug { overflow: auto; + padding: 1em; + border-radius: 10px; + margin: 2em auto; +} +.debug dt { + font-weight: bold; } +/** Draggable items, from the draggable plugin */ li[draggable]::before { content: "↕"; padding: 0.5em; @@ -160,6 +199,28 @@ li[draggable] { border: 1px white dashed; } -#cacheButton.disabled { - display: none; + + + + +/** Toasts are little pop-up informational messages. */ + .toasts { + position: fixed; + z-index: 100; + bottom: 10px; + left: 10px; + text-align: center; + width: calc(100% - 20px); + display: flex; + flex-direction: column; +} +.toast { + border-radius: 0.5em; + padding: 0.2em 2em; + animation: fadeIn ease 1s; + margin: 2px auto; +} +@keyframes fadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } } diff --git a/theme/common.mjs b/theme/common.mjs new file mode 100644 index 0000000..3b8d0c2 --- /dev/null +++ b/theme/common.mjs @@ -0,0 +1,43 @@ +/** + * Common functionality + */ +const Millisecond = 1 +const Second = Millisecond * 1000 +const Minute = Second * 60 + +/** + * Display a transient message to the user. + * + * @param {String} message Message to display + * @param {Number} timeout How long before removing this message + */ +function Toast(message, timeout=5*Second) { + console.info(message) + for (let toasts of document.querySelectorAll(".toasts")) { + let p = toasts.appendChild(document.createElement("p")) + p.classList.add("toast") + p.textContent = message + setTimeout(() => p.remove(), timeout) + } +} + +/** + * Run a function when the DOM has been loaded. + * + * @param {function():void} cb Callback function + */ +function WhenDOMLoaded(cb) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", cb) + } else { + cb() + } +} + +export { + Millisecond, + Second, + Minute, + Toast, + WhenDOMLoaded, +} diff --git a/theme/index.html b/theme/index.html index c7c8770..4457033 100644 --- a/theme/index.html +++ b/theme/index.html @@ -1,32 +1,34 @@ - + MOTH - - + + -

MOTH

+

MOTH

-
+
-
+ Team ID:
Team name:
-
+
+ +
diff --git a/theme/index.mjs b/theme/index.mjs new file mode 100644 index 0000000..adc9e7a --- /dev/null +++ b/theme/index.mjs @@ -0,0 +1,158 @@ +/** + * Functionality for index.html (Login / Puzzles list) + */ +import * as moth from "./moth.mjs" +import * as common from "./common.mjs" + +class App { + constructor(basePath=".") { + this.server = new moth.Server(basePath) + + let uuid = Math.floor(Math.random() * 1000000).toString(16) + this.fakeRegistration = { + TeamId: uuid, + TeamName: `Team ${uuid}`, + } + + for (let form of document.querySelectorAll("form.login")) { + form.addEventListener("submit", event => this.handleLoginSubmit(event)) + } + for (let e of document.querySelectorAll(".logout")) { + e.addEventListener("click", () => this.Logout()) + } + + setInterval(() => this.Update(), common.Minute/3) + this.Update() + } + + handleLoginSubmit(event) { + event.preventDefault() + console.log(event) + } + + /** + * Attempt to log in to the server. + * + * @param {String} teamId + * @param {String} teamName + */ + async Login(teamId, teamName) { + try { + await this.server.Login(teamId, teamName) + common.Toast(`Logged in (team id = ${teamId})`) + this.Update() + } + catch (error) { + common.Toast(error) + } + } + + /** + * Log out of the server by clearing the saved Team ID. + */ + async Logout() { + try { + this.server.Reset() + common.Toast("Logged out") + this.Update() + } + catch (error) { + common.Toast(error) + } + } + + /** + * Update the entire page. + * + * Fetch a new state, and rebuild all dynamic elements on this bage based on + * what's returned. If we're in development mode and not logged in, auto + * login too. + */ + async Update() { + this.state = await this.server.GetState() + for (let e of document.querySelectorAll(".messages")) { + e.innerHTML = this.state.Messages + } + + for (let e of document.querySelectorAll(".login")) { + this.renderLogin(e, !this.server.LoggedIn()) + } + for (let e of document.querySelectorAll(".puzzles")) { + this.renderPuzzles(e, this.server.LoggedIn()) + } + + if (this.state.DevelopmentMode() && !this.server.LoggedIn()) { + common.Toast("Automatically logging in to devel server") + console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration) + return this.Login(this.fakeRegistration.TeamId, this.fakeRegistration.TeamName) + } + } + + /** + * Render a login box. + * + * This just toggles visibility, there's nothing dynamic in a login box. + */ + renderLogin(element, visible) { + element.classList.toggle("hidden", !visible) + } + + /** + * Render a puzzles box. + * + * This updates the list of open puzzles, and adds mothball download links + * if the server is in development mode. + */ + renderPuzzles(element, visible) { + element.classList.toggle("hidden", !visible) + while (element.firstChild) element.firstChild.remove() + for (let cat of this.state.Categories()) { + let pdiv = element.appendChild(document.createElement("div")) + pdiv.classList.add("category") + + let h = pdiv.appendChild(document.createElement("h2")) + h.textContent = cat + + // Extras if we're running a devel server + if (this.state.DevelopmentMode()) { + let a = h.appendChild(document.createElement('a')) + a.classList.add("mothball") + a.textContent = "📦" + a.href = this.server.URL(`mothballer/${cat}.mb`) + a.title = "Download a compiled puzzle for this category" + } + + // List out puzzles in this category + let l = pdiv.appendChild(document.createElement("ul")) + for (let puzzle of this.state.Puzzles(cat)) { + let i = l.appendChild(document.createElement("li")) + + let url = new URL("puzzle.html", window.location) + url.hash = `${puzzle.Category}:${puzzle.Points}` + let a = i.appendChild(document.createElement("a")) + a.textContent = puzzle.Points + a.href = url + a.target = "_blank" + } + + if (!this.state.HasUnsolved(cat)) { + l.appendChild(document.createElement("li")).textContent = "✿" + } + + element.appendChild(pdiv) + } + } +} + +function init() { + window.app = { + server: new App() + } +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init) +} else { + init() +} + \ No newline at end of file diff --git a/theme/logout.html b/theme/logout.html deleted file mode 100644 index 14f3caf..0000000 --- a/theme/logout.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - MOTH - - - - - -

MOTH

-
-

Okay, you've been logged out.

-
- - - diff --git a/theme/moment.min.js b/theme/moment.min.js deleted file mode 100644 index 5787a40..0000000 --- a/theme/moment.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function h(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function d(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n>>0,s=0;sSe(e)?(r=e+1,o-Se(e)):(r=e,o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(Se(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),C("week","w"),C("isoWeek","W"),F("week",5),F("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=D(e)});function je(e,t){return e.slice(t,7).concat(e.slice(0,t))}I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),C("day","d"),C("weekday","e"),C("isoWeekday","E"),F("day",11),F("weekday",11),F("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=D(e)});var Ze="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var $e="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var qe=ae;var Je=ae;var Be=ae;function Qe(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=he(o[t]),u[t]=he(u[t]),l[t]=he(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Xe(){return this.hours()%12||12}function Ke(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function et(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Xe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)+L(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+L(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+L(this.minutes(),2)+L(this.seconds(),2)}),Ke("a",!0),Ke("A",!1),C("hour","h"),F("hour",13),ue("a",et),ue("A",et),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=D(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=D(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i))});var tt,nt=Te("Hours",!0),st={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:He,week:{dow:0,doy:6},weekdays:Ze,weekdaysMin:$e,weekdaysShort:ze,meridiemParse:/[ap]\.?m?\.?/i},it={},rt={};function at(e){return e?e.toLowerCase().replace("_","-"):e}function ot(e){var t=null;if(!it[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=tt._abbr,require("./locale/"+e),ut(t)}catch(e){}return it[e]}function ut(e,t){var n;return e&&((n=l(t)?ht(e):lt(e,t))?tt=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),tt._abbr}function lt(e,t){if(null===t)return delete it[e],null;var n,s=st;if(t.abbr=e,null!=it[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=it[e]._config;else if(null!=t.parentLocale)if(null!=it[t.parentLocale])s=it[t.parentLocale]._config;else{if(null==(n=ot(t.parentLocale)))return rt[t.parentLocale]||(rt[t.parentLocale]=[]),rt[t.parentLocale].push({name:e,config:t}),null;s=n._config}return it[e]=new P(x(s,t)),rt[e]&&rt[e].forEach(function(e){lt(e.name,e.config)}),ut(e),it[e]}function ht(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return tt;if(!o(e)){if(t=ot(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return tt}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ct(e._a[me],s[me]),(e._dayOfYear>Se(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[ve]&&0===e._a[pe]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o;return e<100&&0<=e?(o=new Date(e+400,t,n,s,i,r,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,s,i,r,a),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var mt=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,yt=/Z|[+-]\d\d(?::?\d\d)?/,gt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],vt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function wt(e){var t,n,s,i,r,a,o=e._i,u=mt.exec(o)||_t.exec(o);if(u){for(g(e).iso=!0,t=0,n=gt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},mn.isLocal=function(){return!!this.isValid()&&!this._isUTC},mn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},mn.isUtc=Et,mn.isUTC=Et,mn.zoneAbbr=function(){return this._isUTC?"UTC":""},mn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},mn.dates=n("dates accessor is deprecated. Use date instead.",un),mn.months=n("months accessor is deprecated. Use month instead",Ue),mn.years=n("years accessor is deprecated. Use year instead",Oe),mn.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),mn.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Ot(e))._a){var t=e._isUTC?y(e._a):bt(e._a);this._isDSTShifted=this.isValid()&&0 { p.remove() }, - timeout - ) -} - -function renderNotices(obj) { - let ne = document.getElementById("notices") - if (ne) { - ne.innerHTML = obj - } -} - -function renderPuzzles(obj) { - let puzzlesElement = document.createElement('div') - - document.getElementById("login").style.display = "none" - - // Create a sorted list of category names - let cats = Object.keys(obj) - cats.sort() - if (cats.length == 0) { - toast("No categories to serve!") - } - for (let cat of cats) { - if (cat.startsWith("__")) { - // Skip metadata - continue - } - let puzzles = obj[cat] - - let pdiv = document.createElement('div') - pdiv.className = 'category' - - let h = document.createElement('h2') - pdiv.appendChild(h) - h.textContent = cat - - // Extras if we're running a devel server - if (devel) { - let a = document.createElement('a') - h.insertBefore(a, h.firstChild) - a.textContent = "⬇️" - a.href = "mothballer/" + cat + ".mb" - a.classList.add("mothball") - a.title = "Download a compiled puzzle for this category" - } - - // List out puzzles in this category - let l = document.createElement('ul') - pdiv.appendChild(l) - for (let puzzle of puzzles) { - let points = puzzle - let id = null - - if (Array.isArray(puzzle)) { - points = puzzle[0] - id = puzzle[1] - } - - let i = document.createElement('li') - l.appendChild(i) - i.textContent = " " - - if (points === 0) { - // Sentry: there are no more puzzles in this category - i.textContent = "✿" - } else { - let a = document.createElement('a') - i.appendChild(a) - a.textContent = points - let url = new URL("puzzle.html", window.location) - url.searchParams.set("cat", cat) - url.searchParams.set("points", points) - if (id) { url.searchParams.set("pid", id) } - a.href = url.toString() - } - } - - puzzlesElement.appendChild(pdiv) - } - - // Drop that thing in - let container = document.getElementById("puzzles") - while (container.firstChild) { - container.firstChild.remove() - } - container.appendChild(puzzlesElement) -} - -function renderState(obj) { - window.state = obj - devel = obj.Config.Devel - if (devel) { - let params = new URLSearchParams(window.location.search) - sessionStorage.id = "1" - renderPuzzles(obj.Puzzles) - } else if (Object.keys(obj.Puzzles).length > 0) { - renderPuzzles(obj.Puzzles) - } - renderNotices(obj.Messages) -} - -function heartbeat() { - let teamId = sessionStorage.id || "" - let url = new URL("state", window.location) - url.searchParams.set("id", teamId) - - let fd = new FormData() - fd.append("id", teamId) - fetch(url) - .then(resp => { - if (resp.ok) { - resp.json() - .then(renderState) - .catch(err => { - toast("Error fetching recent state. I'll try again in a moment.") - console.log(err) - }) - } - }) - .catch(err => { - toast("Error fetching recent state. I'll try again in a moment.") - console.log(err) - }) -} - -function showPuzzles() { - let spinner = document.createElement("span") - spinner.classList.add("spinner") - - document.getElementById("login").style.display = "none" - document.getElementById("puzzles").appendChild(spinner) -} - -function login(e) { - e.preventDefault() - let name = document.querySelector("[name=name]").value - let teamId = document.querySelector("[name=id]").value - - fetch("register", { - method: "POST", - body: new FormData(e.target), - }) - .then(resp => { - if (resp.ok) { - resp.json() - .then(obj => { - if ((obj.status == "success") || (obj.data.short == "Already registered")) { - toast("Logged in") - sessionStorage.id = teamId - showPuzzles() - heartbeat() - } else { - toast(obj.data.description) - } - }) - .catch(err => { - toast("Oops, the server has lost its mind. You probably need to tell someone so they can fix it.") - console.log(err, resp) - }) - } else { - toast("Oops, something's wrong with the server. Try again in a few seconds.") - console.log(resp) - } - }) - .catch(err => { - toast("Oops, something went wrong. Try again in a few seconds.") - console.log(err) - }) -} - -function init() { - heartbeat() - setInterval(e => heartbeat(), 40000) - - document.getElementById("login").addEventListener("submit", login) -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init) -} else { - init() -} - diff --git a/theme/moth.mjs b/theme/moth.mjs index 13acc7d..11b9181 100644 --- a/theme/moth.mjs +++ b/theme/moth.mjs @@ -208,6 +208,19 @@ class Puzzle { } return false } + + /** + * Submit a proposed answer for points. + * + * The returned promise will fail if anything goes wrong, including the + * proposed answer being rejected. + * + * @param {String} proposed Answer to submit + * @returns {Promise.} Success message + */ + SubmitAnswer(proposed) { + return this.server.SubmitAnswer(this.Category, this.Points, proposed) + } } /** @@ -228,23 +241,27 @@ class State { /** Configuration */ this.Config = { - /** Is the server in debug mode? + /** Is the server in development mode? * @type {Boolean} */ - Debug: obj.Config.Debug, + Devel: obj.Config.Devel, } + /** Global messages, in HTML * @type {String} */ this.Messages = obj.Messages + /** Map from Team ID to Team Name * @type {Object.} */ this.TeamNames = obj.TeamNames + /** Map from category name to puzzle point values * @type {Object.} */ this.PointsByCategory = obj.Puzzles + /** Log of points awarded * @type {Award[]} */ @@ -278,6 +295,15 @@ class State { return !this.PointsByCategory[category].includes(0) } + /** + * Is the server in development mode? + * + * @returns {Boolean} + */ + DevelopmentMode() { + return this.Config && this.Config.Devel + } + /** * Return all open puzzles. * @@ -313,10 +339,16 @@ class State { * and will send a Team ID with every request, if it can find one. */ class Server { + /** + * @param {String | URL} baseUrl Base URL to server, for constructing API URLs + */ constructor(baseUrl) { + if (!baseUrl) { + throw("Must provide baseURL") + } this.baseUrl = new URL(baseUrl, location) - this.teameIdKey = this.baseUrl.toString() + " teamID" - this.teamId = localStorage[this.teameIdKey] + this.teamIdKey = this.baseUrl.toString() + " teamID" + this.TeamId = localStorage[this.teamIdKey] } /** @@ -326,21 +358,29 @@ class Server { * this function throws an error. * * This always sends teamId. - * If body is set, POST will be used instead of GET + * If args is set, POST will be used instead of GET * * @param {String} path Path to API endpoint - * @param {Object.} body Key/Values to send in POST data + * @param {Object.} args Key/Values to send in POST data * @returns {Promise.} Response */ - fetch(path, body) { + fetch(path, args) { let url = new URL(path, this.baseUrl) - if (this.teamId & (!(body && body.id))) { - url.searchParams.set("id", this.teamId) + if (this.TeamId & (!(args && args.id))) { + url.searchParams.set("id", this.TeamId) } - return fetch(url, { - method: body?"POST":"GET", - body, - }) + + if (args) { + let formData = new FormData() + for (let k in args) { + formData.set(k, args[k]) + } + return fetch(url, { + method: "POST", + body: formData, + }) + } + return fetch(url) } /** @@ -356,7 +396,7 @@ class Server { switch (obj.status) { case "success": return obj.data - case "failure": + case "fail": throw new Error(obj.data.description || obj.data.short || obj.data) case "error": throw new Error(obj.message) @@ -365,20 +405,38 @@ class Server { } } + /** + * Make a new URL for the given resource. + * + * @returns {URL} + */ + URL(url) { + return new URL(url, this.baseUrl) + } + + /** + * Are we logged in to the server? + * + * @returns {Boolean} + */ + LoggedIn() { + return this.TeamId ? true : false + } + /** * Forget about any previous Team ID. * * This is equivalent to logging out. */ Reset() { - localStorage.removeItem(this.teameIdKey) - this.teamId = null + localStorage.removeItem(this.teamIdKey) + this.TeamId = null } /** * Fetch current contest state. * - * @returns {State} + * @returns {Promise.} */ async GetState() { let resp = await this.fetch("/state") @@ -387,37 +445,41 @@ class Server { } /** - * Register a team name with a team ID. - * - * This is similar to, but not exactly the same as, logging in. - * See MOTH documentation for details. - * + * Log in to a team. + * + * This calls the server's registration endpoint; if the call succeds, or + * fails with "team already exists", the login is returned as successful. + * * @param {String} teamId * @param {String} teamName * @returns {Promise.} Success message from server */ - async Register(teamId, teamName) { - let data = await this.call("/login", {id: teamId, name: teamName}) - this.teamId = teamId - this.teamName = teamName - localStorage[this.teameIdKey] = teamId + async Login(teamId, teamName) { + let data = await this.call("/register", {id: teamId, name: teamName}) + this.TeamId = teamId + this.TeamName = teamName + localStorage[this.teamIdKey] = teamId return data.description || data.short } /** - * Submit a puzzle answer for points. + * Submit a proposed answer for points. * * The returned promise will fail if anything goes wrong, including the - * answer being rejected. + * proposed answer being rejected. * * @param {String} category Category of puzzle * @param {Number} points Point value of puzzle - * @param {String} answer Answer to submit - * @returns {Promise.} Was the answer accepted? + * @param {String} proposed Answer to submit + * @returns {Promise.} Success message */ - async SubmitAnswer(category, points, answer) { - await this.call("/answer", {category, points, answer}) - return true + async SubmitAnswer(category, points, proposed) { + let data = await this.call("/answer", { + cat: category, + points, + answer: proposed, + }) + return data.description || data.short } /** diff --git a/theme/puzzle.html b/theme/puzzle.html index a9f0963..a5e4232 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -10,29 +10,25 @@ +

[loading]

-

[loading]

-
-
-

- Starting script... -

-
+
+

+ Starting script... +

+
+

    Puzzle by [loading]

    - Team ID:
    - Answer:
    + + +
    -
    - +
    +
    diff --git a/theme/puzzle.js b/theme/puzzle.js deleted file mode 100644 index ab65341..0000000 --- a/theme/puzzle.js +++ /dev/null @@ -1,225 +0,0 @@ -// jshint asi:true - -// prettify adds classes to various types, returning an HTML string. -function prettify(key, val) { - switch (key) { - case "Body": - return '[HTML]' - } - return val -} - -// devel_addin drops a bunch of development extensions into element e. -// It will only modify stuff inside e. -function devel_addin(e) { - let h = e.appendChild(document.createElement("h2")) - h.textContent = "Developer Output" - - let log = window.puzzle.Debug.Log || [] - if (log.length > 0) { - e.appendChild(document.createElement("h3")).textContent = "Log" - let le = e.appendChild(document.createElement("ul")) - for (let entry of log) { - le.appendChild(document.createElement("li")).textContent = entry - } - } - - e.appendChild(document.createElement("h3")).textContent = "Puzzle object" - - let hobj = JSON.stringify(window.puzzle, prettify, 2) - let d = e.appendChild(document.createElement("pre")) - d.classList.add("object") - d.innerHTML = hobj - - e.appendChild(document.createElement("p")).textContent = "This debugging information will not be available to participants." -} - -// Hash routine used in v3.4 and earlier -function djb2hash(buf) { - let h = 5381 - for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte - // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations. - // So we have to do "unsigned right shift" by zero to get it back to unsigned. - h = (((h * 33) + c) & 0xffffffff) >>> 0 - } - return h -} - -// The routine used to hash answers in compiled puzzle packages -async function sha256Hash(message) { - const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array - const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message - const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string - return hashHex; -} - -// Is the provided answer possibly correct? -async function checkAnswer(answer) { - let answerHashes = [] - answerHashes.push(djb2hash(answer)) - answerHashes.push(await sha256Hash(answer)) - - for (let hash of answerHashes) { - for (let correctHash of window.puzzle.AnswerHashes) { - if (hash == correctHash) { - return true - } - } - } - return false -} - -// Pop up a message -function toast(message, timeout=5000) { - let p = document.createElement("p") - - p.innerText = message - document.getElementById("messages").appendChild(p) - setTimeout( - e => { p.remove() }, - timeout - ) -} - -// When the user submits an answer -function submit(e) { - e.preventDefault() - let data = new FormData(e.target) - - window.data = data - fetch("answer", { - method: "POST", - body: data, - }) - .then(resp => { - if (resp.ok) { - resp.json() - .then(obj => { - toast(obj.data.description) - }) - } else { - toast("Error submitting your answer. Try again in a few seconds.") - console.log(resp) - } - }) - .catch(err => { - toast("Error submitting your answer. Try again in a few seconds.") - console.log(err) - }) -} - -async function loadPuzzle(categoryName, points, puzzleId) { - let puzzle = document.getElementById("puzzle") - let base = "content/" + categoryName + "/" + puzzleId + "/" - - let resp = await fetch(base + "puzzle.json") - if (! resp.ok) { - console.log(resp) - let err = await resp.text() - Array.from(puzzle.childNodes).map(e => e.remove()) - p = puzzle.appendChild(document.createElement("p")) - p.classList.add("Error") - p.textContent = err - return - } - - // Make the whole puzzle available - window.puzzle = await resp.json() - - // Populate authors - document.getElementById("authors").textContent = window.puzzle.Authors.join(", ") - - // If answers are provided, this is the devel server - if (window.puzzle.Answers.length > 0) { - devel_addin(document.getElementById("devel")) - } - - // Load scripts - for (let script of (window.puzzle.Scripts || [])) { - let st = document.createElement("script") - document.head.appendChild(st) - st.src = base + script - } - - // List associated files - for (let fn of (window.puzzle.Attachments || [])) { - let li = document.createElement("li") - let a = document.createElement("a") - a.href = base + fn - a.innerText = fn - li.appendChild(a) - document.getElementById("files").appendChild(li) - } - - // Prefix `base` to relative URLs in the puzzle body - let doc = new DOMParser().parseFromString(window.puzzle.Body, "text/html") - for (let se of doc.querySelectorAll("[src],[href]")) { - se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"") - } - - // If a validation pattern was provided, set that - if (window.puzzle.AnswerPattern) { - document.querySelector("#answer").pattern = window.puzzle.AnswerPattern - } - - // Replace puzzle children with what's in `doc` - Array.from(puzzle.childNodes).map(e => e.remove()) - Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e)) - - document.title = categoryName + " " + points - document.querySelector("body > h1").innerText = document.title - document.querySelector("input[name=cat]").value = categoryName - document.querySelector("input[name=points]").value = points -} - -// Check to see if the answer might be correct -// This might be better done with the "constraint validation API" -// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation#Validating_forms_using_JavaScript -function answerCheck(e) { - let answer = e.target.value - let ok = document.querySelector("#answer_ok") - - // You have to provide someplace to put the check - if (! ok) { - return - } - - checkAnswer(answer) - .then (correct => { - if (correct) { - ok.textContent = "⭕" - ok.title = "Possibly correct" - } else { - ok.textContent = "❌" - ok.title = "Definitely not correct" - } - }) -} - -function init() { - let params = new URLSearchParams(window.location.search) - let categoryName = params.get("cat") - let points = params.get("points") - let puzzleId = params.get("pid") - - if (categoryName && points) { - loadPuzzle(categoryName, points, puzzleId || points) - } - - let teamId = sessionStorage.getItem("id") - if (teamId) { - document.querySelector("input[name=id]").value = teamId - } - - if (document.querySelector("#answer")) { - document.querySelector("#answer").addEventListener("input", answerCheck) - } - document.querySelector("form").addEventListener("submit", submit) -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init) -} else { - init() -} diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs index 5d8d86c..7d845ad 100644 --- a/theme/puzzle.mjs +++ b/theme/puzzle.mjs @@ -1,4 +1,10 @@ +/** + * Functionality for puzzle.html (Puzzle display / answer form) + */ import * as moth from "./moth.mjs" +import * as common from "./common.mjs" + +const server = new moth.Server(".") /** * Handle a submit event on a form. @@ -10,9 +16,22 @@ import * as moth from "./moth.mjs" * * @param {Event} event */ -function formSubmitHandler(event) { +async function formSubmitHandler(event) { event.preventDefault() - console.log(event) + let data = new FormData(event.target) + let proposed = data.get("answer") + let message + + console.group("Submit answer") + console.info(`Proposed answer: ${proposed}`) + try { + message = await window.app.puzzle.SubmitAnswer(proposed) + } + catch (err) { + common.Toast(err) + } + common.Toast(message) + console.groupEnd("Submit answer") } /** @@ -69,12 +88,40 @@ function error(error) { * * @param {String} s */ -function setanswer(s) { +function SetAnswer(s) { let e = document.querySelector("#answer") e.value = s e.dispatchEvent(new Event("input")) } +function writeObject(e, obj) { + let keys = Object.keys(obj) + keys.sort() + for (let key of keys) { + let val = obj[key] + if ((key === "Body") || (!val) || (val.length === 0)) { + continue + } + + let d = e.appendChild(document.createElement("dt")) + d.textContent = key + + let t = e.appendChild(document.createElement("dd")) + if (Array.isArray(val)) { + let vi = t.appendChild(document.createElement("ul")) + vi.multiple = true + for (let a of val) { + let opt = vi.appendChild(document.createElement("li")) + opt.textContent = a + } + } else if (typeof(val) === "object") { + writeObject(t, val) + } else { + t.textContent = val + } + } +} + /** * Load the given puzzle. * @@ -93,16 +140,17 @@ async function loadPuzzle(category, points) { } } - let server = new moth.Server() let puzzle = server.GetPuzzle(category, points) console.time("Populate") await puzzle.Populate() console.timeEnd("Populate") + console.info("Tweaking HTML...") let title = `${category} ${points}` document.querySelector("title").textContent = title document.querySelector("#title").textContent = title document.querySelector("#authors").textContent = puzzle.Authors.join(", ") + document.querySelector("#answer").pattern = window.puzzle.AnswerPattern puzzleElement().innerHTML = puzzle.Body console.info("Adding attached scripts...") @@ -110,7 +158,7 @@ async function loadPuzzle(category, points) { let st = document.createElement("script") document.head.appendChild(st) st.src = new URL(script, contentBase) - } + } console.info("Listing attached files...") for (let fn of (puzzle.Attachments || [])) { @@ -122,6 +170,16 @@ async function loadPuzzle(category, points) { document.getElementById("files").appendChild(li) } + + console.info("Filling debug information...") + for (let e of document.querySelectorAll(".debug")) { + if (puzzle.Answers.length > 0) { + writeObject(e, puzzle) + } else { + e.classList.add("hidden") + } + } + let baseElement = document.head.appendChild(document.createElement("base")) baseElement.href = contentBase @@ -129,11 +187,13 @@ async function loadPuzzle(category, points) { console.info("window.app.puzzle =", window.app.puzzle) console.groupEnd() + + return puzzle } -function init() { +async function init() { window.app = {} - window.setanswer = setanswer + window.setanswer = (str => SetAnswer(str)) for (let form of document.querySelectorAll("form.answer")) { form.addEventListener("submit", formSubmitHandler) @@ -158,12 +218,7 @@ function init() { return } - loadPuzzle(category, points) - .catch(err => error(err)) + window.app.puzzle = await loadPuzzle(category, points) } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init) -} else { - init() -} +common.WhenDOMLoaded(init) diff --git a/theme/scoreboard.css b/theme/scoreboard.css new file mode 100644 index 0000000..470e72d --- /dev/null +++ b/theme/scoreboard.css @@ -0,0 +1,91 @@ +/* GHC displays: 1024x1820 */ +@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) { + html { + font-size: 20pt; + } +} + +#chart { + background-color: rgba(0, 0, 0, 0.8); +} +.logo { + text-align: center; + background-color: rgba(255, 255, 255, 0.2); + font-family: Montserrat, sans-serif; + font-weight: 500; + border-radius: 10px; + font-size: 1.2em; +} +.cyber { + color: black; +} +.fire { + color: #d94a1f; +} +.announcement.floating { + position: fixed; + bottom: 0; + width: 100hw; + max-width: inherit; +} +.announcement { + background-color: rgba(255,255,255,0.5); + color: black; + padding: 0.25em; + border-radius: 5px; + max-width: 20em; + text-align: center; + display: flex; + align-items: flex-end; + justify-content: space-around; + font-size: 1.3em; + flex-wrap: wrap; +} +.announcement div { + margin: 1em; + max-width: 45vw; + text-align: center; + +} +.qrcode { + width: 30vw; +} +.examples { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} +.examples > div { + margin: 0.5em; + max-width: 40%; +} + +#rankings { + width: 100%; + position: relative; + background-color: rgba(0, 0, 0, 0.8); +} + +#rankings span { + font-size: 75%; + display: inline-block; + overflow: hidden; + height: 1.7em; +} +#rankings span.teamname { + font-size: inherit; + color: white; + text-shadow: 0 0 3px black; + opacity: 0.8; + position: absolute; + right: 0.2em; +} +#rankings div * {white-space: nowrap;} +.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} +.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} +.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;} +.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;} +.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;} +.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;} +.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} +.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} diff --git a/theme/scoreboard.html b/theme/scoreboard.html index ed0c339..8b1f787 100644 --- a/theme/scoreboard.html +++ b/theme/scoreboard.html @@ -3,22 +3,17 @@ Scoreboard + - - - + + + + -

    -
    +
    - diff --git a/theme/scoreboard.js b/theme/scoreboard.mjs similarity index 72% rename from theme/scoreboard.js rename to theme/scoreboard.mjs index 104efcb..e2d7cfc 100644 --- a/theme/scoreboard.js +++ b/theme/scoreboard.mjs @@ -1,8 +1,19 @@ // jshint asi:true +// import { Chart, registerables } from "https://cdn.jsdelivr.net/npm/chart.js@3.0.2" +// import {DateTime} from "https://cdn.jsdelivr.net/npm/luxon@1.26.0" +// import "https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@0.1.1" +// Chart.register(...registerables) + +const MILLISECOND = 1 +const SECOND = 1000 * MILLISECOND +const MINUTE = 60 * SECOND + +// If all else fails... +setInterval(() => location.reload(), 30 * SECOND) + function scoreboardInit() { - - chartColors = [ + let chartColors = [ "rgb(255, 99, 132)", "rgb(255, 159, 64)", "rgb(255, 205, 86)", @@ -11,13 +22,71 @@ function scoreboardInit() { "rgb(153, 102, 255)", "rgb(201, 203, 207)" ] - - function update(state) { - window.state = state - + + for (let q of document.querySelectorAll("[data-url]")) { + let url = new URL(q.dataset.url, document.location) + q.textContent = url.hostname + if (url.port) { + q.textContent += `:${url.port}` + } + if (url.pathname != "/") { + q.textContent += url.pathname + } + } + for (let q of document.querySelectorAll(".qrcode")) { + let url = new URL(q.dataset.url, document.location) + let qr = new QRious({ + element: q, + value: url.toString(), + }) + } + + let chart + let canvas = document.querySelector("#chart canvas") + if (canvas) { + chart = new Chart(canvas.getContext("2d"), { + type: "line", + options: { + responsive: true, + scales: { + x: { + type: "time", + time: { + // XXX: the manual says this should do something, it does something in the samples, IDK + tooltipFormat: "HH:mm" + }, + title: { + display: true, + text: "Time" + } + }, + y: { + title: { + display: true, + text: "Points" + } + } + }, + tooltips: { + mode: "index", + intersect: false + }, + hover: { + mode: "nearest", + intersect: true + } + } + }) + } + + async function refresh() { + let resp = await fetch("../state") + let state = await resp.json() + for (let rotate of document.querySelectorAll(".rotate")) { rotate.appendChild(rotate.firstElementChild) } + window.scrollTo(0,0) let element = document.getElementById("rankings") let teamNames = state.TeamNames @@ -28,12 +97,12 @@ function scoreboardInit() { // // We have been doing some variation on this "everybody backs up the server state" trick since 2009. // We have needed it 0 times. - let stateHistory = JSON.parse(localStorage.getItem("stateHistory")) || [] - if (stateHistory.length >= 20) { - stateHistory.shift() + let pointsHistory = JSON.parse(localStorage.getItem("pointsHistory")) || [] + if (pointsHistory.length >= 20) { + pointsHistory.shift() } - stateHistory.push(state) - localStorage.setItem("stateHistory", JSON.stringify(stateHistory)) + pointsHistory.push(pointsLog) + localStorage.setItem("pointsHistory", JSON.stringify(pointsHistory)) let teams = {} let highestCategoryScore = {} // map[string]int @@ -89,7 +158,7 @@ function scoreboardInit() { overall += team.categoryScore[cat] / highestCategoryScore[cat] } - team.historyLine.push({t: new Date(timestamp * 1000), y: overall}) + team.historyLine.push({x: timestamp * 1000, y: overall}) } // Compute overall scores based on current highest @@ -150,14 +219,21 @@ function scoreboardInit() { element.appendChild(row) } - let datasets = [] + if (!chart) { + return + } + + /* + * Update chart + */ + chart.data.datasets = [] for (let i in winners) { if (i > 5) { break } let team = winners[i] let color = chartColors[i % chartColors.length] - datasets.push({ + chart.data.datasets.push({ label: team.name, backgroundColor: color, borderColor: color, @@ -166,70 +242,10 @@ function scoreboardInit() { fill: false }) } - let config = { - type: "line", - data: { - datasets: datasets - }, - options: { - responsive: true, - scales: { - xAxes: [{ - display: true, - type: "time", - time: { - tooltipFormat: "ll HH:mm" - }, - scaleLabel: { - display: true, - labelString: "Time" - } - }], - yAxes: [{ - display: true, - scaleLabel: { - display: true, - labelString: "Points" - } - }] - }, - tooltips: { - mode: "index", - intersect: false - }, - hover: { - mode: "nearest", - intersect: true - } - } - } - - let chart = document.querySelector("#chart") - if (chart) { - let canvas = chart.querySelector("canvas") - if (! canvas) { - canvas = document.createElement("canvas") - chart.appendChild(canvas) - } - - let myline = new Chart(canvas.getContext("2d"), config) - myline.update() - } + chart.update() + window.chart = chart } - function refresh() { - fetch("state") - .then(resp => { - return resp.json() - }) - .then(obj => { - update(obj) - }) - .catch(err => { - console.log(err) - }) - } - function init() { let base = window.location.href.replace("scoreboard.html", "") let location = document.querySelector("#location") @@ -237,7 +253,7 @@ function scoreboardInit() { location.textContent = base } - setInterval(refresh, 60000) + setInterval(refresh, 20 * SECOND) refresh() } diff --git a/theme/token.html b/theme/token.html index 80d6542..a2bce3b 100644 --- a/theme/token.html +++ b/theme/token.html @@ -1,45 +1,29 @@ - + Redeem Token - - +

    Redeem Token

    -
    -
    - - - - Team ID:
    - Token:
    +
    +

    + Have you found a token? +

    +

    + Tokens look like + category:5:xylep-radar-nanox +

    + Tokens may be redeemed here for points in their category. + Tokens can appear anywhere: online, on slips of paper, projected onto screens… +

    +
    + +
    - +
    diff --git a/theme/token.mjs b/theme/token.mjs new file mode 100644 index 0000000..bce773f --- /dev/null +++ b/theme/token.mjs @@ -0,0 +1,48 @@ +/** + * Functionality for token.html + */ +import * as moth from "./moth.mjs" +import * as common from "./common.mjs" + +const server = new moth.Server(".") + +/** + * Handle a submit event on a form. + * + * @param {SubmitEvent} event + */ +async function formSubmitHandler(event) { + event.preventDefault() + + let formData = new FormData(event.target) + let token = formData.get("token") + let vals = token.split(":") + let category = vals[0] + let points = Number(vals[1]) + let proposed = vals[2] + if (!category || !points || !proposed) { + console.info("Not a token:", vals) + common.Toast("This is not a properly-formed token") + return + } + try { + let message = await server.SubmitAnswer(category, points, proposed) + common.Toast(message) + } + catch (error) { + if (error.message == "incorrect answer") { + common.Toast("Unknown token") + } else { + console.error(error) + common.Toast(error) + } + } +} + +function init() { + for (let form of document.querySelectorAll("form.token")) { + form.addEventListener("submit", formSubmitHandler) + } +} + +common.WhenDOMLoaded(init) \ No newline at end of file