Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import { Environment } from "../../Environment";
import { IConfig } from "../../Config/IConfig";

export class BlastPowerupEntity extends AbstractEntity {
public constructor(config: IConfig, cellNumber: number, mapWidth: number, currentTick: number) {
public constructor(config: IConfig, cellNumber: number, mapWidth: number, currentTick: number, createdOverride?: number) {
const engineTelemetry = new CoderOneApi(Environment.Environment, config, true, Environment.Build);
const telemetry = new Telemetry(engineTelemetry, config.IsTelemetryEnabled);
const createdTick = createdOverride ?? currentTick;
const initialValues: IInitialEntityValues = {
cellNumber,
mapWidth,
type: EntityType.BlastPowerup,
created: currentTick,
created: createdTick,
hp: 1,
unitId: undefined,
agentId: undefined,
expires: currentTick + config.BlastPowerupDurationTicks,
expires: createdTick + config.BlastPowerupDurationTicks,
blastDiameter: undefined,
};
super(telemetry, initialValues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import { Environment } from "../../Environment";
import { IConfig } from "../../Config/IConfig";

export class FreezePowerupEntity extends AbstractEntity {
public constructor(config: IConfig, cellNumber: number, mapWidth: number, currentTick: number) {
public constructor(config: IConfig, cellNumber: number, mapWidth: number, currentTick: number, createdOverride?: number) {
const engineTelemetry = new CoderOneApi(Environment.Environment, config, true, Environment.Build);
const telemetry = new Telemetry(engineTelemetry, config.IsTelemetryEnabled);
const createdTick = createdOverride ?? currentTick;
const initialValues: IInitialEntityValues = {
cellNumber,
mapWidth,
type: EntityType.FreezePowerup,
created: currentTick,
created: createdTick,
hp: 1,
unitId: undefined,
agentId: undefined,
expires: currentTick + config.BlastPowerupDurationTicks,
expires: createdTick + config.FreezePowerupDurationTicks,
blastDiameter: undefined,
};
super(telemetry, initialValues);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@ import { EntityTracker } from "./../EntityTracker";
import { UnitTracker } from "./../UnitTracker";
import { reconstructEntity } from "./reconstructEntity";

/**
* Derives a deterministic PRNG seed purely from the input state so that reconstructing the
* same state (e.g. via evaluateNextState) reproduces the same game PRNG stream. Uses an
* FNV-1a style rolling hash over the serialisable tick + layout.
*
* The seed intentionally does NOT depend on config.PrngSeed: getConfig() randomises PrngSeed
* on every call (see getConfig.ts), so mixing it in would reintroduce the non-determinism
* this fix removes. Deriving from state alone guarantees identical (state, actions) inputs
* yield identical output — the property the forward model relies on.
*
* NOTE: this does not reproduce the *original* game's stream position — that would require
* serialising the PRNG state into IGameState (a wire-schema change, out of scope).
*/
const deriveDeterministicSeed = (tick: number, worldState: IWorldState): number => {
let hash = (2166136261 ^ (tick | 0)) >>> 0;
const mix = (value: number) => {
hash = Math.imul(hash ^ (value | 0), 16777619) >>> 0;
};
worldState.units.forEach((unit) => {
const [x, y] = unit.coordinates;
mix(x);
mix(y);
mix(unit.hp);
});
worldState.entities.forEach((entity) => {
mix(entity.x);
mix(entity.y);
mix(entity.hp ?? 0);
});
return hash >>> 0;
};

const reconstructUnitTracker = (config: IConfig, worldState: IWorldState, prngGame: PRNG): UnitTracker => {
const unitTracker = new UnitTracker(prngGame);
worldState.units.forEach((unit) => {
Expand Down Expand Up @@ -63,7 +95,7 @@ export const generateWorldFromState = (
gameTicker: GameTicker
): World => {
const config = getConfig({ MapHeight: height, MapWidth: width });
const randomSeed = Math.floor(Math.random() * Math.random() * (10 ^ 6));
const randomSeed = deriveDeterministicSeed(gameTicker.CurrentTick, worldState);
const mersenneTwisterGame = new MersenneTwister(randomSeed);
const prngGame: PRNG = () => mersenneTwisterGame.random();
const cellReserver = new CellReserver(width * height, prngGame, width);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,38 @@ describe("reconstructEntity", () => {
const expected = entity;
expect(result).toStrictEqual(expected);
});

test(`it preserves a FreezePowerup's created and derives expires from FreezePowerupDurationTicks (not BlastPowerupDurationTicks)`, () => {
const created = 100;
// Distinct durations so a regression to BlastPowerupDurationTicks would be visible.
const config = getConfig({ MapHeight: 6, MapWidth: 6, FreezePowerupDurationTicks: 40, BlastPowerupDurationTicks: 999 });
const entity: IEntity = { created, x: 4, y: 2, type: EntityType.FreezePowerup, expires: created + 40, hp: 1 };
const cellNumber = getCellNumberFromCoordinates([entity.x, entity.y], 6);
const gameTicker = new GameTicker(150, 300); // current tick deliberately != created
const result = reconstructEntity(entity, 6, mock6x6GameState, cellNumber, gameTicker, config).ToJSON();
expect(result.created).toBe(created);
expect(result.expires).toBe(created + config.FreezePowerupDurationTicks);
});

test(`it preserves a BlastPowerup's created and expires on reload`, () => {
const created = 100;
const config = getConfig({ MapHeight: 6, MapWidth: 6, BlastPowerupDurationTicks: 40 });
const entity: IEntity = { created, x: 4, y: 2, type: EntityType.BlastPowerup, expires: created + 40, hp: 1 };
const cellNumber = getCellNumberFromCoordinates([entity.x, entity.y], 6);
const gameTicker = new GameTicker(150, 300);
const result = reconstructEntity(entity, 6, mock6x6GameState, cellNumber, gameTicker, config).ToJSON();
expect(result.created).toBe(created);
expect(result.expires).toBe(created + config.BlastPowerupDurationTicks);
});

test(`it reconstructs a bomb with its serialised blast_diameter, not the owning unit's current diameter`, () => {
// Agent "a" units in mock6x6GameState carry blast_diameter 3; the serialised 5 must win.
const bomb: IEntity = { created: 50, x: 2, y: 2, type: EntityType.Bomb, unit_id: "c", agent_id: "a", blast_diameter: 5, expires: 80 };
const cellNumber = getCellNumberFromCoordinates([bomb.x, bomb.y], 6);
const gameTicker = new GameTicker(60, 300);
const config = getConfig({ MapHeight: 6, MapWidth: 6 });
const result = reconstructEntity(bomb, 6, mock6x6GameState, cellNumber, gameTicker, config).ToJSON();
expect(result.blast_diameter).toBe(5);
expect(result.created).toBe(50);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,27 @@ export const reconstructEntity = (
} else if (type === EntityType.Ammo) {
return new AmmoEntity(config, cellNumber, tick, entity.created);
} else if (type === EntityType.FreezePowerup) {
return new FreezePowerupEntity(config, cellNumber, width, tick);
return new FreezePowerupEntity(config, cellNumber, width, tick, entity.created);
} else if (type === EntityType.Blast) {
return new BlastEntity(config, cellNumber, unit_id, agent_id, entity.expires, tick, entity.created);
} else if (type === EntityType.BlastPowerup) {
return new BlastPowerupEntity(config, cellNumber, width, tick);
return new BlastPowerupEntity(config, cellNumber, width, tick, entity.created);
} else if (type === EntityType.Bomb) {
const unit = worldState.units.find((unit) => {
return unit.agent_id === agent_id;
});

if (unit !== undefined) {
return new BombEntity(config, cellNumber, width, unit_id, agent_id, tick, unit?.blast_diameter, entity.created);
// Prefer the bomb's own serialised blast diameter (the radius it was thrown with);
// fall back to the owning unit's current diameter only when the bomb didn't carry one.
const blastDiameter = entity.blast_diameter ?? unit?.blast_diameter;

if (blastDiameter !== undefined) {
return new BombEntity(config, cellNumber, width, unit_id, agent_id, tick, blastDiameter, entity.created);
}

throw new Error("Agent cannot be undefined since bombs must have an owner");
throw new Error(
`Cannot reconstruct bomb at cell ${cellNumber}: no serialised blast_diameter and no owning unit for agent ${agent_id}`
);
} else {
throw new Error(`Unhandled entity type ${type} when reconstructing entity`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,16 @@ describe("evaluateNextState", () => {

expect(result2).toStrictEqual(expectedState2);
});

test(`it is deterministic: identical (state, actions) inputs reproduce an identical next_state`, async () => {
// Force a spawn every tick while keeping several empty cells so BOTH the spawn location
// and type depend on the game PRNG. This exercises the state-reconstruction seeding path
// (createWorldFromState) that previously used Math.random() and was non-reproducible.
process.env["ENTITY_SPAWN_PROBABILITY_PER_TICK"] = "1";
const runA = await evaluateNextState(telemetry, mock4x4GameState, []);
const runB = await evaluateNextState(telemetry, mock4x4GameState, []);
const spawned = runA.tick_result.events.some((event) => event.type === GameEventType.EntitySpawned);
expect(spawned).toBe(true);
expect(runB).toStrictEqual(runA);
});
});
Loading