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
2 changes: 1 addition & 1 deletion examples/cordon-off/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Once history lives in the Worker assets, the VM is the only publish source: a `w

- One 2000 × 1000 world, cropped to longitude −135°…180°, latitude −60°…85°, with **661,453 land pixels**. The world wraps horizontally; the first and last map columns are water, so no owned wall can cross the seam and enclosure scans stay planar. New players and their bots spread over nine named spawn points in three waves: **Warsaw, Zurich and Moscow** first, **Ulaanbaatar, Riyadh and Bangui** once those are all in use, and **Yulara, Brasília and Denver** once the first six points each hold four humans. Per-player spacing is unchanged. Joiners of an existing country spawn on its territory when it has room, otherwise beside a live teammate.
- Ownership and player coordinates live in two tables with independent grids. `country_chunks` tiles the map in 50 × 25-cell chunks (40 × 40); each row's `color_indexes` is a run-length encoded palette bitmap (0 unowned, 1–64 = `COUNTRY_COLORS[code - 1]`), published only when a claim changes it. Chunks that are entirely water have no row and no subscription, because they can never change owner. `user_chunks` tiles the map in 200 × 100-cell chunks (10 × 10); each row's `coordinates` is a packed binary dot list (5-byte header plus one 4-byte `slot`, chunk-local `x`, chunk-local `y` entry per live player) for the live players inside it, published when one of them moves. Clients subscribe to both grids around the viewport, paint color indexes straight from the shared palette, and join dots by their round-local numeric `slot` to the `users` roster (`name`, `country_id`, `is_bot`, `slot`, last-known `last_x`/`last_y`, keyed by the session identity) client-side at render. Water remains unowned. Arrays are not used as positional store data.
- `RULES` in `shared.ts` sets the 50 ms tick and own/neutral/enemy movement costs of 1/2/4 ticks. Entering or leaving water adds 6 ticks. All movers paint first, then affected countries resolve enclosures in first-change order. Safe gates skip changes that cannot enclose anything; bounded local searches handle small cases, with at most one whole-country scan per country per tick as fallback. Captures can add other affected countries to that pass, but no country runs twice. Enclosures capture land and let defenders reclaim severed extensions. Enclosure is eight-connected: a pixel falls only when nothing reaches the exterior through any of its eight neighbours, so a diagonal gap keeps the ground open and a wall has to be solid. Water remains unowned and gaps through water keep an area open. A closure breached later in the same tick does not capture its interior.
- `RULES` in `shared.ts` sets the 50 ms tick and own/neutral/enemy movement costs of 1/2/4 ticks. Entering or leaving water adds 6 ticks. All movers paint first, except a step into a pixel one enemy owns on all eight sides: that claim would be reclaimed by the same tick's enclosure pass, so it is skipped. Then affected countries resolve enclosures in first-change order. Safe gates skip changes that cannot enclose anything; bounded local searches handle small cases, with at most one whole-country scan per country per tick as fallback. Captures can add other affected countries to that pass, but no country runs twice. Enclosures capture land and let defenders reclaim severed extensions. Enclosure is eight-connected: a pixel falls only when nothing reaches the exterior through any of its eight neighbours, so a diagonal gap keeps the ground open and a wall has to be solid. Water remains unowned and gaps through water keep an area open. A closure breached later in the same tick does not capture its interior.
- Flushes wait for **committed** acknowledgment. At most one commit is in flight; the simulation keeps ticking while it lands, so slow storage lags the published world by one commit instead of stalling the tick clock, and writes never accumulate beyond one pending flush. A write or database connection failure stops the game; restart restores committed territory and reconciles dots. A commit acknowledgment does not measure browser delivery.
- Country creation sends `countryName` once to `/session`, which returns the assigned `country_id`. The sync `player_join` action admits the player with that id and a display name, and returns the internal user id that roster rows are keyed by plus the round-local numeric `slot` that packed dots carry. A newly allocated country with no land or players is removed after ten seconds if its creator never joins.
- The server uses a five-second WebSocket idle timeout with native Ping/Pong. The browser creates an empty presence record after `player_join`; connection teardown removes it, and the simulation uses its leave event to remove the player and start a 15-second reconnect grace. Rejoining with the same session resumes at the saved cell during that window; after it expires, the server releases the session and the browser returns to the lobby. Intentional exits remove presence immediately.
Expand Down
1 change: 0 additions & 1 deletion examples/cordon-off/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,6 @@ function applyMovementPaint(paint: MovementPaint) {
const y = (paint.cell - x) / WIDTH;
const localX = x % COUNTRY_CHUNK_WIDTH;
const localY = y % COUNTRY_CHUNK_HEIGHT;
chunk.colorIndexes[localY * COUNTRY_CHUNK_WIDTH + localX] = paint.code;
const draw = chunk.image.getContext("2d");
if (!draw) return;
draw.fillStyle = COUNTRY_COLORS[paint.code - 1] ?? "#ffffff";
Expand Down
17 changes: 17 additions & 0 deletions examples/cordon-off/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ function summary(samples: number[]) {
};
}

function ownerChecksum(world: World) {
return createHash("sha256")
.update(new Uint8Array(world.owners.buffer))
.digest("hex");
}

function checksum(world: World) {
return createHash("sha256")
.update(new Uint8Array(world.owners.buffer))
Expand Down Expand Up @@ -266,6 +272,7 @@ async function run(client?: ZyncBaseClient) {
// Reset after warming code: every mode measures from the original fixture.
world = fixture();
const initialChecksum = checksum(world);
const initialOwnerChecksum = ownerChecksum(world);
const initialOwnedPixels = [...world.countries.values()].reduce(
(sum, c) => sum + c.count,
0,
Expand Down Expand Up @@ -374,6 +381,16 @@ async function run(client?: ZyncBaseClient) {
movers: world.players.size,
initialOwnedPixels,
initialChecksum,
initialOwnerChecksum,
finalOwnerChecksum: ownerChecksum(world),
captured: [...world.players.values()].reduce(
(sum, player) => sum + player.captured,
0,
),
lost: [...world.countries.values()].reduce(
(sum, country) => sum + (country.lost ?? 0),
0,
),
finalChecksum: checksum(world),
elapsedMs,
simulatedMs: ticks * RULES.tickMs,
Expand Down
2 changes: 1 addition & 1 deletion examples/cordon-off/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ const RING_STEPS = [

/** True when a cell and all eight neighbours belong to one enemy country: the
* claim is a one-pixel hole its owner's enclosure pass is certain to refill. */
function surroundedByEnemy(
export function surroundedByEnemy(
ownerAt: (x: number, y: number) => number | undefined,
x: number,
y: number,
Expand Down
63 changes: 63 additions & 0 deletions examples/cordon-off/world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,69 @@ test("movement pays destination cost, preserves cooldowns, and survives chunk/re
).toBe(true);
});

test("a move into a one-cell hole in enemy land does not paint", () => {
const land = new Uint8Array(WIDTH * HEIGHT).fill(1);
const world = new World(land);
const enemy = world.country("Enemy");
const mover = world.country("Mover");
if (!enemy || !mover) throw new Error("Missing countries");
const left = 80,
right = 120,
top = 80,
bottom = 120;
for (let y = top; y <= bottom; y++)
for (let x = left; x <= right; x++)
world.owners[y * WIDTH + x] = enemy.country_id;
enemy.count = (right - left + 1) * (bottom - top + 1);
world.input(
"walker",
{
name: "Walker",
country_id: mover.country_id,
direction: "idle",
seq: 1,
},
0,
);
const player = world.players.get("walker");
if (!player) throw new Error("Player missing");
player.x = 100;
player.y = 100;
player.credit = 0;
const moverCount = mover.count;
world.dirtyCountryChunks.clear();
world.dirtyUserChunks.clear();
world.input(
"walker",
{
name: "Walker",
country_id: mover.country_id,
direction: "right",
seq: 2,
},
1,
);
for (let tick = 0; tick < RULES.enemy; tick++) world.tick(tick + 2);
expect([player.x, player.y]).toEqual([101, 100]);
// The dot moved, but the surrounded claim was neither painted nor queued.
expect(world.owners[100 * WIDTH + 101]).toBe(enemy.country_id);
expect(enemy.count).toBe(41 * 41);
expect(mover.count).toBe(moverCount);
expect(world.dirtyCountryChunks.size).toBe(0);
expect(world.dirtyUserChunks.has(userChunkIndex(101, 100))).toBe(true);
// The first cell outside the enemy block is a real claim again.
player.x = right;
player.credit = 0;
world.dirtyCountryChunks.clear();
world.dirtyUserChunks.clear();
for (let tick = 0; tick < RULES.neutral; tick++)
world.tick(tick + 20 + RULES.enemy);
expect([player.x, player.y]).toEqual([right + 1, 100]);
expect(world.owners[100 * WIDTH + right + 1]).toBe(mover.country_id);
expect(mover.count).toBe(moverCount + 1);
expect(world.dirtyCountryChunks.size).toBe(1);
});

test("color-index chunks round-trip and reject malformed streams", () => {
const codes = new Uint8Array(COUNTRY_CHUNK_WIDTH * 25);
for (let i = 0; i < codes.length; i++)
Expand Down
7 changes: 7 additions & 0 deletions examples/cordon-off/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
readColorIndexes,
rowId,
steps,
surroundedByEnemy,
USER_CHUNK_COUNT,
type UserChunkRow,
userChunkIndex,
Expand Down Expand Up @@ -119,6 +120,8 @@ const BULK_CAPTURE_FLUSH_CELLS = 100;

export class World {
readonly owners = new Uint16Array(WIDTH * HEIGHT);
private readonly ownerAtCell = (x: number, y: number) =>
this.owners[y * WIDTH + x];
// Country id -> wire owner code (0 = unowned). Filled eagerly for created
// and restored countries and lazily for directly injected rows, so only
// countries with a palette color can paint chunk bytes.
Expand Down Expand Up @@ -1029,6 +1032,10 @@ export class World {
this.dirtyPlayerRows.add(player.id);
}
if (!this.land[to] || owner === player.country_id) return;
// A claim fully surrounded by one enemy country is a one-cell hole the
// defender's enclosure pass would reclaim this same tick. Skipping it
// avoids the claim/reclaim cycle and the redundant country-chunk write.
if (surroundedByEnemy(this.ownerAtCell, x, y, player.country_id)) return;
this.claim(to, player.country_id, false, player.id);
}

Expand Down
Loading