Mid refactor to stop polluting the namespace so much
This commit is contained in:
2
static/js/modules/crypto/main.js
Normal file
2
static/js/modules/crypto/main.js
Normal file
@ -0,0 +1,2 @@
|
||||
import { generate_keypair } from "./paillier.js";
|
||||
export { generate_keypair };
|
14
static/js/modules/crypto/math.js
Normal file
14
static/js/modules/crypto/math.js
Normal file
@ -0,0 +1,14 @@
|
||||
export function mod_exp(a, b, n) {
|
||||
let res = 1n;
|
||||
|
||||
while (b > 0n) {
|
||||
if (b % 2n === 1n) {
|
||||
res = (res * a) % n;
|
||||
}
|
||||
|
||||
b >>= 1n;
|
||||
a = (a * a) % n;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
61
static/js/modules/crypto/paillier.js
Normal file
61
static/js/modules/crypto/paillier.js
Normal file
@ -0,0 +1,61 @@
|
||||
import { random2048, generate_prime } from "./random_primes.js";
|
||||
import { mod_exp } from "./math.js";
|
||||
|
||||
let p, q, pubKey, privKey;
|
||||
|
||||
class PubKey {
|
||||
constructor(p, q) {
|
||||
this.n = p * q;
|
||||
// this.g = this.n + 1n;
|
||||
}
|
||||
|
||||
encrypt(m) {
|
||||
// Compute g^m r^n mod n^2
|
||||
let r = random2048();
|
||||
|
||||
// Resample to avoid modulo bias.
|
||||
while (r >= this.n) {
|
||||
r = random2048();
|
||||
}
|
||||
|
||||
// Compute g^m by binomial theorem.
|
||||
let gm = (1n + this.n * m) % this.n ** 2n;
|
||||
// Compute g^m r^n from crt
|
||||
return (gm * mod_exp(r, this.n, this.n ** 2n)) % this.n ** 2n;
|
||||
}
|
||||
}
|
||||
|
||||
class PrivKey {
|
||||
constructor(p, q) {
|
||||
this.n = p * q;
|
||||
this.lambda = (p - 1n) * (q - 1n);
|
||||
this.mu = mod_exp(this.lambda, this.lambda - 1n, this.n);
|
||||
}
|
||||
|
||||
decrypt(c) {
|
||||
return (
|
||||
(((mod_exp(c, this.lambda, this.n ** 2n) - 1n) / this.n) * this.mu) % this.n
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function generate_keypair() {
|
||||
if (window.sessionStorage.getItem("p") === null) {
|
||||
p = generate_prime();
|
||||
window.sessionStorage.setItem("p", p);
|
||||
} else {
|
||||
p = BigInt(window.sessionStorage.getItem("p"));
|
||||
}
|
||||
|
||||
if (window.sessionStorage.getItem("q") === null) {
|
||||
q = generate_prime();
|
||||
window.sessionStorage.setItem("q", q);
|
||||
} else {
|
||||
q = BigInt(window.sessionStorage.getItem("q"));
|
||||
}
|
||||
|
||||
pubKey = new PubKey(p, q);
|
||||
privKey = new PrivKey(p, q);
|
||||
|
||||
return { pubKey, privKey };
|
||||
}
|
1321
static/js/modules/crypto/random_primes.js
Normal file
1321
static/js/modules/crypto/random_primes.js
Normal file
File diff suppressed because it is too large
Load Diff
33
static/js/modules/interface/barrier.js
Normal file
33
static/js/modules/interface/barrier.js
Normal file
@ -0,0 +1,33 @@
|
||||
import { socket, players } from "./main.js";
|
||||
import { Packet } from "./packet.js";
|
||||
|
||||
/**
|
||||
* Typical barrier type.
|
||||
*
|
||||
* Block all clients until everyone has hit the barrier.
|
||||
*/
|
||||
export class Barrier {
|
||||
constructor() {
|
||||
let resolver;
|
||||
this.promise = new Promise((resolve) => {
|
||||
resolver = resolve;
|
||||
});
|
||||
this.resolver = resolver;
|
||||
this.hits = new Set();
|
||||
}
|
||||
|
||||
wait() {
|
||||
socket.emit("message", Packet.createBarrierSignal());
|
||||
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
resolve(data) {
|
||||
this.hits.add(data.author);
|
||||
|
||||
if (this.hits.size === Object.keys(players).length - 1) {
|
||||
this.hits = new Set();
|
||||
this.resolver();
|
||||
}
|
||||
}
|
||||
}
|
240
static/js/modules/interface/dom.js
Normal file
240
static/js/modules/interface/dom.js
Normal file
@ -0,0 +1,240 @@
|
||||
import {
|
||||
gameState,
|
||||
WAITING,
|
||||
PRE_GAME,
|
||||
PLAYING,
|
||||
us,
|
||||
socket,
|
||||
players,
|
||||
ID,
|
||||
allPlayersReady,
|
||||
startPregame,
|
||||
} from "./main.js";
|
||||
import { Region } from "./map.js";
|
||||
import { Packet } from "./packet.js";
|
||||
|
||||
export function unlockMapDom() {
|
||||
Object.values(REGIONS).forEach((region) => {
|
||||
if (!allRegionsClaimed() && region.owner === null) {
|
||||
document
|
||||
.querySelector(`.node[data-name=${region.name}] .actions`)
|
||||
.classList.remove("hidden");
|
||||
} else if (region.owner === us) {
|
||||
document
|
||||
.querySelector(`.node[data-name=${region.name}] .actions`)
|
||||
.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function lockMapDom() {
|
||||
document.querySelectorAll(".actions").forEach((e) => e.classList.add("hidden"));
|
||||
}
|
||||
|
||||
export function updateDom() {
|
||||
if (gameState !== WAITING) {
|
||||
document.querySelector("#ready-button").style.display = "none";
|
||||
}
|
||||
|
||||
updatePlayerDom();
|
||||
showRemainingReinforcements();
|
||||
updateMapDom();
|
||||
}
|
||||
|
||||
function updateMapDom() {
|
||||
if (us.isPlaying) {
|
||||
unlockMapDom();
|
||||
} else {
|
||||
lockMapDom();
|
||||
}
|
||||
|
||||
if (gameState === PRE_GAME) {
|
||||
document.querySelectorAll(".fortify, .attack").forEach((e) => {
|
||||
e.classList.add("hidden");
|
||||
});
|
||||
} else if (gameState === PLAYING) {
|
||||
document.querySelectorAll(".node button").forEach((e) => {
|
||||
e.classList.remove("hidden");
|
||||
});
|
||||
|
||||
if (us.isPlaying) {
|
||||
document.querySelector("#end-turn").classList.remove("hidden");
|
||||
} else {
|
||||
document.querySelector("#end-turn").classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
for (let region of Region.getAllRegions()) {
|
||||
const element = document.querySelector(`.node[data-name=${region.name}]`);
|
||||
element.querySelector(".strength").textContent = region.strength || "";
|
||||
element.style.backgroundColor =
|
||||
region.owner === null ? "white" : region.owner.getColor();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayerDom() {
|
||||
let list = document.querySelector("#playerList");
|
||||
list.replaceChildren();
|
||||
|
||||
for (let playerId of Object.keys(players).sort()) {
|
||||
let player = players[playerId];
|
||||
|
||||
let statusSpan = document.createElement("div");
|
||||
statusSpan.classList.add("status-span");
|
||||
if (gameState === WAITING) {
|
||||
if (player.ready) {
|
||||
statusSpan.textContent = "R";
|
||||
statusSpan.classList.add("ready");
|
||||
} else {
|
||||
statusSpan.textContent = "N";
|
||||
statusSpan.classList.add("not-ready");
|
||||
}
|
||||
} else {
|
||||
if (player.isPlaying) {
|
||||
statusSpan.textContent = "P";
|
||||
}
|
||||
}
|
||||
|
||||
let idSpan = document.createElement("span");
|
||||
idSpan.style.color = player.getColor();
|
||||
if (playerId === ID) {
|
||||
idSpan.textContent = `${playerId} (you)`;
|
||||
} else {
|
||||
idSpan.textContent = playerId;
|
||||
}
|
||||
|
||||
let newDom = document.createElement("li");
|
||||
newDom.appendChild(statusSpan);
|
||||
newDom.appendChild(idSpan);
|
||||
list.appendChild(newDom);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelector("#ready-button").addEventListener("click", async (ev) => {
|
||||
let nowReady = ev.target.textContent === "Not ready";
|
||||
us.ready = nowReady;
|
||||
|
||||
ev.target.classList.toggle("active");
|
||||
ev.target.textContent = nowReady ? "Ready" : "Not ready";
|
||||
|
||||
socket.emit("message", Packet.createSetReady(nowReady));
|
||||
|
||||
updatePlayerDom();
|
||||
|
||||
if (allPlayersReady()) {
|
||||
await startPregame();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#end-turn").addEventListener("click", async (ev) => {
|
||||
socket.emit("message", Packet.createEndTurn());
|
||||
});
|
||||
|
||||
document.querySelectorAll(".reinforce").forEach((el) =>
|
||||
el.addEventListener("click", (ev) => {
|
||||
let region = ev.target.closest(".node").dataset.name;
|
||||
socket.emit("message", Packet.createRegionClaim(region));
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelectorAll(".attack").forEach((el) =>
|
||||
el.addEventListener("click", (ev) => {
|
||||
let modal = document.querySelector("#modal");
|
||||
|
||||
let region = ev.target.closest(".node").dataset["name"];
|
||||
|
||||
modal.dataset["region"] = ev.target.closest(".node").dataset.name;
|
||||
modal.dataset["action"] = "ATTACK";
|
||||
|
||||
let selector = modal.querySelector(".target");
|
||||
selector.replaceChildren("");
|
||||
for (let neighbour of Region.getRegion(region).neighbours) {
|
||||
let opt = document.createElement("option");
|
||||
opt.value = neighbour.name;
|
||||
opt.textContent = neighbour.name;
|
||||
selector.appendChild(opt);
|
||||
}
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelectorAll(".fortify").forEach((el) =>
|
||||
el.addEventListener("click", (ev) => {
|
||||
let modal = document.querySelector("#modal");
|
||||
|
||||
let region = ev.target.closest(".node").dataset["name"];
|
||||
|
||||
modal.dataset["region"] = region;
|
||||
modal.dataset["action"] = "FORTIFY";
|
||||
|
||||
let selector = modal.querySelector(".target");
|
||||
selector.replaceChildren("");
|
||||
for (let neighbour of Region.getRegion(region).neighbours) {
|
||||
let opt = document.createElement("option");
|
||||
opt.value = neighbour.name;
|
||||
opt.textContent = neighbour.name;
|
||||
selector.appendChild(opt);
|
||||
}
|
||||
|
||||
modal.classList.remove("hidden");
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelector("#modal .submit-modal").addEventListener("click", () => {
|
||||
let modal = document.querySelector("#modal");
|
||||
|
||||
let startRegion = modal.dataset["region"];
|
||||
let action = modal.dataset["action"];
|
||||
|
||||
let amount = modal.querySelector(".amount").value;
|
||||
let endRegion = modal.querySelector(".target").value;
|
||||
|
||||
socket.emit(
|
||||
"message",
|
||||
Packet.createAction(action, startRegion, endRegion, amount)
|
||||
);
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector("#defense-modal .submit-modal")
|
||||
.addEventListener("click", () => {
|
||||
let modal = document.querySelector("#defense-modal");
|
||||
let amount = modal.querySelector(".amount").value;
|
||||
socket.emit("message", Packet.createDefense(amount));
|
||||
});
|
||||
|
||||
document.querySelectorAll(".submit-modal, .cancel-modal").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
el.closest(".modal").classList.add("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#shuffleColors").addEventListener("click", () => {
|
||||
Object.values(players).forEach((player) => {
|
||||
player.resetColor();
|
||||
});
|
||||
updatePlayerDom();
|
||||
});
|
||||
});
|
||||
|
||||
function showRemainingReinforcements() {
|
||||
if (gameState === PRE_GAME) {
|
||||
document.querySelector(
|
||||
"#remaining-reinforcements"
|
||||
).innerHTML = `<span>Remaining placements: ${reinforcementsRemaining()}</span>`;
|
||||
} else if (gameState === PLAYING) {
|
||||
document.querySelector(
|
||||
"#remaining-reinforcements"
|
||||
).innerHTML = `<span>Remaining placements: ${
|
||||
currentPlayer().reinforcementsAvailable - currentPlayer().reinforcementsPlaced
|
||||
}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showDefenseDom(region) {
|
||||
const modal = document.querySelector("#defense-modal");
|
||||
modal.querySelector("span").textContent = region;
|
||||
modal.classList.remove("hidden");
|
||||
}
|
82
static/js/modules/interface/game.js
Normal file
82
static/js/modules/interface/game.js
Normal file
@ -0,0 +1,82 @@
|
||||
import { Player } from "./player";
|
||||
|
||||
const WAITING = 0;
|
||||
const PRE_GAME = 1;
|
||||
const PLAYING = 2;
|
||||
|
||||
export class Game {
|
||||
constructor() {
|
||||
this.us = null;
|
||||
this.players = {};
|
||||
this.state = WAITING;
|
||||
}
|
||||
|
||||
isWaiting() {
|
||||
return this.state === WAITING;
|
||||
}
|
||||
|
||||
isPregame() {
|
||||
return this.state === PRE_GAME;
|
||||
}
|
||||
|
||||
isPlaying() {
|
||||
return this.state === PLAYING;
|
||||
}
|
||||
|
||||
incrementState() {
|
||||
this.state += 1;
|
||||
}
|
||||
|
||||
currentPlayer() {
|
||||
return Object.values(this.players).filter((p) => p.isPlaying)[0];
|
||||
}
|
||||
|
||||
addPlayer(id, name, is_us) {
|
||||
let is_new = this.players[id] === undefined;
|
||||
|
||||
if (this.isWaiting()) {
|
||||
this.players[id] = new Player(id, name, is_us);
|
||||
if (is_us === true) {
|
||||
this.us = this.players[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (is_new) {
|
||||
const event = new CustomEvent("addPlayer");
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
return is_new;
|
||||
}
|
||||
|
||||
removePlayer(id) {
|
||||
if (this.players[id] !== undefined) {
|
||||
const event = new CustomEvent("removePlayer");
|
||||
document.dispatchEvent(event);
|
||||
delete this.players[id];
|
||||
}
|
||||
}
|
||||
|
||||
keepAlive(id) {
|
||||
if (id !== this.us.id) {
|
||||
this.players[id].resetTimeout(this);
|
||||
}
|
||||
}
|
||||
|
||||
setReady(id, ready) {
|
||||
this.players[id].readyState = ready;
|
||||
|
||||
if (this._allPlayersReady()) {
|
||||
this.incrementState();
|
||||
}
|
||||
}
|
||||
|
||||
_allPlayersReady() {
|
||||
for (let player of Object.values(this.players)) {
|
||||
if (!player.readyState) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
135
static/js/modules/interface/main.js
Normal file
135
static/js/modules/interface/main.js
Normal file
@ -0,0 +1,135 @@
|
||||
import { generate_keypair } from "../crypto/main.js";
|
||||
import { Random } from "./random.js";
|
||||
import { Barrier } from "./barrier.js";
|
||||
import { Packet } from "./packet.js";
|
||||
import { updateDom } from "./dom.js";
|
||||
|
||||
export const ID = window.crypto.randomUUID();
|
||||
export let us = null;
|
||||
|
||||
export const game = new Game();
|
||||
|
||||
export let socket;
|
||||
let random;
|
||||
let barrier;
|
||||
let keys;
|
||||
|
||||
// Not totally reliable but better than nothing.
|
||||
window.addEventListener("beforeunload", () => {
|
||||
socket.emit("message", Packet.createDisconnect());
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
socket = io();
|
||||
random = new Random();
|
||||
barrier = new Barrier();
|
||||
keys = generate_keypair();
|
||||
|
||||
socket.on("connect", () => {
|
||||
window.console.log("Connected!");
|
||||
socket.emit("message", Packet.createAnnounce());
|
||||
game.addPlayer(ID, name, true);
|
||||
});
|
||||
|
||||
socket.on("message", async (data) => {
|
||||
// todo validate signature
|
||||
|
||||
switch (data.type) {
|
||||
case "RANDOM":
|
||||
if (data.author === ID) {
|
||||
return;
|
||||
}
|
||||
await random.processCooperativeRandom(data);
|
||||
break;
|
||||
|
||||
case "BARRIER":
|
||||
if (data.author === ID) {
|
||||
return;
|
||||
}
|
||||
barrier.resolve(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
const event = new CustomEvent(data.type, { detail: data });
|
||||
document.dispatchEvent(event);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Process player connect packets: these inform that a new player has joined.
|
||||
*
|
||||
* @param data Packet received
|
||||
*/
|
||||
document.addEventListener("ANNOUNCE", (data) => {
|
||||
if (data.author === ID) return;
|
||||
|
||||
let is_new = game.addPlayer(data.author, data.name, false);
|
||||
|
||||
// When a new player is seen, all announce to ensure they know all players.
|
||||
if (is_new) {
|
||||
socket.emit("message", Packet.createAnnounce());
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DISCONNECT", (data) => {
|
||||
game.removePlayer(data.author);
|
||||
});
|
||||
|
||||
/**
|
||||
* Process keep-alive packets: these are packets that check players are still online.
|
||||
*
|
||||
* @param data Packet received
|
||||
*/
|
||||
document.addEventListener("KEEPALIVE", (data) => {
|
||||
game.keepAlive(data.author);
|
||||
});
|
||||
|
||||
document.addEventListener("ACT", async (data) => {
|
||||
if (data.author !== game.currentPlayer().id) {
|
||||
if (data.action === "DEFENSE") {
|
||||
await game.players[data.author].setDefense(data.amount);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.isWaiting()) {
|
||||
game.setReady(data.author, data.ready);
|
||||
} else if (game.isPregame()) {
|
||||
if (!Region.allRegionsClaimed()) {
|
||||
// Claim a region in the pregame.
|
||||
if (game.currentPlayer().claim(data)) {
|
||||
// Increment to next player.
|
||||
game.currentPlayer().endTurn();
|
||||
}
|
||||
} else if (!Region.allReinforcementsPlaced()) {
|
||||
if (game.currentPlayer().reinforce(data)) {
|
||||
game.currentPlayer().endTurn();
|
||||
}
|
||||
}
|
||||
|
||||
if (Region.allReinforcementsPlaced()) {
|
||||
game.incrementState();
|
||||
}
|
||||
} else {
|
||||
if (await game.currentPlayer().act(data)) {
|
||||
game.currentPlayer().endTurn();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function startPregame() {
|
||||
gameState = PRE_GAME;
|
||||
|
||||
let firstPlayerIndex = await random.get(Object.keys(players).length, "first-player");
|
||||
|
||||
let firstPlayer = Object.values(players).sort((a, b) => (a.id < b.id ? -1 : 1))[
|
||||
firstPlayerIndex
|
||||
];
|
||||
|
||||
firstPlayer.isPlaying = true;
|
||||
await barrier.wait();
|
||||
updateDom();
|
||||
}
|
113
static/js/modules/interface/map.js
Normal file
113
static/js/modules/interface/map.js
Normal file
@ -0,0 +1,113 @@
|
||||
let allPlaced = false;
|
||||
|
||||
// In standard Risk, this is 5
|
||||
const _REINFORCEMENT_MULTIPLIER = 1;
|
||||
|
||||
export const REGIONS = {};
|
||||
|
||||
class Continent {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.yield = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class Region {
|
||||
constructor(name, continent) {
|
||||
this.name = name;
|
||||
this.owner = null;
|
||||
this.strength = 0;
|
||||
this.neighbours = new Set();
|
||||
this.continent = continent;
|
||||
|
||||
REGIONS[name] = this;
|
||||
}
|
||||
|
||||
static setNeighbours(region1, region2) {
|
||||
region1.neighbours.add(region2);
|
||||
region2.neighbours.add(region1);
|
||||
}
|
||||
|
||||
static allRegionsClaimed() {
|
||||
return (
|
||||
Object.values(REGIONS).find((region) => region.owner === null) === undefined
|
||||
);
|
||||
}
|
||||
|
||||
static reinforcementsRemaining() {
|
||||
if (allPlaced) {
|
||||
return 0;
|
||||
} else {
|
||||
let totalStrength = Object.values(REGIONS)
|
||||
.filter((region) => region.owner === us)
|
||||
.reduce((counter, region) => counter + region.strength, 0);
|
||||
let numPlayers = Object.values(players).length;
|
||||
|
||||
return _REINFORCEMENT_MULTIPLIER * (10 - numPlayers) - totalStrength;
|
||||
}
|
||||
}
|
||||
|
||||
static allReinforcementsPlaced() {
|
||||
if (allPlaced) {
|
||||
return true;
|
||||
} else {
|
||||
let totalStrength = Object.values(REGIONS).reduce(
|
||||
(counter, region) => counter + region.strength,
|
||||
0
|
||||
);
|
||||
let numPlayers = Object.values(players).length;
|
||||
|
||||
allPlaced =
|
||||
totalStrength >=
|
||||
numPlayers * _REINFORCEMENT_MULTIPLIER * (10 - numPlayers);
|
||||
return allPlaced;
|
||||
}
|
||||
}
|
||||
|
||||
static getRegion(name) {
|
||||
return REGIONS[name];
|
||||
}
|
||||
|
||||
static getAllRegions() {
|
||||
return Object.values(REGIONS);
|
||||
}
|
||||
|
||||
claim(player) {
|
||||
this.owner = player;
|
||||
this.strength = 1;
|
||||
}
|
||||
|
||||
reinforce(amount) {
|
||||
this.strength += amount;
|
||||
}
|
||||
}
|
||||
|
||||
const EAST = new Continent("East");
|
||||
const WEST = new Continent("West");
|
||||
|
||||
const A = new Region("A", EAST);
|
||||
const B = new Region("B", EAST);
|
||||
const C = new Region("C", EAST);
|
||||
const D = new Region("D", EAST);
|
||||
const J = new Region("J", EAST);
|
||||
|
||||
const F = new Region("F", WEST);
|
||||
const G = new Region("G", WEST);
|
||||
const H = new Region("H", WEST);
|
||||
const I = new Region("I", WEST);
|
||||
const E = new Region("E", WEST);
|
||||
|
||||
Region.setNeighbours(A, B);
|
||||
Region.setNeighbours(A, C);
|
||||
Region.setNeighbours(B, C);
|
||||
Region.setNeighbours(B, J);
|
||||
Region.setNeighbours(C, D);
|
||||
Region.setNeighbours(C, F);
|
||||
Region.setNeighbours(E, J);
|
||||
Region.setNeighbours(E, I);
|
||||
Region.setNeighbours(E, H);
|
||||
Region.setNeighbours(F, J);
|
||||
Region.setNeighbours(F, G);
|
||||
Region.setNeighbours(G, H);
|
||||
Region.setNeighbours(G, I);
|
||||
Region.setNeighbours(H, I);
|
70
static/js/modules/interface/packet.js
Normal file
70
static/js/modules/interface/packet.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { ID } from "./main.js";
|
||||
|
||||
export class Packet {
|
||||
static _createBase(name) {
|
||||
return {
|
||||
type: name,
|
||||
id: window.crypto.randomUUID(),
|
||||
author: ID,
|
||||
};
|
||||
}
|
||||
|
||||
static createAnnounce() {
|
||||
return {
|
||||
...this._createBase("ANNOUNCE"),
|
||||
name: "",
|
||||
};
|
||||
}
|
||||
|
||||
static createDisconnect() {
|
||||
return this._createBase("DISCONNECT");
|
||||
}
|
||||
|
||||
static createKeepAlive() {
|
||||
return this._createBase("KEEPALIVE");
|
||||
}
|
||||
|
||||
static createSetReady(nowReady) {
|
||||
return {
|
||||
...this._createBase("ACT"),
|
||||
action: "READY",
|
||||
ready: nowReady,
|
||||
};
|
||||
}
|
||||
|
||||
static createBarrierSignal() {
|
||||
return this._createBase("BARRIER");
|
||||
}
|
||||
|
||||
static createRegionClaim(region) {
|
||||
return {
|
||||
...this._createBase("ACT"),
|
||||
region: region,
|
||||
};
|
||||
}
|
||||
|
||||
static createAction(action, startRegion, endRegion, amount) {
|
||||
return {
|
||||
...this._createBase("ACT"),
|
||||
startRegion: startRegion,
|
||||
endRegion: endRegion,
|
||||
strength: amount,
|
||||
action: action,
|
||||
};
|
||||
}
|
||||
|
||||
static createDefense(amount) {
|
||||
return {
|
||||
...this._createBase("ACT"),
|
||||
action: "DEFENSE",
|
||||
amount: amount,
|
||||
};
|
||||
}
|
||||
|
||||
static createEndTurn() {
|
||||
return {
|
||||
...this._createBase("ACT"),
|
||||
action: "END",
|
||||
};
|
||||
}
|
||||
}
|
317
static/js/modules/interface/player.js
Normal file
317
static/js/modules/interface/player.js
Normal file
@ -0,0 +1,317 @@
|
||||
import { Packet } from "./packet.js";
|
||||
import { socket } from "./main.js";
|
||||
import { updateDom } from "./dom.js";
|
||||
|
||||
// Timeout to consider a player disconnected
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
const PHASE_REINFORCE = 1;
|
||||
const PHASE_ATTACK = 2;
|
||||
const PHASE_FORTIFY = 3;
|
||||
|
||||
let totalDice = 0;
|
||||
|
||||
export class Player {
|
||||
constructor(id, local) {
|
||||
this.timeout = null;
|
||||
this.id = id;
|
||||
this.ready = false;
|
||||
|
||||
// Data which is reset every turn
|
||||
this.isPlaying = false;
|
||||
this.reinforcementsPlaced = 0;
|
||||
this.reinforcementsAvailable = 0;
|
||||
this.offenderRegion = null;
|
||||
this.turnPhase = PHASE_REINFORCE;
|
||||
|
||||
// Data for defending
|
||||
this.defenderPromise = null;
|
||||
this.defenderAmount = null;
|
||||
|
||||
this.resetColor();
|
||||
|
||||
if (local) {
|
||||
// Emit keepalive messages to inform other players we are still here
|
||||
window.setInterval(() => {
|
||||
socket.emit("message", Packet.createKeepAlive());
|
||||
}, TIMEOUT / 5);
|
||||
}
|
||||
}
|
||||
|
||||
resetTimeout(game) {
|
||||
if (this.timeout !== null) {
|
||||
window.clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
this.timeout = window.setTimeout(() => {
|
||||
game.removePlayer(this);
|
||||
}, TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get our color as used on the board.
|
||||
*/
|
||||
getColor() {
|
||||
return this.color;
|
||||
}
|
||||
|
||||
resetColor() {
|
||||
let randomColor = Math.random() * 360;
|
||||
this.color = `hsl(${randomColor} 57% 50%)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim a region of the map.
|
||||
*
|
||||
* @param data Data received via socket.
|
||||
*/
|
||||
claim(data) {
|
||||
let region = Region.getRegion(data.region);
|
||||
|
||||
if (region.owner === null) {
|
||||
region.claim(this);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinforce a region of the map.
|
||||
*
|
||||
* @param data Data received via socket.
|
||||
*/
|
||||
reinforce(data) {
|
||||
let region = Region.getRegion(data.region);
|
||||
|
||||
if (region.owner === this) {
|
||||
region.reinforce(1);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a generic action packet representing a player's move.
|
||||
*
|
||||
* @param data Data received via socket
|
||||
* @returns {boolean} Whether this player's turn has ended or not.
|
||||
*/
|
||||
async act(data) {
|
||||
console.log(`player: ${this.id}`);
|
||||
console.log(data);
|
||||
|
||||
if (this.turnPhase === PHASE_REINFORCE) {
|
||||
if (data.region !== undefined) {
|
||||
if (this.reinforce(data)) {
|
||||
this.reinforcementsPlaced += 1;
|
||||
}
|
||||
|
||||
if (this.reinforcementsPlaced === this.reinforcementsAvailable) {
|
||||
this.turnPhase = PHASE_ATTACK;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} else {
|
||||
// End turn prematurely.
|
||||
if (data.action === "END") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.turnPhase === PHASE_ATTACK && data.action === "ATTACK") {
|
||||
await this.attack(data);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.turnPhase = PHASE_FORTIFY;
|
||||
|
||||
if (data.action === "FORTIFY") {
|
||||
return this.fortify(data);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an action which is to attack another region.
|
||||
*
|
||||
* @param data Data received via socket
|
||||
*/
|
||||
async attack(data) {
|
||||
let offender = Region.getRegion(data.startRegion);
|
||||
let defender = Region.getRegion(data.endRegion);
|
||||
let offenderStrength = parseInt(data.strength);
|
||||
|
||||
// Basic validation on game state
|
||||
if (
|
||||
offender.owner !== this ||
|
||||
defender.owner === this ||
|
||||
offenderStrength > 3 ||
|
||||
offenderStrength <= 0 ||
|
||||
offenderStrength >= offender.strength ||
|
||||
(this.offenderRegion !== null && this.offenderRegion !== offender)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're the defender, we need to send a packet to state our defense.
|
||||
if (defender.owner === us) {
|
||||
showDefenseDom(defender.name);
|
||||
}
|
||||
|
||||
// Grab the defense amount from
|
||||
let defenderStrength = 0;
|
||||
while (
|
||||
defenderStrength <= 0 ||
|
||||
defenderStrength > Math.min(2, defender.strength)
|
||||
) {
|
||||
defenderStrength = await defender.owner.getDefense();
|
||||
}
|
||||
|
||||
/* How do Risk attacks work?
|
||||
- Offender signs 1-3 armies, defender signs 1-2 armies
|
||||
- Both roll respective dice
|
||||
- Compare pairs of highest die in the rolls to remove armies.
|
||||
*/
|
||||
|
||||
if (this.offenderRegion === null) {
|
||||
this.offenderRegion = offender;
|
||||
}
|
||||
|
||||
let offenderRolls = [];
|
||||
let defenderRolls = [];
|
||||
|
||||
// Get random values
|
||||
for (let i = 0; i < offenderStrength; i++) {
|
||||
offenderRolls.push(await random.get(6, `dice-${totalDice}`));
|
||||
totalDice += 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < defenderStrength; i++) {
|
||||
defenderRolls.push(await random.get(6, `dice-${totalDice}`));
|
||||
totalDice += 1;
|
||||
}
|
||||
|
||||
console.log(`attacker rolls: ${offenderRolls}`);
|
||||
console.log(`defender rolls: ${defenderRolls}`);
|
||||
|
||||
offenderRolls.sort();
|
||||
defenderRolls.sort();
|
||||
|
||||
// Compare and settle.
|
||||
while (offenderRolls.length * defenderRolls.length > 0) {
|
||||
let offenderResult = offenderRolls.pop();
|
||||
let defenderResult = defenderRolls.pop();
|
||||
|
||||
if (offenderResult > defenderResult) {
|
||||
defender.strength -= 1;
|
||||
} else {
|
||||
offender.strength -= 1;
|
||||
}
|
||||
|
||||
if (defender.strength === 0) {
|
||||
defender.strength = offenderRolls.length + 1;
|
||||
offender.strength -= offenderRolls.length + 1;
|
||||
defender.owner = this;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the promises in case they attack again.
|
||||
defender.owner.defenderPromise = null;
|
||||
defender.owner.defenderAmount = null;
|
||||
|
||||
updateDom();
|
||||
}
|
||||
|
||||
async setDefense(amount) {
|
||||
await navigator.locks.request("defender", () => {
|
||||
this.defenderAmount = amount;
|
||||
if (this.defenderPromise !== null) {
|
||||
this.defenderPromise(amount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getDefense() {
|
||||
let promise, resolver;
|
||||
await navigator.locks.request("defender", () => {
|
||||
if (this.defenderAmount === null) {
|
||||
promise = new Promise((resolve) => {
|
||||
resolver = resolve;
|
||||
});
|
||||
this.defenderPromise = resolver;
|
||||
} else {
|
||||
promise = new Promise((resolve) => {
|
||||
resolve(this.defenderAmount);
|
||||
});
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an action which is to attack another region.
|
||||
*
|
||||
* @param data Data received via socket
|
||||
*/
|
||||
fortify(data) {
|
||||
let sender = Region.getRegion(data.startRegion);
|
||||
let receiver = Region.getRegion(data.endRegion);
|
||||
let strength = parseInt(data.strength);
|
||||
|
||||
if (
|
||||
sender.owner === this &&
|
||||
receiver.owner === this &&
|
||||
sender.strength > strength &&
|
||||
strength > 0
|
||||
) {
|
||||
receiver.reinforce(strength);
|
||||
sender.strength -= strength;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of additional reinforcements to gain on this turn.
|
||||
*/
|
||||
additionalReinforcements() {
|
||||
return Math.min(
|
||||
3,
|
||||
Math.floor(
|
||||
Object.values(REGIONS).filter((region) => region.owner === this).length /
|
||||
3
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a player's turn.
|
||||
*/
|
||||
startTurn() {
|
||||
this.isPlaying = true;
|
||||
this.reinforcementsPlaced = 0;
|
||||
this.reinforcementsAvailable = this.additionalReinforcements();
|
||||
this.offenderRegion = null;
|
||||
this.turnPhase = PHASE_REINFORCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* End player's turn
|
||||
*/
|
||||
endTurn() {
|
||||
this.isPlaying = false;
|
||||
this.nextPlayer().startTurn();
|
||||
}
|
||||
|
||||
nextPlayer() {
|
||||
let sorted = Object.values(players).sort((a, b) => (a.id < b.id ? -1 : 1));
|
||||
let ourIndex = sorted.findIndex((player) => player.id === this.id);
|
||||
|
||||
return sorted[(ourIndex + 1) % sorted.length];
|
||||
}
|
||||
}
|
141
static/js/modules/interface/random.js
Normal file
141
static/js/modules/interface/random.js
Normal file
@ -0,0 +1,141 @@
|
||||
import { socket, ID, players } from "./main.js";
|
||||
|
||||
class RandomSession {
|
||||
constructor(range) {
|
||||
this.range = range;
|
||||
this.cipherTexts = {};
|
||||
this.cipherKeys = {};
|
||||
this.ourKey = CryptoJS.lib.WordArray.random(32).toString();
|
||||
// 32-bit as JavaScript does funny stuff at 53-bit levels.
|
||||
this.ourNoise = CryptoJS.lib.WordArray.random(4);
|
||||
this.finalValue = null;
|
||||
this.resolvers = [];
|
||||
}
|
||||
|
||||
cipherText() {
|
||||
return CryptoJS.AES.encrypt(this.ourNoise, this.ourKey).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export class Random {
|
||||
constructor() {
|
||||
this.sessions = {};
|
||||
}
|
||||
|
||||
async get(n, sessionId) {
|
||||
if (this.sessions[sessionId] === undefined) {
|
||||
this.initialiseSession(n, sessionId);
|
||||
}
|
||||
|
||||
let promise;
|
||||
await navigator.locks.request(`random-${sessionId}`, () => {
|
||||
if (this.sessions[sessionId].finalValue === null) {
|
||||
let session = this.sessions[sessionId];
|
||||
let resolver;
|
||||
promise = new Promise((resolve) => {
|
||||
resolver = resolve;
|
||||
});
|
||||
session.resolvers.push(resolver);
|
||||
} else {
|
||||
promise = new Promise((resolve) => {
|
||||
resolve(this.sessions[sessionId].finalValue);
|
||||
});
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a cooperative random session.
|
||||
*/
|
||||
initialiseSession(n, sessionId) {
|
||||
let session = new RandomSession(n);
|
||||
if (sessionId === undefined) {
|
||||
sessionId = window.crypto.randomUUID();
|
||||
}
|
||||
|
||||
this.sessions[sessionId] = session;
|
||||
|
||||
socket.emit("message", {
|
||||
type: "RANDOM",
|
||||
author: ID,
|
||||
session: sessionId,
|
||||
range: n,
|
||||
stage: "CIPHERTEXT",
|
||||
cipherText: session.cipherText(),
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process cooperative random protocol.
|
||||
*
|
||||
* @param data Packet received
|
||||
*/
|
||||
async processCooperativeRandom(data) {
|
||||
// Step 0: extract relevant information from data
|
||||
let session = this.sessions[data.session];
|
||||
const stage = data.stage;
|
||||
|
||||
if (session === undefined) {
|
||||
session = this.initialiseSession(data.range, data.session);
|
||||
}
|
||||
|
||||
if (stage === "CIPHERTEXT") {
|
||||
session.cipherTexts[data.author] = data.cipherText;
|
||||
|
||||
if (
|
||||
Object.keys(session.cipherTexts).length ===
|
||||
Object.keys(players).length - 1
|
||||
) {
|
||||
// Step 3: release our key once all players have sent a ciphertext
|
||||
socket.emit("message", {
|
||||
type: "RANDOM",
|
||||
author: ID,
|
||||
session: data.session,
|
||||
stage: "DECRYPT",
|
||||
cipherKey: session.ourKey,
|
||||
});
|
||||
}
|
||||
} else if (stage === "DECRYPT") {
|
||||
session.cipherKeys[data.author] = data.cipherKey;
|
||||
|
||||
// Step 4: get final random value
|
||||
if (
|
||||
Object.keys(session.cipherKeys).length ===
|
||||
Object.keys(players).length - 1
|
||||
) {
|
||||
// Lock out wait calls as they may resolve to never-ending promises.
|
||||
await navigator.locks.request(`random-${data.session}`, () => {
|
||||
let total = parseInt(session.ourNoise, 16);
|
||||
|
||||
for (let participant of Object.keys(session.cipherKeys)) {
|
||||
let decrypted = CryptoJS.AES.decrypt(
|
||||
session.cipherTexts[participant],
|
||||
session.cipherKeys[participant]
|
||||
).toString();
|
||||
|
||||
total += parseInt(decrypted, 16);
|
||||
}
|
||||
|
||||
session.finalValue = total % session.range;
|
||||
|
||||
this.resolve(data.session);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session by calling any callbacks associated with the session and then deleting it.
|
||||
*
|
||||
* @param sessionId
|
||||
*/
|
||||
resolve(sessionId) {
|
||||
const session = this.sessions[sessionId];
|
||||
for (let resolve of session.resolvers) {
|
||||
resolve(session.finalValue);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user