Riskless/static/js/modules/interface/player.js
2023-05-01 14:42:17 +01:00

546 lines
16 KiB
JavaScript

import { Packet } from "./packet.js";
import { socket, game, random } from "./main.js";
import { RsaPubKey } from "../crypto/rsa.js";
import { PaillierPubKey, ReadOnlyCiphertext } from "../crypto/paillier.js";
import { Region, Strength } from "./map.js";
import { showDefenseDom } from "./dom.js";
import {
proveBitLength,
proveFortify,
proveRegions,
verifyBitLength,
verifyFortify,
verifyRegions,
} from "./proofs.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, rsaKey, paillierKey) {
// Game state
this.totalStrength = 0;
this.ready = false;
// Protocol state
this.timeout = null;
this.id = id;
this.rsaPubKey = RsaPubKey.fromJSON(rsaKey);
this.paillierPubKey = PaillierPubKey.fromJSON(paillierKey);
this.lastPacket = 0;
// 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 / 2);
}
}
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%)`;
}
/**
* Get all regions controlled by this player.
*/
getRegions() {
return Region.getAllRegions().filter((r) => r.owner === this);
}
/**
* 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,
new ReadOnlyCiphertext(this.paillierPubKey, BigInt(data.cipherText))
);
this.totalStrength += 1;
return true;
} else {
return false;
}
}
/**
* Reinforce a region of the map.
*
* Todo. Check object before committing changes.
*
* @param data Data received via socket.
*/
reinforce(data) {
if (!verifyRegions(data.verification, this.paillierPubKey)) {
console.log("Failed to verify reinforcements!");
return false;
}
for (let regionName of Object.keys(data.regions)) {
let region = Region.getRegion(regionName);
if (region.owner === this) {
region.reinforce(
new ReadOnlyCiphertext(
this.paillierPubKey,
BigInt(data.regions[regionName])
)
);
}
}
this.totalStrength += 1;
// request proofs
for (let region of this.getRegions()) {
if ([...region.neighbours.values()].find((r) => r.owner === game.us)) {
region.requestProof();
}
}
return true;
}
sendClaim(region) {
let cipherText = this.paillierPubKey.encrypt(1n);
Region.getRegion(region).claim(this, cipherText);
socket.emit("message", Packet.createRegionClaim(region, cipherText.toString()));
this.totalStrength += 1;
this.endTurn();
}
sendReinforce(region) {
let regions = {};
for (let ourRegion of this.getRegions()) {
let cipherText;
if (ourRegion.name !== region) {
cipherText = this.paillierPubKey.encrypt(0n);
} else {
cipherText = this.paillierPubKey.encrypt(1n);
}
regions[ourRegion.name] = cipherText;
ourRegion.reinforce(cipherText);
}
let verification = proveRegions(regions);
socket.emit("message", Packet.createReinforce(regions, verification));
this.totalStrength += 1;
if (game.isPregame()) {
game.us.endTurn();
if (game.allReinforcementsPlaced()) {
game.incrementState();
}
}
}
/**
* Process an action which is to attack another region.
*
* @param data Data received via socket
*/
fortify(data) {
if (!verifyFortify(data.fortify, this.paillierPubKey)) {
console.log("Failed to verify fortify!");
return false;
}
if (
!Object.keys(data.fortify.fortify).reduce(
(c, r) => c && Region.getRegion(r).owner === this,
true
)
) {
console.log("Invalid fortify");
return false;
}
for (let regionName of Object.keys(data.fortify.fortify)) {
let region = Region.getRegion(regionName);
let c1 = region.strength.cipherText.clone();
let c2 = new ReadOnlyCiphertext(
this.paillierPubKey,
BigInt(data.fortify.fortify[regionName])
);
c1.update(c2);
let v = verifyBitLength({
...data.fortify.rangeProofs[regionName],
cipherText: c1,
});
if (v !== null && v <= 8) {
region.reinforce(c2);
} else {
return false;
}
}
// request proofs
for (let region of this.getRegions()) {
if ([...region.neighbours.values()].find((r) => r.owner === game.us)) {
region.requestProof();
}
}
return true;
}
sendFortify(startRegion, endRegion, amount) {
let fortify = {
[startRegion]: new Ciphertext(this.paillierPubKey, BigInt(-amount)),
[endRegion]: new Ciphertext(this.paillierPubKey, BigInt(amount)),
};
for (let r of this.getRegions()) {
if (!fortify.hasOwnProperty(r.name)) {
fortify[r.name] = new Ciphertext(this.paillierPubKey, 0n);
}
}
for (let r of Object.keys(fortify)) {
Region.getRegion(r).reinforce(fortify[r]);
}
socket.emit("message", Packet.createFortify(proveFortify(fortify)));
}
/**
* Process a generic action packet representing a player's move.
*
* @param data Data received via socket
* @returns {boolean} Whether this player's turn should now end or not.
*/
async act(data) {
if (this.turnPhase === PHASE_REINFORCE) {
if (data.regions !== undefined) {
if (this === game.us) {
this.reinforcementsPlaced += 1;
} else 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") {
if (this === game.us) {
return true;
} else {
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;
}
game.contendedRegion = defender;
// If we're the defender, we need to send a packet to state our defense.
if (defender.owner === game.us) {
showDefenseDom(defender.name);
}
// Grab the defense amount from
let defenderStrength = 0;
while (
defenderStrength <= 0 ||
defenderStrength > Math.min(2, defender.strength)
) {
console.log("waiting");
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.transparentUpdate(-1n);
} else {
offender.strength.transparentUpdate(-1n);
}
}
// Handle aftermath.
if (defender.owner === game.us) {
if (defender.strength.cipherText.plainText === 0n) {
// State we don't control the region. This makes programming easier.
socket.emit("message", Packet.createRegionYield());
} else {
let ct = defender.strength.cipherText.clone();
ct.update(new Ciphertext(ct.pubKey, -1n, 0n));
// Prove we still control the region
let proof = proveBitLength(ct);
// Send proof we maintain it
socket.emit("message", Packet.createRegionProof(proof));
}
} else if (this === game.us) {
if (defender.strength.assumedStrength === 0n) {
// Handle region gain
let newStrength = new Ciphertext(
this.paillierPubKey,
BigInt(offenderRolls.length) + 1n
);
defender.owner = this;
defender.strength = new Strength(newStrength, defender.name);
// Send the new ciphertext
socket.emit("message", Packet.createRegionCapture(newStrength));
} else {
// State we didn't capture. Again, makes programming easier.
socket.emit("message", Packet.createRegionYield());
}
}
let resolutions = await defender.resolveAttack();
if (
resolutions.attackerRes.action === "CAPTURE" &&
resolutions.defenderRes.action === "YIELD"
) {
// Capture occurred.
if (this === game.us) {
// Already done above.
} else {
defender.owner = this;
defender.strength = new Strength(
new ReadOnlyCiphertext(
this.paillierPubKey,
BigInt(resolutions.attackerRes.cipherText)
),
defender.name
);
}
} else if (resolutions.attackerRes.action === "YIELD") {
// Do nothing, no capture occurred.
} else {
// Conflict res. Need to check proof.
let v = verifyBitLength(
resolutions.defenderRes.proof,
defender.owner.paillierPubKey
);
if (v !== null && v <= 8) {
// Accept defender (do nothing)
} else {
// Accept attacker
if (this === game.us) {
// Already done above.
} else {
defender.owner = this;
defender.strength = new Strength(
new ReadOnlyCiphertext(
this.paillierPubKey,
BigInt(resolutions.attackerRes.cipherText)
),
defender.name
);
}
}
}
// Reset the promises in case they attack again.
defender.owner.defenderPromise = null;
defender.owner.defenderAmount = null;
defender.defenderRes = null;
defender.attackerRes = null;
defender.attackResolver = null;
game.contendedRegion = null;
}
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;
}
/**
* Calculate the number of additional reinforcements to gain on this turn.
*/
additionalReinforcements() {
return Math.min(
3,
Math.floor(
Object.values(Region.getAllRegions()).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();
const event = new CustomEvent("playerChange");
document.dispatchEvent(event);
}
nextPlayer() {
// todo move this to :class:Game
let sorted = Object.values(game.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];
}
}