Merge branch 'master' into v2-Prototyping

This commit is contained in:
2025-10-31 23:53:35 +10:00
2 changed files with 439 additions and 64 deletions

144
sample-payload.json Normal file
View File

@@ -0,0 +1,144 @@
{
"type": "DO",
"method": "setTimer",
"args": {
"name": "Subathon",
"end_at": "2025-10-30T19:34:05.867637+00:00",
"is_running": false,
"score_table": {
"bit_score": 0.1,
"t1_score": 5.0,
"t2_score": 10.0,
"t3_score": 20.0,
"score_time": 10
},
"total_contribution": 120.0,
"total_subpoints": 24,
"total_bits": 0,
"goals_met": 4,
"goals_total": 16,
"last_goal": {
"required": 100.0,
"name": "reveal new sub and bit badges"
},
"next_goal": {
"required": 150.0,
"name": "discord watch party + puzzles"
},
"goals": [
{
"required": 25.0,
"name": "merch reveal! (drops on nov 6 anniversary)"
},
{
"required": 50.0,
"name": "chat picks my pfp for a week"
},
{
"required": 75.0,
"name": "make subathon magma board"
},
{
"required": 100.0,
"name": "reveal new sub and bit badges"
},
{
"required": 150.0,
"name": "discord watch party + puzzles"
},
{
"required": 200.0,
"name": "new emotes reveal (static)"
},
{
"required": 300.0,
"name": "karaoke time"
},
{
"required": 400.0,
"name": "voicemod on for an hour"
},
{
"required": 500.0,
"name": "new emotes reveal (animated)"
},
{
"required": 750.0,
"name": "chat picks a skeb to get"
},
{
"required": 1000.0,
"name": "chat picks new emote i will draw"
},
{
"required": 1250.0,
"name": "play peak with mods"
},
{
"required": 1500.0,
"name": "read voicelines/copypastas/memes that chat picks"
},
{
"required": 1750.0,
"name": "add bald merch to merch drop"
},
{
"required": 2000.0,
"name": "play horror game on stream (chat picks)"
},
{
"required": 3000.0,
"name": "upload a song cover to youtube"
}
],
"last_contribution": {
"user_name": null,
"user_id": null,
"amount": 25.0,
"seconds_added": 250,
"timestamp": "2025-10-30T22:43:53.786952+10:00"
},
"leaderboard": [
{
"user_name": "Unknown",
"user_id": 4,
"amount": 25.0
},
{
"user_name": "Maza1862",
"user_id": 5,
"amount": 25.0
},
{
"user_name": "Unknown",
"user_id": 6,
"amount": 25.0
},
{
"user_name": "lalzan",
"user_id": 9,
"amount": 25.0
},
{
"user_name": "DesmondGTX1",
"user_id": 10,
"amount": 5.0
},
{
"user_name": "SokuReload2",
"user_id": 11,
"amount": 5.0
},
{
"user_name": "Unknown",
"user_id": 12,
"amount": 5.0
},
{
"user_name": "Sum1wiserthanu",
"user_id": 13,
"amount": 5.0
}
]
}
}

359
timer.js
View File

