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]; } }