Skip to content

Commit

Permalink
Add hit target nullifer for game integrity maintenance
Browse files Browse the repository at this point in the history
  • Loading branch information
Shigoto-dev19 committed Feb 29, 2024
1 parent 5f659b6 commit 93c974f
Show file tree
Hide file tree
Showing 4 changed files with 187 additions and 45 deletions.
71 changes: 45 additions & 26 deletions src/Battleships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
BoardCircuit,
AttackUtils,
AttackCircuit,
} from './client.js';
} from './provableUtils.js';

export {
Battleships,
Expand Down Expand Up @@ -152,10 +152,24 @@ class Battleships extends SmartContract {
player1Id,
player2Id,
);

// fetch and deserialize the on-chain hitHistory
const serializedHitHistory = this.serializedHitHistory.getAndRequireEquals();
const [
[player1HitCount, player2HitCount],
[player1HitTargets, player2HitTargets],
] = AttackUtils.deserializeHitHistory(serializedHitHistory);

// check if there is a winner
const isOver = player2HitCount.equals(17).or(player1HitCount.equals(17));

// block game progress if there is a winner
isOver.assertFalse(`Game is already over!`);

// deserialize board, also referred as ships
let deserializedBoard = BoardUtils.deserialize(serializedBoard);

//TODO change order and refine error message
// assert that the current player should be the sender
let senderBoardHash = BoardUtils.hash(deserializedBoard);
let senderId = Poseidon.hash([senderBoardHash, ...this.sender.toFields()]);
Expand Down Expand Up @@ -188,21 +202,7 @@ class Battleships extends SmartContract {
let adversaryTarget = AttackUtils.deserializeTarget(adversarySerializedTarget);
let adversaryHitResult = AttackCircuit.attack(deserializedBoard, adversaryTarget);

//TODO check if game is over: at the beginning
// fetch and deserialize the on-chain hitHistory
const serializedHitHistory = this.serializedHitHistory.getAndRequireEquals();
const [player1HitCount, player2HitCount] = AttackUtils.deserializeHitHistory(serializedHitHistory);

// check if there is a winner
const isOver = Provable.if(
turns.value.isEven(),
player2HitCount.add(adversaryHitResult.toField()).equals(17),
player1HitCount.add(adversaryHitResult.toField()).equals(17),
);

//TODO Emit winner event
// block game progress if there is a winner
isOver.assertFalse(`Game is over`);

// update the on-chain hit result
this.hitResult.set(adversaryHitResult);
Expand All @@ -211,17 +211,37 @@ class Battleships extends SmartContract {
let updatedHitRoot = hitWitness.calculateRoot(adversaryHitResult.toField());
this.hitRoot.set(updatedHitRoot);

// update hit history & serialize
let updatedSerializedHitHistory = Provable.if(
// update hit count history & serialize
let updatedSerializedHitCountHistory = Provable.if(
turns.value.isEven(),
AttackUtils.serializeHitHistory([player1HitCount, player2HitCount.add(adversaryHitResult.toField())]),
AttackUtils.serializeHitHistory([player1HitCount.add(adversaryHitResult.toField()), player2HitCount]),
AttackUtils.serializeHitCountHistory([player1HitCount, player2HitCount.add(adversaryHitResult.toField())]),
AttackUtils.serializeHitCountHistory([player1HitCount.add(adversaryHitResult.toField()), player2HitCount]),
);

const playerTarget = AttackUtils.deserializeTarget(serializedTarget);
let isNullifiedCheck = Provable.if(
turns.value.isEven(),
AttackUtils.validateHitTargetUniqueness(playerTarget, player1HitTargets),
AttackUtils.validateHitTargetUniqueness(playerTarget, player2HitTargets),
);

// update the on-chain hitHistory
isNullifiedCheck.assertFalse('Please select a unique target!')

let updatedSerializedHitTargetHistory = Provable.if(
turns.value.isEven(),
AttackUtils.serializeHitTargetHistory([player1HitTargets, AttackUtils.updateHitTargetHistory(adversaryTarget, adversaryHitResult, player2HitTargets, player2HitCount)]),
AttackUtils.serializeHitTargetHistory([AttackUtils.updateHitTargetHistory(adversaryTarget, adversaryHitResult, player1HitTargets, player1HitCount), player2HitTargets]),
);
//TODO check target uniqueness -> ok!
//TODO update hitHistory -> ok!
//TODO update hit target -> ok!
//TODO should serialize the same target and add one
//? update the on-chain hitHistory

const updatedSerializedHitHistory = AttackUtils.serializeHitHistory(updatedSerializedHitCountHistory, updatedSerializedHitTargetHistory);
this.serializedHitHistory.set(updatedSerializedHitHistory);

// validate & update the on-chain serialized target
//? validate & update the on-chain serialized target
AttackUtils.validateTarget(serializedTarget);
this.target.set(serializedTarget);

Expand All @@ -238,11 +258,9 @@ class Battleships extends SmartContract {
@method finalizeGame() { return }
}

//TODO Test attack method
//TODO - Simulate game in tests
//TODO - Add player client class
//TODO Reset game when finished(keep state transition in mind)
//TODO Add nullifer for target to prevent player for attacking the same target more than once
//TODO Add salt when generating player ID --> we want player to be able to reuse same board without generating the same ID

//TODO? Emit event following game actions

Expand All @@ -253,5 +271,6 @@ class Battleships extends SmartContract {
//TODO in the target serialized --> proivde bits to store hit target --> we need to prevent a player from hitting the same target and keep claiming hits?
//TODO --> the good thing we will only prevent the player from hitting hit target and the missed ones won't affect the game
//TODO --> better than nullifier semantics


//TODO --> add one after serialized to distinguish value from initial value
//TODO have a last check on zkapp error handling & messages
//TODO have consistent serialization for target
6 changes: 3 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,15 @@ class BattleShipsClient {
}

displayPlayerGrids() {
const stringifedPlayerGameGrid = stringifyGameGrid(this.playerGridDisplay);
const stringifiedPlayerGameGrid = stringifyGameGrid(this.playerGridDisplay);
const stringifiedAdversaryGameGrid = stringifyGameGrid(this.adversaryGridDisplay);
printPlayerAndAdversaryBoards(stringifedPlayerGameGrid, stringifiedAdversaryGameGrid);
printPlayerAndAdversaryBoards(stringifiedPlayerGameGrid, stringifiedAdversaryGameGrid);
}

displayPlayerStats() {
const turnCount = this.zkapp.turns.get().toNumber();
const serializedHitHistory = this.zkapp.serializedHitHistory.get();
const hitHistory = AttackUtils.deserializeHitHistory(serializedHitHistory).map(f => Number(f.toBigInt()));
const hitHistory = AttackUtils.deserializeHitHistory(serializedHitHistory)[0].map(f => Number(f.toBigInt()));


const hostId = this.zkapp.player1Id.get().toBigInt();
Expand Down
23 changes: 13 additions & 10 deletions src/provable.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Field } from 'o1js';
import { BoardCircuit, BoardUtils, AttackCircuit } from './client';
import { BoardCircuit, BoardUtils, AttackCircuit } from './provableUtils';

/*
Each ship is an array [x, y, z] where
Expand All @@ -16,6 +16,7 @@ describe('Board Tests', () => {

const serializedBoard = BoardUtils.serialize(ships);
const validationHash = BoardCircuit.validateBoard(serializedBoard);

expect(validationHash).toEqual(hash);
}

Expand Down Expand Up @@ -59,6 +60,7 @@ describe('Board Tests', () => {
const serializedBoard = BoardUtils.serialize(ships);
BoardCircuit.validateBoard(serializedBoard);
}

expect(validationRangeError).toThrowError(errorMessage);
}

Expand Down Expand Up @@ -99,10 +101,11 @@ describe('Board Tests', () => {
describe("Collision Checks", () => {
function testCollision(ships: number[][], errorMessage: string) {
const serializedBoard = BoardUtils.serialize(ships);
const validationCollisionrror = () => {
const mockCollisionError = () => {
BoardCircuit.validateBoard(serializedBoard);
}
expect(validationCollisionrror).toThrowError(errorMessage);

expect(mockCollisionError).toThrowError(errorMessage);
}

it("Placement violation: board 1", () => {
Expand All @@ -113,7 +116,7 @@ describe('Board Tests', () => {
[0, 3, 0],
[0, 4, 0],
];
testCollision(ships1, 'Collision occured when placing Ship2!');
testCollision(ships1, 'Invalid Board! Collision occured when placing Ship2!');
});

it("Placement violation: board 2", () => {
Expand All @@ -124,7 +127,7 @@ describe('Board Tests', () => {
[5, 9, 0],
[1, 8, 1],
];
testCollision(ships2, 'Collision occured when placing Ship3!');
testCollision(ships2, 'Invalid Board! Collision occured when placing Ship3!');
});

it("Placement violation: board 3", () => {
Expand All @@ -135,7 +138,7 @@ describe('Board Tests', () => {
[6, 5, 1],
[1, 7, 1],
];
testCollision(ships3, 'Collision occured when placing Ship4!');
testCollision(ships3, 'Invalid Board! Collision occured when placing Ship4!');
});

it("Placement violation: board 4", () => {
Expand All @@ -146,7 +149,7 @@ describe('Board Tests', () => {
[6, 8, 0],
[7, 7, 1],
];
testCollision(ships4, 'Collision occured when placing Ship5!');
testCollision(ships4, 'Invalid Board! Collision occured when placing Ship5!');
});
});
});
Expand Down Expand Up @@ -335,7 +338,7 @@ describe('Attack Tests', () => {
[0, 4, 0],
];
const target1 = [-1, 3];
testOutOfBoundAttack(ships, target1, 'target x coordinate is out of bound!');
testOutOfBoundAttack(ships, target1, 'Target x coordinate is out of bound!');

});

Expand All @@ -348,7 +351,7 @@ describe('Attack Tests', () => {
[0, 4, 0],
];
const target2 = [10, 3];
testOutOfBoundAttack(ships, target2, 'target x coordinate is out of bound!');
testOutOfBoundAttack(ships, target2, 'Target x coordinate is out of bound!');
});

it("Shot range violation turn 3: y out of bounds", () => {
Expand All @@ -360,7 +363,7 @@ describe('Attack Tests', () => {
[0, 4, 0],
];
const target3 = [0, 13];
testOutOfBoundAttack(ships, target3, 'target y coordinate is out of bound!');
testOutOfBoundAttack(ships, target3, 'Target y coordinate is out of bound!');
});
});
});
132 changes: 126 additions & 6 deletions src/provableUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,141 @@ class AttackUtils {
return target.map(Field);
}

static serializeHitHistory(hitHistory: Field[]) {
static serializeHitCountHistory(hitHistory: Field[]) {
const [player1HitCount, player2HitCount] = hitHistory;
const player1HitCountBits = player1HitCount.toBits(5);
const player2HitCountBits = player2HitCount.toBits(5);
const serializedHitHistory = Field.fromBits([...player1HitCountBits, ...player2HitCountBits]);


const serializedHitCountHistory = Field.fromBits([...player1HitCountBits, ...player2HitCountBits]);

return serializedHitCountHistory
}

static serializeHitTargetHistory(hitTargetHistory: Field[][]) {
const [player1HitTargets, player2HitTargets] = hitTargetHistory;
const player1HitTargetBits = player1HitTargets.map(f => f.toBits(7)).flat();
const player2HitTargetBits = player2HitTargets.map(f => f.toBits(7)).flat();

const serializedHitTargetHistory = Field.fromBits([...player1HitTargetBits, ...player2HitTargetBits]);

return serializedHitTargetHistory;
}

static serializeHitHistory(serializedHitCountHistory: Field, serializeHitTargetHistory: Field) {
const hitCountHistoryBits = serializedHitCountHistory.toBits(10);

const hitTargetHistoryBits = serializeHitTargetHistory.toBits(238);

const serializedHitHistory = Field.fromBits(
[
...hitCountHistoryBits,
...hitTargetHistoryBits,
]);

return serializedHitHistory
}

static deserializeHitHistory(serializedHitHistory: Field) {
const bits = serializedHitHistory.toBits(10);
static deserializeHitHistory(serializedHitHistory: Field): [Field[], Field[][]] {
const bits = serializedHitHistory.toBits(248);

const player1HitCount = Field.fromBits(bits.slice(0, 5));
const player2HitCount = Field.fromBits(bits.slice(5, 10));

return [player1HitCount, player2HitCount];
const serializedPlayer1HitTargetHistory = Field.fromBits(bits.slice(10, 129));
const serializedPlayer2HitTargetHistory = Field.fromBits(bits.slice(129, 248));

// playerHitTargets array contains the serialized targets that landed a successful hit.
const player1HitTargets = AttackUtils.deserializeHitTargetHistory(serializedPlayer1HitTargetHistory);
const player2HitTargets = AttackUtils.deserializeHitTargetHistory(serializedPlayer2HitTargetHistory);

return [
[player1HitCount, player2HitCount],
[player1HitTargets, player2HitTargets],
];
}

/**
* We serialize data differently than serializeTarget to showcase an alternative approach and to conserve storage space.
*
* When bitifying two numbers from 0 to 9 together, it typically requires 8 bits in total. However, by employing a technique
* like bitifying 9 + 9*10 + 1, we reduce the storage requirement to 7 bits. This results in a total saving of 32 bits
* because we store a maximum of 32 targets that land a successful hit.
*
* @note Before serializing and storing a target, ensure it successfully hits its intended destination.
*/
static serializeHitTarget(hitTarget: Field[]) {
/**
* Serialization is achieved by mapping a target [x, y] to a single field element: x + 10 * y.
*
* To distinguish serialized hit targets from initial values, we add one to the result.
* For example, if [0, 0] represents a target that successfully hits the adversary,
* serializing it would result in 0, which is indistinguishable from the initial value and prone to errors.
*
* Therefore, we add one to the serialized value to avoid confusion and ensure proper differentiation.
*/
const serializedHitTarget = hitTarget[0].add(hitTarget[1].mul(10)).add(1);
return serializedHitTarget;
}

// returns an array of serialized hitTargets
static deserializeHitTargetHistory(hitTargetHistory: Field) {
const hitTargetHistoryBits = hitTargetHistory.toBits(119);
let hitTargetsBits: Bool[][] = [];

for (let i = 0; i < hitTargetHistoryBits.length; i += 7) {
// Slice the original array into smaller arrays of length 7
const parsedArray = hitTargetHistoryBits.slice(i, i + 7);
// Push the parsed array into the parsedArrays array
hitTargetsBits.push(parsedArray);
}

return hitTargetsBits.map(f => Field.fromBits(f));
}

//TODO validate before update
//TODO should return field
static updateHitTargetHistory(target: Field[], isHit: Bool, playerHitTargets: Field[], index: Field) {
const serializedTarget = AttackUtils.serializeHitTarget(target);

let serializedHitTarget = Provable.if(
isHit,
serializedTarget,
Field(0),
);

const updatedPlayerHitTargets = Provable.witness(Provable.Array(Field, 17), () => {
const hitTargetIndex = Number(index.toBigInt());

// Modifying the function input could compromise the provability of the attack method.
// To prevent this, we create a copy of the input array instead of directly modifying it.
// This ensures that the attack method operates on a separate copy, preserving the integrity of the original input.
let updated = [...playerHitTargets]
updated[hitTargetIndex] = serializedHitTarget;

return updated;
});

return updatedPlayerHitTargets
}

/**
* Ensures a hit Target is not nullified for a given player.
*
* A player should not be able to select a target that has already been hit in a previous turn.
* Allowing such action would unfairly score points multiple times with the same target, compromising game fairness.
* Missed targets are not stored as they do not affect the game state, making it illogical to select them.
*/
static validateHitTargetUniqueness(target: Field[], hitTargetHistory: Field[]) {
// We opt not to deserialize the hitTargetHistory to save computational resources.
// Instead we serialize the target and then directly scan occurrences through an array of serialized targets.
// This approach conserves computational resources and enhances efficiency.
let serializedTarget = AttackUtils.serializeHitTarget(target);
let check = Provable.witness(Bool, () => {
let isNullified = hitTargetHistory.some(item => item.equals(serializedTarget).toBoolean());
return Bool(isNullified)
});

return check
}
}

Expand Down

0 comments on commit 93c974f

Please sign in to comment.