@@ -1,36 +1,147 @@
window.addEventListener("DOMContentLoaded", () => {
const websocket = new WebSocket("wss://lilac.thewisewolf.dev/tracker/timer/ws");
websocket.addEventListener("open", () => {
communicate(websocket);
});
const websocket = new ReconnectingWebSocket("wss://lilac.thewisewolf.dev/tracker/timer/ws");
communicate(websocket);
});
class Timer {
constructor(renderer, end_time, running) {
constructor(renderer, timer_data) {
this.renderer = renderer;
this.end_time = end_time;
this.running = running;
this.destroying = false;
this.update_from_data(timer_data);
}
update_from_data(timer_data) {
this.end_at = new Date(timer_data.end_at);
this.running = timer_data.is_running;
this.name = timer_data.name;
this.score_table = timer_data.score_table;
this.total_contribution = timer_data.total_contribution;
this.total_subpoints = timer_data.total_subpoints;
this.total_bits = timer_data.total_bits;
this.goals_met = timer_data.goals_met;
this.goals_total = timer_data.goals_total;
this.last_goal = timer_data.last_goal;
this.next_goal = timer_data.next_goal;
this.goals = timer_data.goals;
this.last_contribution = timer_data.last_contribution;
this.leaderboard = timer_data.leaderboard;
}
render_time() {
// Render timer status to the document
var timestr;
// How many seconds to the end of time
var dur_seconds = Math.floor( (this.end_time - new Date()) / 1000);
this.renderer.render_time(
Math.floor((this.end_at - new Date()) / 1000)
);
}
if (dur_seconds < 0) {
render() {
// Render goal
if (this.next_goal != null) {
this.renderer.render_current_goal(
this.next_goal.name,
this.total_contribution,
this.next_goal.required,
);
} else if (this.last_goal != null) {
this.renderer.render_current_goal(
this.last_goal.name,
this.total_contribution,
this.last_goal.required,
);
} else {
this.renderer.clear_current_goal();
}
// Render leaderboard
this.renderer.render_users(this.leaderboard);
// Render timer
this.render_time();
}
/**
* Recursive call run per second to update the rendered timer
*/
tick() {
if (!this.destroying) {
this.render_time();
if (this.running) {
setTimeout(this.tick.bind(this), 1000);
}
}
}
}
class TimerRenderer {
constructor() {
// HH:MM or MM:SS
this.timer = document.getElementById("Timer");
// Current Goal:
this.goal_label = document.getElementById("GoalLabel")
// Name of the goal to display
this.goal_name = document.getElementById("GoalName")
// 1524/3000 Points
this.goal_progress = document.getElementById("GoalProgress")
// Top 3 Gifters
this.topusers_label = document.getElementById("TopUsers")
// Leaderboard items
// 1. (or points?)
this.topusers_user1_num = document.getElementById("GiftUserNum1")
// Name of user 1
this.topusers_user1_name = document.getElementById("GiftUserName1")
this.topusers_user2_num = document.getElementById("GiftUserNum2")
this.topusers_user2_name = document.getElementById("GiftUserName2")
this.topusers_user3_num = document.getElementById("GiftUserNum3")
this.topusers_user3_name = document.getElementById("GiftUserName3")
}
/**
* Bring the display into a standard initial state.
*/
reset() {
this.timer.textContent = "00:00";
this.goal_label.textContent = "Current Goal:";
this.goal_name.textContent = "Be awesome";
this.goal_progress.textContent = "00/00 Points";
this.topusers_label.textContent = "Top 3 Gifters";
this.topusers_user1_num.textContent = "1.";
this.topusers_user1_name.textContent = "Anonymous";
this.topusers_user2_num.textContent = "2.";
this.topusers_user2_name.textContent = "Anonymous";
this.topusers_user3_num.textContent = "3.";
this.topusers_user3_name.textContent = "Anonymous";
}
/**
* Render the timer from a time remaining.
*
* @param {integer} seconds_remaining - The number of seconds remaining on the timer.
*/
render_time(seconds_remaining) {
var timestr;
// Render a time remaining given in seconds
if (seconds_remaining <= 0) {
timestr = "00:00";
} else {
var seconds = dur_seconds % 60;
dur_seconds = dur_seconds - seconds;
var minutes = Math.floor(dur_seconds / 60);
var seconds = seconds_remaining % 60;
seconds_remaining = seconds_remaining - seconds;
var minutes = Math.floor(seconds_remaining / 60);
var hours = Math.floor(minutes / 60);
minutes = minutes - 60 * hours;
// Change rendering mode based on hours or minutes left
if (hours > 0) {
timestr = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0');
@@ -38,59 +149,180 @@ class Timer {
timestr = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
}
}
this.renderer.update_time(timestr);
this.timer.textContent = timestr;
}
tick() {
// Recursive call run per second to update and change stage
/**
* Render the current goal.
*
* @param {string} name - The name of the current goal.
* @param {number} current - The number of points we currently have.
* @param {number} required - The number of points to achieve the goal.
*/
render_current_goal(name, current, required) {
// TODO: Some kind of end condition, ability to clear a goal etc
// E.g. if subathon doesn't have a goal at all
// Or after we accomplish all goals
if (this.destroying) {
return
this.goal_name.textContent = name;
// Bitwise OR done to convert floats to integers if needed
this.goal_progress.textContent = String(current | 0) + '/' + String(required | 0) + ' Points'
}
/**
* Clear the current goal field.
*/
clear_current_goal() {
this.goal_name.textContent = "";
this.goal_progress.textContent = "";
this.goal_label.textContent = "";
}
/**
* Render the user leaderboard.
*
* @param {Object[]} users - The user leaderboard in descending (points, name) order.
* @param {integer} users[].amount - The number of points this users has contrib.
* @param {string} users[].user_name - The name of this user.
*/
render_users(users) {
if (users.length >= 1) {
this.topusers_user1_num.textContent = "1.";
this.topusers_user1_name.textContent = users[0].user_name;
} else {
this.topusers_user1_num.textContent = "";
this.topusers_user1_name.textContent = "";
}
this.render_time();
if (this.running) {
setTimeout(this.tick.bind(this), 1000);
if (users.length >= 2) {
this.topusers_user2_num.textContent = "2.";
this.topusers_user2_name.textContent = users[1].user_name;
} else {
this.topusers_user2_num.textContent = "";
this.topusers_user2_name.textContent = "";
}
if (users.length >= 3) {
this.topusers_user3_num.textContent = "3.";
this.topusers_user3_name.textContent = users[2].user_name;
} else {
this.topusers_user3_num.textContent = "";
this.topusers_user3_name.textContent = "";
}
}
}
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.attempt = 0;
this.connect();
this.closing = false;
this.onopen = null;
this.onmessage = null;
this.onclose = null;
this.onerror = null;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.attempt = 0;
if (this.onopen != null) {
this.onopen();
}
};
this.ws.onmessage = (msg) => {
console.log('Received:', msg.data);
if (this.onmessage != null) {
this.onmessage(msg);
}
};
this.ws.onclose = () => {
if (this.closing) {
console.log('Closing Websocket.')
} {
console.warn('WebSocket closed. Reconnecting...');
this.reconnect();
}
};
this.ws.onerror = (err) => {
console.error('WebSocket error:', err);
if (this.onerror != null) {
this.onerror();
}
this.close();
};
}
reconnect() {
const delay = this.getBackoffDelay(this.attempt);
console.log(`Reconnecting in ${delay}ms`);
setTimeout(() => {
this.attempt++;
this.connect();
}, delay);
}
close() {
this.closing = true;
this.ws.close();
if (this.onclose != null) {
this.onclose();
}
}
getBackoffDelay(attempt) {
const base = 500; // 0.5 second
const max = 30000; // 30 seconds
const jitter = Math.random() * 1000;
return Math.min(base * 2 ** attempt + jitter, max);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
console.warn('WebSocket not connected');
}
}
}
class TimerRenderer {
constructor (){
this.background = document.getElementsByClassName("timer-bg")[0];
this.time_element = document.getElementsByClassName("timer-time")[0];
this.bg_width = parseInt(
window.getComputedStyle(
this.background, null
).getPropertyValue('width'),
10
);
this.bg_height = parseInt(
window.getComputedStyle(
this.background, null
).getPropertyValue('height'),
10
)
}
update_time (timestr) {
this.time_element.textContent = timestr;
}
}
function communicate(websocket) {
console.log("Communicating");
const params = new URLSearchParams(window.location.search);
websocket.send(JSON.stringify({type: "init", channel: "SubTimer", community: params.get('community')}));
var renderer = new TimerRenderer();
let timer;
websocket.addEventListener("message", ({ data }) => {
console.log("Rec Event " + data);
websocket.onopen = () => {
websocket.send(
JSON.stringify(
{
type: "init",
channel: "SubTimer",
community: params.get('community')
}
)
);
renderer.reset();
};
websocket.onmessage = ({ data }) => {
const event = JSON.parse(data);
switch (event.type) {
case "DO":
@@ -99,25 +331,24 @@ function communicate(websocket) {
// Call the specified method
switch (event.method) {
case "setTimer":
if (timer != null) {
timer.destroying = true;
if (timer == null) {
timer = new Timer(renderer, args);
} else {
timer.update_from_data(args);
}
timer = new Timer(
renderer,
new Date(args.end_at),
args.running
)
timer.render();
timer.tick();
break;
case "noTimer":
if (timer != null) {
timer.renderer.update_time("--:--")
timer.destroying = true;
timer = null;
}
renderer.reset();
case "endTimer":
if (timer != null) {
timer.renderer.update_time("00:00")
timer.destroying = true;
timer = null;
}
default:
throw new Error(`Unsupported method requested: ${event.method}.`)
@@ -126,5 +357,5 @@ function communicate(websocket) {
default:
throw new Error(`Unsupported event type: ${event.type}.`);
}
});
};
}