546 lines
16 KiB
JavaScript
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];
|
|
}
|
|
}
|