From 0a2c15690b931c618316136e1ac791cc0102acdd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Neto?=
<886455+jpneto@users.noreply.github.com>
Date: Tue, 9 Jun 2026 16:16:42 +0100
Subject: [PATCH] Added Virus War
---
locales/en/apgames.json | 20 ++
locales/en/apresults.json | 1 +
src/games/index.ts | 8 +-
src/games/linage.ts | 8 +-
src/games/viruswar.ts | 446 ++++++++++++++++++++++++++++++++++++++
5 files changed, 477 insertions(+), 6 deletions(-)
create mode 100644 src/games/viruswar.ts
diff --git a/locales/en/apgames.json b/locales/en/apgames.json
index 2ef8e913..48900166 100644
--- a/locales/en/apgames.json
+++ b/locales/en/apgames.json
@@ -250,6 +250,7 @@
"valley": "Get your king to the center space, but your pieces must always move as far as they can. Teamwork is essential.",
"veletas": "Drawless territory game where players control a number of neutral shooters and shoot stones of their own colour from them. In order to win, a player must claim a majority of neutral pieces by having them surrounded by larger groups of their own pieces than their opponent's.",
"verge": "Verge is a game where players attempt to create groups that partition the board, freezing those groups and removing enemy groups. The first player to have no legal placement wins.",
+ "viruswar": "Paper and pencil game about growing and destroying viruses.",
"volcano": "An Icehouse puzzle game for 2 players. Stacks of pyramids are volcanos, some of which are capped. As you move caps around, you cause eruptions that may lead to you capturing pieces. The winner is the first to capture a certain number of trios.",
"volo": "Volo was inspired by the beauty of birds flying in flocks. Each player in turn places a piece or moves one or more pieces (here called 'birds') on the board and attempts to connect them all in one contiguous 'flock.' If a player ends up with one contiguous flock (of any size), he wins.",
"waldmeister": "Players take turns planting trees in an attempt to create groups of either colours or heights. Highest score after two rounds wins.",
@@ -339,6 +340,7 @@
"twinflames": "Twin Flames is a 2026 redesign of the 2013's Product, an original concept by Nick Bentley. The first player must initially place a friendly stone, preventing overly strong opening countermoves and thereby eliminating the need for a pie rule. The remaining moves use a 12* sequence, in which players may place stones of either colour. At the end of the game, a player who has formed a single connected group scores points equal to the size of that group. Another ludeme introduced was the use of walls, or blockers, which reduce overall connectivity and make groups more robust to enemy attacks. The blocker configuration is randomized, increasing game variability and making opening theory more difficult to develop.",
"twixt": "The notation is based on Hansel notation at . Some modifications are that link removal specifically specifies the link direction, and commas separate the moves. To add/remove links, click on the pegs between them. You can also remove a link by clicking on the line itself.",
"unane": "Ūnane, designed by Mark Steere in 2026, is an unification game inspired by the ancient Hawiian game of Kōnane. True to the spirit of Kōnane, Ūnane begins with a checkerboard pattern of stones, has short-range captures, and is extremely simple. \"Ūnane\" is a fusion of Una (Spanish for \"one\") and Kōnane. This is intended as a tribute to Kōnane, not cultural appropriation.\n\nŪnane has common ground with Kōnane, especially in both players are mainly trying to isolate and remove their own stones.",
+ "viruswar": "Virus War (aka, Клоподавка, Bug Supression) is a pencil and paper game played in Russia around the 1970s/80s. The players have `N` multiple moves per turn (`N` depends on the board size). Each player has a home base on opposite corners. On his turn, a player plays N squares on the grid. Plays can be on empty squares (making a new virus), or by replacing an adversary virus with a defensive wall. A wall becomes unplayable. However, players can only play on squares that are adjacent to the group of friendly pieces that includes the player's home base. A stalemated player loses the game.",
"waldmeister": "Players play two games sequentially. In the first round, Player 1 is playing for colours and Player 2 is playing for heights. At the end of the first round, scores are tabulated and scoring groups highlighted. Then in the second round, Player 2 plays first and plays for colours, and Player 1 plays for heights.",
"witch": "The first player does not start as owning any pieces and may remove any piece (other than a crown) on their first turn. The second player chooses their colour on their first turn, after which, removing your opponent's pieces is no longer possible.",
"xana": "Players, on their turns, drop/move a stack (there's a limited amount of stackable pieces) and optionally drop two walls into empty hexes. Stacks without liberties are captured. A stack has liberty if at least one of the adjacent hexes is empty (there is no concept of group of stacks). A hex is accessible if it is empty and connected to a friendly stack by a path of empty hexes. The goal is to build the highest score of territory plus captures. Xana was designed in 2005.",
@@ -3400,6 +3402,20 @@
"name": "Size-7 board (127 cells)"
}
},
+ "viruswar": {
+ "size-10": {
+ "name": "10x10 board (3 moves)"
+ },
+ "size-20": {
+ "name": "20x20 board (4 moves)"
+ },
+ "size-25": {
+ "name": "25x25 board (5 moves)"
+ },
+ "#board": {
+ "name": "30x30 board (6 moves)"
+ }
+ },
"volo": {
"#board": {
"name": "Size-7 board"
@@ -6750,6 +6766,10 @@
"FROZEN_NEIGHBOUR": "You cannot place next one of your frozen groups.",
"INITIAL_INSTRUCTIONS": "Select an empty cell that is not adjacent to one of your frozen groups."
},
+ "viruswar": {
+ "INITIAL_INSTRUCTIONS": "Place {{count}} pieces, each must be adjacent to the group of friendly pieces including your home-base. Pieces can be active viruses (circles), or defensive walls (squares). Either place a virus on an empty cell, or replace an enemy virus with a friendly defensive wall.",
+ "CANNOT_GROW": "Square {{where}} cannot receive a new virus/wall!"
+ },
"volcano": {
"BAD_FROM": "{{from}} doesn't look like a position on the board, or a piece in your stash.",
"INITIAL_INSTRUCTIONS": "Select a cap to move or a piece from your captured pile to place on the board.",
diff --git a/locales/en/apresults.json b/locales/en/apresults.json
index 6edad48a..a3dc73f1 100644
--- a/locales/en/apresults.json
+++ b/locales/en/apresults.json
@@ -497,6 +497,7 @@
"worker": "{{player}} placed an architect at {{where}}."
},
"veletas": "{{player}} placed a {{what}} at {{where}}.",
+ "viruswar": "{{player}} placed viruses/walls at {{where}}.",
"volo": "{{player}} adds a bird at {{where}}."
},
"PLAYSECOND": "{{player}} chose to play second.",
diff --git a/src/games/index.ts b/src/games/index.ts
index c5537ed5..992027be 100644
--- a/src/games/index.ts
+++ b/src/games/index.ts
@@ -260,6 +260,7 @@ import { NarrowsGame, INarrowsState } from "./narrows";
import { InvectorGame, IInvectorState } from "./invector";
import { TricouleurGame, ITricouleurState } from "./tricouleur";
import { PositGame, IPositState } from "./posit";
+import { VirusWarGame, IVirusWarState } from "./viruswar";
export {
APGamesInformation, GameBase, GameBaseSimultaneous, IAPGameState,
@@ -523,6 +524,7 @@ export {
InvectorGame, IInvectorState,
TricouleurGame, ITricouleurState,
PositGame, IPositState,
+ VirusWarGame, IVirusWarState,
};
const games = new Map();
// Manually add each game to the following array
[
@@ -653,7 +655,7 @@ const games = new Map {
if (games.has(g.gameinfo.uid)) {
throw new Error("Another game with the UID '" + g.gameinfo.uid + "' has already been used. Duplicates are not allowed.");
@@ -1185,6 +1187,8 @@ export const GameFactory = (game: string, ...args: any[]): GameBase|GameBaseSimu
return new TricouleurGame(...args);
case "posit":
return new PositGame(...args);
+ case "viruswar":
+ return new VirusWarGame(args[0], ...args.slice(1));
}
return;
}
diff --git a/src/games/linage.ts b/src/games/linage.ts
index 631ed978..54a0b534 100644
--- a/src/games/linage.ts
+++ b/src/games/linage.ts
@@ -522,10 +522,10 @@ export class LinageGame extends GameBase {
return []; // no buttons should appear when typing Komi at start
}
if (this.isPieTurn()) {
- return [{ label: "play second", move: "play-second" }];
+ return [{ label: "playsecond", move: "play-second" }];
}
if (this.isButtonActive()) {
- return [{ label: "take button", move: "take-button" }];
+ return [{ label: "takebutton", move: "take-button" }];
}
return [{ label: "pass", move: "pass" }];
}
@@ -539,9 +539,9 @@ export class LinageGame extends GameBase {
public getPlayerScore(player: number): number {
let komi = this.buttontaker === player ? 0.5 : 0;
if (player === 1 && this.komi !== undefined && this.komi < 0)
- komi = -this.komi;
+ komi += -this.komi;
if (player === 2 && this.komi !== undefined && this.komi > 0)
- komi = this.komi;
+ komi += this.komi;
const terr = this.getTerritories();
return terr.filter(t => t.owner === player).reduce((prev, curr) => prev + curr.cells.length, komi);
diff --git a/src/games/viruswar.ts b/src/games/viruswar.ts
new file mode 100644
index 00000000..d7698db7
--- /dev/null
+++ b/src/games/viruswar.ts
@@ -0,0 +1,446 @@
+import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResult } from "./_base";
+import { APGamesInformation } from "../schemas/gameinfo";
+import { APRenderRep, RowCol } from "@abstractplay/renderer/src/schemas/schema";
+import { APMoveResult } from "../schemas/moveresults";
+import { reviver, UserFacingError, SquareGraph } from "../common";
+import { connectedComponents } from "graphology-components";
+import i18next from "i18next";
+
+export type playerid = 1 | 2 | 3 | 4 | 5 | 6; // 3,4 are dead virus; 5,6 are home-bases
+
+export interface IMoveState extends IIndividualState {
+ currplayer: playerid;
+ board: Map;
+ lastmove?: string;
+};
+
+export interface IVirusWarState extends IAPGameState {
+ winner: playerid[];
+ stack: Array;
+};
+
+export class VirusWarGame extends GameBase {
+ public static readonly gameinfo: APGamesInformation = {
+ name: "Virus War",
+ uid: "viruswar",
+ playercounts: [2],
+ version: "20260609",
+ dateAdded: "2026-06-09",
+ // i18next.t("apgames:descriptions.viruswar")
+ description: "apgames:descriptions.viruswar",
+ notes: "apgames:notes.viruswar",
+ urls: [
+ "https://boardgamegeek.com/boardgame/68214/virus-wars",
+ "https://ptupitsyn.github.io/klopodavka-rs/",
+ "https://sagme.blogspot.com/2025/09/pencil-and-paper-games-dots-and-bugs.html",
+ ],
+ people: [
+ {
+ type: "designer",
+ name: "Traditional",
+ },
+ {
+ type: "coder",
+ name: "João Pedro Neto",
+ urls: ["https://boardgamegeek.com/boardgamedesigner/3829/joao-pedro-neto"],
+ apid: "9228bccd-a1bd-452b-b94f-d05380e6638f",
+ },
+ ],
+ variants: [
+ { uid: "size-10", group: "board" }, // 3 moves
+ { uid: "size-20", group: "board" }, // 4 moves
+ { uid: "size-25", group: "board" }, // 5 moves
+ { uid: "#board", }, // 30x30, 6 moves
+ ],
+ categories: ["goal>immobilize", "other>traditional", "mechanic>place", "mechanic>capture", "board>shape>rect", "board>connect>rect", "components>simple>2per"],
+ flags: ["no-moves", "experimental"]
+ };
+
+ public numplayers = 2;
+ public currplayer: playerid = 1;
+ public board!: Map;
+ public gameover = false;
+ public winner: playerid[] = [];
+ public variants: string[] = [];
+ public stack!: Array;
+ public results: Array = [];
+
+ private boardSize = 30;
+ private numMoves = 6;
+ private dots: string[] = [];
+
+ constructor(state: IVirusWarState | string, variants?: string[]) {
+ super();
+ if (state === undefined) {
+ if ( (variants !== undefined) && (variants.length > 0) ) {
+ this.variants = [...variants];
+ }
+ const size = this.getBoardSize();
+ const board = new Map();
+ if (size === 10) { board.set("a1", 5); board.set("j10", 6); }
+ if (size === 20) { board.set("b2", 5); board.set("s19", 6); }
+ if (size === 25) { board.set("c3", 5); board.set("w23", 6); }
+ if (size === 30) { board.set("c3", 5); board.set("ab28", 6); }
+
+ const fresh: IMoveState = {
+ _version: VirusWarGame.gameinfo.version,
+ _results: [],
+ _timestamp: new Date(),
+ currplayer: 1,
+ board,
+ };
+ this.stack = [fresh];
+ } else {
+ if (typeof state === "string") {
+ state = JSON.parse(state, reviver) as IVirusWarState;
+ }
+ if (state.game !== VirusWarGame.gameinfo.uid) {
+ throw new Error(`The Virus War engine cannot process a game of '${state.game}'.`);
+ }
+ this.gameover = state.gameover;
+ this.winner = [...state.winner];
+ this.variants = state.variants;
+ this.stack = [...state.stack];
+ }
+ this.load();
+ }
+
+ public load(idx = -1): VirusWarGame {
+ if (idx < 0) {
+ idx += this.stack.length;
+ }
+ if ( (idx < 0) || (idx >= this.stack.length) ) {
+ throw new Error("Could not load the requested state from the stack.");
+ }
+
+ const state = this.stack[idx];
+ this.currplayer = state.currplayer;
+ this.board = new Map(state.board);
+ this.lastmove = state.lastmove;
+ this.boardSize = this.getBoardSize();
+ this.numMoves = this.getMoveSize();
+ this.results = [...state._results];
+ return this;
+ }
+
+ private getBoardSize(): number {
+ // Get board size from variants.
+ if (this.variants !== undefined && this.variants.length > 0 &&
+ this.variants[0] !== undefined && this.variants[0].length > 0) {
+ const sizeVariants = this.variants.filter(v => v.includes("size"))
+ if (sizeVariants.length > 0) {
+ const size = sizeVariants[0].match(/\d+/);
+ return parseInt(size![0], 10);
+ }
+ if (isNaN(this.boardSize)) {
+ throw new Error(`Could not determine the board size from variant "${this.variants[0]}"`);
+ }
+ }
+ return 30;
+ }
+
+ private getMoveSize(): number {
+ switch (this.boardSize) {
+ case 10: return 3;
+ case 20: return 4;
+ case 25: return 5;
+ }
+ return 6;
+ }
+
+ public coords2algebraic(x: number, y: number): string {
+ return GameBase.coords2algebraic(x, y, this.boardSize);
+ }
+
+ public algebraic2coords(cell: string): [number, number] {
+ return GameBase.algebraic2coords(cell, this.boardSize);
+ }
+
+ public get graph(): SquareGraph {
+ return new SquareGraph(this.boardSize, this.boardSize);
+ }
+
+ private homeBase(player: playerid): string {
+ switch (this.boardSize) {
+ case 10: return player === 1 ? "a1" : "j10";
+ case 20: return player === 1 ? "b2" : "s19";
+ case 25: return player === 1 ? "c3" : "w23";
+ }
+ return player === 1 ? "c3" : "ab28";
+ }
+
+ public handleClick(move: string, row: number, col: number, piece?: string): IClickResult {
+ try {
+ const cell = this.graph.coords2algebraic(col, row);
+ const newmove = move === "" ? cell : `${move},${cell}`;
+
+ const result = this.validateMove(newmove) as IClickResult;
+ result.move = result.valid ? newmove : move;
+ return result;
+ } catch (e) {
+ return {
+ move,
+ valid: false,
+ message: i18next.t("apgames:validation._general.GENERIC", {move, row, col, piece, emessage: (e as Error).message})
+ }
+ }
+ }
+
+ // return the group of pieces of `player` connected to his home-base
+ private homeGroup(player: playerid, board: Map): string[] {
+ const homebase = this.homeBase(player);
+ const friendlies = player === 1 ? [1,3,5] : [2,4,6];
+ const pieces = [...board.entries()].filter(([,owner]) => friendlies.includes(owner)).map(pair => pair[0]);
+
+ // compute player groups
+ const gPieces = this.graph;
+ for (const node of gPieces.graph.nodes()) {
+ if (! pieces.includes(node)) { // remove squares not with friendly pieces
+ gPieces.graph.dropNode(node);
+ }
+ }
+ const playerGroups : Array> = connectedComponents(gPieces.graph);
+ return playerGroups.filter(gr => gr.includes(homebase))[0]; // select group with home base
+ }
+
+ // get all possible moves adjacent to `group` of `player`
+ private getAdjacentMoves(player: playerid, board: Map): string[] {
+ const prevplayer = player % 2 + 1 as playerid;
+ const g = this.graph;
+ const moves = new Set();
+
+ for (const cell of this.homeGroup(player, board)) {
+ for (const neigh of g.neighbours(cell)) {
+ if (!board.has(neigh) || board.get(neigh)! === prevplayer) {
+ moves.add(neigh);
+ }
+ }
+ }
+ return [...moves];
+ }
+
+ public validateMove(m: string): IValidationResult {
+ const result: IValidationResult = {valid: false, message: i18next.t("apgames:validation._general.DEFAULT_HANDLER")};
+
+ if (m.length === 0) {
+ result.valid = true;
+ result.complete = -1;
+ result.message = i18next.t("apgames:validation.viruswar.INITIAL_INSTRUCTIONS", {count: this.numMoves})
+ return result;
+ }
+
+ const moves = m.split(',');
+
+ try { // check if cells are valid
+ for (const cell of moves) { this.algebraic2coords(cell); }
+ } catch {
+ result.valid = false;
+ result.message = i18next.t("apgames:validation._general.INVALID_MOVE", {move: m});
+ return result;
+ }
+
+ // need to simulate previous placements, to check validity of the last placement
+ // assume they are all correct since they were processed previously by validateMove()
+ const prevplayer = this.currplayer % 2 + 1 as playerid;
+ const clone = new Map(this.board);
+ for (const move of moves.slice(0,-1)) {
+ if (! clone.has(move) ) {
+ clone.set(move, this.currplayer); // drop a new virus on an empty cell
+ } else if (clone.get(move) === prevplayer) {
+ clone.set(move, this.currplayer + 2 as playerid); // kills an enemy virus
+ }
+ }
+
+ // process last placement
+ const lastmove = moves.at(-1)!;
+ let validMoves = this.getAdjacentMoves(this.currplayer, clone);
+
+ if (validMoves.length > 0 && !validMoves.includes(lastmove) ) {
+ result.valid = false;
+ result.message = i18next.t("apgames:validation.viruswar.CANNOT_GROW", {where: lastmove});
+ return result;
+ }
+
+ // update board clone with last move
+ if (! clone.has(lastmove) ) {
+ clone.set(lastmove, this.currplayer); // drop a new virus on an empty cell
+ } else if (clone.get(lastmove) === prevplayer) {
+ clone.set(lastmove, this.currplayer + 2 as playerid); // kills an enemy virus
+ }
+ validMoves = this.getAdjacentMoves(this.currplayer, clone);
+
+ result.valid = true;
+ result.complete = validMoves.length > 0 && moves.length < this.numMoves ? -1 : 1;
+ result.canrender = true;
+ result.message = i18next.t("apgames:validation._general.VALID_MOVE");
+ return result;
+ }
+
+ public move(m: string, {trusted = false, partial = false} = {}): VirusWarGame {
+ if (this.gameover) {
+ throw new UserFacingError("MOVES_GAMEOVER", i18next.t("apgames:MOVES_GAMEOVER"));
+ }
+ m = m.toLowerCase();
+ m = m.replace(/\s+/g, "");
+ if (! trusted) {
+ const result = this.validateMove(m);
+ if (! result.valid) { throw new UserFacingError("VALIDATION_GENERAL", result.message) }
+ }
+
+ if (m === "") { return this; }
+
+ const prevplayer = this.currplayer % 2 + 1 as playerid;
+ for (const move of m.split(',')) {
+ if (! this.board.has(move) ) {
+ this.board.set(move, this.currplayer); // drop a new virus on an empty cell
+ } else if (this.board.get(move) === prevplayer) {
+ this.board.set(move, this.currplayer + 2 as playerid); // kills an enemy virus
+ }
+ }
+ this.results = [{ type: "place", where: m }];
+
+ if (partial) { // if partial, populate dots
+ const validMoves = this.getAdjacentMoves(this.currplayer, this.board);
+ this.dots.push(...validMoves);
+ return this;
+ } else {
+ this.dots = [];
+ }
+
+ this.lastmove = m;
+ this.currplayer = this.currplayer % 2 + 1 as playerid;
+ this.checkEOG();
+ this.saveState();
+ return this;
+ }
+
+ protected checkEOG(): VirusWarGame {
+ // the previous player loses if he was unable to make all mandatory moves
+ if ( this.lastmove !== undefined && this.lastmove!.split(',').length < this.getMoveSize() ) {
+ this.gameover = true;
+ this.winner = [this.currplayer];
+ } else { // if the next player is stalemated, he loses
+ const prevplayer = this.currplayer % 2 + 1 as playerid;
+ const validMoves = this.getAdjacentMoves(this.currplayer, this.board);
+ if ( validMoves.length === 0 ) {
+ this.gameover = true;
+ this.winner = [prevplayer];
+ }
+ }
+
+ if (this.gameover) {
+ this.results.push( {type: "eog"},
+ {type: "winners", players: [...this.winner]} );
+ }
+ return this;
+ }
+
+ public render(): APRenderRep {
+ const g = this.graph;
+ // Build piece string
+ let pstr = "";
+ for (let row = 0; row < this.boardSize; row++) {
+ if (pstr.length > 0) { pstr += "\n"; }
+ const pieces: string[] = [];
+ for (let col = 0; col < this.boardSize; col++) {
+ const cell = this.coords2algebraic(col, row);
+ if (this.board.has(cell)) {
+ const contents = this.board.get(cell)!;
+ if (contents === 1) { pieces.push("A"); }
+ else if (contents === 3) { pieces.push("B"); }
+ else if (contents === 5) { pieces.push("C"); }
+ else if (contents === 2) { pieces.push("D"); }
+ else if (contents === 4) { pieces.push("E"); }
+ else if (contents === 6) { pieces.push("F"); }
+ } else {
+ pieces.push("-");
+ }
+ }
+ pstr += pieces.join("");
+ }
+
+ // Build rep
+ const rep: APRenderRep = {
+ board: {
+ style: "squares",
+ width: this.boardSize,
+ height: this.boardSize,
+ },
+ legend: {
+ A: { name: "piece", colour: 1 },
+ B: { name: "piece-square", colour: 1 },
+ C: { name: "palace", colour: 1 },
+ D: { name: "piece", colour: 2 },
+ E: { name: "piece-square", colour: 2 },
+ F: { name: "palace", colour: 2 },
+ },
+ pieces: pstr
+ };
+
+ // Add annotations
+ rep.annotations = [];
+ if (this.results.length > 0) {
+ for (const move of this.results) {
+ if (move.type === "place") { // note: one `place` has a sequence of placements
+ for (const mv of move.where!.split(',')) {
+ const [x, y] = g.algebraic2coords(mv);
+ rep.annotations.push({type: "enter", targets: [{row: y, col: x}]});
+ }
+ }
+ }
+ }
+
+ if (this.dots.length > 0) {
+ rep.annotations.push({
+ type: "dots",
+ targets: this.dots.map(cell => {
+ const [x, y] = g.algebraic2coords(cell);
+ return {row: y, col: x};
+ }) as [RowCol, ...RowCol[]],
+ });
+ }
+
+ return rep;
+ }
+
+ public state(): IVirusWarState {
+ return {
+ game: VirusWarGame.gameinfo.uid,
+ numplayers: this.numplayers,
+ variants: [...this.variants],
+ gameover: this.gameover,
+ winner: [...this.winner],
+ stack: [...this.stack]
+ };
+ }
+
+ public moveState(): IMoveState {
+ return {
+ _version: VirusWarGame.gameinfo.version,
+ _results: [...this.results],
+ _timestamp: new Date(),
+ currplayer: this.currplayer,
+ lastmove: this.lastmove,
+ board: new Map(this.board),
+ };
+ }
+
+ public chat(node: string[], player: string, results: APMoveResult[], r: APMoveResult): boolean {
+ let resolved = false;
+ switch (r.type) {
+ case "place":
+ node.push(i18next.t("apresults:PLACE.viruswar", { player, where: r.where }));
+ resolved = true;
+ break;
+ case "eog":
+ node.push(i18next.t("apresults:EOG.default"));
+ resolved = true;
+ break;
+ }
+ return resolved;
+ }
+
+ public clone(): VirusWarGame {
+ return new VirusWarGame(this.serialize());
+ }
+}