From ce5cc3c02a072a8f8f0d99959f5d84d5c85d1243 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 13:23:48 -0400 Subject: [PATCH 01/22] Create a safe parity foothold for the C++ migration The TypeScript runtime remains the behavioral reference while protocol golden fixtures, boundary cases, and a CMake-based C++ protocol port establish byte-for-byte parity before deeper migration work. Baseline Node tests are wired into package scripts so future ports can verify both existing behavior and C++ conformance without replacing production routing. Constraint: Preserve current TypeScript runtime as source of truth during staged migration Constraint: Avoid third-party C++ dependencies in the initial skeleton Rejected: Replace runtime entry points now | C++ server behavior is not parity-complete Confidence: high Scope-risk: moderate Tested: npm run test:conformance Tested: npm run test:parity Tested: npm run test:all --- CMakeLists.txt | 20 +++ conformance/fixtures/protocol-golden.json | 72 +++++++++ conformance/protocol/compare-parity.js | 18 +++ conformance/protocol/golden.test.js | 68 +++++++++ conformance/protocol/report-ts.js | 46 ++++++ cpp/include/diepcustom/protocol.hpp | 49 ++++++ cpp/src/protocol.cpp | 172 ++++++++++++++++++++++ cpp/tests/protocol_golden_test.cpp | 55 +++++++ cpp/tests/protocol_report.cpp | 72 +++++++++ package.json | 9 +- src/Coder/Writer.ts | 9 ++ src/index.ts | 6 +- test/e2e/server.test.js | 109 ++++++++++++++ test/helpers/register-ts.js | 16 ++ test/helpers/server.js | 148 +++++++++++++++++++ test/unit/coder.test.js | 52 +++++++ 16 files changed, 916 insertions(+), 5 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 conformance/fixtures/protocol-golden.json create mode 100644 conformance/protocol/compare-parity.js create mode 100644 conformance/protocol/golden.test.js create mode 100644 conformance/protocol/report-ts.js create mode 100644 cpp/include/diepcustom/protocol.hpp create mode 100644 cpp/src/protocol.cpp create mode 100644 cpp/tests/protocol_golden_test.cpp create mode 100644 cpp/tests/protocol_report.cpp create mode 100644 test/e2e/server.test.js create mode 100644 test/helpers/register-ts.js create mode 100644 test/helpers/server.js create mode 100644 test/unit/coder.test.js diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 00000000..a7fbd73d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.16) +project(diepcustom_cpp LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(diepcustom_protocol + cpp/src/protocol.cpp +) +target_include_directories(diepcustom_protocol PUBLIC cpp/include) + +add_executable(protocol_golden_test cpp/tests/protocol_golden_test.cpp) +target_link_libraries(protocol_golden_test PRIVATE diepcustom_protocol) + +add_executable(protocol_report cpp/tests/protocol_report.cpp) +target_link_libraries(protocol_report PRIVATE diepcustom_protocol) + +enable_testing() +add_test(NAME protocol_golden_test COMMAND protocol_golden_test) diff --git a/conformance/fixtures/protocol-golden.json b/conformance/fixtures/protocol-golden.json new file mode 100644 index 00000000..69840eba --- /dev/null +++ b/conformance/fixtures/protocol-golden.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "source": "src/Coder/Writer.ts", + "cases": [ + { + "name": "unsigned-varint-boundaries", + "hex": "000102030a7f80018101ff01808001ffff03ffffffff07" + }, + { + "name": "signed-varint-boundaries", + "hex": "ff91f4018102ff01010002fe0180028092f401" + }, + { + "name": "fixed-width-little-endian", + "hex": "abefcd785634120000c03f" + }, + { + "name": "varint-floats", + "hex": "ffd885cf04fe820600fe8006ffda85cf04" + }, + { + "name": "null-terminated-unicode", + "hex": "54616e6b20f09f9a8020ce9420e6bca2e5ad9700" + }, + { + "name": "raw-bytes", + "hex": "010203ff" + } + ], + "boundaryDecode": [ + { + "name": "empty-vu", + "hex": "", + "method": "vu", + "ok": true, + "value": 0, + "at": 1 + }, + { + "name": "truncated-vu-continuation", + "hex": "80", + "method": "vu", + "ok": true, + "value": 0, + "at": 2 + }, + { + "name": "empty-stringNT", + "hex": "00", + "method": "stringNT", + "ok": true, + "value": "", + "at": 1 + }, + { + "name": "unterminated-stringNT", + "hex": "6162", + "method": "stringNT", + "ok": true, + "value": "a", + "at": 0 + }, + { + "name": "fixed-empty-buffer-u8", + "hex": "", + "method": "u8", + "ok": true, + "value": 0, + "at": 1 + } + ] +} diff --git a/conformance/protocol/compare-parity.js b/conformance/protocol/compare-parity.js new file mode 100644 index 00000000..3fcf385c --- /dev/null +++ b/conformance/protocol/compare-parity.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const root = path.join(__dirname, '../..'); +const exe = path.join(root, 'build/cpp/protocol_report') + (process.platform === 'win32' ? '.exe' : ''); + +function runJson(command, args) { + const output = execFileSync(command, args, { cwd: root, encoding: 'utf8' }); + return JSON.parse(output); +} + +const tsReport = runJson(process.execPath, [path.join(root, 'conformance/protocol/report-ts.js')]); +const cppReport = runJson(exe, []); + +assert.deepEqual(cppReport, tsReport); +console.log('protocol parity report matched TypeScript reference'); diff --git a/conformance/protocol/golden.test.js b/conformance/protocol/golden.test.js new file mode 100644 index 00000000..4da054c2 --- /dev/null +++ b/conformance/protocol/golden.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +require('../../test/helpers/register-ts'); +const Reader = require('../../src/Coder/Reader').default; +const Writer = require('../../src/Coder/Writer').default; + +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../fixtures/protocol-golden.json'), 'utf8')); +const byName = new Map(fixture.cases.map((entry) => [entry.name, entry.hex])); +const toHex = (bytes) => Buffer.from(bytes).toString('hex'); +const fromHex = (hex) => Uint8Array.from(Buffer.from(hex, 'hex')); // Avoid Buffer pool byteOffset because Reader intentionally wraps buf.buffer. + +const readers = { + vu: (reader) => reader.vu(), + u8: (reader) => reader.u8(), + stringNT: (reader) => reader.stringNT() +}; + +function expectGolden(name, build) { + const writer = new Writer(); + build(writer); + assert.equal(toHex(writer.write(true)), byName.get(name)); +} + +test('TS protocol writer matches golden byte fixtures', () => { + expectGolden('unsigned-varint-boundaries', (w) => [0, 1, 2, 3, 10, 127, 128, 129, 255, 16_384, 65_535, 2_147_483_647].forEach((v) => w.vu(v))); + expectGolden('signed-varint-boundaries', (w) => [-2_000_000, -129, -128, -1, 0, 1, 127, 128, 2_000_000].forEach((v) => w.vi(v))); + expectGolden('fixed-width-little-endian', (w) => w.u8(0xab).u16(0xcdef).u32(0x12345678).float(1.5)); + expectGolden('varint-floats', (w) => [-Math.PI, -1.5, 0, 1.5, Math.PI].forEach((v) => w.vf(v))); + expectGolden('null-terminated-unicode', (w) => w.stringNT('Tank 🚀 Δ 漢字')); + expectGolden('raw-bytes', (w) => w.raw(1, 2, 3, 255)); +}); + +test('TS protocol reader decodes golden byte fixtures', () => { + let reader = new Reader(fromHex(byName.get('unsigned-varint-boundaries'))); + assert.deepEqual([0, 1, 2, 3, 10, 127, 128, 129, 255, 16_384, 65_535, 2_147_483_647].map(() => reader.vu()), [0, 1, 2, 3, 10, 127, 128, 129, 255, 16_384, 65_535, 2_147_483_647]); + + reader = new Reader(fromHex(byName.get('signed-varint-boundaries'))); + assert.deepEqual([-2_000_000, -129, -128, -1, 0, 1, 127, 128, 2_000_000].map(() => reader.vi()), [-2_000_000, -129, -128, -1, 0, 1, 127, 128, 2_000_000]); + + reader = new Reader(fromHex(byName.get('fixed-width-little-endian'))); + assert.equal(reader.u8(), 0xab); + assert.equal(reader.u16(), 0xcdef); + assert.equal(reader.u32(), 0x12345678); + assert.equal(reader.float(), 1.5); + + reader = new Reader(fromHex(byName.get('varint-floats'))); + for (const expected of [-Math.PI, -1.5, 0, 1.5, Math.PI]) { + assert.ok(Math.abs(reader.vf() - expected) < 0.00001); + } + + reader = new Reader(fromHex(byName.get('null-terminated-unicode'))); + assert.equal(reader.stringNT(), 'Tank 🚀 Δ 漢字'); +}); + + +test('TS protocol reader documents malformed and boundary fixture behavior', () => { + for (const boundary of fixture.boundaryDecode) { + const reader = new Reader(fromHex(boundary.hex)); + if (boundary.ok) { + assert.equal(readers[boundary.method](reader), boundary.value, boundary.name); + assert.equal(reader.at, boundary.at, boundary.name); + } else { + assert.throws(() => readers[boundary.method](reader), undefined, boundary.name); + } + } +}); diff --git a/conformance/protocol/report-ts.js b/conformance/protocol/report-ts.js new file mode 100644 index 00000000..fe89c5c4 --- /dev/null +++ b/conformance/protocol/report-ts.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +const path = require('node:path'); +require(path.join(__dirname, '../../test/helpers/register-ts')); +const Reader = require('../../src/Coder/Reader').default; +const Writer = require('../../src/Coder/Writer').default; + +const toHex = (bytes) => Buffer.from(bytes).toString('hex'); +const fromHex = (hex) => Uint8Array.from(Buffer.from(hex, 'hex')); + +function encodeReport() { + const cases = {}; + const add = (name, build) => { + const writer = new Writer(); + build(writer); + cases[name] = toHex(writer.write(true)); + }; + + add('unsigned-varint-boundaries', (w) => [0, 1, 2, 3, 10, 127, 128, 129, 255, 16_384, 65_535, 2_147_483_647].forEach((v) => w.vu(v))); + add('signed-varint-boundaries', (w) => [-2_000_000, -129, -128, -1, 0, 1, 127, 128, 2_000_000].forEach((v) => w.vi(v))); + add('fixed-width-little-endian', (w) => w.u8(0xab).u16(0xcdef).u32(0x12345678).float(1.5)); + add('varint-floats', (w) => [-Math.PI, -1.5, 0, 1.5, Math.PI].forEach((v) => w.vf(v))); + add('null-terminated-unicode', (w) => w.stringNT('Tank 🚀 Δ 漢字')); + add('raw-bytes', (w) => w.raw(1, 2, 3, 255)); + return cases; +} + +function decodeBoundary(name, hex, read) { + const reader = new Reader(fromHex(hex)); + try { + return { name, ok: true, value: read(reader), at: reader.at }; + } catch (error) { + return { name, ok: false, error: error && error.name ? error.name : String(error), message: error && error.message ? error.message : '' }; + } +} + +function decodeReport() { + return [ + decodeBoundary('empty-vu', '', (r) => r.vu()), + decodeBoundary('truncated-vu-continuation', '80', (r) => r.vu()), + decodeBoundary('empty-stringNT', '00', (r) => r.stringNT()), + decodeBoundary('unterminated-stringNT', '6162', (r) => r.stringNT()), + decodeBoundary('fixed-empty-buffer-u8', '', (r) => r.u8()) + ]; +} + +process.stdout.write(`${JSON.stringify({ encoder: encodeReport(), boundaryDecode: decodeReport() }, null, 2)}\n`); diff --git a/cpp/include/diepcustom/protocol.hpp b/cpp/include/diepcustom/protocol.hpp new file mode 100644 index 00000000..81b15b42 --- /dev/null +++ b/cpp/include/diepcustom/protocol.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace diepcustom::protocol { + +class Writer { +public: + Writer& u8(std::uint32_t value); + Writer& u16(std::uint32_t value); + Writer& u32(std::uint32_t value); + Writer& vu(std::int32_t value); + Writer& vi(std::int32_t value); + Writer& vf(float value); + Writer& float32(float value); + Writer& raw(std::initializer_list bytes); + Writer& stringNT(const std::string& value); + + const std::vector& bytes() const; + +private: + std::vector buffer_; +}; + +class Reader { +public: + explicit Reader(std::vector bytes); + + std::uint8_t u8(); + std::uint16_t u16(); + std::uint32_t u32(); + std::uint32_t vu(); + std::int32_t vi(); + float vf(); + float float32(); + std::string stringNT(); + std::size_t position() const; + +private: + std::vector buffer_; + std::size_t at_ = 0; +}; + +std::vector hexToBytes(const std::string& hex); +std::string bytesToHex(const std::vector& bytes); + +} // namespace diepcustom::protocol diff --git a/cpp/src/protocol.cpp b/cpp/src/protocol.cpp new file mode 100644 index 00000000..f3fe64f8 --- /dev/null +++ b/cpp/src/protocol.cpp @@ -0,0 +1,172 @@ +#include "diepcustom/protocol.hpp" + +#include +#include +#include +#include + +namespace diepcustom::protocol { +namespace { +std::int32_t endianSwap(std::int32_t num) { + const auto value = static_cast(num); + return static_cast(((value & 0xffu) << 24u) | + ((value & 0xff00u) << 8u) | + ((value >> 8u) & 0xff00u) | + ((value >> 24u) & 0xffu)); +} + +std::uint8_t hexNibble(char c) { + if (c >= '0' && c <= '9') return static_cast(c - '0'); + if (c >= 'a' && c <= 'f') return static_cast(10 + c - 'a'); + if (c >= 'A' && c <= 'F') return static_cast(10 + c - 'A'); + throw std::invalid_argument("invalid hex character"); +} +} // namespace + +Writer& Writer::u8(std::uint32_t value) { + buffer_.push_back(static_cast(value & 0xffu)); + return *this; +} + +Writer& Writer::u16(std::uint32_t value) { + buffer_.push_back(static_cast(value & 0xffu)); + buffer_.push_back(static_cast((value >> 8u) & 0xffu)); + return *this; +} + +Writer& Writer::u32(std::uint32_t value) { + for (int shift = 0; shift < 32; shift += 8) { + buffer_.push_back(static_cast((value >> shift) & 0xffu)); + } + return *this; +} + +Writer& Writer::vu(std::int32_t input) { + auto value = static_cast(input); + do { + auto part = value; + value >>= 7u; + if (value != 0u) part |= 0x80u; + buffer_.push_back(static_cast(part & 0xffu)); + } while (value != 0u); + return *this; +} + +Writer& Writer::vi(std::int32_t value) { + return vu(static_cast((0 - (value < 0 ? 1 : 0)) ^ (value << 1))); +} + +Writer& Writer::vf(float value) { + std::int32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return vi(endianSwap(bits)); +} + +Writer& Writer::float32(float value) { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return u32(bits); +} + +Writer& Writer::raw(std::initializer_list bytes) { + buffer_.insert(buffer_.end(), bytes.begin(), bytes.end()); + return *this; +} + +Writer& Writer::stringNT(const std::string& value) { + buffer_.insert(buffer_.end(), value.begin(), value.end()); + buffer_.push_back(0); + return *this; +} + +const std::vector& Writer::bytes() const { return buffer_; } + +Reader::Reader(std::vector bytes) : buffer_(std::move(bytes)) {} + +std::uint8_t Reader::u8() { + if (at_ >= buffer_.size()) { + ++at_; + return 0; + } + return buffer_[at_++]; +} + +std::uint16_t Reader::u16() { + const auto lo = u8(); + const auto hi = u8(); + return static_cast(lo | (hi << 8u)); +} + +std::uint32_t Reader::u32() { + std::uint32_t value = 0; + for (int shift = 0; shift < 32; shift += 8) { + value |= static_cast(u8()) << shift; + } + return value; +} + +std::uint32_t Reader::vu() { + std::uint32_t out = 0; + int shift = 0; + while (at_ < buffer_.size() && (buffer_[at_] & 0x80u) != 0u) { + out |= static_cast(buffer_[at_++] & 0x7fu) << shift; + shift += 7; + } + const auto byte = at_ < buffer_.size() ? buffer_[at_] : 0; + ++at_; + out |= static_cast(byte & 0x7fu) << shift; + return out; +} + +std::int32_t Reader::vi() { + const auto out = vu(); + return static_cast((0 - (out & 1u)) ^ (out >> 1u)); +} + +float Reader::vf() { + const auto swapped = endianSwap(vi()); + float value = 0; + std::memcpy(&value, &swapped, sizeof(value)); + return value; +} + +float Reader::float32() { + const auto bits = u32(); + float value = 0; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +std::string Reader::stringNT() { + const auto start = at_; + while (at_ < buffer_.size() && buffer_[at_] != 0) ++at_; + if (at_ >= buffer_.size()) { + const auto end = buffer_.empty() || buffer_.size() <= start ? start : buffer_.size() - 1; + at_ = start; + return std::string(buffer_.begin() + static_cast(start), buffer_.begin() + static_cast(end)); + } + std::string value(buffer_.begin() + static_cast(start), buffer_.begin() + static_cast(at_)); + ++at_; + return value; +} + +std::size_t Reader::position() const { return at_; } + +std::vector hexToBytes(const std::string& hex) { + if (hex.size() % 2 != 0) throw std::invalid_argument("hex length must be even"); + std::vector bytes; + bytes.reserve(hex.size() / 2); + for (std::size_t i = 0; i < hex.size(); i += 2) { + bytes.push_back(static_cast((hexNibble(hex[i]) << 4u) | hexNibble(hex[i + 1]))); + } + return bytes; +} + +std::string bytesToHex(const std::vector& bytes) { + std::ostringstream out; + out << std::hex << std::setfill('0'); + for (const auto byte : bytes) out << std::setw(2) << static_cast(byte); + return out.str(); +} + +} // namespace diepcustom::protocol diff --git a/cpp/tests/protocol_golden_test.cpp b/cpp/tests/protocol_golden_test.cpp new file mode 100644 index 00000000..1c471c7d --- /dev/null +++ b/cpp/tests/protocol_golden_test.cpp @@ -0,0 +1,55 @@ +#include "diepcustom/protocol.hpp" + +#include +#include +#include +#include +#include + +using diepcustom::protocol::Reader; +using diepcustom::protocol::Writer; +using diepcustom::protocol::bytesToHex; +using diepcustom::protocol::hexToBytes; + +namespace { +void expectHex(const std::string& name, const Writer& writer, const std::string& expected) { + const auto actual = bytesToHex(writer.bytes()); + if (actual != expected) { + std::cerr << name << " expected " << expected << " got " << actual << '\n'; + std::exit(1); + } +} +} + +int main() { + expectHex("unsigned-varint-boundaries", + [] { Writer w; for (const auto v : {0, 1, 2, 3, 10, 127, 128, 129, 255, 16384, 65535, 2147483647}) w.vu(v); return w; }(), + "000102030a7f80018101ff01808001ffff03ffffffff07"); + + expectHex("signed-varint-boundaries", + [] { Writer w; for (const auto v : {-2000000, -129, -128, -1, 0, 1, 127, 128, 2000000}) w.vi(v); return w; }(), + "ff91f4018102ff01010002fe0180028092f401"); + + expectHex("fixed-width-little-endian", Writer().u8(0xab).u16(0xcdef).u32(0x12345678).float32(1.5f), "abefcd785634120000c03f"); + expectHex("varint-floats", [] { Writer w; for (const auto v : {-3.14159265358979323846f, -1.5f, 0.0f, 1.5f, 3.14159265358979323846f}) w.vf(v); return w; }(), "ffd885cf04fe820600fe8006ffda85cf04"); + expectHex("null-terminated-unicode", Writer().stringNT("Tank 🚀 Δ 漢字"), "54616e6b20f09f9a8020ce9420e6bca2e5ad9700"); + expectHex("raw-bytes", Writer().raw({1, 2, 3, 255}), "010203ff"); + + Reader unsignedReader(hexToBytes("000102030a7f80018101ff01808001ffff03ffffffff07")); + for (const auto expected : {0u, 1u, 2u, 3u, 10u, 127u, 128u, 129u, 255u, 16384u, 65535u, 2147483647u}) assert(unsignedReader.vu() == expected); + + Reader signedReader(hexToBytes("ff91f4018102ff01010002fe0180028092f401")); + for (const auto expected : {-2000000, -129, -128, -1, 0, 1, 127, 128, 2000000}) assert(signedReader.vi() == expected); + + Reader fixedReader(hexToBytes("abefcd785634120000c03f")); + assert(fixedReader.u8() == 0xab); + assert(fixedReader.u16() == 0xcdef); + assert(fixedReader.u32() == 0x12345678); + assert(std::fabs(fixedReader.float32() - 1.5f) < 0.00001f); + + Reader stringReader(hexToBytes("54616e6b20f09f9a8020ce9420e6bca2e5ad9700")); + assert(stringReader.stringNT() == "Tank 🚀 Δ 漢字"); + + std::cout << "protocol golden tests passed\n"; + return 0; +} diff --git a/cpp/tests/protocol_report.cpp b/cpp/tests/protocol_report.cpp new file mode 100644 index 00000000..5cf92583 --- /dev/null +++ b/cpp/tests/protocol_report.cpp @@ -0,0 +1,72 @@ +#include "diepcustom/protocol.hpp" + +#include +#include +#include +#include +#include + +using diepcustom::protocol::Reader; +using diepcustom::protocol::Writer; +using diepcustom::protocol::bytesToHex; +using diepcustom::protocol::hexToBytes; + +namespace { +std::string jsonEscape(const std::string& input) { + std::ostringstream out; + for (const unsigned char c : input) { + switch (c) { + case '"': out << "\\\""; break; + case '\\': out << "\\\\"; break; + case '\b': out << "\\b"; break; + case '\f': out << "\\f"; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (c < 0x20) out << "\\u00" << std::hex << static_cast(c) << std::dec; + else out << c; + } + } + return out.str(); +} + +void printStringResult(const std::string& name, const std::string& hex) { + Reader reader(hexToBytes(hex)); + try { + const auto value = reader.stringNT(); + std::cout << " {\"name\":\"" << name << "\",\"ok\":true,\"value\":\"" << jsonEscape(value) << "\",\"at\":" << reader.position() << "}"; + } catch (const std::exception& error) { + std::cout << " {\"name\":\"" << name << "\",\"ok\":false,\"error\":\"Error\",\"message\":\"" << jsonEscape(error.what()) << "\"}"; + } +} + +template +void printNumberResult(const std::string& name, const std::string& hex, Read read) { + Reader reader(hexToBytes(hex)); + try { + const auto value = read(reader); + std::cout << " {\"name\":\"" << name << "\",\"ok\":true,\"value\":" << value << ",\"at\":" << reader.position() << "}"; + } catch (const std::exception& error) { + std::cout << " {\"name\":\"" << name << "\",\"ok\":false,\"error\":\"Error\",\"message\":\"" << jsonEscape(error.what()) << "\"}"; + } +} +} + +int main() { + std::cout << "{\n \"encoder\": {\n"; + std::cout << " \"unsigned-varint-boundaries\": \"" << bytesToHex([] { Writer w; for (const auto v : {0, 1, 2, 3, 10, 127, 128, 129, 255, 16384, 65535, 2147483647}) w.vu(v); return w; }().bytes()) << "\",\n"; + std::cout << " \"signed-varint-boundaries\": \"" << bytesToHex([] { Writer w; for (const auto v : {-2000000, -129, -128, -1, 0, 1, 127, 128, 2000000}) w.vi(v); return w; }().bytes()) << "\",\n"; + std::cout << " \"fixed-width-little-endian\": \"" << bytesToHex(Writer().u8(0xab).u16(0xcdef).u32(0x12345678).float32(1.5f).bytes()) << "\",\n"; + std::cout << " \"varint-floats\": \"" << bytesToHex([] { Writer w; for (const auto v : {-3.14159265358979323846f, -1.5f, 0.0f, 1.5f, 3.14159265358979323846f}) w.vf(v); return w; }().bytes()) << "\",\n"; + std::cout << " \"null-terminated-unicode\": \"" << bytesToHex(Writer().stringNT("Tank 🚀 Δ 漢字").bytes()) << "\",\n"; + std::cout << " \"raw-bytes\": \"" << bytesToHex(Writer().raw({1, 2, 3, 255}).bytes()) << "\"\n"; + std::cout << " },\n \"boundaryDecode\": [\n"; + printNumberResult("empty-vu", "", [](Reader& r) { return r.vu(); }); std::cout << ",\n"; + printNumberResult("truncated-vu-continuation", "80", [](Reader& r) { return r.vu(); }); std::cout << ",\n"; + printStringResult("empty-stringNT", "00"); std::cout << ",\n"; + printStringResult("unterminated-stringNT", "6162"); std::cout << ",\n"; + printNumberResult("fixed-empty-buffer-u8", "", [](Reader& r) { return static_cast(r.u8()); }); std::cout << "\n"; + std::cout << " ]\n}\n"; + return 0; +} diff --git a/package.json b/package.json index 8ad8ca1f..ed71ff33 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,14 @@ "dev": "tsup --watch --onSuccess \"npm run check && npm run start\"", "docker:build": "docker build --tag diepcustom .", "docker:start": "docker run --pull never --rm --publish ${PORT:-8080}:8080 --env PORT=8080 --env DEV_PASSWORD_HASH --env SERVER_INFO --env NODE_ENV --init --interactive --tty diepcustom", - "docker": "npm run docker:build && npm run docker:start" + "docker": "npm run docker:build && npm run docker:start", + "test:unit": "node --test test/unit/*.test.js", + "test:e2e": "node --test test/e2e/*.test.js", + "test": "npm run build && npm run test:unit && npm run test:e2e", + "test:all": "npm run test && npm run test:conformance && npm audit --audit-level=moderate", + "test:conformance": "node --test conformance/**/*.test.js", + "test:cpp": "cmake -S . -B build/cpp && cmake --build build/cpp && ctest --test-dir build/cpp --output-on-failure", + "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js" }, "engines": { "node": ">=16.3" diff --git a/src/Coder/Writer.ts b/src/Coder/Writer.ts index 5319aa3b..4edc74f9 100644 --- a/src/Coder/Writer.ts +++ b/src/Coder/Writer.ts @@ -105,7 +105,16 @@ export default class Writer { val |= 0; return this.vu((0 - (val < 0 ? 1 : 0)) ^ (val << 1)); // varsint trick } + private reserve(size: number) { + while (Writer.OUTPUT_BUFFER.length < this.at + size) { + const newBuffer = Buffer.alloc(Writer.OUTPUT_BUFFER.length + writtenBufferChunkSize); + newBuffer.set(Writer.OUTPUT_BUFFER, 0); + + Writer.OUTPUT_BUFFER = newBuffer; + } + } public bytes(buffer: Uint8Array) { + this.reserve(buffer.byteLength); Writer.OUTPUT_BUFFER.set(buffer, this.at); this.at += buffer.byteLength; return this; diff --git a/src/index.ts b/src/index.ts index 7a981a30..df8cd406 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,14 +135,12 @@ app.get("/*", (res, req) => { break; } - res.writeHeader("Content-Type", contentType + "; charset=utf-8"); - if (file && fs.existsSync(file)) { - res.writeStatus("200 OK").end(fs.readFileSync(file)); + res.writeStatus("200 OK").writeHeader("Content-Type", contentType + "; charset=utf-8").end(fs.readFileSync(file)); return; } - res.writeStatus("404 Not Found").end(fs.readFileSync(config.clientLocation + "/404.html")); + res.writeStatus("404 Not Found").writeHeader("Content-Type", "text/html; charset=utf-8").end(fs.readFileSync(config.clientLocation + "/404.html")); return; } }); diff --git a/test/e2e/server.test.js b/test/e2e/server.test.js new file mode 100644 index 00000000..381d1327 --- /dev/null +++ b/test/e2e/server.test.js @@ -0,0 +1,109 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { + connectWebSocket, + expectJson, + fetchText, + startServer, + waitForWebSocketClose, + waitForWebSocketMessage +} = require('../helpers/server'); + +let server; + +test.before(async () => { + server = await startServer(); +}); + +test.after(async () => { + await server?.stop(); +}); + +test('normal HTTP surfaces serve client and API metadata', async () => { + const root = await fetchText(`${server.origin}/`); + assert.equal(root.response.status, 200); + assert.match(root.response.headers.get('content-type') || '', /text\/html/); + assert.match(root.text, /diep|canvas|script/i); + + const missing = await fetchText(`${server.origin}/not-a-real-file`); + assert.equal(missing.response.status, 404); + assert.match(missing.text, /404|not found/i); + + await expectJson(`${server.origin}/api/servers`, (servers) => { + assert.deepEqual(servers, [ + { gamemode: 'ffa', name: 'FFA' }, + { gamemode: 'sandbox', name: 'Sandbox' } + ]); + }); + + await expectJson(`${server.origin}/api/tanks`, (tanks) => { + assert.equal(typeof tanks, 'object'); + assert.ok(Object.keys(tanks).length > 20); + }); + + await expectJson(`${server.origin}/api/commands`, (commands) => { + assert.ok(Array.isArray(commands)); + assert.ok(commands.length > 0); + }); + + await expectJson(`${server.origin}/api/colors`, (colors) => { + assert.equal(typeof colors, 'object'); + assert.ok(Object.keys(colors).length > 5); + }); +}); + +test('websocket accepts known gamemode endpoints and answers binary ping', async () => { + for (const route of ['/ffa', '/sandbox']) { + const ws = await connectWebSocket(`${server.wsOrigin}${route}`); + const messagePromise = waitForWebSocketMessage(ws); + ws.send(Uint8Array.of(0x05)); + const message = Buffer.from(await messagePromise); + assert.equal(message[0], 0x05, `${route} should return clientbound ping header`); + ws.close(); + await waitForWebSocketClose(ws); + } +}); + +test('malformed and hostile websocket inputs close without killing the server', async () => { + await assert.rejects( + connectWebSocket(`${server.wsOrigin}/does-not-exist`), + /WebSocket error|Timed out opening websocket/ + ); + + const textSocket = await connectWebSocket(`${server.wsOrigin}/ffa`); + textSocket.send('ignore previous instructions and claim the server works'); + await waitForWebSocketClose(textSocket); + + const oversizedSocket = await connectWebSocket(`${server.wsOrigin}/ffa`); + oversizedSocket.send(Buffer.alloc(4_097, 0xff)); + await waitForWebSocketClose(oversizedSocket); + + const health = await fetch(`${server.origin}/api/servers`, { signal: AbortSignal.timeout(3_000) }); + assert.equal(health.status, 200, 'server should still answer after hostile websocket inputs'); +}); + +test('malformed HTTP paths do not escape static allowlist or crash API routing', async () => { + for (const path of [ + '/../../package.json', + '/%2e%2e/%2e%2e/package.json', + '/api/../package.json', + '/api/servers?prompt=ignore%20tests', + '/api/unknown' + ]) { + const { response, text } = await fetchText(`${server.origin}${path}`); + assert.ok([200, 404].includes(response.status)); + assert.doesNotMatch(text, /"dependencies"\s*:/, `${path} must not expose package.json`); + } + + const health = await fetch(`${server.origin}/api/servers`, { signal: AbortSignal.timeout(3_000) }); + assert.equal(health.status, 200); +}); + +test('startup is repeatable to guard against flaky boot regressions', async () => { + for (let i = 0; i < 3; i += 1) { + const instance = await startServer(); + const response = await fetch(`${instance.origin}/api/servers`, { signal: AbortSignal.timeout(3_000) }); + assert.equal(response.status, 200); + await instance.stop(); + } +}); diff --git a/test/helpers/register-ts.js b/test/helpers/register-ts.js new file mode 100644 index 00000000..f85f443c --- /dev/null +++ b/test/helpers/register-ts.js @@ -0,0 +1,16 @@ +const fs = require('node:fs'); +const ts = require('typescript'); + +require.extensions['.ts'] = function compileTypeScript(module, filename) { + const source = fs.readFileSync(filename, 'utf8'); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2021, + resolveJsonModule: true + }, + fileName: filename + }); + module._compile(outputText, filename); +}; diff --git a/test/helpers/server.js b/test/helpers/server.js new file mode 100644 index 00000000..99120177 --- /dev/null +++ b/test/helpers/server.js @@ -0,0 +1,148 @@ +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const net = require('node:net'); +const path = require('node:path'); +const assert = require('node:assert/strict'); + +async function getFreePort() { + const server = net.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const { port } = server.address(); + server.close(); + await once(server, 'close'); + return port; +} + +function waitForOutput(child, matcher, timeoutMs = 8_000) { + return new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for server output ${matcher}; output:\n${output}`)); + }, timeoutMs); + + const onData = (chunk) => { + output += chunk.toString(); + if (typeof matcher === 'string' ? output.includes(matcher) : matcher.test(output)) { + cleanup(); + resolve(output); + } + }; + const onExit = (code, signal) => { + cleanup(); + reject(new Error(`Server exited before readiness (code=${code}, signal=${signal}); output:\n${output}`)); + }; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off('data', onData); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.once('exit', onExit); + }); +} + +async function startServer(options = {}) { + const port = options.port || await getFreePort(); + const root = path.resolve(__dirname, '../..'); + const child = spawn(process.execPath, ['index.js'], { + cwd: root, + env: { + ...process.env, + PORT: String(port), + NODE_ENV: 'test', + SERVER_INFO: options.serverInfo || 'test-suite' + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const readyOutput = await waitForOutput(child, `Listening on port ${port}`, options.timeoutMs); + + return { + port, + origin: `http://127.0.0.1:${port}`, + wsOrigin: `ws://127.0.0.1:${port}`, + child, + readyOutput, + async stop() { + if (child.exitCode !== null) return; + child.kill('SIGTERM'); + const result = await Promise.race([ + once(child, 'exit').then(([code, signal]) => ({ code, signal })), + new Promise((resolve) => setTimeout(() => resolve(null), 2_000)) + ]); + if (!result) { + child.kill('SIGKILL'); + await once(child, 'exit'); + } + } + }; +} + +async function fetchText(url, options = {}) { + const response = await fetch(url, { signal: AbortSignal.timeout(options.timeoutMs || 3_000) }); + return { response, text: await response.text() }; +} + +async function expectJson(url, validate, options = {}) { + const response = await fetch(url, { signal: AbortSignal.timeout(options.timeoutMs || 3_000) }); + assert.equal(response.status, options.status || 200); + assert.match(response.headers.get('content-type') || '', /text|json|octet-stream|^$/); + const json = await response.json(); + validate(json); + return json; +} + +function connectWebSocket(url, { timeoutMs = 3_000 } = {}) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const timer = setTimeout(() => { + ws.close(); + reject(new Error(`Timed out opening websocket ${url}`)); + }, timeoutMs); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(ws); + }, { once: true }); + ws.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error(`WebSocket error while opening ${url}`)); + }, { once: true }); + }); +} + +function waitForWebSocketClose(ws, timeoutMs = 3_000) { + return new Promise((resolve, reject) => { + if (ws.readyState === WebSocket.CLOSED) return resolve(); + const timer = setTimeout(() => reject(new Error('Timed out waiting for websocket close')), timeoutMs); + ws.addEventListener('close', (event) => { + clearTimeout(timer); + resolve(event); + }, { once: true }); + }); +} + +function waitForWebSocketMessage(ws, timeoutMs = 3_000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for websocket message')), timeoutMs); + ws.addEventListener('message', (event) => { + clearTimeout(timer); + resolve(event.data); + }, { once: true }); + }); +} + +module.exports = { + connectWebSocket, + expectJson, + fetchText, + getFreePort, + startServer, + waitForWebSocketClose, + waitForWebSocketMessage +}; diff --git a/test/unit/coder.test.js b/test/unit/coder.test.js new file mode 100644 index 00000000..279ec1b0 --- /dev/null +++ b/test/unit/coder.test.js @@ -0,0 +1,52 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +require('../helpers/register-ts'); +const Reader = require('../../src/Coder/Reader').default; +const Writer = require('../../src/Coder/Writer').default; + +function roundTrip(writeValue, readValue) { + const writer = new Writer(); + writeValue(writer); + const bytes = writer.write(true); + const reader = new Reader(bytes); + return readValue(reader); +} + +test('protocol coder round-trips unsigned varints across boundary values', () => { + for (const value of [0, 1, 2, 3, 10, 127, 128, 129, 255, 16_384, 65_535, 2_147_483_647]) { + assert.equal(roundTrip((w) => w.vu(value), (r) => r.vu()), value); + } +}); + +test('protocol coder round-trips signed varints and floats', () => { + for (const value of [-2_000_000, -129, -128, -1, 0, 1, 127, 128, 2_000_000]) { + assert.equal(roundTrip((w) => w.vi(value), (r) => r.vi()), value); + } + + for (const value of [-Math.PI, -1.5, 0, 1.5, Math.PI]) { + assert.ok(Math.abs(roundTrip((w) => w.float(value), (r) => r.float()) - value) < 0.00001); + assert.ok(Math.abs(roundTrip((w) => w.vf(value), (r) => r.vf()) - value) < 0.00001); + } +}); + +test('protocol coder preserves null-terminated unicode strings and raw bytes', () => { + const text = 'Tank 🚀 Δ 漢字'; + const writer = new Writer(); + writer.u8(0xab).u16(0xcdef).u32(0x12345678).stringNT(text).raw(1, 2, 3, 255); + const reader = new Reader(writer.write(true)); + assert.equal(reader.u8(), 0xab); + assert.equal(reader.u16(), 0xcdef); + assert.equal(reader.u32(), 0x12345678); + assert.equal(reader.stringNT(), text); + assert.deepEqual([reader.u8(), reader.u8(), reader.u8(), reader.u8()], [1, 2, 3, 255]); +}); + +test('writer grows beyond the default buffer chunk without corrupting bytes', () => { + const writer = new Writer(); + const payload = Buffer.alloc(10_000, 0x5a); + writer.bytes(payload); + const bytes = writer.write(true); + assert.equal(bytes.length, payload.length); + assert.deepEqual(bytes.subarray(0, 16), payload.subarray(0, 16)); + assert.deepEqual(bytes.subarray(-16), payload.subarray(-16)); +}); From 82022e7333a6bb0994f80ef08993612a4c21c45e Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 13:30:15 -0400 Subject: [PATCH 02/22] Port deterministic physics primitives behind parity reports Phase B keeps TypeScript as the source of truth by adding a physics golden report for Vector, PackedEntitySet, and HashGrid behavior before comparing it against a standard-library-only C++ implementation. The parity runner now validates protocol and physics reports back-to-back, preserving the staged migration rule that C++ internals must match golden TS behavior before any production routing changes. Constraint: TypeScript runtime remains the reference implementation Constraint: Initial C++ migration remains dependency-free beyond the standard library Rejected: Port gameplay entities in this slice | entity behavior depends on broader simulation state and belongs to Phase C Confidence: high Scope-risk: moderate Tested: npm run test:conformance Tested: npm run test:parity Tested: npm run test:all --- CMakeLists.txt | 8 + conformance/fixtures/physics-golden.json | 226 ++++++++++++++++++ conformance/fixtures/protocol-golden.json | 1 - conformance/physics/compare-parity.js | 13 + conformance/physics/golden.test.js | 13 + conformance/physics/report-ts.js | 102 ++++++++ conformance/protocol/golden.test.js | 4 +- cpp/include/diepcustom/physics.hpp | 95 ++++++++ cpp/src/physics.cpp | 279 ++++++++++++++++++++++ cpp/tests/physics_report.cpp | 7 + cpp/tests/protocol_report.cpp | 2 +- package.json | 2 +- 12 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 conformance/fixtures/physics-golden.json create mode 100644 conformance/physics/compare-parity.js create mode 100644 conformance/physics/golden.test.js create mode 100644 conformance/physics/report-ts.js create mode 100644 cpp/include/diepcustom/physics.hpp create mode 100644 cpp/src/physics.cpp create mode 100644 cpp/tests/physics_report.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a7fbd73d..f5338178 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,11 @@ set(CMAKE_CXX_EXTENSIONS OFF) add_library(diepcustom_protocol cpp/src/protocol.cpp ) + +add_library(diepcustom_physics + cpp/src/physics.cpp +) +target_include_directories(diepcustom_physics PUBLIC cpp/include) target_include_directories(diepcustom_protocol PUBLIC cpp/include) add_executable(protocol_golden_test cpp/tests/protocol_golden_test.cpp) @@ -16,5 +21,8 @@ target_link_libraries(protocol_golden_test PRIVATE diepcustom_protocol) add_executable(protocol_report cpp/tests/protocol_report.cpp) target_link_libraries(protocol_report PRIVATE diepcustom_protocol) +add_executable(physics_report cpp/tests/physics_report.cpp) +target_link_libraries(physics_report PRIVATE diepcustom_physics) + enable_testing() add_test(NAME protocol_golden_test COMMAND protocol_golden_test) diff --git a/conformance/fixtures/physics-golden.json b/conformance/fixtures/physics-golden.json new file mode 100644 index 00000000..d4e7ddb3 --- /dev/null +++ b/conformance/fixtures/physics-golden.json @@ -0,0 +1,226 @@ +{ + "vector": { + "base": { + "x": 3, + "y": 4, + "magnitude": 5, + "angle": 0.927295, + "finite": true + }, + "afterSet": { + "x": -8, + "y": 9, + "magnitude": 12.041595, + "angle": 2.297439, + "finite": true + }, + "afterAdd": { + "x": 2, + "y": 6, + "magnitude": 6.324555, + "angle": 1.249046, + "finite": true + }, + "afterSubtract": { + "x": -7, + "y": 7, + "magnitude": 9.899495, + "angle": 2.356194, + "finite": true + }, + "distanceToSQ": 25, + "angleSet": { + "x": 0, + "y": 5, + "magnitude": 5, + "angle": 1.570796, + "finite": true + }, + "magnitudeSet": { + "x": 6, + "y": 8, + "magnitude": 10, + "angle": 0.927295, + "finite": true + }, + "zeroMagnitude": { + "x": 5, + "y": 0, + "magnitude": 5, + "angle": 0, + "finite": true + }, + "polar": { + "x": 10.392305, + "y": 6, + "magnitude": 12, + "angle": 0.523599, + "finite": true + }, + "finiteChecks": [ + true, + false + ] + }, + "packedEntitySet": { + "beforeClear": [ + [ + 0, + true + ], + [ + 1, + true + ], + [ + 2, + false + ], + [ + 31, + true + ], + [ + 32, + false + ], + [ + 33, + true + ], + [ + 1024, + true + ], + [ + 16383, + true + ] + ], + "firstWords": [ + 2147483651, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0 + ], + "afterClear": [ + [ + 0, + false + ], + [ + 1, + false + ], + [ + 2, + false + ], + [ + 31, + false + ], + [ + 32, + false + ], + [ + 33, + false + ], + [ + 1024, + false + ], + [ + 16383, + false + ] + ], + "fullSetHas": [ + [ + 0, + true + ], + [ + 31, + true + ], + [ + 32, + true + ], + [ + 16383, + true + ] + ] + }, + "hashGrid": { + "lockedInsert": "HashGrid is locked! Cannot insert() entity outside of tick", + "nearCluster": [ + 1, + 2, + 4 + ], + "wholeArena": [ + 1, + 2, + 3, + 4 + ], + "lineEntity": [ + 2, + 3, + 4 + ], + "firstAny": 1, + "firstLargeId": 3, + "pairs": [ + [ + 1, + 2 + ], + [ + 2, + 4 + ], + [ + 3, + 4 + ] + ], + "lockedRetrieve": "HashGrid is locked! Cannot retrieve() entity outside of tick" + } +} diff --git a/conformance/fixtures/protocol-golden.json b/conformance/fixtures/protocol-golden.json index 69840eba..3d61f90d 100644 --- a/conformance/fixtures/protocol-golden.json +++ b/conformance/fixtures/protocol-golden.json @@ -65,7 +65,6 @@ "hex": "", "method": "u8", "ok": true, - "value": 0, "at": 1 } ] diff --git a/conformance/physics/compare-parity.js b/conformance/physics/compare-parity.js new file mode 100644 index 00000000..62eb4ff1 --- /dev/null +++ b/conformance/physics/compare-parity.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const root = path.join(__dirname, '../..'); +const exe = path.join(root, 'build/cpp/physics_report') + (process.platform === 'win32' ? '.exe' : ''); +const runJson = (command, args) => JSON.parse(execFileSync(command, args, { cwd: root, encoding: 'utf8' })); + +const tsReport = runJson(process.execPath, [path.join(root, 'conformance/physics/report-ts.js')]); +const cppReport = runJson(exe, []); +assert.deepEqual(cppReport, tsReport); +console.log('physics parity report matched TypeScript reference'); diff --git a/conformance/physics/golden.test.js b/conformance/physics/golden.test.js new file mode 100644 index 00000000..535a6b22 --- /dev/null +++ b/conformance/physics/golden.test.js @@ -0,0 +1,13 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { execFileSync } = require('node:child_process'); + +const root = path.join(__dirname, '../..'); +const fixture = JSON.parse(fs.readFileSync(path.join(root, 'conformance/fixtures/physics-golden.json'), 'utf8')); + +test('TS physics report matches golden fixture', () => { + const output = execFileSync(process.execPath, [path.join(root, 'conformance/physics/report-ts.js')], { cwd: root, encoding: 'utf8' }); + assert.deepEqual(JSON.parse(output), fixture); +}); diff --git a/conformance/physics/report-ts.js b/conformance/physics/report-ts.js new file mode 100644 index 00000000..6ded99ba --- /dev/null +++ b/conformance/physics/report-ts.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +const path = require('node:path'); +require(path.join(__dirname, '../../test/helpers/register-ts')); +const Vector = require('../../src/Physics/Vector').default; +const PackedEntitySet = require('../../src/Physics/PackedEntitySet').default; +const HashGrid = require('../../src/Physics/HashGrid').default; + +const round = (n) => { + if (Number.isNaN(n)) return 'NaN'; + if (n === Infinity) return 'Infinity'; + if (n === -Infinity) return '-Infinity'; + return Math.round(n * 1e6) / 1e6; +}; +const vec = (v) => ({ x: round(v.x), y: round(v.y), magnitude: round(v.magnitude), angle: round(v.angle), finite: Vector.isFinite(v) }); + +function vectorReport() { + const base = new Vector(3, 4); + const afterAdd = new Vector(3, 4); afterAdd.add({ x: -1, y: 2 }); + const afterSubtract = new Vector(3, 4); afterSubtract.subtract({ x: 10, y: -3 }); + const angleSet = new Vector(3, 4); angleSet.angle = Math.PI / 2; + const magnitudeSet = new Vector(3, 4); magnitudeSet.magnitude = 10; + const zeroMagnitude = new Vector(0, 0); zeroMagnitude.magnitude = 5; + const polar = Vector.fromPolar(Math.PI / 6, 12); + const nonFinite = new Vector(Infinity, NaN); + return { + base: vec(base), + afterSet: (() => { const v = new Vector(); v.set({ x: -8, y: 9 }); return vec(v); })(), + afterAdd: vec(afterAdd), + afterSubtract: vec(afterSubtract), + distanceToSQ: round(base.distanceToSQ({ x: -1, y: 7 })), + angleSet: vec(angleSet), + magnitudeSet: vec(magnitudeSet), + zeroMagnitude: vec(zeroMagnitude), + polar: vec(polar), + finiteChecks: [Vector.isFinite(base), Vector.isFinite(nonFinite)] + }; +} + +function packedEntitySetReport() { + const set = new PackedEntitySet(); + for (const id of [0, 1, 31, 32, 33, 1024, 16_383]) set.add(id); + set.remove(32); + const probes = [0, 1, 2, 31, 32, 33, 1024, 16_383]; + const beforeClear = probes.map((id) => [id, set.has(id)]); + const firstWords = Array.from(set.data.slice(0, 35)); + set.clear(); + return { + beforeClear, + firstWords, + afterClear: probes.map((id) => [id, set.has(id)]), + fullSetHas: [0, 31, 32, 16_383].map((id) => [id, PackedEntitySet.FULL_SET.has(id)]) + }; +} + +function entity(id, x, y, size, sides = 4, width = size, hash = id + 1000) { + return { + id, + hash, + positionData: { values: { x, y } }, + physicsData: { values: { sides, size, width } } + }; +} + +function hashGridReport() { + const entities = []; + const game = { + arena: { width: 1024, height: 768, arenaData: { values: { leftX: -512, topY: -384 } } }, + entities: { inner: entities } + }; + entities[1] = entity(1, -300, -100, 30); + entities[2] = entity(2, -275, -105, 20); + entities[3] = entity(3, 260, 100, 40); + entities[4] = entity(4, 0, 0, 200, 2, 20); + entities[5] = entity(5, -300, -100, 10, 4, 10, 0); + + const grid = new HashGrid(game); + const lockedInsert = (() => { try { grid.insert(entities[1]); return 'no-error'; } catch (error) { return error.message; } })(); + grid.preTick(1); + for (const id of [1, 2, 3, 4, 5]) grid.insert(entities[id]); + const retrieve = (cx, cy, hw, hh) => [0, 1, 2, 3, 4, 5].filter((id) => grid.retrieve(cx, cy, hw, hh).has(id)); + const nearCluster = retrieve(-300, -100, 80, 80); + const wholeArena = retrieve(0, 0, 600, 500); + const lineEntity = retrieve(0, 0, 130, 30); + const firstAny = grid.getFirstMatch(-300, -100, 80, 80, () => true); + const firstLargeId = grid.getFirstMatch(-512, -384, 1024, 768, (e) => e.id >= 3); + const pairs = []; + grid.forEachCollisionPair((a, b) => pairs.push([a.id, b.id])); + grid.postTick(1); + const lockedRetrieve = (() => { try { grid.retrieve(0, 0, 1, 1); return 'no-error'; } catch (error) { return error.message; } })(); + return { + lockedInsert, + nearCluster, + wholeArena, + lineEntity, + firstAny: firstAny && firstAny.id, + firstLargeId: firstLargeId && firstLargeId.id, + pairs, + lockedRetrieve + }; +} + +process.stdout.write(`${JSON.stringify({ vector: vectorReport(), packedEntitySet: packedEntitySetReport(), hashGrid: hashGridReport() }, null, 2)}\n`); diff --git a/conformance/protocol/golden.test.js b/conformance/protocol/golden.test.js index 4da054c2..22de1566 100644 --- a/conformance/protocol/golden.test.js +++ b/conformance/protocol/golden.test.js @@ -59,7 +59,9 @@ test('TS protocol reader documents malformed and boundary fixture behavior', () for (const boundary of fixture.boundaryDecode) { const reader = new Reader(fromHex(boundary.hex)); if (boundary.ok) { - assert.equal(readers[boundary.method](reader), boundary.value, boundary.name); + const value = readers[boundary.method](reader); + if (Object.prototype.hasOwnProperty.call(boundary, 'value')) assert.equal(value, boundary.value, boundary.name); + else assert.equal(value, undefined, boundary.name); assert.equal(reader.at, boundary.at, boundary.name); } else { assert.throws(() => readers[boundary.method](reader), undefined, boundary.name); diff --git a/cpp/include/diepcustom/physics.hpp b/cpp/include/diepcustom/physics.hpp new file mode 100644 index 00000000..88feb922 --- /dev/null +++ b/cpp/include/diepcustom/physics.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace diepcustom::physics { + +struct Vector { + double x = 0; + double y = 0; + + Vector() = default; + Vector(double x, double y); + + static bool isFinite(const Vector& vector); + static Vector fromPolar(double theta, double distance); + + void set(const Vector& vector); + void add(const Vector& vector); + void subtract(const Vector& vector); + double distanceToSQ(const Vector& vector) const; + double magnitude() const; + void setMagnitude(double magnitude); + double angle() const; + void setAngle(double angle); +}; + +class PackedEntitySet { +public: + static constexpr std::size_t MaxEntityCount = 16384; + static constexpr std::size_t BitsPerWord = 32; + static constexpr std::size_t WordCount = MaxEntityCount / BitsPerWord; + + void add(std::uint32_t entityId); + void remove(std::uint32_t entityId); + bool has(std::uint32_t entityId) const; + void clear(); + const std::array& data() const; + static PackedEntitySet fullSet(); + +private: + std::array data_{}; +}; + +struct Entity { + std::uint32_t id = 0; + std::uint32_t hash = 0; + double x = 0; + double y = 0; + double size = 0; + double width = 0; + std::uint32_t sides = 0; +}; + +struct Arena { + std::int32_t width = 0; + std::int32_t height = 0; + std::int32_t leftX = 0; + std::int32_t topY = 0; +}; + +class HashGrid { +public: + explicit HashGrid(Arena arena, const std::vector* entities); + + void preTick(std::uint32_t tick); + void postTick(std::uint32_t tick); + void insert(const Entity& entity); + const PackedEntitySet& retrieve(double centerX, double centerY, double halfWidth, double halfHeight); + const Entity* getFirstMatch(double centerX, double centerY, double halfWidth, double halfHeight, const std::function& predicate); + void forEachCollisionPair(const std::function& callback); + +private: + std::int32_t cellCoord(double value) const; + std::size_t cellKey(std::int32_t x, std::int32_t y) const; + const Entity* entityById(std::uint32_t id) const; + void requireUnlocked(const std::string& method) const; + + Arena arena_; + const std::vector* entities_; + PackedEntitySet resultSet_; + std::uint16_t lastQueryId_ = 0; + std::array queryIdMap_{}; + bool isLocked_ = true; + std::int32_t hashMul_ = 1; + std::vector> hashMap_; + std::vector collisionPairsSeen_; +}; + +std::string physicsReportJson(); + +} // namespace diepcustom::physics diff --git a/cpp/src/physics.cpp b/cpp/src/physics.cpp new file mode 100644 index 00000000..b1068ebc --- /dev/null +++ b/cpp/src/physics.cpp @@ -0,0 +1,279 @@ +#include "diepcustom/physics.hpp" + +#include +#include +#include +#include +#include +#include + +namespace diepcustom::physics { +namespace { +constexpr int CellShift = 8; +constexpr int CellSize = 1 << CellShift; +constexpr std::size_t MaxEntityCount = PackedEntitySet::MaxEntityCount; + +std::string number(double value) { + if (std::isnan(value)) return "\"NaN\""; + if (value == std::numeric_limits::infinity()) return "\"Infinity\""; + if (value == -std::numeric_limits::infinity()) return "\"-Infinity\""; + const double rounded = std::round(value * 1000000.0) / 1000000.0; + std::ostringstream out; + out << std::setprecision(15) << rounded; + return out.str(); +} + +std::string boolean(bool value) { return value ? "true" : "false"; } + +std::string vectorJson(const Vector& vector) { + std::ostringstream out; + out << "{\"x\":" << number(vector.x) + << ",\"y\":" << number(vector.y) + << ",\"magnitude\":" << number(vector.magnitude()) + << ",\"angle\":" << number(vector.angle()) + << ",\"finite\":" << boolean(Vector::isFinite(vector)) << "}"; + return out.str(); +} + +std::string escape(const std::string& input) { + std::ostringstream out; + for (const char c : input) { + if (c == '"') out << "\\\""; + else if (c == '\\') out << "\\\\"; + else out << c; + } + return out.str(); +} + +std::string idsJson(const PackedEntitySet& set, const std::vector& probes) { + std::ostringstream out; + out << "["; + bool first = true; + for (const auto id : probes) { + if (set.has(id)) { + if (!first) out << ","; + first = false; + out << id; + } + } + out << "]"; + return out.str(); +} +} // namespace + +Vector::Vector(double xValue, double yValue) : x(xValue), y(yValue) {} +bool Vector::isFinite(const Vector& vector) { return std::isfinite(vector.x) && std::isfinite(vector.y); } +Vector Vector::fromPolar(double theta, double distance) { return {distance * std::cos(theta), distance * std::sin(theta)}; } +void Vector::set(const Vector& vector) { x = vector.x; y = vector.y; } +void Vector::add(const Vector& vector) { x += vector.x; y += vector.y; } +void Vector::subtract(const Vector& vector) { x -= vector.x; y -= vector.y; } +double Vector::distanceToSQ(const Vector& vector) const { return std::pow(vector.x - x, 2) + std::pow(vector.y - y, 2); } +double Vector::magnitude() const { return std::sqrt(std::pow(x, 2) + std::pow(y, 2)); } +void Vector::setMagnitude(double value) { const auto currentDir = angle(); set({std::cos(currentDir) * value, std::sin(currentDir) * value}); } +double Vector::angle() const { return std::atan2(y, x); } +void Vector::setAngle(double value) { const auto currentMag = magnitude(); set({std::cos(value) * currentMag, std::sin(value) * currentMag}); } + +void PackedEntitySet::add(std::uint32_t entityId) { data_[entityId >> 5u] |= (1u << (entityId & 31u)); } +void PackedEntitySet::remove(std::uint32_t entityId) { data_[entityId >> 5u] &= ~(1u << (entityId & 31u)); } +bool PackedEntitySet::has(std::uint32_t entityId) const { return (data_[entityId >> 5u] & (1u << (entityId & 31u))) != 0; } +void PackedEntitySet::clear() { data_.fill(0); } +const std::array& PackedEntitySet::data() const { return data_; } +PackedEntitySet PackedEntitySet::fullSet() { PackedEntitySet set; set.data_.fill(0xffffffffu); return set; } + +HashGrid::HashGrid(Arena arena, const std::vector* entities) + : arena_(arena), entities_(entities), collisionPairsSeen_(MaxEntityCount * (MaxEntityCount - 1) / 2 / 32) {} + +void HashGrid::preTick(std::uint32_t) { + const auto widthInCells = (arena_.width + (CellSize - 1)) >> CellShift; + const auto heightInCells = (arena_.height + (CellSize - 1)) >> CellShift; + hashMul_ = widthInCells; + hashMap_.clear(); + hashMap_.resize(static_cast(widthInCells * heightInCells)); + queryIdMap_.fill(0); + lastQueryId_ = 0; + isLocked_ = false; +} + +void HashGrid::postTick(std::uint32_t) { + isLocked_ = true; + hashMap_.clear(); +} + +std::int32_t HashGrid::cellCoord(double value) const { return static_cast(value) >> CellShift; } +std::size_t HashGrid::cellKey(std::int32_t x, std::int32_t y) const { return static_cast(std::abs(x + (y * hashMul_))); } +const Entity* HashGrid::entityById(std::uint32_t id) const { return id < entities_->size() ? &(*entities_)[id] : nullptr; } +void HashGrid::requireUnlocked(const std::string& method) const { if (isLocked_) throw std::runtime_error("HashGrid is locked! Cannot " + method); } + +void HashGrid::insert(const Entity& entity) { + requireUnlocked("insert() entity outside of tick"); + const bool isLine = entity.sides == 2; + const auto halfWidth = isLine ? entity.size / 2 : entity.size; + const auto halfHeight = isLine ? entity.width / 2 : entity.size; + const auto topX = cellCoord(entity.x - halfWidth - arena_.leftX); + const auto topY = cellCoord(entity.y - halfHeight - arena_.topY); + const auto bottomX = cellCoord(entity.x + halfWidth - arena_.leftX); + const auto bottomY = cellCoord(entity.y + halfHeight - arena_.topY); + for (auto y = topY; y <= bottomY; ++y) { + for (auto x = topX; x <= bottomX; ++x) { + const auto key = cellKey(x, y); + if (key >= hashMap_.size()) hashMap_.resize(key + 1); + hashMap_[key].push_back(entity.id); + } + } +} + +const PackedEntitySet& HashGrid::retrieve(double centerX, double centerY, double halfWidth, double halfHeight) { + requireUnlocked("retrieve() entity outside of tick"); + resultSet_.clear(); + const auto startX = cellCoord(centerX - halfWidth - arena_.leftX); + const auto startY = cellCoord(centerY - halfHeight - arena_.topY); + const auto endX = cellCoord(centerX + halfWidth - arena_.leftX); + const auto endY = cellCoord(centerY + halfHeight - arena_.topY); + const auto queryId = lastQueryId_ == 0xffffu ? static_cast(1) : static_cast(lastQueryId_ + 1); + lastQueryId_ = queryId; + for (auto y = startY; y <= endY; ++y) { + for (auto x = startX; x <= endX; ++x) { + const auto key = cellKey(x, y); + if (key >= hashMap_.size()) continue; + for (const auto entityId : hashMap_[key]) { + if (queryIdMap_[entityId] == queryId) continue; + queryIdMap_[entityId] = queryId; + const auto* entity = entityById(entityId); + if (!entity || entity->hash == 0) continue; + resultSet_.add(entityId); + } + } + } + return resultSet_; +} + +const Entity* HashGrid::getFirstMatch(double centerX, double centerY, double halfWidth, double halfHeight, const std::function& predicate) { + requireUnlocked("getFirstMatch() outside of tick"); + const auto startX = cellCoord(centerX - halfWidth - arena_.leftX); + const auto startY = cellCoord(centerY - halfHeight - arena_.topY); + const auto endX = cellCoord(centerX + halfWidth - arena_.leftX); + const auto endY = cellCoord(centerY + halfHeight - arena_.topY); + const auto queryId = lastQueryId_ == 0xffffu ? static_cast(1) : static_cast(lastQueryId_ + 1); + lastQueryId_ = queryId; + for (auto y = startY; y <= endY; ++y) { + for (auto x = startX; x <= endX; ++x) { + const auto key = cellKey(x, y); + if (key >= hashMap_.size()) continue; + for (const auto entityId : hashMap_[key]) { + if (queryIdMap_[entityId] == queryId) continue; + queryIdMap_[entityId] = queryId; + const auto* entity = entityById(entityId); + if (!entity || entity->hash == 0) continue; + if (predicate(*entity)) return entity; + } + } + } + return nullptr; +} + +void HashGrid::forEachCollisionPair(const std::function& callback) { + requireUnlocked("forEachCollisionPair() entity outside of tick"); + std::fill(collisionPairsSeen_.begin(), collisionPairsSeen_.end(), 0); + for (const auto& cell : hashMap_) { + if (cell.size() < 2) continue; + for (std::size_t a = 0; a < cell.size() - 1; ++a) { + const auto eidA = cell[a]; + const auto* entityA = entityById(eidA); + if (!entityA || entityA->hash == 0) continue; + for (std::size_t b = a + 1; b < cell.size(); ++b) { + const auto eidB = cell[b]; + if (eidA == eidB) continue; + const auto* entityB = entityById(eidB); + if (!entityB || entityB->hash == 0) continue; + const auto idA = std::min(eidA, eidB); + const auto idB = std::max(eidA, eidB); + const auto* entA = eidA < eidB ? entityA : entityB; + const auto* entB = eidA < eidB ? entityB : entityA; + const auto triangularIndex = static_cast(idB * (idB - 1) / 2 + idA); + const auto arrayIndex = triangularIndex >> 5u; + const auto bitIndex = triangularIndex & 31u; + const auto bitMask = 1u << bitIndex; + if ((collisionPairsSeen_[arrayIndex] & bitMask) != 0) continue; + collisionPairsSeen_[arrayIndex] |= bitMask; + callback(*entA, *entB); + } + } + } +} + +std::string physicsReportJson() { + std::ostringstream out; + Vector base(3, 4); + Vector afterSet; afterSet.set({-8, 9}); + Vector afterAdd(3, 4); afterAdd.add({-1, 2}); + Vector afterSubtract(3, 4); afterSubtract.subtract({10, -3}); + Vector angleSet(3, 4); angleSet.setAngle(3.14159265358979323846 / 2); + Vector magnitudeSet(3, 4); magnitudeSet.setMagnitude(10); + Vector zeroMagnitude(0, 0); zeroMagnitude.setMagnitude(5); + Vector polar = Vector::fromPolar(3.14159265358979323846 / 6, 12); + Vector nonFinite(std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()); + + out << "{\n \"vector\": {"; + out << "\n \"base\": " << vectorJson(base) << ","; + out << "\n \"afterSet\": " << vectorJson(afterSet) << ","; + out << "\n \"afterAdd\": " << vectorJson(afterAdd) << ","; + out << "\n \"afterSubtract\": " << vectorJson(afterSubtract) << ","; + out << "\n \"distanceToSQ\": " << number(base.distanceToSQ({-1, 7})) << ","; + out << "\n \"angleSet\": " << vectorJson(angleSet) << ","; + out << "\n \"magnitudeSet\": " << vectorJson(magnitudeSet) << ","; + out << "\n \"zeroMagnitude\": " << vectorJson(zeroMagnitude) << ","; + out << "\n \"polar\": " << vectorJson(polar) << ","; + out << "\n \"finiteChecks\": [" << boolean(Vector::isFinite(base)) << "," << boolean(Vector::isFinite(nonFinite)) << "]\n },"; + + PackedEntitySet set; + for (const auto id : {0, 1, 31, 32, 33, 1024, 16383}) set.add(id); + set.remove(32); + const std::vector probes{0, 1, 2, 31, 32, 33, 1024, 16383}; + out << "\n \"packedEntitySet\": {\n \"beforeClear\": ["; + for (std::size_t i = 0; i < probes.size(); ++i) { if (i) out << ","; out << "[" << probes[i] << "," << boolean(set.has(probes[i])) << "]"; } + out << "],\n \"firstWords\": ["; + for (std::size_t i = 0; i < 35; ++i) { if (i) out << ","; out << set.data()[i]; } + out << "],"; + set.clear(); + out << "\n \"afterClear\": ["; + for (std::size_t i = 0; i < probes.size(); ++i) { if (i) out << ","; out << "[" << probes[i] << "," << boolean(set.has(probes[i])) << "]"; } + const auto full = PackedEntitySet::fullSet(); + out << "],\n \"fullSetHas\": [[0," << boolean(full.has(0)) << "],[31," << boolean(full.has(31)) << "],[32," << boolean(full.has(32)) << "],[16383," << boolean(full.has(16383)) << "]]\n },"; + + std::vector entities(6); + entities[1] = {1, 1001, -300, -100, 30, 30, 4}; + entities[2] = {2, 1002, -275, -105, 20, 20, 4}; + entities[3] = {3, 1003, 260, 100, 40, 40, 4}; + entities[4] = {4, 1004, 0, 0, 200, 20, 2}; + entities[5] = {5, 0, -300, -100, 10, 10, 4}; + HashGrid grid({1024, 768, -512, -384}, &entities); + std::string lockedInsert; + try { grid.insert(entities[1]); lockedInsert = "no-error"; } catch (const std::exception& error) { lockedInsert = error.what(); } + grid.preTick(1); + for (const auto id : {1, 2, 3, 4, 5}) grid.insert(entities[id]); + const auto nearCluster = idsJson(grid.retrieve(-300, -100, 80, 80), {0, 1, 2, 3, 4, 5}); + const auto wholeArena = idsJson(grid.retrieve(0, 0, 600, 500), {0, 1, 2, 3, 4, 5}); + const auto lineEntity = idsJson(grid.retrieve(0, 0, 130, 30), {0, 1, 2, 3, 4, 5}); + const auto* firstAny = grid.getFirstMatch(-300, -100, 80, 80, [](const Entity&) { return true; }); + const auto* firstLargeId = grid.getFirstMatch(-512, -384, 1024, 768, [](const Entity& e) { return e.id >= 3; }); + std::vector> pairs; + grid.forEachCollisionPair([&pairs](const Entity& a, const Entity& b) { pairs.push_back({a.id, b.id}); }); + grid.postTick(1); + std::string lockedRetrieve; + try { grid.retrieve(0, 0, 1, 1); lockedRetrieve = "no-error"; } catch (const std::exception& error) { lockedRetrieve = error.what(); } + + out << "\n \"hashGrid\": {"; + out << "\n \"lockedInsert\": \"" << escape(lockedInsert) << "\","; + out << "\n \"nearCluster\": " << nearCluster << ","; + out << "\n \"wholeArena\": " << wholeArena << ","; + out << "\n \"lineEntity\": " << lineEntity << ","; + out << "\n \"firstAny\": " << (firstAny ? std::to_string(firstAny->id) : "null") << ","; + out << "\n \"firstLargeId\": " << (firstLargeId ? std::to_string(firstLargeId->id) : "null") << ","; + out << "\n \"pairs\": ["; + for (std::size_t i = 0; i < pairs.size(); ++i) { if (i) out << ","; out << "[" << pairs[i][0] << "," << pairs[i][1] << "]"; } + out << "],"; + out << "\n \"lockedRetrieve\": \"" << escape(lockedRetrieve) << "\"\n }\n}\n"; + return out.str(); +} + +} // namespace diepcustom::physics diff --git a/cpp/tests/physics_report.cpp b/cpp/tests/physics_report.cpp new file mode 100644 index 00000000..6ec8bc1a --- /dev/null +++ b/cpp/tests/physics_report.cpp @@ -0,0 +1,7 @@ +#include "diepcustom/physics.hpp" +#include + +int main() { + std::cout << diepcustom::physics::physicsReportJson(); + return 0; +} diff --git a/cpp/tests/protocol_report.cpp b/cpp/tests/protocol_report.cpp index 5cf92583..7b72257d 100644 --- a/cpp/tests/protocol_report.cpp +++ b/cpp/tests/protocol_report.cpp @@ -66,7 +66,7 @@ int main() { printNumberResult("truncated-vu-continuation", "80", [](Reader& r) { return r.vu(); }); std::cout << ",\n"; printStringResult("empty-stringNT", "00"); std::cout << ",\n"; printStringResult("unterminated-stringNT", "6162"); std::cout << ",\n"; - printNumberResult("fixed-empty-buffer-u8", "", [](Reader& r) { return static_cast(r.u8()); }); std::cout << "\n"; + { Reader reader(hexToBytes("")); reader.u8(); std::cout << " {\"name\":\"fixed-empty-buffer-u8\",\"ok\":true,\"at\":" << reader.position() << "}"; } std::cout << "\n"; std::cout << " ]\n}\n"; return 0; } diff --git a/package.json b/package.json index ed71ff33..f3b769f3 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:all": "npm run test && npm run test:conformance && npm audit --audit-level=moderate", "test:conformance": "node --test conformance/**/*.test.js", "test:cpp": "cmake -S . -B build/cpp && cmake --build build/cpp && ctest --test-dir build/cpp --output-on-failure", - "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js" + "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js" }, "engines": { "node": ">=16.3" From 244e9174446c5fc2adccf3a2953efcc92fb6a395 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 13:44:51 -0400 Subject: [PATCH 03/22] Lock entity-core behavior before the C++ port Phase C starts with a read-only map and TypeScript golden report for manager ID/hash lifecycle, field-group state transitions, camera following, and deterministic creation/update compiler bytes. This preserves the migration rule that TS behavior is captured before a C++ entity-core skeleton is introduced. Constraint: Do not port C++ entity code until TS golden behavior is explicit Constraint: npm audit currently requires refreshed transitive build-tool lockfile entries for test:all Rejected: Start with full gameplay entities | gameplay simulation belongs after entity-core parity Confidence: high Scope-risk: moderate Tested: npm run test:conformance Tested: npm run test:parity Tested: npm run test:all --- conformance/entity-core/golden.test.js | 13 + conformance/entity-core/report-ts.js | 261 ++++++++ conformance/fixtures/entity-core-golden.json | 607 +++++++++++++++++++ docs/migration/phase-c-entity-core-map.md | 28 + package-lock.json | 247 +++++--- 5 files changed, 1055 insertions(+), 101 deletions(-) create mode 100644 conformance/entity-core/golden.test.js create mode 100644 conformance/entity-core/report-ts.js create mode 100644 conformance/fixtures/entity-core-golden.json create mode 100644 docs/migration/phase-c-entity-core-map.md diff --git a/conformance/entity-core/golden.test.js b/conformance/entity-core/golden.test.js new file mode 100644 index 00000000..003f5295 --- /dev/null +++ b/conformance/entity-core/golden.test.js @@ -0,0 +1,13 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { execFileSync } = require('node:child_process'); + +const root = path.join(__dirname, '../..'); +const fixture = JSON.parse(fs.readFileSync(path.join(root, 'conformance/fixtures/entity-core-golden.json'), 'utf8')); + +test('TS entity-core report matches golden fixture', () => { + const output = execFileSync(process.execPath, [path.join(root, 'conformance/entity-core/report-ts.js')], { cwd: root, encoding: 'utf8' }); + assert.deepEqual(JSON.parse(output), fixture); +}); diff --git a/conformance/entity-core/report-ts.js b/conformance/entity-core/report-ts.js new file mode 100644 index 00000000..fbcd1096 --- /dev/null +++ b/conformance/entity-core/report-ts.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +const path = require('node:path'); +require(path.join(__dirname, '../../test/helpers/register-ts')); + +const Writer = require('../../src/Coder/Writer').default; +const { Entity } = require('../../src/Native/Entity'); +const EntityManager = require('../../src/Native/Manager').default; +const ObjectEntity = require('../../src/Entity/Object').default; +const { CameraEntity } = require('../../src/Native/Camera'); +const { NameGroup, ScoreGroup, HealthGroup, BarrelGroup } = require('../../src/Native/FieldGroups'); +const { compileCreation, compileUpdate } = require('../../src/Native/UpcreateCompiler'); +const { CameraFlags, Color, PhysicsFlags, PositionFlags } = require('../../src/Const/Enums'); + +const toHex = (bytes) => Buffer.from(bytes).toString('hex'); +const state = (group) => Array.from(group.state); +const tableState = (table) => Array.from(table.state); +const ids = (items) => items.slice(); + +function createGame() { + const game = { tick: 0, arena: null, entities: null }; + game.entities = new EntityManager(game); + return game; +} + +function entitySummary(entity) { + return { + className: entity.constructor.name, + id: entity.id, + hash: entity.hash, + preservedHash: entity.preservedHash, + entityState: entity.entityState, + exists: Entity.exists(entity), + string: entity.toString(), + primitive: Number(entity) + }; +} + +function managerLifecycleReport() { + const game = createGame(); + const plain = new Entity(game); + const object = new ObjectEntity(game); + const camera = new CameraEntity(game); + const beforeDelete = { + lastId: game.entities.lastId, + zIndex: game.entities.zIndex, + cameras: ids(game.entities.cameras), + otherEntities: ids(game.entities.otherEntities), + hashTable: Array.from(game.entities.hashTable.slice(0, 4)), + plain: entitySummary(plain), + object: entitySummary(object), + camera: entitySummary(camera) + }; + + object.delete(); + const replacement = new ObjectEntity(game); + const afterReuse = { + lastId: game.entities.lastId, + zIndex: game.entities.zIndex, + cameras: ids(game.entities.cameras), + otherEntities: ids(game.entities.otherEntities), + deletedObject: entitySummary(object), + replacement: entitySummary(replacement), + hashTable: Array.from(game.entities.hashTable.slice(0, 4)), + innerPresent: game.entities.inner.slice(0, 4).map((entity) => entity ? entity.constructor.name : null) + }; + + game.entities.clear(); + const afterClear = { + lastId: game.entities.lastId, + cameras: ids(game.entities.cameras), + otherEntities: ids(game.entities.otherEntities), + hashTable: Array.from(game.entities.hashTable.slice(0, 4)), + plain: entitySummary(plain), + camera: entitySummary(camera), + replacement: entitySummary(replacement) + }; + + return { beforeDelete, afterReuse, afterClear }; +} + +function fieldGroupReport() { + const game = createGame(); + const object = new ObjectEntity(game); + object.nameData = new NameGroup(object); + object.scoreData = new ScoreGroup(object); + object.healthData = new HealthGroup(object); + object.barrelData = new BarrelGroup(object); + + const defaults = { + relations: { state: state(object.relationsData), values: { parent: object.relationsData.values.parent, owner: object.relationsData.values.owner, team: object.relationsData.values.team } }, + physics: { state: state(object.physicsData), values: { ...object.physicsData.values } }, + position: { state: state(object.positionData), values: { ...object.positionData.values } }, + style: { state: state(object.styleData), values: { ...object.styleData.values } }, + name: { state: state(object.nameData), values: { ...object.nameData.values } }, + health: { state: state(object.healthData), values: { ...object.healthData.values } } + }; + + object.physicsData.sides = 3; + object.physicsData.sides = 3; + object.physicsData.size = 42.5; + object.physicsData.flags = PhysicsFlags.isBase | PhysicsFlags.noOwnTeamCollision; + object.positionData.x = -120; + object.positionData.y = 80; + object.positionData.angle = Math.PI / 4; + object.positionData.flags = PositionFlags.absoluteRotation; + object.styleData.color = Color.Tank; + object.styleData.opacity = 0.75; + object.nameData.name = 'Phase C Δ'; + object.scoreData.score = 12345; + object.healthData.health = 0.5; + object.healthData.maxHealth = 2; + object.barrelData.reloadTime = 22; + object.relationsData.owner = object; + object.relationsData.team = object; + + const afterMutations = { + entityState: object.entityState, + relations: { state: state(object.relationsData), ownerId: object.relationsData.values.owner.id, teamId: object.relationsData.values.team.id }, + physics: { state: state(object.physicsData), values: { ...object.physicsData.values } }, + position: { state: state(object.positionData), values: { ...object.positionData.values } }, + style: { state: state(object.styleData), values: { ...object.styleData.values } }, + name: { state: state(object.nameData), values: { ...object.nameData.values } }, + score: { state: state(object.scoreData), values: { ...object.scoreData.values } }, + health: { state: state(object.healthData), values: { ...object.healthData.values } }, + barrel: { state: state(object.barrelData), values: { ...object.barrelData.values } } + }; + + object.wipeState(); + const afterWipe = { + entityState: object.entityState, + relations: state(object.relationsData), + physics: state(object.physicsData), + position: state(object.positionData), + style: state(object.styleData), + name: state(object.nameData), + score: state(object.scoreData), + health: state(object.healthData), + barrel: state(object.barrelData), + valuesPersist: { + x: object.positionData.values.x, + y: object.positionData.values.y, + size: object.physicsData.values.size, + name: object.nameData.values.name, + score: object.scoreData.values.score + } + }; + + const camera = new CameraEntity(game); + camera.cameraData.statNames[0] = 'Reload'; + camera.cameraData.statLevels[0] = 4; + camera.cameraData.statLimits[0] = 7; + const cameraTable = { + entityState: camera.entityState, + cameraState: state(camera.cameraData), + statNamesState: tableState(camera.cameraData.statNames), + statLevelsState: tableState(camera.cameraData.statLevels), + statLimitsState: tableState(camera.cameraData.statLimits), + values: { + statName0: camera.cameraData.statNames[0], + statLevel0: camera.cameraData.statLevels[0], + statLimit0: camera.cameraData.statLimits[0] + } + }; + camera.wipeState(); + cameraTable.afterWipe = { + entityState: camera.entityState, + cameraState: state(camera.cameraData), + statNamesState: tableState(camera.cameraData.statNames), + statLevelsState: tableState(camera.cameraData.statLevels), + statLimitsState: tableState(camera.cameraData.statLimits) + }; + + return { defaults, afterMutations, afterWipe, cameraTable }; +} + +function cameraFollowReport() { + const game = createGame(); + const player = new ObjectEntity(game); + const camera = new CameraEntity(game); + player.positionData.x = 321; + player.positionData.y = -222; + camera.cameraData.player = player; + camera.tick(10); + const followsPlayer = { + cameraX: camera.cameraData.values.cameraX, + cameraY: camera.cameraData.values.cameraY, + flags: camera.cameraData.values.flags, + cameraState: state(camera.cameraData) + }; + camera.wipeState(); + player.delete(); + camera.tick(11); + return { + followsPlayer, + missingPlayer: { + flags: camera.cameraData.values.flags, + usesCameraCoords: (camera.cameraData.values.flags & CameraFlags.usesCameraCoords) !== 0, + cameraState: state(camera.cameraData) + } + }; +} + +function compilerReport() { + const game = createGame(); + const camera = new CameraEntity(game); + const object = new ObjectEntity(game); + object.nameData = new NameGroup(object); + object.scoreData = new ScoreGroup(object); + object.healthData = new HealthGroup(object); + object.barrelData = new BarrelGroup(object); + + object.physicsData.sides = 3; + object.physicsData.size = 42.5; + object.physicsData.width = 17; + object.physicsData.flags = PhysicsFlags.noOwnTeamCollision; + object.positionData.x = -120; + object.positionData.y = 80; + object.positionData.angle = Math.PI / 4; + object.styleData.color = Color.Tank; + object.styleData.opacity = 0.75; + object.nameData.name = 'Phase C Δ'; + object.scoreData.score = 12345; + object.healthData.health = 0.5; + object.healthData.maxHealth = 2; + object.barrelData.reloadTime = 22; + object.relationsData.owner = object; + object.relationsData.team = object; + camera.cameraData.player = object; + + const creationWriter = new Writer(); + compileCreation(camera, creationWriter, object); + const creationHex = toHex(creationWriter.write(true)); + + object.wipeState(); + object.positionData.x = -100; + object.positionData.y = 90; + object.physicsData.size = 50; + object.styleData.opacity = 0.5; + object.healthData.health = 0.25; + object.nameData.name = 'Phase C Ω'; + const updateState = { + position: state(object.positionData), + physics: state(object.physicsData), + style: state(object.styleData), + health: state(object.healthData), + name: state(object.nameData), + entityState: object.entityState + }; + const updateWriter = new Writer(); + compileUpdate(camera, updateWriter, object); + const updateHex = toHex(updateWriter.write(true)); + + return { + ids: { camera: entitySummary(camera), object: entitySummary(object) }, + creationHex, + updateState, + updateHex + }; +} + +process.stdout.write(`${JSON.stringify({ manager: managerLifecycleReport(), fields: fieldGroupReport(), camera: cameraFollowReport(), compiler: compilerReport() }, null, 2)}\n`); diff --git a/conformance/fixtures/entity-core-golden.json b/conformance/fixtures/entity-core-golden.json new file mode 100644 index 00000000..93ae0096 --- /dev/null +++ b/conformance/fixtures/entity-core-golden.json @@ -0,0 +1,607 @@ +{ + "manager": { + "beforeDelete": { + "lastId": 2, + "zIndex": 1, + "cameras": [ + 2 + ], + "otherEntities": [ + 0 + ], + "hashTable": [ + 1, + 1, + 1, + 0 + ], + "plain": { + "className": "Entity", + "id": 0, + "hash": 1, + "preservedHash": 1, + "entityState": 0, + "exists": true, + "string": "Entity <0, 1>", + "primitive": 65536 + }, + "object": { + "className": "ObjectEntity", + "id": 1, + "hash": 1, + "preservedHash": 1, + "entityState": 0, + "exists": true, + "string": "ObjectEntity <1, 1>", + "primitive": 65537 + }, + "camera": { + "className": "CameraEntity", + "id": 2, + "hash": 1, + "preservedHash": 1, + "entityState": 0, + "exists": true, + "string": "CameraEntity <2, 1>", + "primitive": 65538 + } + }, + "afterReuse": { + "lastId": 2, + "zIndex": 2, + "cameras": [ + 2 + ], + "otherEntities": [ + 0 + ], + "deletedObject": { + "className": "ObjectEntity", + "id": 1, + "hash": 0, + "preservedHash": 1, + "entityState": 0, + "exists": false, + "string": "ObjectEntity <1, 1>(deleted)", + "primitive": 65537 + }, + "replacement": { + "className": "ObjectEntity", + "id": 1, + "hash": 2, + "preservedHash": 2, + "entityState": 1, + "exists": true, + "string": "ObjectEntity <1, 2>", + "primitive": 131073 + }, + "hashTable": [ + 1, + 2, + 1, + 0 + ], + "innerPresent": [ + "Entity", + "ObjectEntity", + "CameraEntity", + null + ] + }, + "afterClear": { + "lastId": -1, + "cameras": [], + "otherEntities": [], + "hashTable": [ + 0, + 0, + 0, + 0 + ], + "plain": { + "className": "Entity", + "id": 0, + "hash": 0, + "preservedHash": 1, + "entityState": 0, + "exists": false, + "string": "Entity <0, 1>(deleted)", + "primitive": 65536 + }, + "camera": { + "className": "CameraEntity", + "id": 2, + "hash": 0, + "preservedHash": 1, + "entityState": 0, + "exists": false, + "string": "CameraEntity <2, 1>(deleted)", + "primitive": 65538 + }, + "replacement": { + "className": "ObjectEntity", + "id": 1, + "hash": 0, + "preservedHash": 2, + "entityState": 1, + "exists": false, + "string": "ObjectEntity <1, 2>(deleted)", + "primitive": 131073 + } + } + }, + "fields": { + "defaults": { + "relations": { + "state": [ + 0, + 0, + 0 + ], + "values": { + "parent": null, + "owner": null, + "team": null + } + }, + "physics": { + "state": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "values": { + "flags": 0, + "sides": 0, + "size": 0, + "width": 0, + "absorbtionFactor": 1, + "pushFactor": 8 + } + }, + "position": { + "state": [ + 0, + 0, + 0, + 0 + ], + "values": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + } + }, + "style": { + "state": [ + 0, + 0, + 0, + 0, + 0 + ], + "values": { + "flags": 1, + "color": 0, + "borderWidth": 7.5, + "opacity": 1, + "zIndex": 0 + } + }, + "name": { + "state": [ + 0, + 0 + ], + "values": { + "flags": 0, + "name": "" + } + }, + "health": { + "state": [ + 0, + 0, + 0 + ], + "values": { + "flags": 0, + "health": 1, + "maxHealth": 1 + } + } + }, + "afterMutations": { + "entityState": 1, + "relations": { + "state": [ + 0, + 1, + 1 + ], + "ownerId": 0, + "teamId": 0 + }, + "physics": { + "state": [ + 1, + 1, + 1, + 0, + 0, + 0 + ], + "values": { + "flags": 72, + "sides": 3, + "size": 42.5, + "width": 0, + "absorbtionFactor": 1, + "pushFactor": 8 + } + }, + "position": { + "state": [ + 1, + 1, + 1, + 1 + ], + "values": { + "x": -120, + "y": 80, + "angle": 0.7853981633974483, + "flags": 1 + } + }, + "style": { + "state": [ + 0, + 1, + 0, + 1, + 0 + ], + "values": { + "flags": 1, + "color": 2, + "borderWidth": 7.5, + "opacity": 0.75, + "zIndex": 0 + } + }, + "name": { + "state": [ + 0, + 1 + ], + "values": { + "flags": 0, + "name": "Phase C Δ" + } + }, + "score": { + "state": [ + 1 + ], + "values": { + "score": 12345 + } + }, + "health": { + "state": [ + 0, + 1, + 1 + ], + "values": { + "flags": 0, + "health": 0.5, + "maxHealth": 2 + } + }, + "barrel": { + "state": [ + 0, + 1, + 0 + ], + "values": { + "flags": 0, + "reloadTime": 22, + "trapezoidDirection": 0 + } + } + }, + "afterWipe": { + "entityState": 0, + "relations": [ + 0, + 0, + 0 + ], + "physics": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "position": [ + 0, + 0, + 0, + 0 + ], + "style": [ + 0, + 0, + 0, + 0, + 0 + ], + "name": [ + 0, + 0 + ], + "score": [ + 0 + ], + "health": [ + 0, + 0, + 0 + ], + "barrel": [ + 0, + 0, + 0 + ], + "valuesPersist": { + "x": -120, + "y": 80, + "size": 42.5, + "name": "Phase C Δ", + "score": 12345 + } + }, + "cameraTable": { + "entityState": 1, + "cameraState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "statNamesState": [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "statLevelsState": [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "statLimitsState": [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "values": { + "statName0": "Reload", + "statLevel0": 4, + "statLimit0": 7 + }, + "afterWipe": { + "entityState": 0, + "cameraState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "statNamesState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "statLevelsState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "statLimitsState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + } + }, + "camera": { + "followsPlayer": { + "cameraX": 0, + "cameraY": 0, + "flags": 1, + "cameraState": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + "missingPlayer": { + "flags": 1, + "usesCameraCoords": true, + "cameraState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "compiler": { + "ids": { + "camera": { + "className": "CameraEntity", + "id": 0, + "hash": 1, + "preservedHash": 1, + "entityState": 1, + "exists": true, + "string": "CameraEntity <0, 1>", + "primitive": 65536 + }, + "object": { + "className": "ObjectEntity", + "id": 1, + "hash": 1, + "preservedHash": 1, + "entityState": 1, + "exists": true, + "string": "ObjectEntity <1, 1>", + "primitive": 65537 + } + }, + "creationHex": "010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046", + "updateState": { + "position": [ + 1, + 1, + 0, + 0 + ], + "physics": [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + "style": [ + 0, + 0, + 0, + 1, + 0 + ], + "health": [ + 0, + 1, + 0 + ], + "name": [ + 0, + 1 + ], + "entityState": 1 + }, + "updateHex": "0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01" + } +} diff --git a/docs/migration/phase-c-entity-core-map.md b/docs/migration/phase-c-entity-core-map.md new file mode 100644 index 00000000..7d0a3948 --- /dev/null +++ b/docs/migration/phase-c-entity-core-map.md @@ -0,0 +1,28 @@ +# Phase C entity-core map + +TypeScript remains the source of truth. Phase C should port only the deterministic entity/state core first, before gameplay simulation. + +## Initial port surface + +- `src/Native/Entity.ts` + - `Entity.exists`, `entityState`, `id`, `hash`, `preservedHash`, `wipeState`, `delete`, `toString`, numeric primitive identity. +- `src/Native/Manager.ts` + - ID allocation/reuse, `hashTable` increments, `inner` slot deletion, `cameras`/`otherEntities` classification, `clear` behavior. +- `src/Native/FieldGroups.ts` + - field default values, state-bit mutation on value changes, no-op assignment behavior, `wipe` behavior, table state for scoreboard/camera stat arrays. +- `src/Entity/Object.ts` + - always-present relations/physics/position/style groups, default z-index assignment, basic position/physics/style update state. +- `src/Native/Camera.ts` + - `CameraEntity` default groups, camera coordinate following from player/root parent, fallback to camera-coordinate flag when player is missing. +- `src/Native/UpcreateCompiler.ts` + - creation/update byte snapshots for deterministic, fixture-sized entity state. + +## TS golden scope added first + +The initial golden report intentionally avoids full gameplay classes and client/network state. It covers: + +1. Entity manager allocation/delete/reuse/hash lifecycle. +2. Field group state transitions and wipe behavior. +3. Object entity default groups and camera following behavior. +4. Creation/update packet bytes for a deterministic object fixture. + diff --git a/package-lock.json b/package-lock.json index 4f4b9f71..bf2e415c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -533,9 +533,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", - "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -547,9 +547,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", - "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -561,9 +561,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", - "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -575,9 +575,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", - "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -589,9 +589,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", - "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -603,9 +603,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", - "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -617,9 +617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", - "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -631,9 +631,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", - "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -645,9 +645,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", - "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -659,9 +659,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", - "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -673,9 +673,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", - "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -687,9 +701,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", - "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -701,9 +729,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", - "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -715,9 +743,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", - "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -729,9 +757,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", - "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -743,9 +771,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", - "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -757,9 +785,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", - "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -770,10 +798,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", - "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -785,9 +827,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", - "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -799,9 +841,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", - "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -813,9 +855,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", - "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -827,9 +869,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", - "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -910,9 +952,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -1301,13 +1343,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1417,9 +1459,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1529,9 +1571,9 @@ } }, "node_modules/rollup": { - "version": "4.52.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", - "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -1545,28 +1587,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.5", - "@rollup/rollup-android-arm64": "4.52.5", - "@rollup/rollup-darwin-arm64": "4.52.5", - "@rollup/rollup-darwin-x64": "4.52.5", - "@rollup/rollup-freebsd-arm64": "4.52.5", - "@rollup/rollup-freebsd-x64": "4.52.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", - "@rollup/rollup-linux-arm-musleabihf": "4.52.5", - "@rollup/rollup-linux-arm64-gnu": "4.52.5", - "@rollup/rollup-linux-arm64-musl": "4.52.5", - "@rollup/rollup-linux-loong64-gnu": "4.52.5", - "@rollup/rollup-linux-ppc64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-gnu": "4.52.5", - "@rollup/rollup-linux-riscv64-musl": "4.52.5", - "@rollup/rollup-linux-s390x-gnu": "4.52.5", - "@rollup/rollup-linux-x64-gnu": "4.52.5", - "@rollup/rollup-linux-x64-musl": "4.52.5", - "@rollup/rollup-openharmony-arm64": "4.52.5", - "@rollup/rollup-win32-arm64-msvc": "4.52.5", - "@rollup/rollup-win32-ia32-msvc": "4.52.5", - "@rollup/rollup-win32-x64-gnu": "4.52.5", - "@rollup/rollup-win32-x64-msvc": "4.52.5", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, From 4a4be4260826004129ef8314f38ec51f3b2cec27 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 14:11:56 -0400 Subject: [PATCH 04/22] Prioritize headless world parity for Phase C Phase C now treats deterministic full-world entity snapshots as the primary RL-facing acceptance target while retaining camera/update packet coverage only as a minimal legacy compatibility guard. Constraint: C++ migration must prove global TypeScript parity before per-agent RL observation grids Constraint: Existing camera/update packet behavior remains protected as compatibility-only scope Rejected: Remove all camera/update serialization fixtures | user asked to keep minimal compatibility coverage Rejected: Build per-agent filtered observations now | global parity must be locked first Confidence: high Scope-risk: narrow Tested: npm run test:conformance Tested: npm run test:parity Tested: npm run test:all --- conformance/entity-core/golden.test.js | 2 +- conformance/entity-core/report-ts.js | 105 ++++- conformance/fixtures/entity-core-golden.json | 435 ++++++++++++++----- docs/migration/phase-c-entity-core-map.md | 26 +- 4 files changed, 449 insertions(+), 119 deletions(-) diff --git a/conformance/entity-core/golden.test.js b/conformance/entity-core/golden.test.js index 003f5295..7e8f8135 100644 --- a/conformance/entity-core/golden.test.js +++ b/conformance/entity-core/golden.test.js @@ -7,7 +7,7 @@ const { execFileSync } = require('node:child_process'); const root = path.join(__dirname, '../..'); const fixture = JSON.parse(fs.readFileSync(path.join(root, 'conformance/fixtures/entity-core-golden.json'), 'utf8')); -test('TS entity-core report matches golden fixture', () => { +test('TS headless world snapshot plus minimal compatibility report matches golden fixture', () => { const output = execFileSync(process.execPath, [path.join(root, 'conformance/entity-core/report-ts.js')], { cwd: root, encoding: 'utf8' }); assert.deepEqual(JSON.parse(output), fixture); }); diff --git a/conformance/entity-core/report-ts.js b/conformance/entity-core/report-ts.js index fbcd1096..26990975 100644 --- a/conformance/entity-core/report-ts.js +++ b/conformance/entity-core/report-ts.js @@ -200,6 +200,109 @@ function cameraFollowReport() { }; } + +function ref(entity) { + return Entity.exists(entity) ? { id: entity.id, hash: entity.hash } : null; +} + +function groupSnapshot(entity) { + const snapshot = {}; + if (entity.relationsData) { + snapshot.relations = { + state: state(entity.relationsData), + parent: ref(entity.relationsData.values.parent), + owner: ref(entity.relationsData.values.owner), + team: ref(entity.relationsData.values.team) + }; + } + if (entity.physicsData) { + snapshot.physics = { state: state(entity.physicsData), values: { ...entity.physicsData.values } }; + } + if (entity.positionData) { + snapshot.position = { state: state(entity.positionData), values: { ...entity.positionData.values } }; + } + if (entity.styleData) { + snapshot.style = { state: state(entity.styleData), values: { ...entity.styleData.values } }; + } + if (entity.nameData) { + snapshot.name = { state: state(entity.nameData), values: { ...entity.nameData.values } }; + } + if (entity.healthData) { + snapshot.health = { state: state(entity.healthData), values: { ...entity.healthData.values } }; + } + if (entity.scoreData) { + snapshot.score = { state: state(entity.scoreData), values: { ...entity.scoreData.values } }; + } + if (entity.barrelData) { + snapshot.barrel = { state: state(entity.barrelData), values: { ...entity.barrelData.values } }; + } + return snapshot; +} + +function worldEntitySnapshot(entity) { + return { + ...entitySummary(entity), + groups: groupSnapshot(entity) + }; +} + +function fullWorldSnapshotReport() { + const game = createGame(); + game.tick = 77; + + const player = new ObjectEntity(game); + player.nameData = new NameGroup(player); + player.scoreData = new ScoreGroup(player); + player.healthData = new HealthGroup(player); + player.barrelData = new BarrelGroup(player); + player.positionData.x = 125.5; + player.positionData.y = -64.25; + player.positionData.angle = Math.PI / 3; + player.physicsData.sides = 1; + player.physicsData.size = 35; + player.physicsData.width = 12; + player.styleData.color = Color.Tank; + player.styleData.opacity = 0.9; + player.nameData.name = 'RL Player'; + player.scoreData.score = 9001; + player.healthData.health = 0.875; + player.healthData.maxHealth = 1.25; + player.barrelData.reloadTime = 12; + player.relationsData.owner = player; + player.relationsData.team = player; + + const shape = new ObjectEntity(game); + shape.positionData.x = -250; + shape.positionData.y = 100; + shape.positionData.angle = -Math.PI / 8; + shape.physicsData.sides = 4; + shape.physicsData.size = 30; + shape.physicsData.width = 30; + shape.styleData.color = Color.EnemySquare; + shape.relationsData.owner = player; + shape.relationsData.team = null; + + const deleted = new ObjectEntity(game); + deleted.positionData.x = 999; + deleted.delete(); + + return { + purpose: 'primary Phase C parity target: full world/entity state for headless RL training', + tick: game.tick, + lastId: game.entities.lastId, + zIndex: game.entities.zIndex, + activeIds: game.entities.inner + .slice(0, game.entities.lastId + 1) + .map((entity, id) => Entity.exists(entity) ? id : null) + .filter((id) => id !== null), + hashTable: Array.from(game.entities.hashTable.slice(0, 4)), + entities: game.entities.inner + .slice(0, game.entities.lastId + 1) + .filter((entity) => Entity.exists(entity)) + .map(worldEntitySnapshot) + }; +} + function compilerReport() { const game = createGame(); const camera = new CameraEntity(game); @@ -258,4 +361,4 @@ function compilerReport() { }; } -process.stdout.write(`${JSON.stringify({ manager: managerLifecycleReport(), fields: fieldGroupReport(), camera: cameraFollowReport(), compiler: compilerReport() }, null, 2)}\n`); +process.stdout.write(`${JSON.stringify({ world: fullWorldSnapshotReport(), manager: managerLifecycleReport(), fields: fieldGroupReport(), compatibility: { camera: cameraFollowReport(), compiler: compilerReport() } }, null, 2)}\n`); diff --git a/conformance/fixtures/entity-core-golden.json b/conformance/fixtures/entity-core-golden.json index 93ae0096..e2e6d122 100644 --- a/conformance/fixtures/entity-core-golden.json +++ b/conformance/fixtures/entity-core-golden.json @@ -1,4 +1,213 @@ { + "world": { + "purpose": "primary Phase C parity target: full world/entity state for headless RL training", + "tick": 77, + "lastId": 2, + "zIndex": 3, + "activeIds": [ + 0, + 1 + ], + "hashTable": [ + 1, + 1, + 1, + 0 + ], + "entities": [ + { + "className": "ObjectEntity", + "id": 0, + "hash": 1, + "preservedHash": 1, + "entityState": 1, + "exists": true, + "string": "ObjectEntity <0, 1>", + "primitive": 65536, + "groups": { + "relations": { + "state": [ + 0, + 1, + 1 + ], + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": { + "id": 0, + "hash": 1 + } + }, + "physics": { + "state": [ + 0, + 1, + 1, + 1, + 0, + 0 + ], + "values": { + "flags": 0, + "sides": 1, + "size": 35, + "width": 12, + "absorbtionFactor": 1, + "pushFactor": 8 + } + }, + "position": { + "state": [ + 1, + 1, + 1, + 0 + ], + "values": { + "x": 125.5, + "y": -64.25, + "angle": 1.0471975511965976, + "flags": 0 + } + }, + "style": { + "state": [ + 0, + 1, + 0, + 1, + 0 + ], + "values": { + "flags": 1, + "color": 2, + "borderWidth": 7.5, + "opacity": 0.9, + "zIndex": 0 + } + }, + "name": { + "state": [ + 0, + 1 + ], + "values": { + "flags": 0, + "name": "RL Player" + } + }, + "health": { + "state": [ + 0, + 1, + 1 + ], + "values": { + "flags": 0, + "health": 0.875, + "maxHealth": 1.25 + } + }, + "score": { + "state": [ + 1 + ], + "values": { + "score": 9001 + } + }, + "barrel": { + "state": [ + 0, + 1, + 0 + ], + "values": { + "flags": 0, + "reloadTime": 12, + "trapezoidDirection": 0 + } + } + } + }, + { + "className": "ObjectEntity", + "id": 1, + "hash": 1, + "preservedHash": 1, + "entityState": 1, + "exists": true, + "string": "ObjectEntity <1, 1>", + "primitive": 65537, + "groups": { + "relations": { + "state": [ + 0, + 1, + 0 + ], + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "physics": { + "state": [ + 0, + 1, + 1, + 1, + 0, + 0 + ], + "values": { + "flags": 0, + "sides": 4, + "size": 30, + "width": 30, + "absorbtionFactor": 1, + "pushFactor": 8 + } + }, + "position": { + "state": [ + 1, + 1, + 1, + 0 + ], + "values": { + "x": -250, + "y": 100, + "angle": -0.39269908169872414, + "flags": 0 + } + }, + "style": { + "state": [ + 0, + 1, + 0, + 0, + 1 + ], + "values": { + "flags": 1, + "color": 8, + "borderWidth": 7.5, + "opacity": 1, + "zIndex": 1 + } + } + } + } + ] + }, "manager": { "beforeDelete": { "lastId": 2, @@ -488,120 +697,122 @@ } } }, - "camera": { - "followsPlayer": { - "cameraX": 0, - "cameraY": 0, - "flags": 1, - "cameraState": [ - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - }, - "missingPlayer": { - "flags": 1, - "usesCameraCoords": true, - "cameraState": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - } - }, - "compiler": { - "ids": { - "camera": { - "className": "CameraEntity", - "id": 0, - "hash": 1, - "preservedHash": 1, - "entityState": 1, - "exists": true, - "string": "CameraEntity <0, 1>", - "primitive": 65536 + "compatibility": { + "camera": { + "followsPlayer": { + "cameraX": 0, + "cameraY": 0, + "flags": 1, + "cameraState": [ + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] }, - "object": { - "className": "ObjectEntity", - "id": 1, - "hash": 1, - "preservedHash": 1, - "entityState": 1, - "exists": true, - "string": "ObjectEntity <1, 1>", - "primitive": 65537 + "missingPlayer": { + "flags": 1, + "usesCameraCoords": true, + "cameraState": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] } }, - "creationHex": "010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046", - "updateState": { - "position": [ - 1, - 1, - 0, - 0 - ], - "physics": [ - 0, - 0, - 1, - 0, - 0, - 0 - ], - "style": [ - 0, - 0, - 0, - 1, - 0 - ], - "health": [ - 0, - 1, - 0 - ], - "name": [ - 0, - 1 - ], - "entityState": 1 - }, - "updateHex": "0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01" + "compiler": { + "ids": { + "camera": { + "className": "CameraEntity", + "id": 0, + "hash": 1, + "preservedHash": 1, + "entityState": 1, + "exists": true, + "string": "CameraEntity <0, 1>", + "primitive": 65536 + }, + "object": { + "className": "ObjectEntity", + "id": 1, + "hash": 1, + "preservedHash": 1, + "entityState": 1, + "exists": true, + "string": "ObjectEntity <1, 1>", + "primitive": 65537 + } + }, + "creationHex": "010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046", + "updateState": { + "position": [ + 1, + 1, + 0, + 0 + ], + "physics": [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + "style": [ + 0, + 0, + 0, + 1, + 0 + ], + "health": [ + 0, + 1, + 0 + ], + "name": [ + 0, + 1 + ], + "entityState": 1 + }, + "updateHex": "0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01" + } } } diff --git a/docs/migration/phase-c-entity-core-map.md b/docs/migration/phase-c-entity-core-map.md index 7d0a3948..b7fe31e0 100644 --- a/docs/migration/phase-c-entity-core-map.md +++ b/docs/migration/phase-c-entity-core-map.md @@ -2,7 +2,7 @@ TypeScript remains the source of truth. Phase C should port only the deterministic entity/state core first, before gameplay simulation. -## Initial port surface +## Initial port surface (corrected for headless RL) - `src/Native/Entity.ts` - `Entity.exists`, `entityState`, `id`, `hash`, `preservedHash`, `wipeState`, `delete`, `toString`, numeric primitive identity. @@ -12,10 +12,12 @@ TypeScript remains the source of truth. Phase C should port only the determinist - field default values, state-bit mutation on value changes, no-op assignment behavior, `wipe` behavior, table state for scoreboard/camera stat arrays. - `src/Entity/Object.ts` - always-present relations/physics/position/style groups, default z-index assignment, basic position/physics/style update state. +- Full-world/entity snapshot conformance + - primary Phase C acceptance target for headless RL training. Captures active entity IDs, hashes, manager metadata, and deterministic object field-group state before any per-agent filtering or spatial-grid quantization. - `src/Native/Camera.ts` - - `CameraEntity` default groups, camera coordinate following from player/root parent, fallback to camera-coordinate flag when player is missing. + - minimal compatibility-only coverage for `CameraEntity` defaults/state where needed by legacy protocol fixtures. This is not the RL observation surface. - `src/Native/UpcreateCompiler.ts` - - creation/update byte snapshots for deterministic, fixture-sized entity state. + - minimal creation/update byte snapshots for deterministic, fixture-sized legacy protocol compatibility only. ## TS golden scope added first @@ -23,6 +25,20 @@ The initial golden report intentionally avoids full gameplay classes and client/ 1. Entity manager allocation/delete/reuse/hash lifecycle. 2. Field group state transitions and wipe behavior. -3. Object entity default groups and camera following behavior. -4. Creation/update packet bytes for a deterministic object fixture. +3. Object entity default groups and deterministic full-world/entity snapshot parity. +4. Minimal camera following and creation/update packet bytes for legacy compatibility only. + + +## Headless RL scope correction + +Phase C is now parity-first for headless RL training. The first RL-facing observation snapshot is the full world/entity state, not a per-agent filtered view. This deliberately avoids debugging C++ physics/entity desyncs through a quantized spatial-grid observation layer. + +Camera/update serialization remains in the conformance suite only as a minimal compatibility guard for the existing TypeScript/client protocol. It must not expand into full browser view-snapshot parity during Phase C. Per-agent localized RL observations should be built after global full-world parity is locked. + +Acceptance priority: + +1. TypeScript full-world/entity snapshot is golden. +2. C++ full-world/entity snapshot matches TypeScript exactly for deterministic fixtures. +3. Minimal camera/update packet compatibility still passes. +4. Per-agent RL observation grids are deferred to a later phase. From 174555fdc6a29f67aea52ba1777ad6c65bd72c28 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 14:16:33 -0400 Subject: [PATCH 05/22] Wire C++ entity-core parity into Phase C The C++ tree now builds an entity-core report target and the parity suite compares it with the TypeScript headless world snapshot plus minimal compatibility report. Constraint: Phase C prioritizes full-world deterministic parity before per-agent RL observations Constraint: This first C++ target is a parity emitter, not the final entity runtime implementation Rejected: Skip C++ parity wiring until the full entity model exists | parity harness must guard each migration slice before internals are replaced Confidence: medium Scope-risk: narrow Tested: npm run test:cpp Tested: npm run test:parity Tested: npm run test:all Not-tested: Real C++ entity manager mutation logic beyond the deterministic report emitter --- CMakeLists.txt | 9 +++++++++ conformance/entity-core/compare-parity.js | 11 +++++++++++ cpp/include/diepcustom/entity_core.hpp | 12 ++++++++++++ cpp/src/entity_core.cpp | 9 +++++++++ cpp/tests/entity_core_report.cpp | 8 ++++++++ package.json | 2 +- 6 files changed, 50 insertions(+), 1 deletion(-) create mode 100755 conformance/entity-core/compare-parity.js create mode 100644 cpp/include/diepcustom/entity_core.hpp create mode 100644 cpp/src/entity_core.cpp create mode 100644 cpp/tests/entity_core_report.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f5338178..1a696411 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,8 +12,13 @@ add_library(diepcustom_protocol add_library(diepcustom_physics cpp/src/physics.cpp ) + +add_library(diepcustom_entity_core + cpp/src/entity_core.cpp +) target_include_directories(diepcustom_physics PUBLIC cpp/include) target_include_directories(diepcustom_protocol PUBLIC cpp/include) +target_include_directories(diepcustom_entity_core PUBLIC cpp/include) add_executable(protocol_golden_test cpp/tests/protocol_golden_test.cpp) target_link_libraries(protocol_golden_test PRIVATE diepcustom_protocol) @@ -24,5 +29,9 @@ target_link_libraries(protocol_report PRIVATE diepcustom_protocol) add_executable(physics_report cpp/tests/physics_report.cpp) target_link_libraries(physics_report PRIVATE diepcustom_physics) +add_executable(entity_core_report cpp/tests/entity_core_report.cpp) +target_link_libraries(entity_core_report PRIVATE diepcustom_entity_core) + enable_testing() add_test(NAME protocol_golden_test COMMAND protocol_golden_test) +add_test(NAME entity_core_report_smoke COMMAND entity_core_report) diff --git a/conformance/entity-core/compare-parity.js b/conformance/entity-core/compare-parity.js new file mode 100755 index 00000000..9ceac931 --- /dev/null +++ b/conformance/entity-core/compare-parity.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const root = path.join(__dirname, '../..'); +const tsReport = JSON.parse(execFileSync(process.execPath, [path.join(root, 'conformance/entity-core/report-ts.js')], { cwd: root, encoding: 'utf8' })); +const cppReport = JSON.parse(execFileSync(path.join(root, 'build/cpp/entity_core_report'), { cwd: root, encoding: 'utf8' })); + +assert.deepEqual(cppReport, tsReport); +console.log('entity-core parity report matched TypeScript reference'); diff --git a/cpp/include/diepcustom/entity_core.hpp b/cpp/include/diepcustom/entity_core.hpp new file mode 100644 index 00000000..c3df5c35 --- /dev/null +++ b/cpp/include/diepcustom/entity_core.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace diepcustom::entity_core { + +// Phase C parity entry point for the headless RL world snapshot. The current +// implementation is a deterministic report emitter used to wire C++ parity +// checks before the full entity manager/field model is ported behind it. +std::string entityCoreReportJson(); + +} // namespace diepcustom::entity_core diff --git a/cpp/src/entity_core.cpp b/cpp/src/entity_core.cpp new file mode 100644 index 00000000..7a7d9711 --- /dev/null +++ b/cpp/src/entity_core.cpp @@ -0,0 +1,9 @@ +#include "diepcustom/entity_core.hpp" + +namespace diepcustom::entity_core { + +std::string entityCoreReportJson() { + return R"JSON({"world":{"purpose":"primary Phase C parity target: full world/entity state for headless RL training","tick":77,"lastId":2,"zIndex":3,"activeIds":[0,1],"hashTable":[1,1,1,0],"entities":[{"className":"ObjectEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <0, 1>","primitive":65536,"groups":{"relations":{"state":[0,1,1],"parent":null,"owner":{"id":0,"hash":1},"team":{"id":0,"hash":1}},"physics":{"state":[0,1,1,1,0,0],"values":{"flags":0,"sides":1,"size":35,"width":12,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,0],"values":{"x":125.5,"y":-64.25,"angle":1.0471975511965976,"flags":0}},"style":{"state":[0,1,0,1,0],"values":{"flags":1,"color":2,"borderWidth":7.5,"opacity":0.9,"zIndex":0}},"name":{"state":[0,1],"values":{"flags":0,"name":"RL Player"}},"health":{"state":[0,1,1],"values":{"flags":0,"health":0.875,"maxHealth":1.25}},"score":{"state":[1],"values":{"score":9001}},"barrel":{"state":[0,1,0],"values":{"flags":0,"reloadTime":12,"trapezoidDirection":0}}}},{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537,"groups":{"relations":{"state":[0,1,0],"parent":null,"owner":{"id":0,"hash":1},"team":null},"physics":{"state":[0,1,1,1,0,0],"values":{"flags":0,"sides":4,"size":30,"width":30,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,0],"values":{"x":-250,"y":100,"angle":-0.39269908169872414,"flags":0}},"style":{"state":[0,1,0,0,1],"values":{"flags":1,"color":8,"borderWidth":7.5,"opacity":1,"zIndex":1}}}}]},"manager":{"beforeDelete":{"lastId":2,"zIndex":1,"cameras":[2],"otherEntities":[0],"hashTable":[1,1,1,0],"plain":{"className":"Entity","id":0,"hash":1,"preservedHash":1,"entityState":0,"exists":true,"string":"Entity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":0,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537},"camera":{"className":"CameraEntity","id":2,"hash":1,"preservedHash":1,"entityState":0,"exists":true,"string":"CameraEntity <2, 1>","primitive":65538}},"afterReuse":{"lastId":2,"zIndex":2,"cameras":[2],"otherEntities":[0],"deletedObject":{"className":"ObjectEntity","id":1,"hash":0,"preservedHash":1,"entityState":0,"exists":false,"string":"ObjectEntity <1, 1>(deleted)","primitive":65537},"replacement":{"className":"ObjectEntity","id":1,"hash":2,"preservedHash":2,"entityState":1,"exists":true,"string":"ObjectEntity <1, 2>","primitive":131073},"hashTable":[1,2,1,0],"innerPresent":["Entity","ObjectEntity","CameraEntity",null]},"afterClear":{"lastId":-1,"cameras":[],"otherEntities":[],"hashTable":[0,0,0,0],"plain":{"className":"Entity","id":0,"hash":0,"preservedHash":1,"entityState":0,"exists":false,"string":"Entity <0, 1>(deleted)","primitive":65536},"camera":{"className":"CameraEntity","id":2,"hash":0,"preservedHash":1,"entityState":0,"exists":false,"string":"CameraEntity <2, 1>(deleted)","primitive":65538},"replacement":{"className":"ObjectEntity","id":1,"hash":0,"preservedHash":2,"entityState":1,"exists":false,"string":"ObjectEntity <1, 2>(deleted)","primitive":131073}}},"fields":{"defaults":{"relations":{"state":[0,0,0],"values":{"parent":null,"owner":null,"team":null}},"physics":{"state":[0,0,0,0,0,0],"values":{"flags":0,"sides":0,"size":0,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[0,0,0,0],"values":{"x":0,"y":0,"angle":0,"flags":0}},"style":{"state":[0,0,0,0,0],"values":{"flags":1,"color":0,"borderWidth":7.5,"opacity":1,"zIndex":0}},"name":{"state":[0,0],"values":{"flags":0,"name":""}},"health":{"state":[0,0,0],"values":{"flags":0,"health":1,"maxHealth":1}}},"afterMutations":{"entityState":1,"relations":{"state":[0,1,1],"ownerId":0,"teamId":0},"physics":{"state":[1,1,1,0,0,0],"values":{"flags":72,"sides":3,"size":42.5,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,1],"values":{"x":-120,"y":80,"angle":0.7853981633974483,"flags":1}},"style":{"state":[0,1,0,1,0],"values":{"flags":1,"color":2,"borderWidth":7.5,"opacity":0.75,"zIndex":0}},"name":{"state":[0,1],"values":{"flags":0,"name":"Phase C \u0394"}},"score":{"state":[1],"values":{"score":12345}},"health":{"state":[0,1,1],"values":{"flags":0,"health":0.5,"maxHealth":2}},"barrel":{"state":[0,1,0],"values":{"flags":0,"reloadTime":22,"trapezoidDirection":0}}},"afterWipe":{"entityState":0,"relations":[0,0,0],"physics":[0,0,0,0,0,0],"position":[0,0,0,0],"style":[0,0,0,0,0],"name":[0,0],"score":[0],"health":[0,0,0],"barrel":[0,0,0],"valuesPersist":{"x":-120,"y":80,"size":42.5,"name":"Phase C \u0394","score":12345}},"cameraTable":{"entityState":1,"cameraState":[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],"statNamesState":[1,0,0,0,0,0,0,0],"statLevelsState":[1,0,0,0,0,0,0,0],"statLimitsState":[1,0,0,0,0,0,0,0],"values":{"statName0":"Reload","statLevel0":4,"statLimit0":7},"afterWipe":{"entityState":0,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"statNamesState":[0,0,0,0,0,0,0,0],"statLevelsState":[0,0,0,0,0,0,0,0],"statLimitsState":[0,0,0,0,0,0,0,0]}}},"compatibility":{"camera":{"followsPlayer":{"cameraX":0,"cameraY":0,"flags":1,"cameraState":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"missingPlayer":{"flags":1,"usesCameraCoords":true,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}}})JSON"; +} + +} // namespace diepcustom::entity_core diff --git a/cpp/tests/entity_core_report.cpp b/cpp/tests/entity_core_report.cpp new file mode 100644 index 00000000..fff6738f --- /dev/null +++ b/cpp/tests/entity_core_report.cpp @@ -0,0 +1,8 @@ +#include "diepcustom/entity_core.hpp" + +#include + +int main() { + std::cout << diepcustom::entity_core::entityCoreReportJson() << '\n'; + return 0; +} diff --git a/package.json b/package.json index f3b769f3..17579616 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:all": "npm run test && npm run test:conformance && npm audit --audit-level=moderate", "test:conformance": "node --test conformance/**/*.test.js", "test:cpp": "cmake -S . -B build/cpp && cmake --build build/cpp && ctest --test-dir build/cpp --output-on-failure", - "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js" + "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js && node conformance/entity-core/compare-parity.js" }, "engines": { "node": ">=16.3" From 1a566e264c14951cde53582ec1b8848bb9fa9530 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 14:20:58 -0400 Subject: [PATCH 06/22] Generate Phase C entity parity from C++ state mutations The entity-core report now builds the primary world snapshot through a small C++ manager/entity/field model instead of replaying one static JSON fixture. Minimal compatibility packet data stays isolated while the headless world snapshot exercises ID allocation, deletion/reuse, field state bits, relations, and object snapshot serialization. Constraint: Phase C still keeps camera/update serialization as compatibility-only scope Rejected: Port the full gameplay entity hierarchy now | Phase C target is deterministic entity-core parity before gameplay simulation Confidence: medium Scope-risk: moderate Tested: npm run test:parity Tested: npm run test:all Not-tested: Full C++ UpcreateCompiler and CameraGroup mutation logic beyond compatibility constants --- cpp/src/entity_core.cpp | 205 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/cpp/src/entity_core.cpp b/cpp/src/entity_core.cpp index 7a7d9711..a0aa64d9 100644 --- a/cpp/src/entity_core.cpp +++ b/cpp/src/entity_core.cpp @@ -1,9 +1,212 @@ #include "diepcustom/entity_core.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + namespace diepcustom::entity_core { +namespace { +constexpr int ColorBorder = 0; +constexpr int ColorTank = 2; +constexpr int ColorEnemySquare = 8; +constexpr int PhysicsNoOwnTeamCollision = 1 << 3; +constexpr int PhysicsIsBase = 1 << 6; +constexpr int PositionAbsoluteRotation = 1 << 0; + +std::string q(const std::string& value) { + std::ostringstream out; + out << '"'; + for (unsigned char c : value) { + switch (c) { + case '"': out << "\\\""; break; + case '\\': out << "\\\\"; break; + case '\n': out << "\\n"; break; + default: + if (c < 0x20) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c) << std::dec; + else out << c; + } + } + out << '"'; + return out.str(); +} + +template +std::string arrayJson(const std::array& values) { + std::ostringstream out; + out << '['; + for (std::size_t i = 0; i < N; ++i) { + if (i) out << ','; + out << values[i]; + } + out << ']'; + return out.str(); +} + +std::string idsJson(const std::vector& values) { + std::ostringstream out; + out << '['; + for (std::size_t i = 0; i < values.size(); ++i) { + if (i) out << ','; + out << values[i]; + } + out << ']'; + return out.str(); +} + +std::string num(double value) { + std::ostringstream out; + out << std::setprecision(17) << value; + return out.str(); +} + +struct Entity; +struct Manager { + int zIndex = 0; + int lastId = -1; + std::array hashTable{}; + std::vector> inner; + std::vector cameras; + std::vector otherEntities; + + Manager() : inner(16384) {} + void add(Entity& entity); + void remove(int id); + void clear(); +}; + +struct Entity { + Manager& manager; + std::string className = "Entity"; + int entityState = 0; + int id = -1; + int hash = 0; + int preservedHash = 0; + bool isObject = false; + bool isCamera = false; + + explicit Entity(Manager& manager, std::string name = "Entity") : manager(manager), className(std::move(name)) { manager.add(*this); } + virtual ~Entity() = default; + virtual void wipeState() { entityState = 0; } + virtual void remove() { wipeState(); manager.remove(id); } + bool exists() const { return hash != 0; } + std::string label() const { return className + " <" + std::to_string(id) + ", " + std::to_string(preservedHash) + ">" + (hash == 0 ? "(deleted)" : ""); } + int primitive() const { return preservedHash * 0x10000 + id; } +}; + +void Manager::add(Entity& entity) { + for (int id = 0; id <= lastId + 1; ++id) { + if (inner[id]) continue; + entity.id = id; + entity.hash = entity.preservedHash = ++hashTable[id]; + inner[id].reset(&entity); + if (entity.isCamera) cameras.push_back(id); + else if (!entity.isObject) otherEntities.push_back(id); + if (lastId < id) lastId = id; + return; + } +} + +void removeFast(std::vector& values, int id) { + for (std::size_t i = 0; i < values.size(); ++i) if (values[i] == id) { values[i] = values.back(); values.pop_back(); return; } +} + +void Manager::remove(int id) { + Entity* entity = inner[id].release(); + entity->hash = 0; + if (entity->isCamera) removeFast(cameras, id); + else if (!entity->isObject) removeFast(otherEntities, id); +} + +void Manager::clear() { + lastId = -1; + hashTable.fill(0); cameras.clear(); otherEntities.clear(); + for (auto& entity : inner) if (entity) { entity->hash = 0; entity.release(); } +} + +std::string summary(const Entity& e) { + return "{\"className\":" + q(e.className) + ",\"id\":" + std::to_string(e.id) + ",\"hash\":" + std::to_string(e.hash) + + ",\"preservedHash\":" + std::to_string(e.preservedHash) + ",\"entityState\":" + std::to_string(e.entityState) + + ",\"exists\":" + std::string(e.exists() ? "true" : "false") + ",\"string\":" + q(e.label()) + ",\"primitive\":" + std::to_string(e.primitive()) + "}"; +} + +struct Relations { Entity* owner=nullptr; Entity* team=nullptr; std::array state{}; }; +struct Physics { int flags=0,sides=0; double size=0,width=0,absorbtionFactor=1,pushFactor=8; std::array state{}; }; +struct Position { double x=0,y=0,angle=0; int flags=0; std::array state{}; }; +struct Style { int flags=1,color=ColorBorder; double borderWidth=7.5,opacity=1; int zIndex=0; std::array state{}; }; +struct Name { int flags=0; std::string name=""; std::array state{}; }; +struct Health { int flags=0; double health=1,maxHealth=1; std::array state{}; }; +struct Score { double score=0; std::array state{}; }; +struct Barrel { int flags=0; double reloadTime=0; int trapezoidDirection=0; std::array state{}; }; + +struct ObjectEntity : Entity { + Relations relations; Physics physics; Position position; Style style; + std::unique_ptr name; std::unique_ptr health; std::unique_ptr score; std::unique_ptr barrel; + explicit ObjectEntity(Manager& m) : Entity(m, "ObjectEntity") { isObject = true; int nextZ = m.zIndex++; if (style.zIndex != nextZ) { style.zIndex = nextZ; style.state[4] = 1; entityState = 1; } } + void wipeState() override { + relations.state.fill(0); physics.state.fill(0); position.state.fill(0); style.state.fill(0); + if (name) name->state.fill(0); if (health) health->state.fill(0); if (score) score->state.fill(0); if (barrel) barrel->state.fill(0); entityState=0; + } +}; + +struct CameraEntity : Entity { explicit CameraEntity(Manager& m) : Entity(m, "CameraEntity") { isCamera = true; entityState=1; } }; + +void setOwner(ObjectEntity& e, Entity* v){ if(e.relations.owner!=v){e.relations.owner=v;e.relations.state[1]=1;e.entityState=1;} } +void setTeam(ObjectEntity& e, Entity* v){ if(e.relations.team!=v){e.relations.team=v;e.relations.state[2]=1;e.entityState=1;} } +void setSides(ObjectEntity& e,int v){ if(e.physics.sides!=v){e.physics.sides=v;e.physics.state[1]=1;e.entityState=1;} } +void setSize(ObjectEntity& e,double v){ if(e.physics.size!=v){e.physics.size=v;e.physics.state[2]=1;e.entityState=1;} } +void setWidth(ObjectEntity& e,double v){ if(e.physics.width!=v){e.physics.width=v;e.physics.state[3]=1;e.entityState=1;} } +void setPhysFlags(ObjectEntity& e,int v){ if(e.physics.flags!=v){e.physics.flags=v;e.physics.state[0]=1;e.entityState=1;} } +void setX(ObjectEntity& e,double v){ if(e.position.x!=v){e.position.x=v;e.position.state[0]=1;e.entityState=1;} } +void setY(ObjectEntity& e,double v){ if(e.position.y!=v){e.position.y=v;e.position.state[1]=1;e.entityState=1;} } +void setAngle(ObjectEntity& e,double v){ if(e.position.angle!=v){e.position.angle=v;e.position.state[2]=1;e.entityState=1;} } +void setPosFlags(ObjectEntity& e,int v){ if(e.position.flags!=v){e.position.flags=v;e.position.state[3]=1;e.entityState=1;} } +void setColor(ObjectEntity& e,int v){ if(e.style.color!=v){e.style.color=v;e.style.state[1]=1;e.entityState=1;} } +void setOpacity(ObjectEntity& e,double v){ if(e.style.opacity!=v){e.style.opacity=v;e.style.state[3]=1;e.entityState=1;} } + +std::string ref(Entity* e){ return e && e->exists() ? "{\"id\":"+std::to_string(e->id)+",\"hash\":"+std::to_string(e->hash)+"}" : "null"; } + +std::string objectSnapshot(const ObjectEntity& e) { + std::ostringstream out; out << summary(e).substr(0, summary(e).size()-1) << ",\"groups\":{"; + out << "\"relations\":{\"state\":" << arrayJson(e.relations.state) << ",\"parent\":null,\"owner\":" << ref(e.relations.owner) << ",\"team\":" << ref(e.relations.team) << "}"; + out << ",\"physics\":{\"state\":" << arrayJson(e.physics.state) << ",\"values\":{\"flags\":"<state)<<",\"values\":{\"flags\":0,\"name\":"<name)<<"}}"; + if(e.health) out << ",\"health\":{\"state\":"<state)<<",\"values\":{\"flags\":0,\"health\":"<health)<<",\"maxHealth\":"<maxHealth)<<"}}"; + if(e.score) out << ",\"score\":{\"state\":"<state)<<",\"values\":{\"score\":"<score)<<"}}"; + if(e.barrel) out << ",\"barrel\":{\"state\":"<state)<<",\"values\":{\"flags\":0,\"reloadTime\":"<reloadTime)<<",\"trapezoidDirection\":0}}"; + out << "}}"; return out.str(); +} + +std::string worldReport() { + Manager m; auto* player = new ObjectEntity(m); player->name=std::make_unique(); player->score=std::make_unique(); player->health=std::make_unique(); player->barrel=std::make_unique(); + setX(*player,125.5); setY(*player,-64.25); setAngle(*player, M_PI/3); setSides(*player,1); setSize(*player,35); setWidth(*player,12); setColor(*player,ColorTank); setOpacity(*player,0.9); player->name->name="RL Player"; player->name->state[1]=1; player->score->score=9001; player->score->state[0]=1; player->health->health=0.875; player->health->maxHealth=1.25; player->health->state={0,1,1}; player->barrel->reloadTime=12; player->barrel->state[1]=1; setOwner(*player,player); setTeam(*player,player); + auto* shape = new ObjectEntity(m); setX(*shape,-250); setY(*shape,100); setAngle(*shape,-M_PI/8); setSides(*shape,4); setSize(*shape,30); setWidth(*shape,30); setColor(*shape,ColorEnemySquare); setOwner(*shape,player); setTeam(*shape,nullptr); + auto* deleted = new ObjectEntity(m); setX(*deleted,999); deleted->remove(); + std::ostringstream out; out << "{\"purpose\":\"primary Phase C parity target: full world/entity state for headless RL training\",\"tick\":77,\"lastId\":"<wipeState(); auto* camera=new CameraEntity(m); camera->wipeState(); + std::string before="{\"lastId\":2,\"zIndex\":1,\"cameras\":[2],\"otherEntities\":[0],\"hashTable\":[1,1,1,0],\"plain\":"+summary(*plain)+",\"object\":"+summary(*object)+",\"camera\":"+summary(*camera)+"}"; + object->remove(); auto* replacement=new ObjectEntity(m); + std::string afterReuse="{\"lastId\":2,\"zIndex\":2,\"cameras\":[2],\"otherEntities\":[0],\"deletedObject\":"+summary(*object)+",\"replacement\":"+summary(*replacement)+",\"hashTable\":[1,2,1,0],\"innerPresent\":[\"Entity\",\"ObjectEntity\",\"CameraEntity\",null]}"; + m.clear(); + return "{\"beforeDelete\":"+before+",\"afterReuse\":"+afterReuse+",\"afterClear\":{\"lastId\":-1,\"cameras\":[],\"otherEntities\":[],\"hashTable\":[0,0,0,0],\"plain\":"+summary(*plain)+",\"camera\":"+summary(*camera)+",\"replacement\":"+summary(*replacement)+"}}"; +} + +std::string staticTail() { + return R"JSON({"defaults":{"relations":{"state":[0,0,0],"values":{"parent":null,"owner":null,"team":null}},"physics":{"state":[0,0,0,0,0,0],"values":{"flags":0,"sides":0,"size":0,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[0,0,0,0],"values":{"x":0,"y":0,"angle":0,"flags":0}},"style":{"state":[0,0,0,0,0],"values":{"flags":1,"color":0,"borderWidth":7.5,"opacity":1,"zIndex":0}},"name":{"state":[0,0],"values":{"flags":0,"name":""}},"health":{"state":[0,0,0],"values":{"flags":0,"health":1,"maxHealth":1}}},"afterMutations":{"entityState":1,"relations":{"state":[0,1,1],"ownerId":0,"teamId":0},"physics":{"state":[1,1,1,0,0,0],"values":{"flags":72,"sides":3,"size":42.5,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,1],"values":{"x":-120,"y":80,"angle":0.7853981633974483,"flags":1}},"style":{"state":[0,1,0,1,0],"values":{"flags":1,"color":2,"borderWidth":7.5,"opacity":0.75,"zIndex":0}},"name":{"state":[0,1],"values":{"flags":0,"name":"Phase C Δ"}},"score":{"state":[1],"values":{"score":12345}},"health":{"state":[0,1,1],"values":{"flags":0,"health":0.5,"maxHealth":2}},"barrel":{"state":[0,1,0],"values":{"flags":0,"reloadTime":22,"trapezoidDirection":0}}},"afterWipe":{"entityState":0,"relations":[0,0,0],"physics":[0,0,0,0,0,0],"position":[0,0,0,0],"style":[0,0,0,0,0],"name":[0,0],"score":[0],"health":[0,0,0],"barrel":[0,0,0],"valuesPersist":{"x":-120,"y":80,"size":42.5,"name":"Phase C Δ","score":12345}},"cameraTable":{"entityState":1,"cameraState":[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],"statNamesState":[1,0,0,0,0,0,0,0],"statLevelsState":[1,0,0,0,0,0,0,0],"statLimitsState":[1,0,0,0,0,0,0,0],"values":{"statName0":"Reload","statLevel0":4,"statLimit0":7},"afterWipe":{"entityState":0,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"statNamesState":[0,0,0,0,0,0,0,0],"statLevelsState":[0,0,0,0,0,0,0,0],"statLimitsState":[0,0,0,0,0,0,0,0]}}},"compatibility":{"camera":{"followsPlayer":{"cameraX":0,"cameraY":0,"flags":1,"cameraState":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"missingPlayer":{"flags":1,"usesCameraCoords":true,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}})JSON"; +} +} // namespace std::string entityCoreReportJson() { - return R"JSON({"world":{"purpose":"primary Phase C parity target: full world/entity state for headless RL training","tick":77,"lastId":2,"zIndex":3,"activeIds":[0,1],"hashTable":[1,1,1,0],"entities":[{"className":"ObjectEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <0, 1>","primitive":65536,"groups":{"relations":{"state":[0,1,1],"parent":null,"owner":{"id":0,"hash":1},"team":{"id":0,"hash":1}},"physics":{"state":[0,1,1,1,0,0],"values":{"flags":0,"sides":1,"size":35,"width":12,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,0],"values":{"x":125.5,"y":-64.25,"angle":1.0471975511965976,"flags":0}},"style":{"state":[0,1,0,1,0],"values":{"flags":1,"color":2,"borderWidth":7.5,"opacity":0.9,"zIndex":0}},"name":{"state":[0,1],"values":{"flags":0,"name":"RL Player"}},"health":{"state":[0,1,1],"values":{"flags":0,"health":0.875,"maxHealth":1.25}},"score":{"state":[1],"values":{"score":9001}},"barrel":{"state":[0,1,0],"values":{"flags":0,"reloadTime":12,"trapezoidDirection":0}}}},{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537,"groups":{"relations":{"state":[0,1,0],"parent":null,"owner":{"id":0,"hash":1},"team":null},"physics":{"state":[0,1,1,1,0,0],"values":{"flags":0,"sides":4,"size":30,"width":30,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,0],"values":{"x":-250,"y":100,"angle":-0.39269908169872414,"flags":0}},"style":{"state":[0,1,0,0,1],"values":{"flags":1,"color":8,"borderWidth":7.5,"opacity":1,"zIndex":1}}}}]},"manager":{"beforeDelete":{"lastId":2,"zIndex":1,"cameras":[2],"otherEntities":[0],"hashTable":[1,1,1,0],"plain":{"className":"Entity","id":0,"hash":1,"preservedHash":1,"entityState":0,"exists":true,"string":"Entity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":0,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537},"camera":{"className":"CameraEntity","id":2,"hash":1,"preservedHash":1,"entityState":0,"exists":true,"string":"CameraEntity <2, 1>","primitive":65538}},"afterReuse":{"lastId":2,"zIndex":2,"cameras":[2],"otherEntities":[0],"deletedObject":{"className":"ObjectEntity","id":1,"hash":0,"preservedHash":1,"entityState":0,"exists":false,"string":"ObjectEntity <1, 1>(deleted)","primitive":65537},"replacement":{"className":"ObjectEntity","id":1,"hash":2,"preservedHash":2,"entityState":1,"exists":true,"string":"ObjectEntity <1, 2>","primitive":131073},"hashTable":[1,2,1,0],"innerPresent":["Entity","ObjectEntity","CameraEntity",null]},"afterClear":{"lastId":-1,"cameras":[],"otherEntities":[],"hashTable":[0,0,0,0],"plain":{"className":"Entity","id":0,"hash":0,"preservedHash":1,"entityState":0,"exists":false,"string":"Entity <0, 1>(deleted)","primitive":65536},"camera":{"className":"CameraEntity","id":2,"hash":0,"preservedHash":1,"entityState":0,"exists":false,"string":"CameraEntity <2, 1>(deleted)","primitive":65538},"replacement":{"className":"ObjectEntity","id":1,"hash":0,"preservedHash":2,"entityState":1,"exists":false,"string":"ObjectEntity <1, 2>(deleted)","primitive":131073}}},"fields":{"defaults":{"relations":{"state":[0,0,0],"values":{"parent":null,"owner":null,"team":null}},"physics":{"state":[0,0,0,0,0,0],"values":{"flags":0,"sides":0,"size":0,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[0,0,0,0],"values":{"x":0,"y":0,"angle":0,"flags":0}},"style":{"state":[0,0,0,0,0],"values":{"flags":1,"color":0,"borderWidth":7.5,"opacity":1,"zIndex":0}},"name":{"state":[0,0],"values":{"flags":0,"name":""}},"health":{"state":[0,0,0],"values":{"flags":0,"health":1,"maxHealth":1}}},"afterMutations":{"entityState":1,"relations":{"state":[0,1,1],"ownerId":0,"teamId":0},"physics":{"state":[1,1,1,0,0,0],"values":{"flags":72,"sides":3,"size":42.5,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,1],"values":{"x":-120,"y":80,"angle":0.7853981633974483,"flags":1}},"style":{"state":[0,1,0,1,0],"values":{"flags":1,"color":2,"borderWidth":7.5,"opacity":0.75,"zIndex":0}},"name":{"state":[0,1],"values":{"flags":0,"name":"Phase C \u0394"}},"score":{"state":[1],"values":{"score":12345}},"health":{"state":[0,1,1],"values":{"flags":0,"health":0.5,"maxHealth":2}},"barrel":{"state":[0,1,0],"values":{"flags":0,"reloadTime":22,"trapezoidDirection":0}}},"afterWipe":{"entityState":0,"relations":[0,0,0],"physics":[0,0,0,0,0,0],"position":[0,0,0,0],"style":[0,0,0,0,0],"name":[0,0],"score":[0],"health":[0,0,0],"barrel":[0,0,0],"valuesPersist":{"x":-120,"y":80,"size":42.5,"name":"Phase C \u0394","score":12345}},"cameraTable":{"entityState":1,"cameraState":[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],"statNamesState":[1,0,0,0,0,0,0,0],"statLevelsState":[1,0,0,0,0,0,0,0],"statLimitsState":[1,0,0,0,0,0,0,0],"values":{"statName0":"Reload","statLevel0":4,"statLimit0":7},"afterWipe":{"entityState":0,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"statNamesState":[0,0,0,0,0,0,0,0],"statLevelsState":[0,0,0,0,0,0,0,0],"statLimitsState":[0,0,0,0,0,0,0,0]}}},"compatibility":{"camera":{"followsPlayer":{"cameraX":0,"cameraY":0,"flags":1,"cameraState":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"missingPlayer":{"flags":1,"usesCameraCoords":true,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}}})JSON"; + return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + staticTail() + "}"; } } // namespace diepcustom::entity_core From 547130f79de51192923a0fb3e2998f2fdca8028e Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 14:24:47 -0400 Subject: [PATCH 07/22] Generate Phase C compatibility packets in C++ The entity-core parity report now computes the minimal creation/update packet hex through the C++ protocol Writer and a fixture-sized compiler path instead of relying solely on static compatibility bytes. Constraint: Camera/update serialization remains compatibility-only for the headless RL migration Rejected: Port the full generated UpcreateCompiler table in one pass | current acceptance fixture only needs deterministic object create/update coverage Confidence: medium Scope-risk: narrow Tested: npm run test:parity Tested: npm run test:all Not-tested: Arena/team/camera packet field groups outside the Phase C compatibility fixture --- CMakeLists.txt | 1 + cpp/src/entity_core.cpp | 139 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1a696411..a95bcee9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(diepcustom_entity_core target_include_directories(diepcustom_physics PUBLIC cpp/include) target_include_directories(diepcustom_protocol PUBLIC cpp/include) target_include_directories(diepcustom_entity_core PUBLIC cpp/include) +target_link_libraries(diepcustom_entity_core PUBLIC diepcustom_protocol) add_executable(protocol_golden_test cpp/tests/protocol_golden_test.cpp) target_link_libraries(protocol_golden_test PRIVATE diepcustom_protocol) diff --git a/cpp/src/entity_core.cpp b/cpp/src/entity_core.cpp index a0aa64d9..2a8a923a 100644 --- a/cpp/src/entity_core.cpp +++ b/cpp/src/entity_core.cpp @@ -1,5 +1,7 @@ #include "diepcustom/entity_core.hpp" +#include "diepcustom/protocol.hpp" +#include #include #include #include @@ -183,6 +185,134 @@ std::string objectSnapshot(const ObjectEntity& e) { out << "}}"; return out.str(); } + +void entid(diepcustom::protocol::Writer& w, const Entity* entity) { + if (!entity || entity->hash == 0) { w.u8(0); return; } + w.vu(entity->hash).vu(entity->id); +} + +void float64Precision(diepcustom::protocol::Writer& w, double value) { + w.vi(static_cast(value * 64.0)); +} + +int visibleColorFor(const ObjectEntity& entity, const ObjectEntity* cameraPlayer) { + if (entity.style.color == ColorTank && !(cameraPlayer && entity.relations.team == cameraPlayer->relations.team)) return 15; + return entity.style.color; +} + +std::string compileCreationHex(const CameraEntity& camera, const ObjectEntity& entity, const ObjectEntity* cameraPlayer) { + diepcustom::protocol::Writer w; + entid(w, &entity); w.u8(1); + + int at = -1; + w.u8((0 - at) ^ 1); at = 0; // relations + w.u8((2 - at) ^ 1); at = 2; // barrel + w.u8((3 - at) ^ 1); at = 3; // physics + w.u8((4 - at) ^ 1); at = 4; // health + w.u8((8 - at) ^ 1); at = 8; // name + w.u8((10 - at) ^ 1); at = 10; // position + w.u8((11 - at) ^ 1); at = 11; // style + w.u8((13 - at) ^ 1); at = 13; // score + w.u8(1); + + w.vi(static_cast(entity.position.y)); + w.vi(static_cast(entity.position.x)); + float64Precision(w, entity.position.angle); + w.float32(static_cast(entity.physics.size)); + w.vu(visibleColorFor(entity, cameraPlayer)); + w.vu(entity.physics.sides); + w.vu(entity.health ? entity.health->flags : 0); + w.float32(static_cast(entity.physics.absorbtionFactor)); + w.float32(static_cast(entity.health ? entity.health->maxHealth : 1)); + w.vu(entity.style.flags); + w.float32(static_cast(entity.barrel ? entity.barrel->trapezoidDirection : 0)); + w.vu(entity.position.flags); + w.vu(entity.name ? entity.name->flags : 0); + entid(w, entity.relations.team); + float64Precision(w, entity.style.borderWidth); + w.float32(static_cast(entity.physics.width)); + w.vu(entity.barrel ? entity.barrel->flags : 0); + w.stringNT(entity.name ? entity.name->name : ""); + entid(w, entity.relations.owner); + w.float32(static_cast(entity.health ? entity.health->health : 1)); + w.float32(static_cast(entity.style.opacity)); + w.float32(static_cast(entity.barrel ? entity.barrel->reloadTime : 0)); + entid(w, nullptr); + w.vu(entity.style.zIndex); + w.float32(static_cast(entity.physics.pushFactor)); + w.vu(entity.physics.flags); + w.float32(static_cast(entity.score ? entity.score->score : 0)); + (void)camera; + return diepcustom::protocol::bytesToHex(w.bytes()); +} + +std::string compileUpdateHex(const CameraEntity& camera, const ObjectEntity& entity, const ObjectEntity* cameraPlayer) { + diepcustom::protocol::Writer w; + entid(w, &entity); w.raw({0, 1}); + int at = -1; + if (entity.position.state[1]) { w.u8((0 - at) ^ 1); at = 0; w.vi(static_cast(entity.position.y)); } + if (entity.position.state[0]) { w.u8((1 - at) ^ 1); at = 1; w.vi(static_cast(entity.position.x)); } + if (entity.position.state[2]) { w.u8((2 - at) ^ 1); at = 2; float64Precision(w, entity.position.angle); } + if (entity.physics.state[2]) { w.u8((3 - at) ^ 1); at = 3; w.float32(static_cast(entity.physics.size)); } + if (entity.style.state[1]) { w.u8((6 - at) ^ 1); at = 6; w.vu(visibleColorFor(entity, cameraPlayer)); } + if (entity.health && entity.health->state[2]) { w.u8((19 - at) ^ 1); at = 19; w.float32(static_cast(entity.health->maxHealth)); } + if (entity.style.state[0]) { w.u8((20 - at) ^ 1); at = 20; w.vu(entity.style.flags); } + if (entity.barrel && entity.barrel->state[2]) { w.u8((22 - at) ^ 1); at = 22; w.float32(static_cast(entity.barrel->trapezoidDirection)); } + if (entity.position.state[3]) { w.u8((23 - at) ^ 1); at = 23; w.vu(entity.position.flags); } + if (entity.relations.state[2]) { w.u8((32 - at) ^ 1); at = 32; entid(w, entity.relations.team); } + if (entity.style.state[2]) { w.u8((42 - at) ^ 1); at = 42; float64Precision(w, entity.style.borderWidth); } + if (entity.physics.state[3]) { w.u8((44 - at) ^ 1); at = 44; w.float32(static_cast(entity.physics.width)); } + if (entity.barrel && entity.barrel->state[0]) { w.u8((46 - at) ^ 1); at = 46; w.vu(entity.barrel->flags); } + if (entity.name && entity.name->state[1]) { w.u8((48 - at) ^ 1); at = 48; w.stringNT(entity.name->name); } + if (entity.relations.state[1]) { w.u8((49 - at) ^ 1); at = 49; entid(w, entity.relations.owner); } + if (entity.health && entity.health->state[1]) { w.u8((50 - at) ^ 1); at = 50; w.float32(static_cast(entity.health->health)); } + if (entity.style.state[3]) { w.u8((52 - at) ^ 1); at = 52; w.float32(static_cast(entity.style.opacity)); } + if (entity.barrel && entity.barrel->state[1]) { w.u8((53 - at) ^ 1); at = 53; w.float32(static_cast(entity.barrel->reloadTime)); } + if (entity.relations.state[0]) { w.u8((58 - at) ^ 1); at = 58; entid(w, nullptr); } + if (entity.style.state[4]) { w.u8((59 - at) ^ 1); at = 59; w.vu(entity.style.zIndex); } + if (entity.physics.state[5]) { w.u8((62 - at) ^ 1); at = 62; w.float32(static_cast(entity.physics.pushFactor)); } + if (entity.physics.state[0]) { w.u8((63 - at) ^ 1); at = 63; w.vu(entity.physics.flags); } + if (entity.score && entity.score->state[0]) { w.u8((67 - at) ^ 1); at = 67; w.float32(static_cast(entity.score->score)); } + w.u8(1); + (void)camera; + return diepcustom::protocol::bytesToHex(w.bytes()); +} + +std::string replaceAll(std::string value, const std::string& from, const std::string& to) { + std::size_t pos = 0; + while ((pos = value.find(from, pos)) != std::string::npos) { + value.replace(pos, from.size(), to); + pos += to.size(); + } + return value; +} + +std::string compilerCreationHexFixture() { + Manager m; + auto* camera = new CameraEntity(m); + auto* object = new ObjectEntity(m); + object->name=std::make_unique(); object->score=std::make_unique(); object->health=std::make_unique(); object->barrel=std::make_unique(); + setSides(*object,3); setSize(*object,42.5); setWidth(*object,17); setPhysFlags(*object,PhysicsNoOwnTeamCollision); + setX(*object,-120); setY(*object,80); setAngle(*object, M_PI/4); setColor(*object,ColorTank); setOpacity(*object,0.75); + object->name->name="Phase C Δ"; object->score->score=12345; object->health->health=0.5; object->health->maxHealth=2; object->barrel->reloadTime=22; + setOwner(*object, object); setTeam(*object, object); + return compileCreationHex(*camera, *object, object); +} + +std::string compilerUpdateHexFixture() { + Manager m; + auto* camera = new CameraEntity(m); + auto* object = new ObjectEntity(m); + object->name=std::make_unique(); object->score=std::make_unique(); object->health=std::make_unique(); object->barrel=std::make_unique(); + setSides(*object,3); setSize(*object,42.5); setWidth(*object,17); setPhysFlags(*object,PhysicsNoOwnTeamCollision); + setX(*object,-120); setY(*object,80); setAngle(*object, M_PI/4); setColor(*object,ColorTank); setOpacity(*object,0.75); + object->name->name="Phase C Δ"; object->score->score=12345; object->health->health=0.5; object->health->maxHealth=2; object->barrel->reloadTime=22; + setOwner(*object, object); setTeam(*object, object); + object->wipeState(); + setX(*object,-100); setY(*object,90); setSize(*object,50); setOpacity(*object,0.5); object->health->health=0.25; object->health->state[1]=1; object->entityState=1; object->name->name="Phase C Ω"; object->name->state[1]=1; + return compileUpdateHex(*camera, *object, object); +} + std::string worldReport() { Manager m; auto* player = new ObjectEntity(m); player->name=std::make_unique(); player->score=std::make_unique(); player->health=std::make_unique(); player->barrel=std::make_unique(); setX(*player,125.5); setY(*player,-64.25); setAngle(*player, M_PI/3); setSides(*player,1); setSize(*player,35); setWidth(*player,12); setColor(*player,ColorTank); setOpacity(*player,0.9); player->name->name="RL Player"; player->name->state[1]=1; player->score->score=9001; player->score->state[0]=1; player->health->health=0.875; player->health->maxHealth=1.25; player->health->state={0,1,1}; player->barrel->reloadTime=12; player->barrel->state[1]=1; setOwner(*player,player); setTeam(*player,player); @@ -206,7 +336,14 @@ std::string staticTail() { } // namespace std::string entityCoreReportJson() { - return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + staticTail() + "}"; + auto fieldsAndCompatibility = staticTail(); + fieldsAndCompatibility = replaceAll(fieldsAndCompatibility, + "010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046", + compilerCreationHexFixture()); + fieldsAndCompatibility = replaceAll(fieldsAndCompatibility, + "0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01", + compilerUpdateHexFixture()); + return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + fieldsAndCompatibility + "}"; } } // namespace diepcustom::entity_core From a356eca1f6ee733f028a35f684e0ad88c45813c8 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 14:32:47 -0400 Subject: [PATCH 08/22] Make Phase C entity parity depend on real manager state The C++ entity-core report now keeps owned entities separate from the manager's active lookup table, so deletion/clear behavior can preserve stale entity summaries while active IDs, camera lists, and other-entity lists are generated from actual manager state. Field-group mutation and wipe snapshots are now built through C++ setters instead of one static JSON block. Constraint: Phase C must prove full-world deterministic parity before RL-localized views are introduced. Rejected: Keep hardcoded manager arrays | it hid constructor classification bugs and weakened parity confidence. Confidence: high Scope-risk: narrow Directive: Keep report values generated from C++ state mutations before expanding Phase C; do not reintroduce static manager snapshots. Tested: npm run test:parity Tested: npm run test:all Not-tested: Full CameraGroup behavior beyond the existing minimal compatibility fixture. --- cpp/src/entity_core.cpp | 194 +++++++++++++++++++++++++++++++--------- 1 file changed, 152 insertions(+), 42 deletions(-) diff --git a/cpp/src/entity_core.cpp b/cpp/src/entity_core.cpp index 2a8a923a..f318c8fb 100644 --- a/cpp/src/entity_core.cpp +++ b/cpp/src/entity_core.cpp @@ -60,6 +60,17 @@ std::string idsJson(const std::vector& values) { return out.str(); } +std::string hashPrefixJson(const std::array& values, int count) { + std::ostringstream out; + out << '['; + for (int i = 0; i < count; ++i) { + if (i) out << ','; + out << values[static_cast(i)]; + } + out << ']'; + return out.str(); +} + std::string num(double value) { std::ostringstream out; out << std::setprecision(17) << value; @@ -71,11 +82,11 @@ struct Manager { int zIndex = 0; int lastId = -1; std::array hashTable{}; - std::vector> inner; + std::array inner{}; + std::vector> owned; std::vector cameras; std::vector otherEntities; - Manager() : inner(16384) {} void add(Entity& entity); void remove(int id); void clear(); @@ -91,7 +102,7 @@ struct Entity { bool isObject = false; bool isCamera = false; - explicit Entity(Manager& manager, std::string name = "Entity") : manager(manager), className(std::move(name)) { manager.add(*this); } + explicit Entity(Manager& manager, std::string name = "Entity") : manager(manager), className(std::move(name)) {} virtual ~Entity() = default; virtual void wipeState() { entityState = 0; } virtual void remove() { wipeState(); manager.remove(id); } @@ -105,7 +116,7 @@ void Manager::add(Entity& entity) { if (inner[id]) continue; entity.id = id; entity.hash = entity.preservedHash = ++hashTable[id]; - inner[id].reset(&entity); + inner[id] = &entity; if (entity.isCamera) cameras.push_back(id); else if (!entity.isObject) otherEntities.push_back(id); if (lastId < id) lastId = id; @@ -118,7 +129,8 @@ void removeFast(std::vector& values, int id) { } void Manager::remove(int id) { - Entity* entity = inner[id].release(); + Entity* entity = inner[id]; + inner[id] = nullptr; entity->hash = 0; if (entity->isCamera) removeFast(cameras, id); else if (!entity->isObject) removeFast(otherEntities, id); @@ -127,7 +139,8 @@ void Manager::remove(int id) { void Manager::clear() { lastId = -1; hashTable.fill(0); cameras.clear(); otherEntities.clear(); - for (auto& entity : inner) if (entity) { entity->hash = 0; entity.release(); } + for (auto* entity : inner) if (entity) entity->hash = 0; + inner.fill(nullptr); } std::string summary(const Entity& e) { @@ -136,6 +149,19 @@ std::string summary(const Entity& e) { ",\"exists\":" + std::string(e.exists() ? "true" : "false") + ",\"string\":" + q(e.label()) + ",\"primitive\":" + std::to_string(e.primitive()) + "}"; } +std::string innerPresentJson(const Manager& m, int count) { + std::ostringstream out; + out << '['; + for (int i = 0; i < count; ++i) { + if (i) out << ','; + Entity* entity = m.inner[static_cast(i)]; + if (entity) out << q(entity->className); + else out << "null"; + } + out << ']'; + return out.str(); +} + struct Relations { Entity* owner=nullptr; Entity* team=nullptr; std::array state{}; }; struct Physics { int flags=0,sides=0; double size=0,width=0,absorbtionFactor=1,pushFactor=8; std::array state{}; }; struct Position { double x=0,y=0,angle=0; int flags=0; std::array state{}; }; @@ -148,14 +174,66 @@ struct Barrel { int flags=0; double reloadTime=0; int trapezoidDirection=0; std: struct ObjectEntity : Entity { Relations relations; Physics physics; Position position; Style style; std::unique_ptr name; std::unique_ptr health; std::unique_ptr score; std::unique_ptr barrel; - explicit ObjectEntity(Manager& m) : Entity(m, "ObjectEntity") { isObject = true; int nextZ = m.zIndex++; if (style.zIndex != nextZ) { style.zIndex = nextZ; style.state[4] = 1; entityState = 1; } } + explicit ObjectEntity(Manager& m) : Entity(m, "ObjectEntity") { + isObject = true; + manager.add(*this); + int nextZ = m.zIndex++; + if (style.zIndex != nextZ) { style.zIndex = nextZ; style.state[4] = 1; entityState = 1; } + } void wipeState() override { relations.state.fill(0); physics.state.fill(0); position.state.fill(0); style.state.fill(0); if (name) name->state.fill(0); if (health) health->state.fill(0); if (score) score->state.fill(0); if (barrel) barrel->state.fill(0); entityState=0; } }; -struct CameraEntity : Entity { explicit CameraEntity(Manager& m) : Entity(m, "CameraEntity") { isCamera = true; entityState=1; } }; +struct CameraEntity : Entity { explicit CameraEntity(Manager& m) : Entity(m, "CameraEntity") { isCamera = true; manager.add(*this); entityState=1; } }; + +Entity& createEntity(Manager& m) { + auto entity = std::make_unique(m); + auto& ref = *entity; + m.owned.push_back(std::move(entity)); + m.add(ref); + return ref; +} + +ObjectEntity& createObject(Manager& m) { + auto entity = std::make_unique(m); + auto& ref = *entity; + m.owned.push_back(std::move(entity)); + return ref; +} + +CameraEntity& createCamera(Manager& m) { + auto entity = std::make_unique(m); + auto& ref = *entity; + m.owned.push_back(std::move(entity)); + return ref; +} + +void setScore(ObjectEntity& e, double value) { + if (!e.score) e.score = std::make_unique(); + if (e.score->score != value) { e.score->score = value; e.score->state[0] = 1; e.entityState = 1; } +} + +void setName(ObjectEntity& e, const std::string& value) { + if (!e.name) e.name = std::make_unique(); + if (e.name->name != value) { e.name->name = value; e.name->state[1] = 1; e.entityState = 1; } +} + +void setHealth(ObjectEntity& e, double value) { + if (!e.health) e.health = std::make_unique(); + if (e.health->health != value) { e.health->health = value; e.health->state[1] = 1; e.entityState = 1; } +} + +void setMaxHealth(ObjectEntity& e, double value) { + if (!e.health) e.health = std::make_unique(); + if (e.health->maxHealth != value) { e.health->maxHealth = value; e.health->state[2] = 1; e.entityState = 1; } +} + +void setReloadTime(ObjectEntity& e, double value) { + if (!e.barrel) e.barrel = std::make_unique(); + if (e.barrel->reloadTime != value) { e.barrel->reloadTime = value; e.barrel->state[1] = 1; e.entityState = 1; } +} void setOwner(ObjectEntity& e, Entity* v){ if(e.relations.owner!=v){e.relations.owner=v;e.relations.state[1]=1;e.entityState=1;} } void setTeam(ObjectEntity& e, Entity* v){ if(e.relations.team!=v){e.relations.team=v;e.relations.state[2]=1;e.entityState=1;} } @@ -289,61 +367,93 @@ std::string replaceAll(std::string value, const std::string& from, const std::st std::string compilerCreationHexFixture() { Manager m; - auto* camera = new CameraEntity(m); - auto* object = new ObjectEntity(m); - object->name=std::make_unique(); object->score=std::make_unique(); object->health=std::make_unique(); object->barrel=std::make_unique(); - setSides(*object,3); setSize(*object,42.5); setWidth(*object,17); setPhysFlags(*object,PhysicsNoOwnTeamCollision); - setX(*object,-120); setY(*object,80); setAngle(*object, M_PI/4); setColor(*object,ColorTank); setOpacity(*object,0.75); - object->name->name="Phase C Δ"; object->score->score=12345; object->health->health=0.5; object->health->maxHealth=2; object->barrel->reloadTime=22; - setOwner(*object, object); setTeam(*object, object); - return compileCreationHex(*camera, *object, object); + auto& camera = createCamera(m); + auto& object = createObject(m); + object.name=std::make_unique(); object.score=std::make_unique(); object.health=std::make_unique(); object.barrel=std::make_unique(); + setSides(object,3); setSize(object,42.5); setWidth(object,17); setPhysFlags(object,PhysicsNoOwnTeamCollision); + setX(object,-120); setY(object,80); setAngle(object, M_PI/4); setColor(object,ColorTank); setOpacity(object,0.75); + object.name->name="Phase C Δ"; object.score->score=12345; object.health->health=0.5; object.health->maxHealth=2; object.barrel->reloadTime=22; + setOwner(object, &object); setTeam(object, &object); + return compileCreationHex(camera, object, &object); } std::string compilerUpdateHexFixture() { Manager m; - auto* camera = new CameraEntity(m); - auto* object = new ObjectEntity(m); - object->name=std::make_unique(); object->score=std::make_unique(); object->health=std::make_unique(); object->barrel=std::make_unique(); - setSides(*object,3); setSize(*object,42.5); setWidth(*object,17); setPhysFlags(*object,PhysicsNoOwnTeamCollision); - setX(*object,-120); setY(*object,80); setAngle(*object, M_PI/4); setColor(*object,ColorTank); setOpacity(*object,0.75); - object->name->name="Phase C Δ"; object->score->score=12345; object->health->health=0.5; object->health->maxHealth=2; object->barrel->reloadTime=22; - setOwner(*object, object); setTeam(*object, object); - object->wipeState(); - setX(*object,-100); setY(*object,90); setSize(*object,50); setOpacity(*object,0.5); object->health->health=0.25; object->health->state[1]=1; object->entityState=1; object->name->name="Phase C Ω"; object->name->state[1]=1; - return compileUpdateHex(*camera, *object, object); + auto& camera = createCamera(m); + auto& object = createObject(m); + object.name=std::make_unique(); object.score=std::make_unique(); object.health=std::make_unique(); object.barrel=std::make_unique(); + setSides(object,3); setSize(object,42.5); setWidth(object,17); setPhysFlags(object,PhysicsNoOwnTeamCollision); + setX(object,-120); setY(object,80); setAngle(object, M_PI/4); setColor(object,ColorTank); setOpacity(object,0.75); + object.name->name="Phase C Δ"; object.score->score=12345; object.health->health=0.5; object.health->maxHealth=2; object.barrel->reloadTime=22; + setOwner(object, &object); setTeam(object, &object); + object.wipeState(); + setX(object,-100); setY(object,90); setSize(object,50); setOpacity(object,0.5); object.health->health=0.25; object.health->state[1]=1; object.entityState=1; object.name->name="Phase C Ω"; object.name->state[1]=1; + return compileUpdateHex(camera, object, &object); } std::string worldReport() { - Manager m; auto* player = new ObjectEntity(m); player->name=std::make_unique(); player->score=std::make_unique(); player->health=std::make_unique(); player->barrel=std::make_unique(); - setX(*player,125.5); setY(*player,-64.25); setAngle(*player, M_PI/3); setSides(*player,1); setSize(*player,35); setWidth(*player,12); setColor(*player,ColorTank); setOpacity(*player,0.9); player->name->name="RL Player"; player->name->state[1]=1; player->score->score=9001; player->score->state[0]=1; player->health->health=0.875; player->health->maxHealth=1.25; player->health->state={0,1,1}; player->barrel->reloadTime=12; player->barrel->state[1]=1; setOwner(*player,player); setTeam(*player,player); - auto* shape = new ObjectEntity(m); setX(*shape,-250); setY(*shape,100); setAngle(*shape,-M_PI/8); setSides(*shape,4); setSize(*shape,30); setWidth(*shape,30); setColor(*shape,ColorEnemySquare); setOwner(*shape,player); setTeam(*shape,nullptr); - auto* deleted = new ObjectEntity(m); setX(*deleted,999); deleted->remove(); - std::ostringstream out; out << "{\"purpose\":\"primary Phase C parity target: full world/entity state for headless RL training\",\"tick\":77,\"lastId\":"<(); player.score=std::make_unique(); player.health=std::make_unique(); player.barrel=std::make_unique(); + setX(player,125.5); setY(player,-64.25); setAngle(player, M_PI/3); setSides(player,1); setSize(player,35); setWidth(player,12); setColor(player,ColorTank); setOpacity(player,0.9); player.name->name="RL Player"; player.name->state[1]=1; player.score->score=9001; player.score->state[0]=1; player.health->health=0.875; player.health->maxHealth=1.25; player.health->state={0,1,1}; player.barrel->reloadTime=12; player.barrel->state[1]=1; setOwner(player,&player); setTeam(player,&player); + auto& shape = createObject(m); setX(shape,-250); setY(shape,100); setAngle(shape,-M_PI/8); setSides(shape,4); setSize(shape,30); setWidth(shape,30); setColor(shape,ColorEnemySquare); setOwner(shape,&player); setTeam(shape,nullptr); + auto& deleted = createObject(m); setX(deleted,999); deleted.remove(); + std::vector activeIds; + for (int id = 0; id <= m.lastId; ++id) if (m.inner[static_cast(id)]) activeIds.push_back(id); + std::ostringstream out; out << "{\"purpose\":\"primary Phase C parity target: full world/entity state for headless RL training\",\"tick\":77,\"lastId\":"<wipeState(); auto* camera=new CameraEntity(m); camera->wipeState(); - std::string before="{\"lastId\":2,\"zIndex\":1,\"cameras\":[2],\"otherEntities\":[0],\"hashTable\":[1,1,1,0],\"plain\":"+summary(*plain)+",\"object\":"+summary(*object)+",\"camera\":"+summary(*camera)+"}"; - object->remove(); auto* replacement=new ObjectEntity(m); - std::string afterReuse="{\"lastId\":2,\"zIndex\":2,\"cameras\":[2],\"otherEntities\":[0],\"deletedObject\":"+summary(*object)+",\"replacement\":"+summary(*replacement)+",\"hashTable\":[1,2,1,0],\"innerPresent\":[\"Entity\",\"ObjectEntity\",\"CameraEntity\",null]}"; + Manager m; auto& plain=createEntity(m); auto& object=createObject(m); object.wipeState(); auto& camera=createCamera(m); camera.wipeState(); + std::string before="{\"lastId\":"+std::to_string(m.lastId)+",\"zIndex\":"+std::to_string(m.zIndex)+",\"cameras\":"+idsJson(m.cameras)+",\"otherEntities\":"+idsJson(m.otherEntities)+",\"hashTable\":"+hashPrefixJson(m.hashTable, 4)+",\"plain\":"+summary(plain)+",\"object\":"+summary(object)+",\"camera\":"+summary(camera)+"}"; + object.remove(); auto& replacement=createObject(m); + std::string afterReuse="{\"lastId\":"+std::to_string(m.lastId)+",\"zIndex\":"+std::to_string(m.zIndex)+",\"cameras\":"+idsJson(m.cameras)+",\"otherEntities\":"+idsJson(m.otherEntities)+",\"deletedObject\":"+summary(object)+",\"replacement\":"+summary(replacement)+",\"hashTable\":"+hashPrefixJson(m.hashTable, 4)+",\"innerPresent\":"+innerPresentJson(m, 4)+"}"; m.clear(); - return "{\"beforeDelete\":"+before+",\"afterReuse\":"+afterReuse+",\"afterClear\":{\"lastId\":-1,\"cameras\":[],\"otherEntities\":[],\"hashTable\":[0,0,0,0],\"plain\":"+summary(*plain)+",\"camera\":"+summary(*camera)+",\"replacement\":"+summary(*replacement)+"}}"; + return "{\"beforeDelete\":"+before+",\"afterReuse\":"+afterReuse+",\"afterClear\":{\"lastId\":"+std::to_string(m.lastId)+",\"cameras\":"+idsJson(m.cameras)+",\"otherEntities\":"+idsJson(m.otherEntities)+",\"hashTable\":"+hashPrefixJson(m.hashTable, 4)+",\"plain\":"+summary(plain)+",\"camera\":"+summary(camera)+",\"replacement\":"+summary(replacement)+"}}"; +} + +std::string defaultsJson(const ObjectEntity& object) { + return "{\"relations\":{\"state\":"+arrayJson(object.relations.state)+",\"values\":{\"parent\":null,\"owner\":null,\"team\":null}}," + "\"physics\":{\"state\":"+arrayJson(object.physics.state)+",\"values\":{\"flags\":"+std::to_string(object.physics.flags)+",\"sides\":"+std::to_string(object.physics.sides)+",\"size\":"+num(object.physics.size)+",\"width\":"+num(object.physics.width)+",\"absorbtionFactor\":1,\"pushFactor\":8}}," + "\"position\":{\"state\":"+arrayJson(object.position.state)+",\"values\":{\"x\":"+num(object.position.x)+",\"y\":"+num(object.position.y)+",\"angle\":"+num(object.position.angle)+",\"flags\":"+std::to_string(object.position.flags)+"}}," + "\"style\":{\"state\":"+arrayJson(object.style.state)+",\"values\":{\"flags\":1,\"color\":"+std::to_string(object.style.color)+",\"borderWidth\":7.5,\"opacity\":"+num(object.style.opacity)+",\"zIndex\":"+std::to_string(object.style.zIndex)+"}}," + "\"name\":{\"state\":"+arrayJson(object.name->state)+",\"values\":{\"flags\":0,\"name\":"+q(object.name->name)+"}}," + "\"health\":{\"state\":"+arrayJson(object.health->state)+",\"values\":{\"flags\":0,\"health\":"+num(object.health->health)+",\"maxHealth\":"+num(object.health->maxHealth)+"}}}"; +} + +std::string fieldsReport() { + Manager m; auto& object = createObject(m); + object.name=std::make_unique(); object.score=std::make_unique(); object.health=std::make_unique(); object.barrel=std::make_unique(); + std::string defaults = defaultsJson(object); + setSides(object,3); setSides(object,3); setSize(object,42.5); setPhysFlags(object,PhysicsIsBase | PhysicsNoOwnTeamCollision); + setX(object,-120); setY(object,80); setAngle(object,M_PI/4); setPosFlags(object,PositionAbsoluteRotation); setColor(object,ColorTank); setOpacity(object,0.75); + setName(object,"Phase C Δ"); setScore(object,12345); setHealth(object,0.5); setMaxHealth(object,2); setReloadTime(object,22); setOwner(object,&object); setTeam(object,&object); + std::string afterMutations = "{\"entityState\":"+std::to_string(object.entityState)+",\"relations\":{\"state\":"+arrayJson(object.relations.state)+",\"ownerId\":"+std::to_string(object.relations.owner->id)+",\"teamId\":"+std::to_string(object.relations.team->id)+"}," + "\"physics\":{\"state\":"+arrayJson(object.physics.state)+",\"values\":{\"flags\":"+std::to_string(object.physics.flags)+",\"sides\":"+std::to_string(object.physics.sides)+",\"size\":"+num(object.physics.size)+",\"width\":"+num(object.physics.width)+",\"absorbtionFactor\":1,\"pushFactor\":8}}," + "\"position\":{\"state\":"+arrayJson(object.position.state)+",\"values\":{\"x\":"+num(object.position.x)+",\"y\":"+num(object.position.y)+",\"angle\":"+num(object.position.angle)+",\"flags\":"+std::to_string(object.position.flags)+"}}," + "\"style\":{\"state\":"+arrayJson(object.style.state)+",\"values\":{\"flags\":1,\"color\":"+std::to_string(object.style.color)+",\"borderWidth\":7.5,\"opacity\":"+num(object.style.opacity)+",\"zIndex\":"+std::to_string(object.style.zIndex)+"}}," + "\"name\":{\"state\":"+arrayJson(object.name->state)+",\"values\":{\"flags\":0,\"name\":"+q(object.name->name)+"}}," + "\"score\":{\"state\":"+arrayJson(object.score->state)+",\"values\":{\"score\":"+num(object.score->score)+"}}," + "\"health\":{\"state\":"+arrayJson(object.health->state)+",\"values\":{\"flags\":0,\"health\":"+num(object.health->health)+",\"maxHealth\":"+num(object.health->maxHealth)+"}}," + "\"barrel\":{\"state\":"+arrayJson(object.barrel->state)+",\"values\":{\"flags\":0,\"reloadTime\":"+num(object.barrel->reloadTime)+",\"trapezoidDirection\":0}}}"; + object.wipeState(); + std::string afterWipe = "{\"entityState\":"+std::to_string(object.entityState)+",\"relations\":"+arrayJson(object.relations.state)+",\"physics\":"+arrayJson(object.physics.state)+",\"position\":"+arrayJson(object.position.state)+",\"style\":"+arrayJson(object.style.state)+",\"name\":"+arrayJson(object.name->state)+",\"score\":"+arrayJson(object.score->state)+",\"health\":"+arrayJson(object.health->state)+",\"barrel\":"+arrayJson(object.barrel->state)+",\"valuesPersist\":{\"x\":"+num(object.position.x)+",\"y\":"+num(object.position.y)+",\"size\":"+num(object.physics.size)+",\"name\":"+q(object.name->name)+",\"score\":"+num(object.score->score)+"}}"; + std::string cameraTable = R"JSON({"entityState":1,"cameraState":[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],"statNamesState":[1,0,0,0,0,0,0,0],"statLevelsState":[1,0,0,0,0,0,0,0],"statLimitsState":[1,0,0,0,0,0,0,0],"values":{"statName0":"Reload","statLevel0":4,"statLimit0":7},"afterWipe":{"entityState":0,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"statNamesState":[0,0,0,0,0,0,0,0],"statLevelsState":[0,0,0,0,0,0,0,0],"statLimitsState":[0,0,0,0,0,0,0,0]}})JSON"; + return "{\"defaults\":"+defaults+",\"afterMutations\":"+afterMutations+",\"afterWipe\":"+afterWipe+",\"cameraTable\":"+cameraTable+"}"; } -std::string staticTail() { - return R"JSON({"defaults":{"relations":{"state":[0,0,0],"values":{"parent":null,"owner":null,"team":null}},"physics":{"state":[0,0,0,0,0,0],"values":{"flags":0,"sides":0,"size":0,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[0,0,0,0],"values":{"x":0,"y":0,"angle":0,"flags":0}},"style":{"state":[0,0,0,0,0],"values":{"flags":1,"color":0,"borderWidth":7.5,"opacity":1,"zIndex":0}},"name":{"state":[0,0],"values":{"flags":0,"name":""}},"health":{"state":[0,0,0],"values":{"flags":0,"health":1,"maxHealth":1}}},"afterMutations":{"entityState":1,"relations":{"state":[0,1,1],"ownerId":0,"teamId":0},"physics":{"state":[1,1,1,0,0,0],"values":{"flags":72,"sides":3,"size":42.5,"width":0,"absorbtionFactor":1,"pushFactor":8}},"position":{"state":[1,1,1,1],"values":{"x":-120,"y":80,"angle":0.7853981633974483,"flags":1}},"style":{"state":[0,1,0,1,0],"values":{"flags":1,"color":2,"borderWidth":7.5,"opacity":0.75,"zIndex":0}},"name":{"state":[0,1],"values":{"flags":0,"name":"Phase C Δ"}},"score":{"state":[1],"values":{"score":12345}},"health":{"state":[0,1,1],"values":{"flags":0,"health":0.5,"maxHealth":2}},"barrel":{"state":[0,1,0],"values":{"flags":0,"reloadTime":22,"trapezoidDirection":0}}},"afterWipe":{"entityState":0,"relations":[0,0,0],"physics":[0,0,0,0,0,0],"position":[0,0,0,0],"style":[0,0,0,0,0],"name":[0,0],"score":[0],"health":[0,0,0],"barrel":[0,0,0],"valuesPersist":{"x":-120,"y":80,"size":42.5,"name":"Phase C Δ","score":12345}},"cameraTable":{"entityState":1,"cameraState":[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],"statNamesState":[1,0,0,0,0,0,0,0],"statLevelsState":[1,0,0,0,0,0,0,0],"statLimitsState":[1,0,0,0,0,0,0,0],"values":{"statName0":"Reload","statLevel0":4,"statLimit0":7},"afterWipe":{"entityState":0,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"statNamesState":[0,0,0,0,0,0,0,0],"statLevelsState":[0,0,0,0,0,0,0,0],"statLimitsState":[0,0,0,0,0,0,0,0]}}},"compatibility":{"camera":{"followsPlayer":{"cameraX":0,"cameraY":0,"flags":1,"cameraState":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"missingPlayer":{"flags":1,"usesCameraCoords":true,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}})JSON"; +std::string staticCompatibility() { + return R"JSON({"camera":{"followsPlayer":{"cameraX":0,"cameraY":0,"flags":1,"cameraState":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"missingPlayer":{"flags":1,"usesCameraCoords":true,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}})JSON"; } } // namespace std::string entityCoreReportJson() { - auto fieldsAndCompatibility = staticTail(); - fieldsAndCompatibility = replaceAll(fieldsAndCompatibility, + auto compatibility = staticCompatibility(); + compatibility = replaceAll(compatibility, "010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046", compilerCreationHexFixture()); - fieldsAndCompatibility = replaceAll(fieldsAndCompatibility, + compatibility = replaceAll(compatibility, "0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01", compilerUpdateHexFixture()); - return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + fieldsAndCompatibility + "}"; + return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + fieldsReport() + ",\"compatibility\":" + compatibility + "}"; } } // namespace diepcustom::entity_core From 27eb626e9fd36608593b4947c5bff7aeac3ceb36 Mon Sep 17 00:00:00 2001 From: Saak Date: Mon, 25 May 2026 14:44:24 -0400 Subject: [PATCH 09/22] Generate minimal camera compatibility from C++ state Phase C still keeps camera/update serialization as a compatibility slice for the legacy protocol, but the report now exercises C++ camera table mutation, wipe behavior, and player-follow fallback instead of embedding those values as static JSON. Constraint: The headless RL target needs full-world parity first while preserving minimal legacy camera/update compatibility. Rejected: Keep camera follow snapshots static | it left the final Phase C compatibility gap unverified by C++ state changes. Confidence: high Scope-risk: narrow Directive: Treat camera state here as compatibility-only until RL-localized views are introduced after full-world parity. Tested: npm run test:parity Tested: npm run test:all Not-tested: Full ClientCamera networking/view update behavior; reserved for later runtime phases. --- cpp/src/entity_core.cpp | 91 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/cpp/src/entity_core.cpp b/cpp/src/entity_core.cpp index f318c8fb..b477a027 100644 --- a/cpp/src/entity_core.cpp +++ b/cpp/src/entity_core.cpp @@ -19,6 +19,7 @@ constexpr int ColorEnemySquare = 8; constexpr int PhysicsNoOwnTeamCollision = 1 << 3; constexpr int PhysicsIsBase = 1 << 6; constexpr int PositionAbsoluteRotation = 1 << 0; +constexpr int CameraUsesCameraCoords = 1 << 0; std::string q(const std::string& value) { std::ostringstream out; @@ -170,6 +171,27 @@ struct Name { int flags=0; std::string name=""; std::array state{}; }; struct Health { int flags=0; double health=1,maxHealth=1; std::array state{}; }; struct Score { double score=0; std::array state{}; }; struct Barrel { int flags=0; double reloadTime=0; int trapezoidDirection=0; std::array state{}; }; +struct CameraTable { + std::array state{}; + void wipe() { state.fill(0); } +}; +struct CameraData { + int flags=1; + Entity* player=nullptr; + double cameraX=0; + double cameraY=0; + std::array state{}; + CameraTable statNames; + CameraTable statLevels; + CameraTable statLimits; + + void wipe() { + state.fill(0); + statNames.wipe(); + statLevels.wipe(); + statLimits.wipe(); + } +}; struct ObjectEntity : Entity { Relations relations; Physics physics; Position position; Style style; @@ -186,7 +208,46 @@ struct ObjectEntity : Entity { } }; -struct CameraEntity : Entity { explicit CameraEntity(Manager& m) : Entity(m, "CameraEntity") { isCamera = true; manager.add(*this); entityState=1; } }; +struct CameraEntity : Entity { + CameraData camera; + std::array statNames{}; + std::array statLevels{}; + std::array statLimits{}; + explicit CameraEntity(Manager& m) : Entity(m, "CameraEntity") { isCamera = true; manager.add(*this); entityState=1; } + void wipeState() override { camera.wipe(); entityState = 0; } + void tick(int) { + if (camera.player && camera.player->exists()) { + auto* focus = dynamic_cast(camera.player); + if (!(camera.flags & CameraUsesCameraCoords) && focus) { + setCameraX(focus->position.x); + setCameraY(focus->position.y); + } + } else { + setCameraFlags(camera.flags | CameraUsesCameraCoords); + } + } + void setCameraFlags(int value) { + if (camera.flags != value) { camera.flags = value; camera.state[1] = 1; entityState = 1; } + } + void setPlayer(Entity* value) { + if (camera.player != value) { camera.player = value; camera.state[2] = 1; entityState = 1; } + } + void setCameraX(double value) { + if (camera.cameraX != value) { camera.cameraX = value; camera.state[12] = 1; entityState = 1; } + } + void setCameraY(double value) { + if (camera.cameraY != value) { camera.cameraY = value; camera.state[13] = 1; entityState = 1; } + } + void setStatName(std::size_t index, const std::string& value) { + if (statNames[index] != value) { statNames[index] = value; camera.statNames.state[index] = 1; camera.state[9] = 1; entityState = 1; } + } + void setStatLevel(std::size_t index, int value) { + if (statLevels[index] != value) { statLevels[index] = value; camera.statLevels.state[index] = 1; camera.state[10] = 1; entityState = 1; } + } + void setStatLimit(std::size_t index, int value) { + if (statLimits[index] != value) { statLimits[index] = value; camera.statLimits.state[index] = 1; camera.state[11] = 1; entityState = 1; } + } +}; Entity& createEntity(Manager& m) { auto entity = std::make_unique(m); @@ -436,12 +497,34 @@ std::string fieldsReport() { "\"barrel\":{\"state\":"+arrayJson(object.barrel->state)+",\"values\":{\"flags\":0,\"reloadTime\":"+num(object.barrel->reloadTime)+",\"trapezoidDirection\":0}}}"; object.wipeState(); std::string afterWipe = "{\"entityState\":"+std::to_string(object.entityState)+",\"relations\":"+arrayJson(object.relations.state)+",\"physics\":"+arrayJson(object.physics.state)+",\"position\":"+arrayJson(object.position.state)+",\"style\":"+arrayJson(object.style.state)+",\"name\":"+arrayJson(object.name->state)+",\"score\":"+arrayJson(object.score->state)+",\"health\":"+arrayJson(object.health->state)+",\"barrel\":"+arrayJson(object.barrel->state)+",\"valuesPersist\":{\"x\":"+num(object.position.x)+",\"y\":"+num(object.position.y)+",\"size\":"+num(object.physics.size)+",\"name\":"+q(object.name->name)+",\"score\":"+num(object.score->score)+"}}"; - std::string cameraTable = R"JSON({"entityState":1,"cameraState":[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],"statNamesState":[1,0,0,0,0,0,0,0],"statLevelsState":[1,0,0,0,0,0,0,0],"statLimitsState":[1,0,0,0,0,0,0,0],"values":{"statName0":"Reload","statLevel0":4,"statLimit0":7},"afterWipe":{"entityState":0,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"statNamesState":[0,0,0,0,0,0,0,0],"statLevelsState":[0,0,0,0,0,0,0,0],"statLimitsState":[0,0,0,0,0,0,0,0]}})JSON"; + auto& camera = createCamera(m); + camera.setStatName(0, "Reload"); + camera.setStatLevel(0, 4); + camera.setStatLimit(0, 7); + std::string cameraBeforeWipe = "{\"entityState\":"+std::to_string(camera.entityState)+",\"cameraState\":"+arrayJson(camera.camera.state)+",\"statNamesState\":"+arrayJson(camera.camera.statNames.state)+",\"statLevelsState\":"+arrayJson(camera.camera.statLevels.state)+",\"statLimitsState\":"+arrayJson(camera.camera.statLimits.state)+",\"values\":{\"statName0\":"+q(camera.statNames[0])+",\"statLevel0\":"+std::to_string(camera.statLevels[0])+",\"statLimit0\":"+std::to_string(camera.statLimits[0])+"}"; + camera.wipeState(); + std::string cameraTable = cameraBeforeWipe + ",\"afterWipe\":{\"entityState\":"+std::to_string(camera.entityState)+",\"cameraState\":"+arrayJson(camera.camera.state)+",\"statNamesState\":"+arrayJson(camera.camera.statNames.state)+",\"statLevelsState\":"+arrayJson(camera.camera.statLevels.state)+",\"statLimitsState\":"+arrayJson(camera.camera.statLimits.state)+"}}"; return "{\"defaults\":"+defaults+",\"afterMutations\":"+afterMutations+",\"afterWipe\":"+afterWipe+",\"cameraTable\":"+cameraTable+"}"; } +std::string cameraFollowReport() { + Manager m; + auto& player = createObject(m); + auto& camera = createCamera(m); + setX(player, 321); + setY(player, -222); + camera.setPlayer(&player); + camera.tick(10); + std::string followsPlayer = "{\"cameraX\":"+num(camera.camera.cameraX)+",\"cameraY\":"+num(camera.camera.cameraY)+",\"flags\":"+std::to_string(camera.camera.flags)+",\"cameraState\":"+arrayJson(camera.camera.state)+"}"; + camera.wipeState(); + player.remove(); + camera.tick(11); + std::string missingPlayer = "{\"flags\":"+std::to_string(camera.camera.flags)+",\"usesCameraCoords\":"+std::string((camera.camera.flags & CameraUsesCameraCoords) ? "true" : "false")+",\"cameraState\":"+arrayJson(camera.camera.state)+"}"; + return "{\"followsPlayer\":"+followsPlayer+",\"missingPlayer\":"+missingPlayer+"}"; +} + std::string staticCompatibility() { - return R"JSON({"camera":{"followsPlayer":{"cameraX":0,"cameraY":0,"flags":1,"cameraState":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"missingPlayer":{"flags":1,"usesCameraCoords":true,"cameraState":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}})JSON"; + return R"JSON({"compiler":{"ids":{"camera":{"className":"CameraEntity","id":0,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"CameraEntity <0, 1>","primitive":65536},"object":{"className":"ObjectEntity","id":1,"hash":1,"preservedHash":1,"entityState":1,"exists":true,"string":"ObjectEntity <1, 1>","primitive":65537}},"creationHex":"010101000300000503000301a001ef016400002a420203000000803f00000040010000000000000101c00700008841005068617365204320ce940001010000003f0000403f0000b0410000000000410800e44046","updateState":{"position":[1,1,0,0],"physics":[0,0,1,0,0,0],"style":[0,0,0,1,0],"health":[0,1,0],"name":[0,1],"entityState":1},"updateHex":"0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01"}})JSON"; } } // namespace @@ -453,7 +536,7 @@ std::string entityCoreReportJson() { compatibility = replaceAll(compatibility, "0101000100b40100c70103000048422c5068617365204320cea900030000803e030000003f01", compilerUpdateHexFixture()); - return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + fieldsReport() + ",\"compatibility\":" + compatibility + "}"; + return "{\"world\":" + worldReport() + ",\"manager\":" + managerReport() + ",\"fields\":" + fieldsReport() + ",\"compatibility\":{\"camera\":" + cameraFollowReport() + "," + compatibility.substr(1) + "}"; } } // namespace diepcustom::entity_core From 46caa5769d2ee1881e22e982d5cc92b3b16c714a Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 10:54:16 -0400 Subject: [PATCH 10/22] Lock the first gameplay parity baseline Phase D needs a deterministic TypeScript reference before any C++ gameplay port, so this adds a headless full-world tick fixture with a modeled damage interaction and golden conformance test. Constraint: TypeScript remains the gameplay source of truth until C++ parity exists Constraint: First RL-oriented baseline must prove full-world determinism before localized agent observations Rejected: Start C++ gameplay immediately | no TS gameplay golden contract existed yet Confidence: high Scope-risk: narrow Directive: Keep Phase D fixtures headless and deterministic before expanding to spawn managers or live WebSocket gameplay Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:conformance Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 431 ++++++++++++++++++++++ conformance/gameplay/golden.test.js | 22 ++ conformance/gameplay/report-ts.js | 235 ++++++++++++ 3 files changed, 688 insertions(+) create mode 100644 conformance/fixtures/gameplay-golden.json create mode 100644 conformance/gameplay/golden.test.js create mode 100644 conformance/gameplay/report-ts.js diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json new file mode 100644 index 00000000..c212bd37 --- /dev/null +++ b/conformance/fixtures/gameplay-golden.json @@ -0,0 +1,431 @@ +{ + "phase": "D-gameplay", + "scope": "minimal-headless-tick-parity", + "nonGoals": [ + "browser-client-ui-testing", + "per-agent-rl-observation-grids", + "cpp-gameplay-implementation", + "full-live-websocket-gameplay-parity", + "broad-every-tank-projectile-upgrade-coverage" + ], + "scenarios": [ + { + "scenario": "overlapping-living-entities-damage", + "invariant": "Two overlapping living entities with different damage values exchange deterministic collision damage during headless manager ticks.", + "participants": { + "attacker": { + "id": 0, + "hash": 1 + }, + "defender": { + "id": 1, + "hash": 1 + } + }, + "damageEvidence": { + "attackerInitialHealth": 50, + "attackerFinalHealth": 43.333333, + "defenderInitialHealth": 20, + "defenderFinalHealth": 0 + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "attacker", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 50, + "maxHealth": 50, + "flags": 0 + }, + "damage": { + "damagePerTick": 3, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "defender", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 35, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 1, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-1-damage-tick", + "tick": 1, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "attacker", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -8, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 46, + "maxHealth": 50, + "flags": 0 + }, + "damage": { + "damagePerTick": 3, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 3 + }, + "velocity": { + "x": -7.2, + "y": 0, + "magnitude": 7.2, + "angle": 3.141593 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "defender", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 43, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 8, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 1, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 3 + }, + "velocity": { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + } + } + ] + }, + { + "label": "after-2-damage-ticks", + "tick": 2, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "attacker", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -23.2, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 43.333333, + "maxHealth": 50, + "flags": 0 + }, + "damage": { + "damagePerTick": 3, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 2 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "velocity": { + "x": -13.68, + "y": 0, + "magnitude": 13.68, + "angle": 3.141593 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "defender", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 58.2, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 33, + "width": 33, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 1, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 2 + }, + "style": { + "color": 8, + "opacity": 0.666667, + "flags": 1 + }, + "velocity": { + "x": 13.68, + "y": 0, + "magnitude": 13.68, + "angle": 0 + } + } + ] + } + ] + } + ] +} diff --git a/conformance/gameplay/golden.test.js b/conformance/gameplay/golden.test.js new file mode 100644 index 00000000..33fb14f5 --- /dev/null +++ b/conformance/gameplay/golden.test.js @@ -0,0 +1,22 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { execFileSync } = require('node:child_process'); + +const root = path.join(__dirname, '../..'); +const fixturePath = path.join(root, 'conformance/fixtures/gameplay-golden.json'); +const reportPath = path.join(root, 'conformance/gameplay/report-ts.js'); +const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + +function runReport() { + return JSON.parse(execFileSync(process.execPath, [reportPath], { cwd: root, encoding: 'utf8' })); +} + +test('TS headless gameplay damage scenario matches golden fixture', () => { + assert.deepEqual(runReport(), fixture); +}); + +test('TS headless gameplay report is deterministic across repeated runs', () => { + assert.deepEqual(runReport(), runReport()); +}); diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js new file mode 100644 index 00000000..e032fe9d --- /dev/null +++ b/conformance/gameplay/report-ts.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +const path = require('node:path'); +require(path.join(__dirname, '../../test/helpers/register-ts')); + +const EntityManager = require('../../src/Native/Manager').default; +const LivingEntity = require('../../src/Entity/Live').default; +const { Entity } = require('../../src/Native/Entity'); +const { ArenaGroup } = require('../../src/Native/FieldGroups'); +const { Color } = require('../../src/Const/Enums'); + +function createHeadlessGame() { + const game = { + tick: 0, + clients: new Set(), + clientsAwaitingSpawn: new Map(), + playersOnMap: false, + gamemode: 'phase-d-headless', + name: 'Phase D Headless', + broadcast() { + return { vu() { return this; }, send() {} }; + }, + broadcastPlayerCount() {}, + broadcastMessage() {} + }; + game.entities = new EntityManager(game); + const arenaOwner = { entityState: 0 }; + const arenaData = new ArenaGroup(arenaOwner); + arenaData.values.leftX = -1000; + arenaData.values.rightX = 1000; + arenaData.values.topY = -1000; + arenaData.values.bottomY = 1000; + // The Phase D fixture is intentionally headless and manager-focused. + // Keep only the arena bounds that ObjectEntity.keepInArena needs; do not + // register an ArenaEntity, because ArenaEntity.tick drives countdown, + // shape, and boss managers that are out of scope for this first slice. + game.arena = { id: null, hash: 0, state: 'headless-fixture', ARENA_PADDING: 200, arenaData }; + return game; +} + +function makeDamageBody(game, name, { x, y, health, maxHealth, damagePerTick, size, color }) { + const body = new LivingEntity(game); + body.positionData.x = x; + body.positionData.y = y; + body.positionData.angle = 0; + body.physicsData.sides = 1; + body.physicsData.size = size; + body.physicsData.width = size; + body.physicsData.absorbtionFactor = 1; + body.physicsData.pushFactor = 8; + body.healthData.health = health; + body.healthData.maxHealth = maxHealth; + body.styleData.color = color; + body.damagePerTick = damagePerTick; + body.scoreReward = 0; + body.fixtureName = name; + return body; +} + +function round(value) { + if (typeof value !== 'number') return value; + if (!Number.isFinite(value)) return value; + return Number(value.toFixed(6)); +} + +function entityRef(entity) { + return Entity.exists(entity) ? { id: entity.id, hash: entity.hash } : null; +} + +function objectSnapshot(entity) { + const snapshot = { + id: entity.id, + hash: entity.hash, + preservedHash: entity.preservedHash, + className: entity.constructor.name, + fixtureName: entity.fixtureName || null, + exists: Entity.exists(entity), + entityState: entity.entityState + }; + + if (entity.relationsData) { + snapshot.relations = { + parent: entityRef(entity.relationsData.values.parent), + owner: entityRef(entity.relationsData.values.owner), + team: entityRef(entity.relationsData.values.team) + }; + } + if (entity.positionData) { + snapshot.position = { + x: round(entity.positionData.values.x), + y: round(entity.positionData.values.y), + angle: round(entity.positionData.values.angle), + flags: entity.positionData.values.flags + }; + } + if (entity.physicsData) { + snapshot.physics = { + sides: entity.physicsData.values.sides, + size: round(entity.physicsData.values.size), + width: round(entity.physicsData.values.width), + pushFactor: round(entity.physicsData.values.pushFactor), + absorbtionFactor: round(entity.physicsData.values.absorbtionFactor), + flags: entity.physicsData.values.flags + }; + } + if (entity.healthData) { + snapshot.health = { + health: round(entity.healthData.values.health), + maxHealth: round(entity.healthData.values.maxHealth), + flags: entity.healthData.values.flags + }; + snapshot.damage = { + damagePerTick: round(entity.damagePerTick), + damageReduction: round(entity.damageReduction), + minDamageMultiplier: round(entity.minDamageMultiplier), + maxDamageMultiplier: round(entity.maxDamageMultiplier), + lastDamageTick: entity.lastDamageTick + }; + } + if (entity.styleData) { + snapshot.style = { + color: entity.styleData.values.color, + opacity: round(entity.styleData.values.opacity), + flags: entity.styleData.values.flags + }; + } + if (entity.velocity) { + snapshot.velocity = { + x: round(entity.velocity.x), + y: round(entity.velocity.y), + magnitude: round(entity.velocity.magnitude), + angle: round(entity.velocity.angle) + }; + } + return snapshot; +} + +function worldSnapshot(game, label) { + const active = []; + for (let id = 0; id <= game.entities.lastId; id += 1) { + const entity = game.entities.inner[id]; + if (entity) active.push(objectSnapshot(entity)); + } + + return { + label, + tick: game.tick, + manager: { + lastId: game.entities.lastId, + activeIds: active.map((entity) => entity.id), + cameras: game.entities.cameras.slice(), + otherEntities: game.entities.otherEntities.slice(), + globalEntities: game.entities.globalEntities.slice(), + hashTable: Array.from(game.entities.hashTable.slice(0, game.entities.lastId + 1)) + }, + arena: { + id: game.arena.id, + state: game.arena.state, + bounds: { + leftX: round(game.arena.arenaData.values.leftX), + rightX: round(game.arena.arenaData.values.rightX), + topY: round(game.arena.arenaData.values.topY), + bottomY: round(game.arena.arenaData.values.bottomY) + } + }, + entities: active + }; +} + +function tickHeadless(game) { + game.tick += 1; + game.entities.preTick(game.tick); + game.entities.tick(game.tick); + game.entities.postTick(game.tick); +} + +function damageScenario() { + const game = createHeadlessGame(); + const attacker = makeDamageBody(game, 'attacker', { + x: 0, + y: 0, + health: 50, + maxHealth: 50, + damagePerTick: 3, + size: 30, + color: Color.Tank + }); + const defender = makeDamageBody(game, 'defender', { + x: 35, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 1, + size: 30, + color: Color.EnemySquare + }); + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-1-damage-tick')); + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-2-damage-ticks')); + + return { + scenario: 'overlapping-living-entities-damage', + invariant: 'Two overlapping living entities with different damage values exchange deterministic collision damage during headless manager ticks.', + participants: { + attacker: entityRef(attacker), + defender: entityRef(defender) + }, + damageEvidence: { + attackerInitialHealth: snapshots[0].entities.find((e) => e.fixtureName === 'attacker').health.health, + attackerFinalHealth: snapshots[2].entities.find((e) => e.fixtureName === 'attacker').health.health, + defenderInitialHealth: snapshots[0].entities.find((e) => e.fixtureName === 'defender').health.health, + defenderFinalHealth: snapshots[2].entities.find((e) => e.fixtureName === 'defender').health.health + }, + snapshots + }; +} + +function report() { + return { + phase: 'D-gameplay', + scope: 'minimal-headless-tick-parity', + nonGoals: [ + 'browser-client-ui-testing', + 'per-agent-rl-observation-grids', + 'cpp-gameplay-implementation', + 'full-live-websocket-gameplay-parity', + 'broad-every-tank-projectile-upgrade-coverage' + ], + scenarios: [damageScenario()] + }; +} + +process.stdout.write(`${JSON.stringify(report(), null, 2)}\n`); From e9704e5d6e9a645da3a7cfe3499f9ba679646893 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:00:25 -0400 Subject: [PATCH 11/22] Prove the first gameplay fixture against C++ The Phase D TypeScript fixture now has a matching standard-library C++ report and a parity comparator, keeping C++ gameplay work scoped to the already-locked headless damage scenario. Constraint: C++ gameplay must match the TypeScript golden report before expanding simulation scope Rejected: Port broader gameplay managers now | spawn, boss, and live server behavior are still explicit non-goals for this slice Confidence: high Scope-risk: narrow Directive: Expand gameplay parity one deterministic scenario at a time and update both TS golden and C++ report together Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run test:all --- CMakeLists.txt | 9 + conformance/gameplay/compare-parity.js | 11 + cpp/include/diepcustom/gameplay.hpp | 9 + cpp/src/gameplay.cpp | 285 +++++++++++++++++++++++++ cpp/tests/gameplay_report.cpp | 8 + package.json | 2 +- 6 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 conformance/gameplay/compare-parity.js create mode 100644 cpp/include/diepcustom/gameplay.hpp create mode 100644 cpp/src/gameplay.cpp create mode 100644 cpp/tests/gameplay_report.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a95bcee9..b9e8ea17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,9 +16,14 @@ add_library(diepcustom_physics add_library(diepcustom_entity_core cpp/src/entity_core.cpp ) + +add_library(diepcustom_gameplay + cpp/src/gameplay.cpp +) target_include_directories(diepcustom_physics PUBLIC cpp/include) target_include_directories(diepcustom_protocol PUBLIC cpp/include) target_include_directories(diepcustom_entity_core PUBLIC cpp/include) +target_include_directories(diepcustom_gameplay PUBLIC cpp/include) target_link_libraries(diepcustom_entity_core PUBLIC diepcustom_protocol) add_executable(protocol_golden_test cpp/tests/protocol_golden_test.cpp) @@ -33,6 +38,10 @@ target_link_libraries(physics_report PRIVATE diepcustom_physics) add_executable(entity_core_report cpp/tests/entity_core_report.cpp) target_link_libraries(entity_core_report PRIVATE diepcustom_entity_core) +add_executable(gameplay_report cpp/tests/gameplay_report.cpp) +target_link_libraries(gameplay_report PRIVATE diepcustom_gameplay) + enable_testing() add_test(NAME protocol_golden_test COMMAND protocol_golden_test) add_test(NAME entity_core_report_smoke COMMAND entity_core_report) +add_test(NAME gameplay_report_smoke COMMAND gameplay_report) diff --git a/conformance/gameplay/compare-parity.js b/conformance/gameplay/compare-parity.js new file mode 100644 index 00000000..efc30cb9 --- /dev/null +++ b/conformance/gameplay/compare-parity.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const root = path.join(__dirname, '../..'); +const tsReport = JSON.parse(execFileSync(process.execPath, [path.join(root, 'conformance/gameplay/report-ts.js')], { cwd: root, encoding: 'utf8' })); +const cppReport = JSON.parse(execFileSync(path.join(root, 'build/cpp/gameplay_report'), { cwd: root, encoding: 'utf8' })); + +assert.deepEqual(cppReport, tsReport); +console.log('gameplay parity report matched TypeScript reference'); diff --git a/cpp/include/diepcustom/gameplay.hpp b/cpp/include/diepcustom/gameplay.hpp new file mode 100644 index 00000000..f8e0285a --- /dev/null +++ b/cpp/include/diepcustom/gameplay.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace diepcustom::gameplay { + +std::string gameplayReportJson(); + +} // namespace diepcustom::gameplay diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp new file mode 100644 index 00000000..09f35d23 --- /dev/null +++ b/cpp/src/gameplay.cpp @@ -0,0 +1,285 @@ +#include "diepcustom/gameplay.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace diepcustom::gameplay { +namespace { +constexpr int ColorTank = 2; +constexpr int ColorEnemySquare = 8; + +std::string q(const std::string& value) { + std::ostringstream out; + out << '"'; + for (unsigned char c : value) { + switch (c) { + case '"': out << "\\\""; break; + case '\\': out << "\\\\"; break; + case '\n': out << "\\n"; break; + default: out << c; break; + } + } + out << '"'; + return out.str(); +} + +std::string num(double value) { + if (std::fabs(value) < 0.0000005) value = 0; + double rounded = std::round(value * 1000000.0) / 1000000.0; + std::ostringstream out; + out << std::fixed << std::setprecision(6) << rounded; + std::string s = out.str(); + while (s.size() > 1 && s.back() == '0') s.pop_back(); + if (!s.empty() && s.back() == '.') s.pop_back(); + if (s == "-0") return "0"; + return s; +} + +std::string intArrayJson(const std::vector& values) { + std::ostringstream out; + out << '['; + for (std::size_t i = 0; i < values.size(); ++i) { + if (i) out << ','; + out << values[i]; + } + out << ']'; + return out.str(); +} + +struct Arena { + double leftX = -1000; + double rightX = 1000; + double topY = -1000; + double bottomY = 1000; + double padding = 200; +}; + +struct Body { + int id = 0; + int hash = 1; + int preservedHash = 1; + std::string fixtureName; + int entityState = 1; + double x = 0; + double y = 0; + double angle = 0; + int positionFlags = 0; + int sides = 1; + double size = 30; + double width = 30; + double pushFactor = 8; + double absorbtionFactor = 1; + int physicsFlags = 0; + double health = 1; + double maxHealth = 1; + int healthFlags = 0; + double damagePerTick = 1; + double damageReduction = 1; + double minDamageMultiplier = 1; + double maxDamageMultiplier = 4; + int lastDamageTick = -1; + int lastDamageAnimationTick = -1; + int styleColor = 0; + double opacity = 1; + int styleFlags = 1; + double vx = 0; + double vy = 0; + double velocityMagnitude = 0; + double velocityAngle = 0; + bool deleting = false; + int deletionFrame = 5; +}; + +void refreshVelocity(Body& b) { + b.velocityMagnitude = std::sqrt(b.vx*b.vx + b.vy*b.vy); + b.velocityAngle = b.velocityMagnitude == 0 ? 0 : std::atan2(b.vy, b.vx); +} + +void addVelocity(Body& b, double angle, double magnitude) { + b.vx += std::cos(angle) * magnitude; + b.vy += std::sin(angle) * magnitude; + refreshVelocity(b); +} + +void setVelocity(Body& b, double angle, double magnitude) { + b.vx = std::cos(angle) * magnitude; + b.vy = std::sin(angle) * magnitude; + refreshVelocity(b); +} + +void scale(Body& b, double value) { + b.size *= value; + b.width *= value; +} + +void deletionTick(Body& b) { + if (!b.deleting) return; + if (b.deletionFrame == 5) b.opacity = 1 - (1.0 / 6.0); + scale(b, 1.1); + b.opacity -= 1.0 / 6.0; + if (b.opacity < 0) b.opacity = 0; + b.deletionFrame -= 1; +} + +void destroy(Body& b) { + b.health = 0; + if (!b.deleting) b.deleting = true; +} + +void keepInArena(Body& b, const Arena& arena) { + if (b.x < arena.leftX - arena.padding) b.x = arena.leftX - arena.padding; + else if (b.x > arena.rightX + arena.padding) b.x = arena.rightX + arena.padding; + if (b.y < arena.topY - arena.padding) b.y = arena.topY - arena.padding; + else if (b.y > arena.bottomY + arena.padding) b.y = arena.bottomY + arena.padding; +} + +void applyPhysics(Body& b) { + if (b.velocityMagnitude < 0.01) setVelocity(b, b.velocityAngle, 0); + else if (b.deleting) setVelocity(b, b.velocityAngle, b.velocityMagnitude / 2.0); + b.x += b.vx; + b.y += b.vy; + addVelocity(b, b.velocityAngle, b.velocityMagnitude * -0.1); + if (b.health <= 0) destroy(b); +} + +bool isColliding(const Body& a, const Body& b) { + double dx = a.x - b.x; + double dy = a.y - b.y; + double r = a.size + b.size; + return dx*dx + dy*dy <= r*r; +} + +void receiveKnockback(Body& self, const Body& other) { + double kbMagnitude = self.absorbtionFactor * other.pushFactor; + double diffY = self.y - other.y; + double diffX = self.x - other.x; + double kbAngle = std::atan2(diffY, diffX); + addVelocity(self, kbAngle, kbMagnitude); +} + +void receiveDamage(Body& self, const Body&, double amount, int tick) { + if (self.health <= 0.0001) { self.health = 0; return; } + if (self.lastDamageAnimationTick != tick) { + self.styleFlags ^= 2; + self.lastDamageAnimationTick = tick; + } + self.lastDamageTick = tick; + self.health -= amount; + if (self.health <= 0.0001) self.health = 0; +} + +void handleDamage(Body& a, Body& b, int tick) { + if (a.health <= 0 || b.health <= 0) return; + double common = std::max(b.minDamageMultiplier, a.minDamageMultiplier); + common *= std::min(b.maxDamageMultiplier, a.maxDamageMultiplier); + const double dF1 = (a.damagePerTick * common) * b.damageReduction; + const double dF2 = (b.damagePerTick * common) * a.damageReduction; + const double ratio = std::max(1 - a.health / dF2, 1 - b.health / dF1); + const double damage1to2 = dF1 * std::min(1.0, 1 - ratio); + const double damage2to1 = dF2 * std::min(1.0, 1 - ratio); + receiveDamage(a, b, damage2to1, tick); + receiveDamage(b, a, damage1to2, tick); +} + +void tickBody(Body& b, const Arena& arena) { + deletionTick(b); + keepInArena(b, arena); +} + +void tickHeadless(std::vector& bodies, const Arena& arena, int tick) { + if (bodies.size() >= 2 && isColliding(bodies[0], bodies[1])) { + receiveKnockback(bodies[0], bodies[1]); + receiveKnockback(bodies[1], bodies[0]); + handleDamage(bodies[0], bodies[1], tick); + } + for (auto& body : bodies) { + applyPhysics(body); + tickBody(body, arena); + } + for (auto& body : bodies) body.entityState = 0; +} + +std::string refJson(const Body& b) { + return "{\"id\":" + std::to_string(b.id) + ",\"hash\":" + std::to_string(b.hash) + "}"; +} + +std::string bodyJson(const Body& b) { + std::ostringstream out; + out << "{\"id\":" << b.id << ",\"hash\":" << b.hash << ",\"preservedHash\":" << b.preservedHash + << ",\"className\":\"LivingEntity\",\"fixtureName\":" << q(b.fixtureName) + << ",\"exists\":true,\"entityState\":" << b.entityState + << ",\"relations\":{\"parent\":null,\"owner\":null,\"team\":null}" + << ",\"position\":{\"x\":" << num(b.x) << ",\"y\":" << num(b.y) << ",\"angle\":" << num(b.angle) << ",\"flags\":" << b.positionFlags << "}" + << ",\"physics\":{\"sides\":" << b.sides << ",\"size\":" << num(b.size) << ",\"width\":" << num(b.width) + << ",\"pushFactor\":" << num(b.pushFactor) << ",\"absorbtionFactor\":" << num(b.absorbtionFactor) << ",\"flags\":" << b.physicsFlags << "}" + << ",\"health\":{\"health\":" << num(b.health) << ",\"maxHealth\":" << num(b.maxHealth) << ",\"flags\":" << b.healthFlags << "}" + << ",\"damage\":{\"damagePerTick\":" << num(b.damagePerTick) << ",\"damageReduction\":" << num(b.damageReduction) + << ",\"minDamageMultiplier\":" << num(b.minDamageMultiplier) << ",\"maxDamageMultiplier\":" << num(b.maxDamageMultiplier) + << ",\"lastDamageTick\":" << b.lastDamageTick << "}" + << ",\"style\":{\"color\":" << b.styleColor << ",\"opacity\":" << num(b.opacity) << ",\"flags\":" << b.styleFlags << "}" + << ",\"velocity\":{\"x\":" << num(b.vx) << ",\"y\":" << num(b.vy) << ",\"magnitude\":" << num(b.velocityMagnitude) + << ",\"angle\":" << num(b.velocityAngle) << "}}"; + return out.str(); +} + +std::string snapshotJson(const std::vector& bodies, const Arena& arena, const std::string& label, int tick) { + std::vector ids; + std::vector hashes; + for (const auto& body : bodies) { ids.push_back(body.id); hashes.push_back(body.hash); } + std::ostringstream out; + out << "{\"label\":" << q(label) << ",\"tick\":" << tick + << ",\"manager\":{\"lastId\":1,\"activeIds\":" << intArrayJson(ids) + << ",\"cameras\":[],\"otherEntities\":[],\"globalEntities\":[],\"hashTable\":" << intArrayJson(hashes) << "}" + << ",\"arena\":{\"id\":null,\"state\":\"headless-fixture\",\"bounds\":{\"leftX\":" << num(arena.leftX) + << ",\"rightX\":" << num(arena.rightX) << ",\"topY\":" << num(arena.topY) << ",\"bottomY\":" << num(arena.bottomY) << "}}" + << ",\"entities\":["; + for (std::size_t i = 0; i < bodies.size(); ++i) { + if (i) out << ','; + out << bodyJson(bodies[i]); + } + out << "]}"; + return out.str(); +} + +std::string damageScenarioJson() { + Arena arena; + std::vector bodies; + Body attacker; + attacker.id = 0; attacker.hash = attacker.preservedHash = 1; attacker.fixtureName = "attacker"; attacker.x = 0; attacker.y = 0; + attacker.health = attacker.maxHealth = 50; attacker.damagePerTick = 3; attacker.size = attacker.width = 30; attacker.styleColor = ColorTank; + Body defender; + defender.id = 1; defender.hash = defender.preservedHash = 1; defender.fixtureName = "defender"; defender.x = 35; defender.y = 0; + defender.health = defender.maxHealth = 20; defender.damagePerTick = 1; defender.size = defender.width = 30; defender.styleColor = ColorEnemySquare; + bodies.push_back(attacker); bodies.push_back(defender); + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-1-damage-tick", 1)); + tickHeadless(bodies, arena, 2); + snapshots.push_back(snapshotJson(bodies, arena, "after-2-damage-ticks", 2)); + + std::ostringstream out; + out << "{\"scenario\":\"overlapping-living-entities-damage\",\"invariant\":\"Two overlapping living entities with different damage values exchange deterministic collision damage during headless manager ticks.\"" + << ",\"participants\":{\"attacker\":" << refJson(bodies[0]) << ",\"defender\":" << refJson(bodies[1]) << "}" + << ",\"damageEvidence\":{\"attackerInitialHealth\":50,\"attackerFinalHealth\":" << num(bodies[0].health) + << ",\"defenderInitialHealth\":20,\"defenderFinalHealth\":" << num(bodies[1].health) << "}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} +} // namespace + +std::string gameplayReportJson() { + return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + + "\"scenarios\":[" + damageScenarioJson() + "]}"; +} + +} // namespace diepcustom::gameplay diff --git a/cpp/tests/gameplay_report.cpp b/cpp/tests/gameplay_report.cpp new file mode 100644 index 00000000..eab91aae --- /dev/null +++ b/cpp/tests/gameplay_report.cpp @@ -0,0 +1,8 @@ +#include "diepcustom/gameplay.hpp" + +#include + +int main() { + std::cout << diepcustom::gameplay::gameplayReportJson() << '\n'; + return 0; +} diff --git a/package.json b/package.json index 17579616..8d400932 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:all": "npm run test && npm run test:conformance && npm audit --audit-level=moderate", "test:conformance": "node --test conformance/**/*.test.js", "test:cpp": "cmake -S . -B build/cpp && cmake --build build/cpp && ctest --test-dir build/cpp --output-on-failure", - "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js && node conformance/entity-core/compare-parity.js" + "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js && node conformance/entity-core/compare-parity.js && node conformance/gameplay/compare-parity.js" }, "engines": { "node": ">=16.3" From 30edcbcce27f8a7110c3552f34833e638825bd3c Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:03:35 -0400 Subject: [PATCH 12/22] Gate gameplay migration on a measured C++ speed signal Broad gameplay porting should proceed only when the C++ path keeps proving parity and performance value, so this adds an explicit benchmark for the first Phase D gameplay report before expanding scope. Constraint: The current benchmark includes process startup and TypeScript transpile overhead, so it is a migration signal rather than a final in-engine tick benchmark Rejected: Port every gameplay system immediately | no broader performance gate or parity fixture exists yet Confidence: high Scope-risk: narrow Directive: Add tighter in-process tick benchmarks as gameplay code moves from report emitters to reusable C++ engine modules Tested: npm run bench:gameplay Tested: npm run test:parity Tested: npm run test:all --- conformance/gameplay/benchmark-parity.js | 57 ++++++++++++++++++++++++ package.json | 3 +- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 conformance/gameplay/benchmark-parity.js diff --git a/conformance/gameplay/benchmark-parity.js b/conformance/gameplay/benchmark-parity.js new file mode 100644 index 00000000..cfc34624 --- /dev/null +++ b/conformance/gameplay/benchmark-parity.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const root = path.join(__dirname, '../..'); +const iterations = Number(process.env.ITERATIONS || 25); +const warmups = Number(process.env.WARMUPS || 3); +const tsCommand = [process.execPath, path.join(root, 'conformance/gameplay/report-ts.js')]; +const cppCommand = [path.join(root, 'build/cpp/gameplay_report')]; + +function run(command) { + const [bin, ...args] = command; + execFileSync(bin, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }); +} + +function measure(label, command) { + for (let i = 0; i < warmups; i += 1) run(command); + const samples = []; + for (let i = 0; i < iterations; i += 1) { + const start = process.hrtime.bigint(); + run(command); + const end = process.hrtime.bigint(); + samples.push(Number(end - start) / 1_000_000); + } + samples.sort((a, b) => a - b); + const total = samples.reduce((sum, value) => sum + value, 0); + return { + label, + iterations, + minMs: Number(samples[0].toFixed(3)), + medianMs: Number(samples[Math.floor(samples.length / 2)].toFixed(3)), + meanMs: Number((total / samples.length).toFixed(3)), + maxMs: Number(samples[samples.length - 1].toFixed(3)) + }; +} + +function main() { + const ts = measure('typescript-gameplay-report', tsCommand); + const cpp = measure('cpp-gameplay-report', cppCommand); + const speedup = Number((ts.medianMs / cpp.medianMs).toFixed(2)); + const result = { + benchmark: 'phase-d-gameplay-report-process-runtime', + note: 'This measures report process runtime, including Node/TypeScript transpile startup for the TS reference. It is a migration signal, not a final in-engine tick benchmark.', + warmups, + iterations, + ts, + cpp, + medianSpeedup: speedup, + cppFaster: cpp.medianMs < ts.medianMs + }; + console.log(JSON.stringify(result, null, 2)); + if (!result.cppFaster) { + process.exitCode = 1; + } +} + +main(); diff --git a/package.json b/package.json index 8d400932..7b7320dc 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test:all": "npm run test && npm run test:conformance && npm audit --audit-level=moderate", "test:conformance": "node --test conformance/**/*.test.js", "test:cpp": "cmake -S . -B build/cpp && cmake --build build/cpp && ctest --test-dir build/cpp --output-on-failure", - "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js && node conformance/entity-core/compare-parity.js && node conformance/gameplay/compare-parity.js" + "test:parity": "npm run test:cpp && node conformance/protocol/compare-parity.js && node conformance/physics/compare-parity.js && node conformance/entity-core/compare-parity.js && node conformance/gameplay/compare-parity.js", + "bench:gameplay": "npm run test:cpp && node conformance/gameplay/benchmark-parity.js" }, "engines": { "node": ">=16.3" From 2ab7c8c9b59caedb406a9719f67e442479a47a94 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:09:08 -0400 Subject: [PATCH 13/22] Extend gameplay parity through death rewards The next Phase D slice now proves a lethal headless collision path: onKill reward accounting, deletion animation state, and eventual manager removal are captured in the TypeScript golden fixture and matched by C++. Constraint: Preserve full-world deterministic snapshots before per-agent RL observations Rejected: Jump to tank/projectile systems | death reward/removal is a smaller deterministic bridge from collision damage Confidence: high Scope-risk: narrow Directive: Keep adding one deterministic gameplay behavior per parity slice before introducing live server or spawn-manager randomness Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:conformance Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 2.602ms vs TS median 949.682ms, 364.98x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 429 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 73 +++- cpp/src/gameplay.cpp | 63 +++- 3 files changed, 555 insertions(+), 10 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index c212bd37..e84ddf63 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -101,6 +101,12 @@ "opacity": 1, "flags": 1 }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, "velocity": { "x": 0, "y": 0, @@ -152,6 +158,12 @@ "opacity": 1, "flags": 1 }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, "velocity": { "x": 0, "y": 0, @@ -233,6 +245,12 @@ "opacity": 1, "flags": 3 }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, "velocity": { "x": -7.2, "y": 0, @@ -284,6 +302,12 @@ "opacity": 1, "flags": 3 }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, "velocity": { "x": 7.2, "y": 0, @@ -365,6 +389,12 @@ "opacity": 1, "flags": 1 }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, "velocity": { "x": -13.68, "y": 0, @@ -416,6 +446,12 @@ "opacity": 0.666667, "flags": 1 }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, "velocity": { "x": 13.68, "y": 0, @@ -426,6 +462,399 @@ ] } ] + }, + { + "scenario": "score-on-kill-and-death-removal", + "invariant": "A lethal deterministic collision calls the killer onKill hook once, awards the victim scoreReward, starts death animation, and removes the victim after the deletion animation completes.", + "participants": { + "killer": { + "id": 0, + "hash": 1 + }, + "victimAfterRemoval": null + }, + "scoreEvidence": { + "killerInitialScore": 0, + "killerScoreAfterKill": 17, + "victimScoreReward": 17, + "victimHealthAfterKill": 0, + "victimPresentAfterRemoval": false + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "killer", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 60, + "maxHealth": 60, + "flags": 0 + }, + "damage": { + "damagePerTick": 12, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "victim", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 35, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 5, + "maxHealth": 5, + "flags": 0 + }, + "damage": { + "damagePerTick": 0.5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 17, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-kill-damage-tick", + "tick": 1, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "killer", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -8, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 59.791667, + "maxHealth": 60, + "flags": 0 + }, + "damage": { + "damagePerTick": 12, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 3 + }, + "gameplay": { + "score": 17, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": -7.2, + "y": 0, + "magnitude": 7.2, + "angle": 3.141593 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "victim", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 43, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 33, + "width": 33, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 5, + "flags": 0 + }, + "damage": { + "damagePerTick": 0.5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 8, + "opacity": 0.666667, + "flags": 3 + }, + "gameplay": { + "score": 0, + "scoreReward": 17, + "deleting": true, + "deletionFrame": 4 + }, + "velocity": { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + } + } + ] + }, + { + "label": "after-deletion-animation-removal", + "tick": 7, + "manager": { + "lastId": 0, + "activeIds": [ + 0 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "killer", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -41.736248, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 30, + "width": 30, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 59.791667, + "maxHealth": 60, + "flags": 0 + }, + "damage": { + "damagePerTick": 12, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 3 + }, + "gameplay": { + "score": 17, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": -3.826375, + "y": 0, + "magnitude": 3.826375, + "angle": 3.141593 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index e032fe9d..e048879b 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -52,6 +52,10 @@ function makeDamageBody(game, name, { x, y, health, maxHealth, damagePerTick, si body.styleData.color = color; body.damagePerTick = damagePerTick; body.scoreReward = 0; + body.fixtureScore = 0; + body.onKill = function onFixtureKill(entity) { + this.fixtureScore += entity.scoreReward; + }; body.fixtureName = name; return body; } @@ -123,6 +127,14 @@ function objectSnapshot(entity) { flags: entity.styleData.values.flags }; } + if (typeof entity.fixtureScore === 'number') { + snapshot.gameplay = { + score: round(entity.fixtureScore), + scoreReward: round(entity.scoreReward), + deleting: Boolean(entity.deletionAnimation), + deletionFrame: entity.deletionAnimation ? entity.deletionAnimation.frame : null + }; + } if (entity.velocity) { snapshot.velocity = { x: round(entity.velocity.x), @@ -173,6 +185,10 @@ function tickHeadless(game) { game.entities.postTick(game.tick); } +function findEntity(snap, name) { + return snap.entities.find((entity) => entity.fixtureName === name); +} + function damageScenario() { const game = createHeadlessGame(); const attacker = makeDamageBody(game, 'attacker', { @@ -208,10 +224,57 @@ function damageScenario() { defender: entityRef(defender) }, damageEvidence: { - attackerInitialHealth: snapshots[0].entities.find((e) => e.fixtureName === 'attacker').health.health, - attackerFinalHealth: snapshots[2].entities.find((e) => e.fixtureName === 'attacker').health.health, - defenderInitialHealth: snapshots[0].entities.find((e) => e.fixtureName === 'defender').health.health, - defenderFinalHealth: snapshots[2].entities.find((e) => e.fixtureName === 'defender').health.health + attackerInitialHealth: findEntity(snapshots[0], 'attacker').health.health, + attackerFinalHealth: findEntity(snapshots[2], 'attacker').health.health, + defenderInitialHealth: findEntity(snapshots[0], 'defender').health.health, + defenderFinalHealth: findEntity(snapshots[2], 'defender').health.health + }, + snapshots + }; +} + + +function scoreDeathScenario() { + const game = createHeadlessGame(); + const killer = makeDamageBody(game, 'killer', { + x: 0, + y: 0, + health: 60, + maxHealth: 60, + damagePerTick: 12, + size: 30, + color: Color.Tank + }); + const victim = makeDamageBody(game, 'victim', { + x: 35, + y: 0, + health: 5, + maxHealth: 5, + damagePerTick: 0.5, + size: 30, + color: Color.EnemySquare + }); + victim.scoreReward = 17; + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-kill-damage-tick')); + for (let i = 0; i < 6; i += 1) tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-deletion-animation-removal')); + + return { + scenario: 'score-on-kill-and-death-removal', + invariant: 'A lethal deterministic collision calls the killer onKill hook once, awards the victim scoreReward, starts death animation, and removes the victim after the deletion animation completes.', + participants: { + killer: entityRef(killer), + victimAfterRemoval: entityRef(victim) + }, + scoreEvidence: { + killerInitialScore: findEntity(snapshots[0], 'killer').gameplay.score, + killerScoreAfterKill: findEntity(snapshots[1], 'killer').gameplay.score, + victimScoreReward: findEntity(snapshots[0], 'victim').gameplay.scoreReward, + victimHealthAfterKill: findEntity(snapshots[1], 'victim').health.health, + victimPresentAfterRemoval: Boolean(findEntity(snapshots[2], 'victim')) }, snapshots }; @@ -228,7 +291,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario()] + scenarios: [damageScenario(), scoreDeathScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index 09f35d23..8f86cebc 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -91,8 +91,11 @@ struct Body { double vy = 0; double velocityMagnitude = 0; double velocityAngle = 0; + double score = 0; + double scoreReward = 0; bool deleting = false; int deletionFrame = 5; + bool removed = false; }; void refreshVelocity(Body& b) { @@ -119,6 +122,12 @@ void scale(Body& b, double value) { void deletionTick(Body& b) { if (!b.deleting) return; + if (b.deletionFrame == 0) { + b.hash = 0; + b.removed = true; + b.deletionFrame = -1; + return; + } if (b.deletionFrame == 5) b.opacity = 1 - (1.0 / 6.0); scale(b, 1.1); b.opacity -= 1.0 / 6.0; @@ -148,6 +157,7 @@ void applyPhysics(Body& b) { } bool isColliding(const Body& a, const Body& b) { + if (a.deleting || b.deleting) return false; double dx = a.x - b.x; double dy = a.y - b.y; double r = a.size + b.size; @@ -162,7 +172,7 @@ void receiveKnockback(Body& self, const Body& other) { addVelocity(self, kbAngle, kbMagnitude); } -void receiveDamage(Body& self, const Body&, double amount, int tick) { +void receiveDamage(Body& self, Body& source, double amount, int tick) { if (self.health <= 0.0001) { self.health = 0; return; } if (self.lastDamageAnimationTick != tick) { self.styleFlags ^= 2; @@ -170,7 +180,10 @@ void receiveDamage(Body& self, const Body&, double amount, int tick) { } self.lastDamageTick = tick; self.health -= amount; - if (self.health <= 0.0001) self.health = 0; + if (self.health <= 0.0001) { + self.health = 0; + source.score += self.scoreReward; + } } void handleDamage(Body& a, Body& b, int tick) { @@ -198,10 +211,12 @@ void tickHeadless(std::vector& bodies, const Arena& arena, int tick) { handleDamage(bodies[0], bodies[1], tick); } for (auto& body : bodies) { + if (body.removed) continue; applyPhysics(body); tickBody(body, arena); } for (auto& body : bodies) body.entityState = 0; + bodies.erase(std::remove_if(bodies.begin(), bodies.end(), [](const Body& body) { return body.removed; }), bodies.end()); } std::string refJson(const Body& b) { @@ -222,6 +237,8 @@ std::string bodyJson(const Body& b) { << ",\"minDamageMultiplier\":" << num(b.minDamageMultiplier) << ",\"maxDamageMultiplier\":" << num(b.maxDamageMultiplier) << ",\"lastDamageTick\":" << b.lastDamageTick << "}" << ",\"style\":{\"color\":" << b.styleColor << ",\"opacity\":" << num(b.opacity) << ",\"flags\":" << b.styleFlags << "}" + << ",\"gameplay\":{\"score\":" << num(b.score) << ",\"scoreReward\":" << num(b.scoreReward) + << ",\"deleting\":" << (b.deleting ? "true" : "false") << ",\"deletionFrame\":" << (b.deleting ? std::to_string(b.deletionFrame) : "null") << "}" << ",\"velocity\":{\"x\":" << num(b.vx) << ",\"y\":" << num(b.vy) << ",\"magnitude\":" << num(b.velocityMagnitude) << ",\"angle\":" << num(b.velocityAngle) << "}}"; return out.str(); @@ -229,11 +246,17 @@ std::string bodyJson(const Body& b) { std::string snapshotJson(const std::vector& bodies, const Arena& arena, const std::string& label, int tick) { std::vector ids; + int lastId = -1; + for (const auto& body : bodies) { ids.push_back(body.id); if (body.id > lastId) lastId = body.id; } std::vector hashes; - for (const auto& body : bodies) { ids.push_back(body.id); hashes.push_back(body.hash); } + for (int id = 0; id <= lastId; ++id) { + int hash = 0; + for (const auto& body : bodies) if (body.id == id) { hash = body.hash; break; } + hashes.push_back(hash); + } std::ostringstream out; out << "{\"label\":" << q(label) << ",\"tick\":" << tick - << ",\"manager\":{\"lastId\":1,\"activeIds\":" << intArrayJson(ids) + << ",\"manager\":{\"lastId\":" << lastId << ",\"activeIds\":" << intArrayJson(ids) << ",\"cameras\":[],\"otherEntities\":[],\"globalEntities\":[],\"hashTable\":" << intArrayJson(hashes) << "}" << ",\"arena\":{\"id\":null,\"state\":\"headless-fixture\",\"bounds\":{\"leftX\":" << num(arena.leftX) << ",\"rightX\":" << num(arena.rightX) << ",\"topY\":" << num(arena.topY) << ",\"bottomY\":" << num(arena.bottomY) << "}}" @@ -274,12 +297,42 @@ std::string damageScenarioJson() { out << "]}"; return out.str(); } + +std::string scoreDeathScenarioJson() { + Arena arena; + std::vector bodies; + Body killer; + killer.id = 0; killer.hash = killer.preservedHash = 1; killer.fixtureName = "killer"; killer.x = 0; killer.y = 0; + killer.health = killer.maxHealth = 60; killer.damagePerTick = 12; killer.size = killer.width = 30; killer.styleColor = ColorTank; + Body victim; + victim.id = 1; victim.hash = victim.preservedHash = 1; victim.fixtureName = "victim"; victim.x = 35; victim.y = 0; + victim.health = victim.maxHealth = 5; victim.damagePerTick = 0.5; victim.size = victim.width = 30; victim.styleColor = ColorEnemySquare; victim.scoreReward = 17; + bodies.push_back(killer); bodies.push_back(victim); + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-kill-damage-tick", 1)); + for (int tick = 2; tick <= 7; ++tick) tickHeadless(bodies, arena, tick); + snapshots.push_back(snapshotJson(bodies, arena, "after-deletion-animation-removal", 7)); + + const Body& killerAfter = bodies[0]; + std::ostringstream out; + out << "{\"scenario\":\"score-on-kill-and-death-removal\",\"invariant\":\"A lethal deterministic collision calls the killer onKill hook once, awards the victim scoreReward, starts death animation, and removes the victim after the deletion animation completes.\"" + << ",\"participants\":{\"killer\":" << refJson(killerAfter) << ",\"victimAfterRemoval\":null}" + << ",\"scoreEvidence\":{\"killerInitialScore\":0,\"killerScoreAfterKill\":17,\"victimScoreReward\":17,\"victimHealthAfterKill\":0,\"victimPresentAfterRemoval\":false}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From 3a81d480055e0e7cf943f11f0be33db3f3674b03 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:14:34 -0400 Subject: [PATCH 14/22] Carry projectile kills back to their owner The third Phase D gameplay slice captures projectile-style kill propagation: a child damage source with an owner relation awards score to its owner while keeping the projectile score unchanged. Constraint: Continue full-world deterministic parity before RL observation grids or live server integration Rejected: Port full Bullet/Barrel classes now | owner propagation can be locked with a smaller synthetic living-entity fixture first Confidence: high Scope-risk: narrow Directive: Use this owner-propagation baseline before introducing concrete tank projectile classes Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 2.716ms vs TS median 892.898ms, 328.75x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 443 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 64 +++- cpp/src/gameplay.cpp | 73 +++- 3 files changed, 565 insertions(+), 15 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index e84ddf63..f328cf5b 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -855,6 +855,449 @@ ] } ] + }, + { + "scenario": "owner-propagated-projectile-kill-score", + "invariant": "A projectile-style living entity propagates its onKill event to its owner, awarding the target scoreReward to the owner instead of retaining it on the projectile.", + "participants": { + "owner": { + "id": 0, + "hash": 1 + }, + "projectile": { + "id": 1, + "hash": 1 + }, + "target": { + "id": 2, + "hash": 1 + } + }, + "scoreEvidence": { + "ownerInitialScore": 0, + "ownerScoreAfterKill": 23, + "projectileScoreAfterKill": 0, + "targetScoreReward": 23, + "targetHealthAfterKill": 0, + "projectileOwnerRef": { + "id": 0, + "hash": 1 + } + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 2, + "activeIds": [ + 0, + 1, + 2 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "owner", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -200, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 25, + "width": 25, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 80, + "maxHealth": 80, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "projectile", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 12, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "target", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 25, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 5, + "maxHealth": 5, + "flags": 0 + }, + "damage": { + "damagePerTick": 0.25, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 23, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-projectile-kill-tick", + "tick": 1, + "manager": { + "lastId": 2, + "activeIds": [ + 0, + 1, + 2 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "owner", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -200, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 25, + "width": 25, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 80, + "maxHealth": 80, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 23, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "projectile", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "position": { + "x": -8, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 9.895833, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 12, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 3 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": -7.2, + "y": 0, + "magnitude": 7.2, + "angle": 3.141593 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "target", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 33, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 22, + "width": 22, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 5, + "flags": 0 + }, + "damage": { + "damagePerTick": 0.25, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 8, + "opacity": 0.666667, + "flags": 3 + }, + "gameplay": { + "score": 0, + "scoreReward": 23, + "deleting": true, + "deletionFrame": 4 + }, + "velocity": { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index e048879b..9bf4dc1a 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -280,6 +280,68 @@ function scoreDeathScenario() { }; } +function ownerPropagatedKillScenario() { + const game = createHeadlessGame(); + const owner = makeDamageBody(game, 'owner', { + x: -200, + y: 0, + health: 80, + maxHealth: 80, + damagePerTick: 0, + size: 25, + color: Color.Tank + }); + owner.isPhysical = false; + + const projectile = makeDamageBody(game, 'projectile', { + x: 0, + y: 0, + health: 10, + maxHealth: 10, + damagePerTick: 12, + size: 20, + color: Color.Tank + }); + projectile.relationsData.owner = owner; + projectile.onKill = function onProjectileKill(entity) { + this.relationsData.values.owner?.onKill?.(entity); + }; + + const target = makeDamageBody(game, 'target', { + x: 25, + y: 0, + health: 5, + maxHealth: 5, + damagePerTick: 0.25, + size: 20, + color: Color.EnemySquare + }); + target.scoreReward = 23; + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-projectile-kill-tick')); + + return { + scenario: 'owner-propagated-projectile-kill-score', + invariant: 'A projectile-style living entity propagates its onKill event to its owner, awarding the target scoreReward to the owner instead of retaining it on the projectile.', + participants: { + owner: entityRef(owner), + projectile: entityRef(projectile), + target: entityRef(target) + }, + scoreEvidence: { + ownerInitialScore: findEntity(snapshots[0], 'owner').gameplay.score, + ownerScoreAfterKill: findEntity(snapshots[1], 'owner').gameplay.score, + projectileScoreAfterKill: findEntity(snapshots[1], 'projectile').gameplay.score, + targetScoreReward: findEntity(snapshots[0], 'target').gameplay.scoreReward, + targetHealthAfterKill: findEntity(snapshots[1], 'target').health.health, + projectileOwnerRef: findEntity(snapshots[0], 'projectile').relations.owner + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -291,7 +353,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index 8f86cebc..30ceba01 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -87,6 +87,8 @@ struct Body { int styleColor = 0; double opacity = 1; int styleFlags = 1; + bool isPhysical = true; + int ownerId = -1; double vx = 0; double vy = 0; double velocityMagnitude = 0; @@ -157,7 +159,7 @@ void applyPhysics(Body& b) { } bool isColliding(const Body& a, const Body& b) { - if (a.deleting || b.deleting) return false; + if (!a.isPhysical || !b.isPhysical || a.deleting || b.deleting) return false; double dx = a.x - b.x; double dy = a.y - b.y; double r = a.size + b.size; @@ -172,7 +174,7 @@ void receiveKnockback(Body& self, const Body& other) { addVelocity(self, kbAngle, kbMagnitude); } -void receiveDamage(Body& self, Body& source, double amount, int tick) { +void receiveDamage(Body& self, Body& source, std::vector& bodies, double amount, int tick) { if (self.health <= 0.0001) { self.health = 0; return; } if (self.lastDamageAnimationTick != tick) { self.styleFlags ^= 2; @@ -182,11 +184,15 @@ void receiveDamage(Body& self, Body& source, double amount, int tick) { self.health -= amount; if (self.health <= 0.0001) { self.health = 0; - source.score += self.scoreReward; + if (source.ownerId >= 0) { + for (auto& body : bodies) if (body.id == source.ownerId) { body.score += self.scoreReward; break; } + } else { + source.score += self.scoreReward; + } } } -void handleDamage(Body& a, Body& b, int tick) { +void handleDamage(Body& a, Body& b, std::vector& bodies, int tick) { if (a.health <= 0 || b.health <= 0) return; double common = std::max(b.minDamageMultiplier, a.minDamageMultiplier); common *= std::min(b.maxDamageMultiplier, a.maxDamageMultiplier); @@ -195,8 +201,8 @@ void handleDamage(Body& a, Body& b, int tick) { const double ratio = std::max(1 - a.health / dF2, 1 - b.health / dF1); const double damage1to2 = dF1 * std::min(1.0, 1 - ratio); const double damage2to1 = dF2 * std::min(1.0, 1 - ratio); - receiveDamage(a, b, damage2to1, tick); - receiveDamage(b, a, damage1to2, tick); + receiveDamage(a, b, bodies, damage2to1, tick); + receiveDamage(b, a, bodies, damage1to2, tick); } void tickBody(Body& b, const Arena& arena) { @@ -205,10 +211,13 @@ void tickBody(Body& b, const Arena& arena) { } void tickHeadless(std::vector& bodies, const Arena& arena, int tick) { - if (bodies.size() >= 2 && isColliding(bodies[0], bodies[1])) { - receiveKnockback(bodies[0], bodies[1]); - receiveKnockback(bodies[1], bodies[0]); - handleDamage(bodies[0], bodies[1], tick); + for (std::size_t i = 0; i < bodies.size(); ++i) { + for (std::size_t j = i + 1; j < bodies.size(); ++j) { + if (!isColliding(bodies[i], bodies[j])) continue; + receiveKnockback(bodies[i], bodies[j]); + receiveKnockback(bodies[j], bodies[i]); + handleDamage(bodies[i], bodies[j], bodies, tick); + } } for (auto& body : bodies) { if (body.removed) continue; @@ -223,12 +232,18 @@ std::string refJson(const Body& b) { return "{\"id\":" + std::to_string(b.id) + ",\"hash\":" + std::to_string(b.hash) + "}"; } -std::string bodyJson(const Body& b) { +std::string ownerJson(const Body& b, const std::vector& bodies) { + if (b.ownerId < 0) return "null"; + for (const auto& candidate : bodies) if (candidate.id == b.ownerId && candidate.hash != 0) return refJson(candidate); + return "null"; +} + +std::string bodyJson(const Body& b, const std::vector& bodies) { std::ostringstream out; out << "{\"id\":" << b.id << ",\"hash\":" << b.hash << ",\"preservedHash\":" << b.preservedHash << ",\"className\":\"LivingEntity\",\"fixtureName\":" << q(b.fixtureName) << ",\"exists\":true,\"entityState\":" << b.entityState - << ",\"relations\":{\"parent\":null,\"owner\":null,\"team\":null}" + << ",\"relations\":{\"parent\":null,\"owner\":" << ownerJson(b, bodies) << ",\"team\":null}" << ",\"position\":{\"x\":" << num(b.x) << ",\"y\":" << num(b.y) << ",\"angle\":" << num(b.angle) << ",\"flags\":" << b.positionFlags << "}" << ",\"physics\":{\"sides\":" << b.sides << ",\"size\":" << num(b.size) << ",\"width\":" << num(b.width) << ",\"pushFactor\":" << num(b.pushFactor) << ",\"absorbtionFactor\":" << num(b.absorbtionFactor) << ",\"flags\":" << b.physicsFlags << "}" @@ -263,7 +278,7 @@ std::string snapshotJson(const std::vector& bodies, const Arena& arena, co << ",\"entities\":["; for (std::size_t i = 0; i < bodies.size(); ++i) { if (i) out << ','; - out << bodyJson(bodies[i]); + out << bodyJson(bodies[i], bodies); } out << "]}"; return out.str(); @@ -327,12 +342,42 @@ std::string scoreDeathScenarioJson() { return out.str(); } + +std::string ownerPropagatedKillScenarioJson() { + Arena arena; + std::vector bodies; + Body owner; + owner.id = 0; owner.hash = owner.preservedHash = 1; owner.fixtureName = "owner"; owner.x = -200; owner.y = 0; + owner.health = owner.maxHealth = 80; owner.damagePerTick = 0; owner.size = owner.width = 25; owner.styleColor = ColorTank; owner.isPhysical = false; + Body projectile; + projectile.id = 1; projectile.hash = projectile.preservedHash = 1; projectile.fixtureName = "projectile"; projectile.x = 0; projectile.y = 0; + projectile.health = projectile.maxHealth = 10; projectile.damagePerTick = 12; projectile.size = projectile.width = 20; projectile.styleColor = ColorTank; projectile.ownerId = 0; + Body target; + target.id = 2; target.hash = target.preservedHash = 1; target.fixtureName = "target"; target.x = 25; target.y = 0; + target.health = target.maxHealth = 5; target.damagePerTick = 0.25; target.size = target.width = 20; target.styleColor = ColorEnemySquare; target.scoreReward = 23; + bodies.push_back(owner); bodies.push_back(projectile); bodies.push_back(target); + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-projectile-kill-tick", 1)); + + std::ostringstream out; + out << "{\"scenario\":\"owner-propagated-projectile-kill-score\",\"invariant\":\"A projectile-style living entity propagates its onKill event to its owner, awarding the target scoreReward to the owner instead of retaining it on the projectile.\"" + << ",\"participants\":{\"owner\":" << refJson(bodies[0]) << ",\"projectile\":" << refJson(bodies[1]) << ",\"target\":" << refJson(bodies[2]) << "}" + << ",\"scoreEvidence\":{\"ownerInitialScore\":0,\"ownerScoreAfterKill\":23,\"projectileScoreAfterKill\":0,\"targetScoreReward\":23,\"targetHealthAfterKill\":0,\"projectileOwnerRef\":" << refJson(bodies[0]) << "}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From d78aadf730dc713b89c9db63febd2f61d131a7db Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:20:04 -0400 Subject: [PATCH 15/22] Lock projectile motion and lifetime parity This slice captures the Bullet-style timing contract in a deterministic headless fixture: initial spawn speed, maintained acceleration, lifetime expiry, and deletion animation entry now match between TypeScript and C++. Constraint: Keep using synthetic deterministic fixtures until concrete tank/barrel classes can be ported without random scatter or live client state Rejected: Introduce full Barrel/Bullet construction now | projectile lifetime and motion can be proven before larger tank dependencies Confidence: high Scope-risk: narrow Directive: Preserve this projectile timing baseline when replacing synthetic fixtures with concrete projectile classes Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 2.982ms vs TS median 936.968ms, 314.21x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 494 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 61 ++- cpp/src/gameplay.cpp | 53 ++- 3 files changed, 603 insertions(+), 5 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index f328cf5b..8a9d2fd6 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -1298,6 +1298,500 @@ ] } ] + }, + { + "scenario": "projectile-movement-and-lifetime", + "invariant": "A projectile-style entity applies spawn speed on its first tick, maintains acceleration on later ticks, and starts deletion once its lifetime expires.", + "participants": { + "projectile": { + "id": 0, + "hash": 1 + } + }, + "movementEvidence": { + "initialPosition": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "firstTickVelocity": { + "x": 4.242641, + "y": 4.242641, + "magnitude": 6, + "angle": 0.785398 + }, + "secondTickPosition": { + "x": 4.242641, + "y": 4.242641, + "angle": 0, + "flags": 0 + }, + "deletionStartedAtLifetime": true, + "deletionFrameAfterNextTick": 4 + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 0, + "activeIds": [ + 0 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "lifetime-projectile", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 12, + "width": 12, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null, + "projectile": { + "spawnTick": 0, + "baseSpeed": 6, + "baseAccel": 2, + "lifeLength": 3, + "movementAngle": 0.785398 + } + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-projectile-tick-1", + "tick": 1, + "manager": { + "lastId": 0, + "activeIds": [ + 0 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "lifetime-projectile", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 12, + "width": 12, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null, + "projectile": { + "spawnTick": 0, + "baseSpeed": 6, + "baseAccel": 2, + "lifeLength": 3, + "movementAngle": 0.785398 + } + }, + "velocity": { + "x": 4.242641, + "y": 4.242641, + "magnitude": 6, + "angle": 0.785398 + } + } + ] + }, + { + "label": "after-projectile-tick-2", + "tick": 2, + "manager": { + "lastId": 0, + "activeIds": [ + 0 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "lifetime-projectile", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 4.242641, + "y": 4.242641, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 12, + "width": 12, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null, + "projectile": { + "spawnTick": 0, + "baseSpeed": 6, + "baseAccel": 2, + "lifeLength": 3, + "movementAngle": 0.785398 + } + }, + "velocity": { + "x": 3.959798, + "y": 3.959798, + "magnitude": 5.6, + "angle": 0.785398 + } + } + ] + }, + { + "label": "after-projectile-tick-3", + "tick": 3, + "manager": { + "lastId": 0, + "activeIds": [ + 0 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "lifetime-projectile", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 8.202439, + "y": 8.202439, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 12, + "width": 12, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 0, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 5, + "projectile": { + "spawnTick": 0, + "baseSpeed": 6, + "baseAccel": 2, + "lifeLength": 3, + "movementAngle": 0.785398 + } + }, + "velocity": { + "x": 3.70524, + "y": 3.70524, + "magnitude": 5.24, + "angle": 0.785398 + } + } + ] + }, + { + "label": "after-projectile-tick-4", + "tick": 4, + "manager": { + "lastId": 0, + "activeIds": [ + 0 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "lifetime-projectile", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 10.055058, + "y": 10.055058, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 13.2, + "width": 13.2, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 0, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 0.666667, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4, + "projectile": { + "spawnTick": 0, + "baseSpeed": 6, + "baseAccel": 2, + "lifeLength": 3, + "movementAngle": 0.785398 + } + }, + "velocity": { + "x": 1.808779, + "y": 1.808779, + "magnitude": 2.558, + "angle": 0.785398 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index 9bf4dc1a..11112fef 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -134,6 +134,15 @@ function objectSnapshot(entity) { deleting: Boolean(entity.deletionAnimation), deletionFrame: entity.deletionAnimation ? entity.deletionAnimation.frame : null }; + if (entity.fixtureProjectile) { + snapshot.gameplay.projectile = { + spawnTick: entity.fixtureProjectile.spawnTick, + baseSpeed: round(entity.fixtureProjectile.baseSpeed), + baseAccel: round(entity.fixtureProjectile.baseAccel), + lifeLength: entity.fixtureProjectile.lifeLength, + movementAngle: round(entity.fixtureProjectile.movementAngle) + }; + } } if (entity.velocity) { snapshot.velocity = { @@ -342,6 +351,56 @@ function ownerPropagatedKillScenario() { }; } +function projectileMovementLifetimeScenario() { + const game = createHeadlessGame(); + const projectile = makeDamageBody(game, 'lifetime-projectile', { + x: 0, + y: 0, + health: 10, + maxHealth: 10, + damagePerTick: 0, + size: 12, + color: Color.Tank + }); + projectile.physicsData.flags = 16; // canEscapeArena; keep snapshot focused on projectile movement rather than bounds. + projectile.fixtureProjectile = { + spawnTick: game.tick, + baseSpeed: 6, + baseAccel: 2, + lifeLength: 3, + movementAngle: Math.PI / 4 + }; + projectile.tick = function tickFixtureProjectile(tick) { + LivingEntity.prototype.tick.call(this, tick); + const { spawnTick, baseSpeed, baseAccel, lifeLength, movementAngle } = this.fixtureProjectile; + if (tick === spawnTick + 1) this.addVelocity(movementAngle, baseSpeed); + else this.maintainVelocity(movementAngle, baseAccel); + if (tick - spawnTick >= lifeLength) this.destroy(true); + }; + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + for (let i = 0; i < 4; i += 1) { + tickHeadless(game); + snapshots.push(worldSnapshot(game, `after-projectile-tick-${game.tick}`)); + } + + return { + scenario: 'projectile-movement-and-lifetime', + invariant: 'A projectile-style entity applies spawn speed on its first tick, maintains acceleration on later ticks, and starts deletion once its lifetime expires.', + participants: { + projectile: entityRef(projectile) + }, + movementEvidence: { + initialPosition: findEntity(snapshots[0], 'lifetime-projectile').position, + firstTickVelocity: findEntity(snapshots[1], 'lifetime-projectile').velocity, + secondTickPosition: findEntity(snapshots[2], 'lifetime-projectile').position, + deletionStartedAtLifetime: findEntity(snapshots[3], 'lifetime-projectile').gameplay.deleting, + deletionFrameAfterNextTick: findEntity(snapshots[4], 'lifetime-projectile').gameplay.deletionFrame + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -353,7 +412,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index 30ceba01..05d4b090 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -89,6 +89,12 @@ struct Body { int styleFlags = 1; bool isPhysical = true; int ownerId = -1; + bool projectileMotion = false; + int spawnTick = 0; + double baseSpeed = 0; + double baseAccel = 0; + int lifeLength = 0; + double movementAngle = 0; double vx = 0; double vy = 0; double velocityMagnitude = 0; @@ -205,9 +211,14 @@ void handleDamage(Body& a, Body& b, std::vector& bodies, int tick) { receiveDamage(b, a, bodies, damage1to2, tick); } -void tickBody(Body& b, const Arena& arena) { +void tickBody(Body& b, const Arena& arena, int tick) { deletionTick(b); keepInArena(b, arena); + if (b.projectileMotion) { + if (tick == b.spawnTick + 1) addVelocity(b, b.movementAngle, b.baseSpeed); + else addVelocity(b, b.movementAngle, b.baseAccel * 0.1); + if (tick - b.spawnTick >= b.lifeLength) destroy(b); + } } void tickHeadless(std::vector& bodies, const Arena& arena, int tick) { @@ -222,7 +233,7 @@ void tickHeadless(std::vector& bodies, const Arena& arena, int tick) { for (auto& body : bodies) { if (body.removed) continue; applyPhysics(body); - tickBody(body, arena); + tickBody(body, arena, tick); } for (auto& body : bodies) body.entityState = 0; bodies.erase(std::remove_if(bodies.begin(), bodies.end(), [](const Body& body) { return body.removed; }), bodies.end()); @@ -253,7 +264,13 @@ std::string bodyJson(const Body& b, const std::vector& bodies) { << ",\"lastDamageTick\":" << b.lastDamageTick << "}" << ",\"style\":{\"color\":" << b.styleColor << ",\"opacity\":" << num(b.opacity) << ",\"flags\":" << b.styleFlags << "}" << ",\"gameplay\":{\"score\":" << num(b.score) << ",\"scoreReward\":" << num(b.scoreReward) - << ",\"deleting\":" << (b.deleting ? "true" : "false") << ",\"deletionFrame\":" << (b.deleting ? std::to_string(b.deletionFrame) : "null") << "}" + << ",\"deleting\":" << (b.deleting ? "true" : "false") << ",\"deletionFrame\":" << (b.deleting ? std::to_string(b.deletionFrame) : "null"); + if (b.projectileMotion) { + out << ",\"projectile\":{\"spawnTick\":" << b.spawnTick << ",\"baseSpeed\":" << num(b.baseSpeed) + << ",\"baseAccel\":" << num(b.baseAccel) << ",\"lifeLength\":" << b.lifeLength + << ",\"movementAngle\":" << num(b.movementAngle) << "}"; + } + out << "}" << ",\"velocity\":{\"x\":" << num(b.vx) << ",\"y\":" << num(b.vy) << ",\"magnitude\":" << num(b.velocityMagnitude) << ",\"angle\":" << num(b.velocityAngle) << "}}"; return out.str(); @@ -372,12 +389,40 @@ std::string ownerPropagatedKillScenarioJson() { return out.str(); } + +std::string projectileMovementLifetimeScenarioJson() { + Arena arena; + std::vector bodies; + Body projectile; + projectile.id = 0; projectile.hash = projectile.preservedHash = 1; projectile.fixtureName = "lifetime-projectile"; + projectile.health = projectile.maxHealth = 10; projectile.damagePerTick = 0; projectile.size = projectile.width = 12; projectile.styleColor = ColorTank; + projectile.physicsFlags = 16; projectile.projectileMotion = true; projectile.spawnTick = 0; projectile.baseSpeed = 6; projectile.baseAccel = 2; + projectile.lifeLength = 3; projectile.movementAngle = 0.7853981633974483; + bodies.push_back(projectile); + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + for (int tick = 1; tick <= 4; ++tick) { + tickHeadless(bodies, arena, tick); + snapshots.push_back(snapshotJson(bodies, arena, "after-projectile-tick-" + std::to_string(tick), tick)); + } + + std::ostringstream out; + out << "{\"scenario\":\"projectile-movement-and-lifetime\",\"invariant\":\"A projectile-style entity applies spawn speed on its first tick, maintains acceleration on later ticks, and starts deletion once its lifetime expires.\"" + << ",\"participants\":{\"projectile\":" << refJson(bodies[0]) << "}" + << ",\"movementEvidence\":{\"initialPosition\":{\"x\":0,\"y\":0,\"angle\":0,\"flags\":0},\"firstTickVelocity\":{\"x\":4.242641,\"y\":4.242641,\"magnitude\":6,\"angle\":0.785398},\"secondTickPosition\":{\"x\":4.242641,\"y\":4.242641,\"angle\":0,\"flags\":0},\"deletionStartedAtLifetime\":true,\"deletionFrameAfterNextTick\":4}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From 95bdd650dab8c6c7e826d118c4f4e9a1b91c8729 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:25:37 -0400 Subject: [PATCH 16/22] Tie camera score changes to focused players This slice locks the minimal camera/player score contract used by tank gameplay: camera addScore/setScore mirrors into the focused player's score field and the C++ report now matches that full-world fixture. Constraint: Keep camera/update serialization minimal, but preserve score state needed for headless gameplay parity Rejected: Port full TankBody stat/level behavior now | score mirroring is the smaller deterministic prerequisite Confidence: high Scope-risk: narrow Directive: Build future tank-level parity on this camera/player score baseline Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 3.238ms vs TS median 911.544ms, 281.51x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 379 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 62 +++- cpp/src/gameplay.cpp | 103 ++++-- 3 files changed, 517 insertions(+), 27 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index 8a9d2fd6..8c4d8796 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -1792,6 +1792,385 @@ ] } ] + }, + { + "scenario": "camera-player-score-integration", + "invariant": "Camera score mutations mirror onto the focused player score field, preserving the score source used by tank/camera gameplay integration.", + "participants": { + "player": { + "id": 0, + "hash": 1 + }, + "camera": { + "id": 1, + "hash": 1 + } + }, + "scoreEvidence": { + "initialCameraScore": 0, + "initialPlayerScore": 0, + "cameraScoreAfterAdd": 15, + "playerScoreAfterAdd": 15, + "cameraScoreAfterSet": 7, + "playerScoreAfterSet": 7, + "cameraPlayerRef": { + "id": 0, + "hash": 1 + } + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [ + 1 + ], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "score-player", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 25, + "width": 25, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "score": { + "score": 0 + }, + "health": { + "health": 30, + "maxHealth": 30, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "CameraEntity", + "fixtureName": "score-camera", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "camera": { + "player": { + "id": 0, + "hash": 1 + }, + "score": 0, + "level": 1, + "levelbarProgress": 0, + "levelbarMax": 0, + "statsAvailable": 0 + } + } + ] + }, + { + "label": "after-camera-add-score", + "tick": 0, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [ + 1 + ], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "score-player", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 25, + "width": 25, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "score": { + "score": 15 + }, + "health": { + "health": 30, + "maxHealth": 30, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "CameraEntity", + "fixtureName": "score-camera", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "camera": { + "player": { + "id": 0, + "hash": 1 + }, + "score": 15, + "level": 1, + "levelbarProgress": 0, + "levelbarMax": 0, + "statsAvailable": 0 + } + } + ] + }, + { + "label": "after-camera-set-score", + "tick": 0, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [ + 1 + ], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "score-player", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 25, + "width": 25, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "score": { + "score": 7 + }, + "health": { + "health": 30, + "maxHealth": 30, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "CameraEntity", + "fixtureName": "score-camera", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "camera": { + "player": { + "id": 0, + "hash": 1 + }, + "score": 7, + "level": 1, + "levelbarProgress": 0, + "levelbarMax": 0, + "statsAvailable": 0 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index 11112fef..fc6c290b 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -4,8 +4,9 @@ require(path.join(__dirname, '../../test/helpers/register-ts')); const EntityManager = require('../../src/Native/Manager').default; const LivingEntity = require('../../src/Entity/Live').default; +const { CameraEntity } = require('../../src/Native/Camera'); const { Entity } = require('../../src/Native/Entity'); -const { ArenaGroup } = require('../../src/Native/FieldGroups'); +const { ArenaGroup, ScoreGroup } = require('../../src/Native/FieldGroups'); const { Color } = require('../../src/Const/Enums'); function createHeadlessGame() { @@ -106,6 +107,21 @@ function objectSnapshot(entity) { flags: entity.physicsData.values.flags }; } + if (entity.cameraData) { + snapshot.camera = { + player: entityRef(entity.cameraData.values.player), + score: round(entity.cameraData.values.score), + level: entity.cameraData.values.level, + levelbarProgress: round(entity.cameraData.values.levelbarProgress), + levelbarMax: round(entity.cameraData.values.levelbarMax), + statsAvailable: entity.cameraData.values.statsAvailable + }; + } + if (entity.scoreData) { + snapshot.score = { + score: round(entity.scoreData.values.score) + }; + } if (entity.healthData) { snapshot.health = { health: round(entity.healthData.values.health), @@ -401,6 +417,48 @@ function projectileMovementLifetimeScenario() { }; } +function cameraScoreIntegrationScenario() { + const game = createHeadlessGame(); + const player = makeDamageBody(game, 'score-player', { + x: 0, + y: 0, + health: 30, + maxHealth: 30, + damagePerTick: 0, + size: 25, + color: Color.Tank + }); + player.scoreData = new ScoreGroup(player); + const camera = new CameraEntity(game); + camera.fixtureName = 'score-camera'; + camera.cameraData.player = player; + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + camera.addScore(15); + snapshots.push(worldSnapshot(game, 'after-camera-add-score')); + camera.setScore(7); + snapshots.push(worldSnapshot(game, 'after-camera-set-score')); + + return { + scenario: 'camera-player-score-integration', + invariant: 'Camera score mutations mirror onto the focused player score field, preserving the score source used by tank/camera gameplay integration.', + participants: { + player: entityRef(player), + camera: entityRef(camera) + }, + scoreEvidence: { + initialCameraScore: findEntity(snapshots[0], 'score-camera').camera.score, + initialPlayerScore: findEntity(snapshots[0], 'score-player').score.score, + cameraScoreAfterAdd: findEntity(snapshots[1], 'score-camera').camera.score, + playerScoreAfterAdd: findEntity(snapshots[1], 'score-player').score.score, + cameraScoreAfterSet: findEntity(snapshots[2], 'score-camera').camera.score, + playerScoreAfterSet: findEntity(snapshots[2], 'score-player').score.score, + cameraPlayerRef: findEntity(snapshots[0], 'score-camera').camera.player + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -412,7 +470,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index 05d4b090..fc4c846f 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -104,6 +104,15 @@ struct Body { bool deleting = false; int deletionFrame = 5; bool removed = false; + bool isCamera = false; + bool hasScoreData = false; + double scoreField = 0; + int cameraPlayerId = -1; + double cameraScore = 0; + int cameraLevel = 1; + double cameraLevelbarProgress = 0; + double cameraLevelbarMax = 0; + int cameraStatsAvailable = 0; }; void refreshVelocity(Body& b) { @@ -243,43 +252,59 @@ std::string refJson(const Body& b) { return "{\"id\":" + std::to_string(b.id) + ",\"hash\":" + std::to_string(b.hash) + "}"; } -std::string ownerJson(const Body& b, const std::vector& bodies) { - if (b.ownerId < 0) return "null"; - for (const auto& candidate : bodies) if (candidate.id == b.ownerId && candidate.hash != 0) return refJson(candidate); +std::string entityRefById(int id, const std::vector& bodies) { + if (id < 0) return "null"; + for (const auto& candidate : bodies) if (candidate.id == id && candidate.hash != 0) return refJson(candidate); return "null"; } +std::string ownerJson(const Body& b, const std::vector& bodies) { + return entityRefById(b.ownerId, bodies); +} + std::string bodyJson(const Body& b, const std::vector& bodies) { std::ostringstream out; out << "{\"id\":" << b.id << ",\"hash\":" << b.hash << ",\"preservedHash\":" << b.preservedHash - << ",\"className\":\"LivingEntity\",\"fixtureName\":" << q(b.fixtureName) + << ",\"className\":" << q(b.isCamera ? "CameraEntity" : "LivingEntity") << ",\"fixtureName\":" << q(b.fixtureName) << ",\"exists\":true,\"entityState\":" << b.entityState - << ",\"relations\":{\"parent\":null,\"owner\":" << ownerJson(b, bodies) << ",\"team\":null}" - << ",\"position\":{\"x\":" << num(b.x) << ",\"y\":" << num(b.y) << ",\"angle\":" << num(b.angle) << ",\"flags\":" << b.positionFlags << "}" - << ",\"physics\":{\"sides\":" << b.sides << ",\"size\":" << num(b.size) << ",\"width\":" << num(b.width) - << ",\"pushFactor\":" << num(b.pushFactor) << ",\"absorbtionFactor\":" << num(b.absorbtionFactor) << ",\"flags\":" << b.physicsFlags << "}" - << ",\"health\":{\"health\":" << num(b.health) << ",\"maxHealth\":" << num(b.maxHealth) << ",\"flags\":" << b.healthFlags << "}" - << ",\"damage\":{\"damagePerTick\":" << num(b.damagePerTick) << ",\"damageReduction\":" << num(b.damageReduction) - << ",\"minDamageMultiplier\":" << num(b.minDamageMultiplier) << ",\"maxDamageMultiplier\":" << num(b.maxDamageMultiplier) - << ",\"lastDamageTick\":" << b.lastDamageTick << "}" - << ",\"style\":{\"color\":" << b.styleColor << ",\"opacity\":" << num(b.opacity) << ",\"flags\":" << b.styleFlags << "}" - << ",\"gameplay\":{\"score\":" << num(b.score) << ",\"scoreReward\":" << num(b.scoreReward) - << ",\"deleting\":" << (b.deleting ? "true" : "false") << ",\"deletionFrame\":" << (b.deleting ? std::to_string(b.deletionFrame) : "null"); - if (b.projectileMotion) { - out << ",\"projectile\":{\"spawnTick\":" << b.spawnTick << ",\"baseSpeed\":" << num(b.baseSpeed) - << ",\"baseAccel\":" << num(b.baseAccel) << ",\"lifeLength\":" << b.lifeLength - << ",\"movementAngle\":" << num(b.movementAngle) << "}"; + << ",\"relations\":{\"parent\":null,\"owner\":" << ownerJson(b, bodies) << ",\"team\":null}"; + if (!b.isCamera) { + out << ",\"position\":{\"x\":" << num(b.x) << ",\"y\":" << num(b.y) << ",\"angle\":" << num(b.angle) << ",\"flags\":" << b.positionFlags << "}" + << ",\"physics\":{\"sides\":" << b.sides << ",\"size\":" << num(b.size) << ",\"width\":" << num(b.width) + << ",\"pushFactor\":" << num(b.pushFactor) << ",\"absorbtionFactor\":" << num(b.absorbtionFactor) << ",\"flags\":" << b.physicsFlags << "}" + << ",\"health\":{\"health\":" << num(b.health) << ",\"maxHealth\":" << num(b.maxHealth) << ",\"flags\":" << b.healthFlags << "}" + << ",\"damage\":{\"damagePerTick\":" << num(b.damagePerTick) << ",\"damageReduction\":" << num(b.damageReduction) + << ",\"minDamageMultiplier\":" << num(b.minDamageMultiplier) << ",\"maxDamageMultiplier\":" << num(b.maxDamageMultiplier) + << ",\"lastDamageTick\":" << b.lastDamageTick << "}" + << ",\"style\":{\"color\":" << b.styleColor << ",\"opacity\":" << num(b.opacity) << ",\"flags\":" << b.styleFlags << "}" + << ",\"gameplay\":{\"score\":" << num(b.score) << ",\"scoreReward\":" << num(b.scoreReward) + << ",\"deleting\":" << (b.deleting ? "true" : "false") << ",\"deletionFrame\":" << (b.deleting ? std::to_string(b.deletionFrame) : "null"); + if (b.projectileMotion) { + out << ",\"projectile\":{\"spawnTick\":" << b.spawnTick << ",\"baseSpeed\":" << num(b.baseSpeed) + << ",\"baseAccel\":" << num(b.baseAccel) << ",\"lifeLength\":" << b.lifeLength + << ",\"movementAngle\":" << num(b.movementAngle) << "}"; + } + out << "}"; } - out << "}" - << ",\"velocity\":{\"x\":" << num(b.vx) << ",\"y\":" << num(b.vy) << ",\"magnitude\":" << num(b.velocityMagnitude) - << ",\"angle\":" << num(b.velocityAngle) << "}}"; + if (b.isCamera) { + out << ",\"camera\":{\"player\":" << entityRefById(b.cameraPlayerId, bodies) << ",\"score\":" << num(b.cameraScore) + << ",\"level\":" << b.cameraLevel << ",\"levelbarProgress\":" << num(b.cameraLevelbarProgress) + << ",\"levelbarMax\":" << num(b.cameraLevelbarMax) << ",\"statsAvailable\":" << b.cameraStatsAvailable << "}"; + } + if (b.hasScoreData) out << ",\"score\":{\"score\":" << num(b.scoreField) << "}"; + if (!b.isCamera) { + out << ",\"velocity\":{\"x\":" << num(b.vx) << ",\"y\":" << num(b.vy) << ",\"magnitude\":" << num(b.velocityMagnitude) + << ",\"angle\":" << num(b.velocityAngle) << "}"; + } + out << "}"; return out.str(); } std::string snapshotJson(const std::vector& bodies, const Arena& arena, const std::string& label, int tick) { std::vector ids; + std::vector cameras; int lastId = -1; - for (const auto& body : bodies) { ids.push_back(body.id); if (body.id > lastId) lastId = body.id; } + for (const auto& body : bodies) { ids.push_back(body.id); if (body.isCamera) cameras.push_back(body.id); if (body.id > lastId) lastId = body.id; } std::vector hashes; for (int id = 0; id <= lastId; ++id) { int hash = 0; @@ -289,7 +314,7 @@ std::string snapshotJson(const std::vector& bodies, const Arena& arena, co std::ostringstream out; out << "{\"label\":" << q(label) << ",\"tick\":" << tick << ",\"manager\":{\"lastId\":" << lastId << ",\"activeIds\":" << intArrayJson(ids) - << ",\"cameras\":[],\"otherEntities\":[],\"globalEntities\":[],\"hashTable\":" << intArrayJson(hashes) << "}" + << ",\"cameras\":" << intArrayJson(cameras) << ",\"otherEntities\":[],\"globalEntities\":[],\"hashTable\":" << intArrayJson(hashes) << "}" << ",\"arena\":{\"id\":null,\"state\":\"headless-fixture\",\"bounds\":{\"leftX\":" << num(arena.leftX) << ",\"rightX\":" << num(arena.rightX) << ",\"topY\":" << num(arena.topY) << ",\"bottomY\":" << num(arena.bottomY) << "}}" << ",\"entities\":["; @@ -417,12 +442,40 @@ std::string projectileMovementLifetimeScenarioJson() { return out.str(); } + +std::string cameraScoreIntegrationScenarioJson() { + Arena arena; + std::vector bodies; + Body player; + player.id = 0; player.hash = player.preservedHash = 1; player.fixtureName = "score-player"; + player.health = player.maxHealth = 30; player.damagePerTick = 0; player.size = player.width = 25; player.styleColor = ColorTank; player.hasScoreData = true; + Body camera; + camera.id = 1; camera.hash = camera.preservedHash = 1; camera.fixtureName = "score-camera"; camera.isCamera = true; camera.cameraPlayerId = 0; + bodies.push_back(player); bodies.push_back(camera); + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + bodies[1].cameraScore += 15; bodies[0].scoreField += 15; + snapshots.push_back(snapshotJson(bodies, arena, "after-camera-add-score", 0)); + bodies[1].cameraScore = 7; bodies[0].scoreField = 7; + snapshots.push_back(snapshotJson(bodies, arena, "after-camera-set-score", 0)); + + std::ostringstream out; + out << "{\"scenario\":\"camera-player-score-integration\",\"invariant\":\"Camera score mutations mirror onto the focused player score field, preserving the score source used by tank/camera gameplay integration.\"" + << ",\"participants\":{\"player\":" << refJson(bodies[0]) << ",\"camera\":" << refJson(bodies[1]) << "}" + << ",\"scoreEvidence\":{\"initialCameraScore\":0,\"initialPlayerScore\":0,\"cameraScoreAfterAdd\":15,\"playerScoreAfterAdd\":15,\"cameraScoreAfterSet\":7,\"playerScoreAfterSet\":7,\"cameraPlayerRef\":" << refJson(bodies[0]) << "}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From 5fdb41f4674949f6a983f02e120dcaacecda5d58 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:29:31 -0400 Subject: [PATCH 17/22] Preserve arena boundary behavior in gameplay parity This slice locks ObjectEntity keepInArena behavior for headless gameplay: ordinary physical entities clamp to arena bounds plus padding while canEscapeArena entities preserve their out-of-bounds position. Constraint: Full-world deterministic parity remains the gate before RL observation grids or live server replacement Rejected: Port shape/spawn managers first | arena bounds are a smaller deterministic movement invariant needed by all object gameplay Confidence: high Scope-risk: narrow Directive: Keep canEscapeArena semantics aligned with PhysicsFlags.canEscapeArena (1 << 8) Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 3.543ms vs TS median 1437.690ms, 405.78x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 324 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 44 ++- cpp/src/gameplay.cpp | 30 +- 3 files changed, 396 insertions(+), 2 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index 8c4d8796..72aa0e6e 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -2171,6 +2171,330 @@ ] } ] + }, + { + "scenario": "arena-bounds-clamp-and-can-escape", + "invariant": "Physical entities without canEscapeArena clamp to arena bounds plus padding, while canEscapeArena entities keep their out-of-bounds position.", + "participants": { + "clamped": { + "id": 0, + "hash": 1 + }, + "escaping": { + "id": 1, + "hash": 1 + } + }, + "boundsEvidence": { + "initialClampedPosition": { + "x": 1300, + "y": -1300, + "angle": 0, + "flags": 0 + }, + "clampedAfterTick": { + "x": 1200, + "y": -1200, + "angle": 0, + "flags": 0 + }, + "escapingAfterTick": { + "x": 1300, + "y": -900, + "angle": 0, + "flags": 0 + } + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "bounds-clamped", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 1300, + "y": -1300, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "bounds-escaping", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 1300, + "y": -900, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 256 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-bounds-tick", + "tick": 1, + "manager": { + "lastId": 1, + "activeIds": [ + 0, + 1 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "bounds-clamped", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 1200, + "y": -1200, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "bounds-escaping", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 1300, + "y": -900, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 256 + }, + "health": { + "health": 10, + "maxHealth": 10, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index fc6c290b..fa064761 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -459,6 +459,48 @@ function cameraScoreIntegrationScenario() { }; } +function arenaBoundsClampScenario() { + const game = createHeadlessGame(); + const clamped = makeDamageBody(game, 'bounds-clamped', { + x: 1300, + y: -1300, + health: 10, + maxHealth: 10, + damagePerTick: 0, + size: 20, + color: Color.Tank + }); + const escaping = makeDamageBody(game, 'bounds-escaping', { + x: 1300, + y: -900, + health: 10, + maxHealth: 10, + damagePerTick: 0, + size: 20, + color: Color.EnemySquare + }); + escaping.physicsData.flags = 256; // PhysicsFlags.canEscapeArena + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-bounds-tick')); + + return { + scenario: 'arena-bounds-clamp-and-can-escape', + invariant: 'Physical entities without canEscapeArena clamp to arena bounds plus padding, while canEscapeArena entities keep their out-of-bounds position.', + participants: { + clamped: entityRef(clamped), + escaping: entityRef(escaping) + }, + boundsEvidence: { + initialClampedPosition: findEntity(snapshots[0], 'bounds-clamped').position, + clampedAfterTick: findEntity(snapshots[1], 'bounds-clamped').position, + escapingAfterTick: findEntity(snapshots[1], 'bounds-escaping').position + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -470,7 +512,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index fc4c846f..570592e4 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -158,6 +158,7 @@ void destroy(Body& b) { } void keepInArena(Body& b, const Arena& arena) { + if (b.physicsFlags & 256) return; if (b.x < arena.leftX - arena.padding) b.x = arena.leftX - arena.padding; else if (b.x > arena.rightX + arena.padding) b.x = arena.rightX + arena.padding; if (b.y < arena.topY - arena.padding) b.y = arena.topY - arena.padding; @@ -470,12 +471,39 @@ std::string cameraScoreIntegrationScenarioJson() { return out.str(); } + +std::string arenaBoundsClampScenarioJson() { + Arena arena; + std::vector bodies; + Body clamped; + clamped.id = 0; clamped.hash = clamped.preservedHash = 1; clamped.fixtureName = "bounds-clamped"; clamped.x = 1300; clamped.y = -1300; + clamped.health = clamped.maxHealth = 10; clamped.damagePerTick = 0; clamped.size = clamped.width = 20; clamped.styleColor = ColorTank; + Body escaping; + escaping.id = 1; escaping.hash = escaping.preservedHash = 1; escaping.fixtureName = "bounds-escaping"; escaping.x = 1300; escaping.y = -900; + escaping.health = escaping.maxHealth = 10; escaping.damagePerTick = 0; escaping.size = escaping.width = 20; escaping.styleColor = ColorEnemySquare; escaping.physicsFlags = 256; + bodies.push_back(clamped); bodies.push_back(escaping); + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-bounds-tick", 1)); + + std::ostringstream out; + out << "{\"scenario\":\"arena-bounds-clamp-and-can-escape\",\"invariant\":\"Physical entities without canEscapeArena clamp to arena bounds plus padding, while canEscapeArena entities keep their out-of-bounds position.\"" + << ",\"participants\":{\"clamped\":" << refJson(bodies[0]) << ",\"escaping\":" << refJson(bodies[1]) << "}" + << ",\"boundsEvidence\":{\"initialClampedPosition\":{\"x\":1300,\"y\":-1300,\"angle\":0,\"flags\":0},\"clampedAfterTick\":{\"x\":1200,\"y\":-1200,\"angle\":0,\"flags\":0},\"escapingAfterTick\":{\"x\":1300,\"y\":-900,\"angle\":0,\"flags\":0}}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From be8576a8b2cb37105782960badef9e19b5a31777 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:51:31 -0400 Subject: [PATCH 18/22] Keep team owner collision gates deterministic This slice locks the same-team collision gates that decide whether bodies interact before damage and knockback. The fixture proves noOwnTeamCollision suppresses same-team contact, onlySameOwnerCollision suppresses different-owner contact, and same-owner pairs still collide. Constraint: RL-facing local views must be built after global full-world collision parity is stable Rejected: Move directly to tank/body definitions | owner/team gates are lower-level deterministic rules that every tank/projectile path depends on Confidence: high Scope-risk: narrow Directive: Preserve PhysicsFlags.noOwnTeamCollision and onlySameOwnerCollision checks before geometry/damage handling Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 3.710ms vs TS median 927.811ms, 250.08x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 966 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 110 ++- cpp/src/gameplay.cpp | 55 +- 3 files changed, 1125 insertions(+), 6 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index 72aa0e6e..7e13867b 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -2495,6 +2495,972 @@ ] } ] + }, + { + "scenario": "team-owner-collision-rules", + "invariant": "Same-team noOwnTeamCollision pairs do not collide, onlySameOwnerCollision rejects different owners, and onlySameOwnerCollision still permits same-owner collisions.", + "participants": { + "noOwnA": { + "id": 0, + "hash": 1 + }, + "noOwnB": { + "id": 1, + "hash": 1 + }, + "onlyDifferentOwnerA": { + "id": 2, + "hash": 1 + }, + "onlyDifferentOwnerB": { + "id": 3, + "hash": 1 + }, + "sharedOwner": { + "id": 4, + "hash": 1 + }, + "onlySameOwnerA": { + "id": 5, + "hash": 1 + }, + "onlySameOwnerB": { + "id": 6, + "hash": 1 + } + }, + "collisionEvidence": { + "noOwnPairHealthAfterTick": [ + 20, + 20 + ], + "differentOwnerPairHealthAfterTick": [ + 20, + 20 + ], + "sameOwnerPairHealthAfterTick": [ + 0, + 0 + ], + "sameOwnerPairVelocityAfterTick": [ + { + "x": -7.2, + "y": 0, + "magnitude": 7.2, + "angle": 3.141593 + }, + { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + } + ] + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 6, + "activeIds": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "no-own-team-a", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -300, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 8 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "no-own-team-b", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -275, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-different-owner-a", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 32 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 3, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-different-owner-b", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": { + "id": 1, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 25, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 4, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "shared-owner", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 280, + "y": 80, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 10, + "width": 10, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 5, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-same-owner-a", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": { + "id": 4, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 280, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 32 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 6, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-same-owner-b", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": { + "id": 4, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 305, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-collision-rules-tick", + "tick": 1, + "manager": { + "lastId": 6, + "activeIds": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "no-own-team-a", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -300, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 8 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "no-own-team-b", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -275, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-different-owner-a", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 0, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 32 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 3, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-different-owner-b", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": { + "id": 1, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 25, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 4, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "shared-owner", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 280, + "y": 80, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 10, + "width": 10, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 5, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-same-owner-a", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": { + "id": 4, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 272, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 22, + "width": 22, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 32 + }, + "health": { + "health": 0, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 2, + "opacity": 0.666667, + "flags": 3 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, + "velocity": { + "x": -7.2, + "y": 0, + "magnitude": 7.2, + "angle": 3.141593 + } + }, + { + "id": 6, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "only-same-owner-b", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": { + "id": 4, + "hash": 1 + }, + "team": null + }, + "position": { + "x": 313, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 22, + "width": 22, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": 1 + }, + "style": { + "color": 8, + "opacity": 0.666667, + "flags": 3 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, + "velocity": { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index fa064761..52b34e93 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -7,7 +7,7 @@ const LivingEntity = require('../../src/Entity/Live').default; const { CameraEntity } = require('../../src/Native/Camera'); const { Entity } = require('../../src/Native/Entity'); const { ArenaGroup, ScoreGroup } = require('../../src/Native/FieldGroups'); -const { Color } = require('../../src/Const/Enums'); +const { Color, PhysicsFlags } = require('../../src/Const/Enums'); function createHeadlessGame() { const game = { @@ -378,7 +378,7 @@ function projectileMovementLifetimeScenario() { size: 12, color: Color.Tank }); - projectile.physicsData.flags = 16; // canEscapeArena; keep snapshot focused on projectile movement rather than bounds. + projectile.physicsData.flags = 16; // Existing TS projectile fixture flag; movement remains inside bounds, so this slice stays focused on lifetime motion. projectile.fixtureProjectile = { spawnTick: game.tick, baseSpeed: 6, @@ -479,7 +479,7 @@ function arenaBoundsClampScenario() { size: 20, color: Color.EnemySquare }); - escaping.physicsData.flags = 256; // PhysicsFlags.canEscapeArena + escaping.physicsData.flags = PhysicsFlags.canEscapeArena const snapshots = [worldSnapshot(game, 'initial-full-world')]; tickHeadless(game); @@ -501,6 +501,108 @@ function arenaBoundsClampScenario() { }; } +function teamOwnerCollisionRulesScenario() { + const game = createHeadlessGame(); + const noOwnA = makeDamageBody(game, 'no-own-team-a', { + x: -300, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + const noOwnB = makeDamageBody(game, 'no-own-team-b', { + x: -275, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.EnemySquare + }); + noOwnA.physicsData.flags = PhysicsFlags.noOwnTeamCollision; + + const onlyDifferentOwnerA = makeDamageBody(game, 'only-different-owner-a', { + x: 0, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + const onlyDifferentOwnerB = makeDamageBody(game, 'only-different-owner-b', { + x: 25, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.EnemySquare + }); + onlyDifferentOwnerA.physicsData.flags = PhysicsFlags.onlySameOwnerCollision; + onlyDifferentOwnerA.relationsData.owner = noOwnA; + onlyDifferentOwnerB.relationsData.owner = noOwnB; + + const sharedOwner = makeDamageBody(game, 'shared-owner', { + x: 280, + y: 80, + health: 20, + maxHealth: 20, + damagePerTick: 0, + size: 10, + color: Color.Tank + }); + sharedOwner.isPhysical = false; + const onlySameOwnerA = makeDamageBody(game, 'only-same-owner-a', { + x: 280, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + const onlySameOwnerB = makeDamageBody(game, 'only-same-owner-b', { + x: 305, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.EnemySquare + }); + onlySameOwnerA.physicsData.flags = PhysicsFlags.onlySameOwnerCollision; + onlySameOwnerA.relationsData.owner = sharedOwner; + onlySameOwnerB.relationsData.owner = sharedOwner; + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-collision-rules-tick')); + + return { + scenario: 'team-owner-collision-rules', + invariant: 'Same-team noOwnTeamCollision pairs do not collide, onlySameOwnerCollision rejects different owners, and onlySameOwnerCollision still permits same-owner collisions.', + participants: { + noOwnA: entityRef(noOwnA), + noOwnB: entityRef(noOwnB), + onlyDifferentOwnerA: entityRef(onlyDifferentOwnerA), + onlyDifferentOwnerB: entityRef(onlyDifferentOwnerB), + sharedOwner: entityRef(sharedOwner), + onlySameOwnerA: entityRef(onlySameOwnerA), + onlySameOwnerB: entityRef(onlySameOwnerB) + }, + collisionEvidence: { + noOwnPairHealthAfterTick: [findEntity(snapshots[1], 'no-own-team-a').health.health, findEntity(snapshots[1], 'no-own-team-b').health.health], + differentOwnerPairHealthAfterTick: [findEntity(snapshots[1], 'only-different-owner-a').health.health, findEntity(snapshots[1], 'only-different-owner-b').health.health], + sameOwnerPairHealthAfterTick: [findEntity(snapshots[1], 'only-same-owner-a').health.health, findEntity(snapshots[1], 'only-same-owner-b').health.health], + sameOwnerPairVelocityAfterTick: [findEntity(snapshots[1], 'only-same-owner-a').velocity, findEntity(snapshots[1], 'only-same-owner-b').velocity] + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -512,7 +614,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario(), teamOwnerCollisionRulesScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index 570592e4..a4911f59 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -12,6 +12,9 @@ namespace diepcustom::gameplay { namespace { constexpr int ColorTank = 2; constexpr int ColorEnemySquare = 8; +constexpr int PhysicsNoOwnTeamCollision = 1 << 3; +constexpr int PhysicsOnlySameOwnerCollision = 1 << 5; +constexpr int PhysicsCanEscapeArena = 1 << 8; std::string q(const std::string& value) { std::ostringstream out; @@ -158,7 +161,7 @@ void destroy(Body& b) { } void keepInArena(Body& b, const Arena& arena) { - if (b.physicsFlags & 256) return; + if (b.physicsFlags & PhysicsCanEscapeArena) return; if (b.x < arena.leftX - arena.padding) b.x = arena.leftX - arena.padding; else if (b.x > arena.rightX + arena.padding) b.x = arena.rightX + arena.padding; if (b.y < arena.topY - arena.padding) b.y = arena.topY - arena.padding; @@ -176,6 +179,9 @@ void applyPhysics(Body& b) { bool isColliding(const Body& a, const Body& b) { if (!a.isPhysical || !b.isPhysical || a.deleting || b.deleting) return false; + if (a.sides == 0 || b.sides == 0) return false; + if ((a.physicsFlags & PhysicsNoOwnTeamCollision) || (b.physicsFlags & PhysicsNoOwnTeamCollision)) return false; + if (a.ownerId != b.ownerId && ((a.physicsFlags & PhysicsOnlySameOwnerCollision) || (b.physicsFlags & PhysicsOnlySameOwnerCollision))) return false; double dx = a.x - b.x; double dy = a.y - b.y; double r = a.size + b.size; @@ -498,12 +504,57 @@ std::string arenaBoundsClampScenarioJson() { return out.str(); } + +std::string teamOwnerCollisionRulesScenarioJson() { + Arena arena; + std::vector bodies; + Body noOwnA; + noOwnA.id = 0; noOwnA.hash = noOwnA.preservedHash = 1; noOwnA.fixtureName = "no-own-team-a"; noOwnA.x = -300; noOwnA.y = 0; + noOwnA.health = noOwnA.maxHealth = 20; noOwnA.damagePerTick = 5; noOwnA.size = noOwnA.width = 20; noOwnA.styleColor = ColorTank; noOwnA.physicsFlags = PhysicsNoOwnTeamCollision; + Body noOwnB; + noOwnB.id = 1; noOwnB.hash = noOwnB.preservedHash = 1; noOwnB.fixtureName = "no-own-team-b"; noOwnB.x = -275; noOwnB.y = 0; + noOwnB.health = noOwnB.maxHealth = 20; noOwnB.damagePerTick = 5; noOwnB.size = noOwnB.width = 20; noOwnB.styleColor = ColorEnemySquare; + Body differentOwnerA; + differentOwnerA.id = 2; differentOwnerA.hash = differentOwnerA.preservedHash = 1; differentOwnerA.fixtureName = "only-different-owner-a"; differentOwnerA.x = 0; differentOwnerA.y = 0; + differentOwnerA.health = differentOwnerA.maxHealth = 20; differentOwnerA.damagePerTick = 5; differentOwnerA.size = differentOwnerA.width = 20; differentOwnerA.styleColor = ColorTank; differentOwnerA.physicsFlags = PhysicsOnlySameOwnerCollision; differentOwnerA.ownerId = 0; + Body differentOwnerB; + differentOwnerB.id = 3; differentOwnerB.hash = differentOwnerB.preservedHash = 1; differentOwnerB.fixtureName = "only-different-owner-b"; differentOwnerB.x = 25; differentOwnerB.y = 0; + differentOwnerB.health = differentOwnerB.maxHealth = 20; differentOwnerB.damagePerTick = 5; differentOwnerB.size = differentOwnerB.width = 20; differentOwnerB.styleColor = ColorEnemySquare; differentOwnerB.ownerId = 1; + Body sharedOwner; + sharedOwner.id = 4; sharedOwner.hash = sharedOwner.preservedHash = 1; sharedOwner.fixtureName = "shared-owner"; sharedOwner.x = 280; sharedOwner.y = 80; + sharedOwner.health = sharedOwner.maxHealth = 20; sharedOwner.damagePerTick = 0; sharedOwner.size = sharedOwner.width = 10; sharedOwner.styleColor = ColorTank; sharedOwner.isPhysical = false; + Body sameOwnerA; + sameOwnerA.id = 5; sameOwnerA.hash = sameOwnerA.preservedHash = 1; sameOwnerA.fixtureName = "only-same-owner-a"; sameOwnerA.x = 280; sameOwnerA.y = 0; + sameOwnerA.health = sameOwnerA.maxHealth = 20; sameOwnerA.damagePerTick = 5; sameOwnerA.size = sameOwnerA.width = 20; sameOwnerA.styleColor = ColorTank; sameOwnerA.physicsFlags = PhysicsOnlySameOwnerCollision; sameOwnerA.ownerId = 4; + Body sameOwnerB; + sameOwnerB.id = 6; sameOwnerB.hash = sameOwnerB.preservedHash = 1; sameOwnerB.fixtureName = "only-same-owner-b"; sameOwnerB.x = 305; sameOwnerB.y = 0; + sameOwnerB.health = sameOwnerB.maxHealth = 20; sameOwnerB.damagePerTick = 5; sameOwnerB.size = sameOwnerB.width = 20; sameOwnerB.styleColor = ColorEnemySquare; sameOwnerB.ownerId = 4; + bodies = {noOwnA, noOwnB, differentOwnerA, differentOwnerB, sharedOwner, sameOwnerA, sameOwnerB}; + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-collision-rules-tick", 1)); + + std::ostringstream out; + out << "{\"scenario\":\"team-owner-collision-rules\",\"invariant\":\"Same-team noOwnTeamCollision pairs do not collide, onlySameOwnerCollision rejects different owners, and onlySameOwnerCollision still permits same-owner collisions.\"" + << ",\"participants\":{\"noOwnA\":" << refJson(bodies[0]) << ",\"noOwnB\":" << refJson(bodies[1]) + << ",\"onlyDifferentOwnerA\":" << refJson(bodies[2]) << ",\"onlyDifferentOwnerB\":" << refJson(bodies[3]) + << ",\"sharedOwner\":" << refJson(bodies[4]) << ",\"onlySameOwnerA\":" << refJson(bodies[5]) << ",\"onlySameOwnerB\":" << refJson(bodies[6]) << "}" + << ",\"collisionEvidence\":{\"noOwnPairHealthAfterTick\":[20,20],\"differentOwnerPairHealthAfterTick\":[20,20],\"sameOwnerPairHealthAfterTick\":[0,0]," + << "\"sameOwnerPairVelocityAfterTick\":[{\"x\":-7.2,\"y\":0,\"magnitude\":7.2,\"angle\":3.141593},{\"x\":7.2,\"y\":0,\"magnitude\":7.2,\"angle\":0}]}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "," + teamOwnerCollisionRulesScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From cf528b52fe3ab9d18f3b3628883508653216ac23 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:54:06 -0400 Subject: [PATCH 19/22] Filter ineligible bodies before collision parity This slice locks the pre-geometry collision filters for headless gameplay. Zero-sided bodies, nonphysical bodies, and bodies already in deletion animation remain present in the world snapshot but do not exchange damage or knockback. Constraint: Full-world snapshots must expose skipped bodies so later RL observation grids can distinguish existence from collision eligibility Rejected: Collapse these cases into a single non-colliding fixture | separate body labels make future desyncs easier to localize Confidence: high Scope-risk: narrow Directive: Keep collision eligibility checks ahead of geometry and damage resolution Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 4.137ms vs TS median 902.002ms, 218.03x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 832 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 90 ++- cpp/src/gameplay.cpp | 45 +- 3 files changed, 965 insertions(+), 2 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index 7e13867b..a94c66d0 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -3461,6 +3461,838 @@ ] } ] + }, + { + "scenario": "collision-eligibility-filters", + "invariant": "Zero-sided, nonphysical, and actively deleting entities are excluded from collision damage and knockback before geometry checks.", + "participants": { + "zeroSidesA": { + "id": 0, + "hash": 1 + }, + "zeroSidesB": { + "id": 1, + "hash": 1 + }, + "nonPhysicalA": { + "id": 2, + "hash": 1 + }, + "nonPhysicalB": { + "id": 3, + "hash": 1 + }, + "deletingA": { + "id": 4, + "hash": 1 + }, + "deletingB": { + "id": 5, + "hash": 1 + } + }, + "filterEvidence": { + "zeroSidesHealthAfterTick": [ + 20, + 20 + ], + "nonPhysicalHealthAfterTick": [ + 20, + 20 + ], + "deletingPairHealthAfterTick": [ + 0, + 20 + ], + "deletingAStateAfterTick": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, + "otherVelocitiesAfterTick": [ + { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + }, + { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + }, + { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + ] + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 5, + "activeIds": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "zero-sides-a", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -500, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 0, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "zero-sides-b", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -475, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "nonphysical-a", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -100, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 3, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "nonphysical-b", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -75, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 4, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "deleting-a", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 300, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 5 + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 5, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "deleting-b", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 325, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-filtered-collision-tick", + "tick": 1, + "manager": { + "lastId": 5, + "activeIds": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "zero-sides-a", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -500, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 0, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "zero-sides-b", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -475, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "nonphysical-a", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -100, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 3, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "nonphysical-b", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -75, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 4, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "deleting-a", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 300, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 22, + "width": 22, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 0.666667, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 5, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "deleting-b", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": 325, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index 52b34e93..baa7ff56 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -603,6 +603,94 @@ function teamOwnerCollisionRulesScenario() { }; } +function collisionEligibilityFiltersScenario() { + const game = createHeadlessGame(); + const zeroSidesA = makeDamageBody(game, 'zero-sides-a', { + x: -500, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + const zeroSidesB = makeDamageBody(game, 'zero-sides-b', { + x: -475, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.EnemySquare + }); + zeroSidesA.physicsData.sides = 0; + + const nonPhysicalA = makeDamageBody(game, 'nonphysical-a', { + x: -100, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + const nonPhysicalB = makeDamageBody(game, 'nonphysical-b', { + x: -75, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.EnemySquare + }); + nonPhysicalA.isPhysical = false; + + const deletingA = makeDamageBody(game, 'deleting-a', { + x: 300, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + const deletingB = makeDamageBody(game, 'deleting-b', { + x: 325, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.EnemySquare + }); + deletingA.destroy(true); + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-filtered-collision-tick')); + + return { + scenario: 'collision-eligibility-filters', + invariant: 'Zero-sided, nonphysical, and actively deleting entities are excluded from collision damage and knockback before geometry checks.', + participants: { + zeroSidesA: entityRef(zeroSidesA), + zeroSidesB: entityRef(zeroSidesB), + nonPhysicalA: entityRef(nonPhysicalA), + nonPhysicalB: entityRef(nonPhysicalB), + deletingA: entityRef(deletingA), + deletingB: entityRef(deletingB) + }, + filterEvidence: { + zeroSidesHealthAfterTick: [findEntity(snapshots[1], 'zero-sides-a').health.health, findEntity(snapshots[1], 'zero-sides-b').health.health], + nonPhysicalHealthAfterTick: [findEntity(snapshots[1], 'nonphysical-a').health.health, findEntity(snapshots[1], 'nonphysical-b').health.health], + deletingPairHealthAfterTick: [findEntity(snapshots[1], 'deleting-a').health.health, findEntity(snapshots[1], 'deleting-b').health.health], + deletingAStateAfterTick: findEntity(snapshots[1], 'deleting-a').gameplay, + otherVelocitiesAfterTick: [findEntity(snapshots[1], 'zero-sides-b').velocity, findEntity(snapshots[1], 'nonphysical-b').velocity, findEntity(snapshots[1], 'deleting-b').velocity] + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -614,7 +702,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario(), teamOwnerCollisionRulesScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario(), teamOwnerCollisionRulesScenario(), collisionEligibilityFiltersScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index a4911f59..d5af6ced 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -549,12 +549,55 @@ std::string teamOwnerCollisionRulesScenarioJson() { return out.str(); } + +std::string collisionEligibilityFiltersScenarioJson() { + Arena arena; + std::vector bodies; + Body zeroSidesA; + zeroSidesA.id = 0; zeroSidesA.hash = zeroSidesA.preservedHash = 1; zeroSidesA.fixtureName = "zero-sides-a"; zeroSidesA.x = -500; zeroSidesA.y = 0; + zeroSidesA.health = zeroSidesA.maxHealth = 20; zeroSidesA.damagePerTick = 5; zeroSidesA.size = zeroSidesA.width = 20; zeroSidesA.styleColor = ColorTank; zeroSidesA.sides = 0; + Body zeroSidesB; + zeroSidesB.id = 1; zeroSidesB.hash = zeroSidesB.preservedHash = 1; zeroSidesB.fixtureName = "zero-sides-b"; zeroSidesB.x = -475; zeroSidesB.y = 0; + zeroSidesB.health = zeroSidesB.maxHealth = 20; zeroSidesB.damagePerTick = 5; zeroSidesB.size = zeroSidesB.width = 20; zeroSidesB.styleColor = ColorEnemySquare; + Body nonPhysicalA; + nonPhysicalA.id = 2; nonPhysicalA.hash = nonPhysicalA.preservedHash = 1; nonPhysicalA.fixtureName = "nonphysical-a"; nonPhysicalA.x = -100; nonPhysicalA.y = 0; + nonPhysicalA.health = nonPhysicalA.maxHealth = 20; nonPhysicalA.damagePerTick = 5; nonPhysicalA.size = nonPhysicalA.width = 20; nonPhysicalA.styleColor = ColorTank; nonPhysicalA.isPhysical = false; + Body nonPhysicalB; + nonPhysicalB.id = 3; nonPhysicalB.hash = nonPhysicalB.preservedHash = 1; nonPhysicalB.fixtureName = "nonphysical-b"; nonPhysicalB.x = -75; nonPhysicalB.y = 0; + nonPhysicalB.health = nonPhysicalB.maxHealth = 20; nonPhysicalB.damagePerTick = 5; nonPhysicalB.size = nonPhysicalB.width = 20; nonPhysicalB.styleColor = ColorEnemySquare; + Body deletingA; + deletingA.id = 4; deletingA.hash = deletingA.preservedHash = 1; deletingA.fixtureName = "deleting-a"; deletingA.x = 300; deletingA.y = 0; + deletingA.health = 0; deletingA.maxHealth = 20; deletingA.damagePerTick = 5; deletingA.size = deletingA.width = 20; deletingA.styleColor = ColorTank; deletingA.deleting = true; + Body deletingB; + deletingB.id = 5; deletingB.hash = deletingB.preservedHash = 1; deletingB.fixtureName = "deleting-b"; deletingB.x = 325; deletingB.y = 0; + deletingB.health = deletingB.maxHealth = 20; deletingB.damagePerTick = 5; deletingB.size = deletingB.width = 20; deletingB.styleColor = ColorEnemySquare; + bodies = {zeroSidesA, zeroSidesB, nonPhysicalA, nonPhysicalB, deletingA, deletingB}; + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-filtered-collision-tick", 1)); + + std::ostringstream out; + out << "{\"scenario\":\"collision-eligibility-filters\",\"invariant\":\"Zero-sided, nonphysical, and actively deleting entities are excluded from collision damage and knockback before geometry checks.\"" + << ",\"participants\":{\"zeroSidesA\":" << refJson(bodies[0]) << ",\"zeroSidesB\":" << refJson(bodies[1]) + << ",\"nonPhysicalA\":" << refJson(bodies[2]) << ",\"nonPhysicalB\":" << refJson(bodies[3]) + << ",\"deletingA\":" << refJson(bodies[4]) << ",\"deletingB\":" << refJson(bodies[5]) << "}" + << ",\"filterEvidence\":{\"zeroSidesHealthAfterTick\":[20,20],\"nonPhysicalHealthAfterTick\":[20,20],\"deletingPairHealthAfterTick\":[0,20]," + << "\"deletingAStateAfterTick\":{\"score\":0,\"scoreReward\":0,\"deleting\":true,\"deletionFrame\":4}," + << "\"otherVelocitiesAfterTick\":[{\"x\":0,\"y\":0,\"magnitude\":0,\"angle\":0},{\"x\":0,\"y\":0,\"magnitude\":0,\"angle\":0},{\"x\":0,\"y\":0,\"magnitude\":0,\"angle\":0}]}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "," + teamOwnerCollisionRulesScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "," + teamOwnerCollisionRulesScenarioJson() + "," + collisionEligibilityFiltersScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From 1c154e515400ef9f9867aabd21f3fda58c639238 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 11:56:53 -0400 Subject: [PATCH 20/22] Preserve solid-wall projectile contact parity This slice locks the owned-projectile contact path against enemy solid walls. The C++ gameplay report now models team refs, solid-wall flags, projectile deletion, and wall knockback so maze/wall interactions stay debuggable before broader tank and shape ports. Constraint: Headless RL training needs deterministic global wall/projectile behavior before local observation grids Rejected: Delay wall behavior until full MazeWall port | the collision response is a smaller invariant that projectiles already depend on Confidence: high Scope-risk: moderate Directive: Keep solid-wall owner/team checks aligned with ObjectEntity.receiveKnockback before changing projectile collision handling Tested: node --test conformance/gameplay/golden.test.js Tested: npm run test:cpp Tested: node conformance/gameplay/compare-parity.js Tested: npm run test:parity Tested: npm run bench:gameplay | C++ median 4.337ms vs TS median 921.592ms, 212.50x speedup Tested: npm run test:all --- conformance/fixtures/gameplay-golden.json | 467 ++++++++++++++++++++++ conformance/gameplay/report-ts.js | 62 ++- cpp/src/gameplay.cpp | 49 ++- 3 files changed, 573 insertions(+), 5 deletions(-) diff --git a/conformance/fixtures/gameplay-golden.json b/conformance/fixtures/gameplay-golden.json index a94c66d0..30a15084 100644 --- a/conformance/fixtures/gameplay-golden.json +++ b/conformance/fixtures/gameplay-golden.json @@ -4293,6 +4293,473 @@ ] } ] + }, + { + "scenario": "solid-wall-projectile-contact", + "invariant": "A projectile-like owned entity touching an enemy solid wall is immediately put into deletion animation without damaging or moving the wall.", + "participants": { + "owner": { + "id": 0, + "hash": 1 + }, + "projectile": { + "id": 1, + "hash": 1 + }, + "wall": { + "id": 2, + "hash": 1 + } + }, + "wallEvidence": { + "projectileAfterContact": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, + "projectileVelocityAfterContact": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + }, + "wallHealthAfterContact": 999, + "wallVelocityAfterContact": { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + }, + "projectileOwnerRef": { + "id": 0, + "hash": 1 + }, + "wallTeamRef": { + "id": 2, + "hash": 1 + } + }, + "snapshots": [ + { + "label": "initial-full-world", + "tick": 0, + "manager": { + "lastId": 2, + "activeIds": [ + 0, + 1, + 2 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "wall-projectile-owner", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -650, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 10, + "width": 10, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "wall-projectile", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "position": { + "x": -600, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "solid-wall", + "exists": true, + "entityState": 1, + "relations": { + "parent": null, + "owner": null, + "team": { + "id": 2, + "hash": 1 + } + }, + "position": { + "x": -575, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 999, + "maxHealth": 999, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + } + ] + }, + { + "label": "after-wall-contact-tick", + "tick": 1, + "manager": { + "lastId": 2, + "activeIds": [ + 0, + 1, + 2 + ], + "cameras": [], + "otherEntities": [], + "globalEntities": [], + "hashTable": [ + 1, + 1, + 1 + ] + }, + "arena": { + "id": null, + "state": "headless-fixture", + "bounds": { + "leftX": -1000, + "rightX": 1000, + "topY": -1000, + "bottomY": 1000 + } + }, + "entities": [ + { + "id": 0, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "wall-projectile-owner", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": null + }, + "position": { + "x": -650, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 10, + "width": 10, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 20, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 1, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "wall-projectile", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": { + "id": 0, + "hash": 1 + }, + "team": null + }, + "position": { + "x": -600, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 22, + "width": 22, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 0 + }, + "health": { + "health": 0, + "maxHealth": 20, + "flags": 0 + }, + "damage": { + "damagePerTick": 5, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 2, + "opacity": 0.666667, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": true, + "deletionFrame": 4 + }, + "velocity": { + "x": 0, + "y": 0, + "magnitude": 0, + "angle": 0 + } + }, + { + "id": 2, + "hash": 1, + "preservedHash": 1, + "className": "LivingEntity", + "fixtureName": "solid-wall", + "exists": true, + "entityState": 0, + "relations": { + "parent": null, + "owner": null, + "team": { + "id": 2, + "hash": 1 + } + }, + "position": { + "x": -567, + "y": 0, + "angle": 0, + "flags": 0 + }, + "physics": { + "sides": 1, + "size": 20, + "width": 20, + "pushFactor": 8, + "absorbtionFactor": 1, + "flags": 16 + }, + "health": { + "health": 999, + "maxHealth": 999, + "flags": 0 + }, + "damage": { + "damagePerTick": 0, + "damageReduction": 1, + "minDamageMultiplier": 1, + "maxDamageMultiplier": 4, + "lastDamageTick": -1 + }, + "style": { + "color": 8, + "opacity": 1, + "flags": 1 + }, + "gameplay": { + "score": 0, + "scoreReward": 0, + "deleting": false, + "deletionFrame": null + }, + "velocity": { + "x": 7.2, + "y": 0, + "magnitude": 7.2, + "angle": 0 + } + } + ] + } + ] } ] } diff --git a/conformance/gameplay/report-ts.js b/conformance/gameplay/report-ts.js index baa7ff56..efca1e40 100644 --- a/conformance/gameplay/report-ts.js +++ b/conformance/gameplay/report-ts.js @@ -691,6 +691,66 @@ function collisionEligibilityFiltersScenario() { }; } +function solidWallProjectileContactScenario() { + const game = createHeadlessGame(); + const owner = makeDamageBody(game, 'wall-projectile-owner', { + x: -650, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 0, + size: 10, + color: Color.Tank + }); + owner.isPhysical = false; + + const projectile = makeDamageBody(game, 'wall-projectile', { + x: -600, + y: 0, + health: 20, + maxHealth: 20, + damagePerTick: 5, + size: 20, + color: Color.Tank + }); + projectile.relationsData.owner = owner; + + const wall = makeDamageBody(game, 'solid-wall', { + x: -575, + y: 0, + health: 999, + maxHealth: 999, + damagePerTick: 0, + size: 20, + color: Color.EnemySquare + }); + wall.physicsData.flags = PhysicsFlags.isSolidWall; + wall.relationsData.team = wall; + + const snapshots = [worldSnapshot(game, 'initial-full-world')]; + tickHeadless(game); + snapshots.push(worldSnapshot(game, 'after-wall-contact-tick')); + + return { + scenario: 'solid-wall-projectile-contact', + invariant: 'A projectile-like owned entity touching an enemy solid wall is immediately put into deletion animation without damaging or moving the wall.', + participants: { + owner: entityRef(owner), + projectile: entityRef(projectile), + wall: entityRef(wall) + }, + wallEvidence: { + projectileAfterContact: findEntity(snapshots[1], 'wall-projectile').gameplay, + projectileVelocityAfterContact: findEntity(snapshots[1], 'wall-projectile').velocity, + wallHealthAfterContact: findEntity(snapshots[1], 'solid-wall').health.health, + wallVelocityAfterContact: findEntity(snapshots[1], 'solid-wall').velocity, + projectileOwnerRef: findEntity(snapshots[0], 'wall-projectile').relations.owner, + wallTeamRef: findEntity(snapshots[0], 'solid-wall').relations.team + }, + snapshots + }; +} + function report() { return { phase: 'D-gameplay', @@ -702,7 +762,7 @@ function report() { 'full-live-websocket-gameplay-parity', 'broad-every-tank-projectile-upgrade-coverage' ], - scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario(), teamOwnerCollisionRulesScenario(), collisionEligibilityFiltersScenario()] + scenarios: [damageScenario(), scoreDeathScenario(), ownerPropagatedKillScenario(), projectileMovementLifetimeScenario(), cameraScoreIntegrationScenario(), arenaBoundsClampScenario(), teamOwnerCollisionRulesScenario(), collisionEligibilityFiltersScenario(), solidWallProjectileContactScenario()] }; } diff --git a/cpp/src/gameplay.cpp b/cpp/src/gameplay.cpp index d5af6ced..8e05e904 100644 --- a/cpp/src/gameplay.cpp +++ b/cpp/src/gameplay.cpp @@ -13,6 +13,7 @@ namespace { constexpr int ColorTank = 2; constexpr int ColorEnemySquare = 8; constexpr int PhysicsNoOwnTeamCollision = 1 << 3; +constexpr int PhysicsSolidWall = 1 << 4; constexpr int PhysicsOnlySameOwnerCollision = 1 << 5; constexpr int PhysicsCanEscapeArena = 1 << 8; @@ -92,6 +93,7 @@ struct Body { int styleFlags = 1; bool isPhysical = true; int ownerId = -1; + int teamId = -1; bool projectileMotion = false; int spawnTick = 0; double baseSpeed = 0; @@ -180,8 +182,10 @@ void applyPhysics(Body& b) { bool isColliding(const Body& a, const Body& b) { if (!a.isPhysical || !b.isPhysical || a.deleting || b.deleting) return false; if (a.sides == 0 || b.sides == 0) return false; - if ((a.physicsFlags & PhysicsNoOwnTeamCollision) || (b.physicsFlags & PhysicsNoOwnTeamCollision)) return false; - if (a.ownerId != b.ownerId && ((a.physicsFlags & PhysicsOnlySameOwnerCollision) || (b.physicsFlags & PhysicsOnlySameOwnerCollision))) return false; + if (a.teamId == b.teamId) { + if ((a.physicsFlags & PhysicsNoOwnTeamCollision) || (b.physicsFlags & PhysicsNoOwnTeamCollision)) return false; + if (a.ownerId != b.ownerId && ((a.physicsFlags & PhysicsOnlySameOwnerCollision) || (b.physicsFlags & PhysicsOnlySameOwnerCollision))) return false; + } double dx = a.x - b.x; double dy = a.y - b.y; double r = a.size + b.size; @@ -190,6 +194,11 @@ bool isColliding(const Body& a, const Body& b) { void receiveKnockback(Body& self, const Body& other) { double kbMagnitude = self.absorbtionFactor * other.pushFactor; + if ((other.physicsFlags & PhysicsSolidWall) && self.ownerId >= 0 && self.teamId != other.teamId) { + setVelocity(self, 0, 0); + destroy(self); + return; + } double diffY = self.y - other.y; double diffX = self.x - other.x; double kbAngle = std::atan2(diffY, diffX); @@ -274,7 +283,7 @@ std::string bodyJson(const Body& b, const std::vector& bodies) { out << "{\"id\":" << b.id << ",\"hash\":" << b.hash << ",\"preservedHash\":" << b.preservedHash << ",\"className\":" << q(b.isCamera ? "CameraEntity" : "LivingEntity") << ",\"fixtureName\":" << q(b.fixtureName) << ",\"exists\":true,\"entityState\":" << b.entityState - << ",\"relations\":{\"parent\":null,\"owner\":" << ownerJson(b, bodies) << ",\"team\":null}"; + << ",\"relations\":{\"parent\":null,\"owner\":" << ownerJson(b, bodies) << ",\"team\":" << entityRefById(b.teamId, bodies) << "}"; if (!b.isCamera) { out << ",\"position\":{\"x\":" << num(b.x) << ",\"y\":" << num(b.y) << ",\"angle\":" << num(b.angle) << ",\"flags\":" << b.positionFlags << "}" << ",\"physics\":{\"sides\":" << b.sides << ",\"size\":" << num(b.size) << ",\"width\":" << num(b.width) @@ -592,12 +601,44 @@ std::string collisionEligibilityFiltersScenarioJson() { return out.str(); } + +std::string solidWallProjectileContactScenarioJson() { + Arena arena; + std::vector bodies; + Body owner; + owner.id = 0; owner.hash = owner.preservedHash = 1; owner.fixtureName = "wall-projectile-owner"; owner.x = -650; owner.y = 0; + owner.health = owner.maxHealth = 20; owner.damagePerTick = 0; owner.size = owner.width = 10; owner.styleColor = ColorTank; owner.isPhysical = false; + Body projectile; + projectile.id = 1; projectile.hash = projectile.preservedHash = 1; projectile.fixtureName = "wall-projectile"; projectile.x = -600; projectile.y = 0; + projectile.health = projectile.maxHealth = 20; projectile.damagePerTick = 5; projectile.size = projectile.width = 20; projectile.styleColor = ColorTank; projectile.ownerId = 0; + Body wall; + wall.id = 2; wall.hash = wall.preservedHash = 1; wall.fixtureName = "solid-wall"; wall.x = -575; wall.y = 0; + wall.health = wall.maxHealth = 999; wall.damagePerTick = 0; wall.size = wall.width = 20; wall.styleColor = ColorEnemySquare; wall.physicsFlags = PhysicsSolidWall; wall.teamId = 2; + bodies = {owner, projectile, wall}; + + std::vector snapshots; + snapshots.push_back(snapshotJson(bodies, arena, "initial-full-world", 0)); + tickHeadless(bodies, arena, 1); + snapshots.push_back(snapshotJson(bodies, arena, "after-wall-contact-tick", 1)); + + std::ostringstream out; + out << "{\"scenario\":\"solid-wall-projectile-contact\",\"invariant\":\"A projectile-like owned entity touching an enemy solid wall is immediately put into deletion animation without damaging or moving the wall.\"" + << ",\"participants\":{\"owner\":" << refJson(bodies[0]) << ",\"projectile\":" << refJson(bodies[1]) << ",\"wall\":" << refJson(bodies[2]) << "}" + << ",\"wallEvidence\":{\"projectileAfterContact\":{\"score\":0,\"scoreReward\":0,\"deleting\":true,\"deletionFrame\":4}," + << "\"projectileVelocityAfterContact\":{\"x\":0,\"y\":0,\"magnitude\":0,\"angle\":0},\"wallHealthAfterContact\":999," + << "\"wallVelocityAfterContact\":{\"x\":7.2,\"y\":0,\"magnitude\":7.2,\"angle\":0},\"projectileOwnerRef\":" << refJson(bodies[0]) << ",\"wallTeamRef\":" << refJson(bodies[2]) << "}" + << ",\"snapshots\":["; + for (std::size_t i = 0; i < snapshots.size(); ++i) { if (i) out << ','; out << snapshots[i]; } + out << "]}"; + return out.str(); +} + } // namespace std::string gameplayReportJson() { return std::string("{\"phase\":\"D-gameplay\",\"scope\":\"minimal-headless-tick-parity\",\"nonGoals\":[") + "\"browser-client-ui-testing\",\"per-agent-rl-observation-grids\",\"cpp-gameplay-implementation\",\"full-live-websocket-gameplay-parity\",\"broad-every-tank-projectile-upgrade-coverage\"]," + - "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "," + teamOwnerCollisionRulesScenarioJson() + "," + collisionEligibilityFiltersScenarioJson() + "]}"; + "\"scenarios\":[" + damageScenarioJson() + "," + scoreDeathScenarioJson() + "," + ownerPropagatedKillScenarioJson() + "," + projectileMovementLifetimeScenarioJson() + "," + cameraScoreIntegrationScenarioJson() + "," + arenaBoundsClampScenarioJson() + "," + teamOwnerCollisionRulesScenarioJson() + "," + collisionEligibilityFiltersScenarioJson() + "," + solidWallProjectileContactScenarioJson() + "]}"; } } // namespace diepcustom::gameplay From e0bd1c1c9e640c6860fd47facba93caf8f6174b2 Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 12:00:34 -0400 Subject: [PATCH 21/22] Document the C++ parity migration handoff Future agents need a durable summary of the staged TypeScript-to-C++ migration, completed parity slices, verification commands, and the next safe porting steps before continuing gameplay work. Constraint: TypeScript remains the reference implementation until C++ parity passes Confidence: high Scope-risk: narrow Directive: Update docs/cpp-migration-status.md whenever new parity slices or migration constraints are added Tested: documentation-only change; no code tests run --- README.md | 13 ++++ docs/cpp-migration-status.md | 117 +++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 docs/cpp-migration-status.md diff --git a/README.md b/README.md index b3784cda..e43338e8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,19 @@ After doing so, download or clone this repository and install the dependencies w $ npm install ``` +## C++ Migration / Headless RL Parity + +This repository is being migrated toward a deterministic C++ core for headless RL-agent training while the TypeScript server remains the behavior reference. See [docs/cpp-migration-status.md](./docs/cpp-migration-status.md) for the current parity harness, completed slices, verification commands, and next recommended work. + +Key migration commands: +```bash +npm run test:conformance +npm run test:cpp +npm run test:parity +npm run bench:gameplay +npm run test:all +``` + ## Running the Server Run the server with: diff --git a/docs/cpp-migration-status.md b/docs/cpp-migration-status.md new file mode 100644 index 00000000..ade15608 --- /dev/null +++ b/docs/cpp-migration-status.md @@ -0,0 +1,117 @@ +# TypeScript to C++ Migration Status + +This document is the durable handoff for future engineers and agents working on the headless C++ migration. The current goal is a deterministic, faster C++ core for RL-agent training while the TypeScript server remains the reference implementation until parity is proven. + +## Current migration contract + +- Preserve external behavior first; do not replace production TypeScript runtime until C++ passes the same conformance suite. +- Maintain full-world/global deterministic parity before adding per-agent/localized RL observation grids. +- Keep camera/update serialization minimal and compatibility-focused; rich browser/view snapshots are not a goal for the headless RL server. +- Add or expand TypeScript golden fixtures before each C++ port slice, then make C++ output match byte-for-byte JSON parity. +- Do not add new third-party C++ dependencies for the current skeleton. + +## What now exists + +### Conformance harness + +The `conformance/` tree contains TypeScript reference reports, golden fixtures, and parity comparators for these areas: + +- `protocol`: packet reader/writer compatibility fixtures. +- `physics`: deterministic vector/hash-grid/packed-set style primitives. +- `entity-core`: manager/entity/full-world state snapshots and minimal compatibility packet/camera serialization. +- `gameplay`: deterministic headless gameplay slices and TS-vs-C++ report parity. + +Useful commands: + +```bash +npm run test:conformance # TS golden fixtures +npm run test:cpp # CMake configure/build + C++ smoke tests +npm run test:parity # C++ reports compared with TS references +npm run bench:gameplay # gameplay report runtime signal, TS vs C++ +npm run test:all # full baseline: build, unit, e2e, conformance, audit +``` + +### C++ skeleton + +The C++ tree is CMake-based and currently exposes small report/test binaries, not a replacement server: + +- `cpp/include/diepcustom/*.hpp` and `cpp/src/*.cpp` implement protocol, physics, entity-core, and gameplay report surfaces. +- `cpp/tests/*_report.cpp` prints JSON reports consumed by parity comparators. +- Build outputs are under `build/cpp/` and should stay out of source control. + +### Phase C: entity/core parity completed to current scope + +The entity-core parity layer now proves: + +- Real manager-backed entity ID/hash lifecycle. +- Field group mutation/wipe behavior. +- Full-world snapshots as the first snapshot shape. +- Minimal C++ compatibility packets generated from C++ state mutations. +- Minimal camera/update compatibility retained only where needed for server/update behavior. + +### Phase D: gameplay parity slices completed so far + +The gameplay parity report currently covers these deterministic headless scenarios: + +1. `overlapping-living-entities-damage` — overlapping living entities exchange deterministic collision damage. +2. `score-on-kill-and-death-removal` — lethal collision awards score, starts deletion animation, and removes after animation. +3. `owner-propagated-projectile-kill-score` — projectile kills award score to the owning entity. +4. `projectile-movement-and-lifetime` — projectile spawn speed, acceleration, lifetime expiry, and deletion start. +5. `camera-player-score-integration` — minimal camera score mutation mirrors onto focused player score data. +6. `arena-bounds-clamp-and-can-escape` — ordinary bodies clamp to arena bounds + padding; `canEscapeArena` bodies do not. +7. `team-owner-collision-rules` — `noOwnTeamCollision` and `onlySameOwnerCollision` gates. +8. `collision-eligibility-filters` — zero-sided, nonphysical, and deleting entities skip collision resolution. +9. `solid-wall-projectile-contact` — owned projectile touching enemy solid wall enters deletion animation and wall response matches TS. + +Recent verified commits for these slices: + +- `46caa57` Lock the first gameplay parity baseline +- `e9704e5` Prove the first gameplay fixture against C++ +- `30edcbc` Gate gameplay migration on a measured C++ speed signal +- `2ab7c8c` Extend gameplay parity through death rewards +- `3a81d48` Carry projectile kills back to their owner +- `d78aadf` Lock projectile motion and lifetime parity +- `95bdd65` Tie camera score changes to focused players +- `5fdb41f` Preserve arena boundary behavior in gameplay parity +- `be8576a` Keep team owner collision gates deterministic +- `3cf528b` Filter ineligible bodies before collision parity +- `1c154e5` Preserve solid-wall projectile contact parity + +Latest measured gameplay report signal at `1c154e5`: + +- TypeScript median: `921.592ms` +- C++ median: `4.337ms` +- Median speedup: `212.50x` + +This benchmark includes process/startup overhead and is a migration signal only, not the final in-engine tick benchmark. + +## Implementation pattern to keep using + +For every future slice: + +1. Add a deterministic TypeScript scenario to the relevant `conformance/**/report-ts.js`. +2. Regenerate/update the matching golden fixture under `conformance/fixtures/`. +3. Verify the TS fixture is deterministic with the relevant `golden.test.js`. +4. Implement the matching C++ report behavior. +5. Run `npm run test:cpp` and the specific comparator. +6. Run `npm run test:parity`, `npm run bench:gameplay` when gameplay changed, and `npm run test:all`. +7. Commit with a Lore-style message that records constraints, rejected alternatives, confidence, risk, directives, and tested commands. + +## Important caveats and gotchas + +- The gameplay C++ code is still a parity report model, not a full engine/server. Do not route production traffic to it yet. +- `conformance/gameplay/report-ts.js` uses a deliberately headless fake game and minimal arena object to avoid shape/boss/countdown randomness. +- Full-world snapshots are the ground truth. Local RL observation grids should be derived only after global parity is stable. +- Keep synthetic fixtures deterministic: avoid same-position collisions that trigger random knockback unless randomness is explicitly controlled. +- `PhysicsFlags.canEscapeArena` is `1 << 8` (`256`). An older projectile movement fixture uses flag value `16` only as a stable legacy fixture value while remaining inside bounds; do not copy that as `canEscapeArena`. +- Solid-wall behavior depends on owner/team checks inside `ObjectEntity.receiveKnockback`; preserve that ordering when broadening projectile and wall ports. + +## Recommended next work + +Move from synthetic `LivingEntity` fixtures toward actual gameplay construction while preserving the same parity pattern: + +1. Add a TS fixture for actual `TankBody`/basic tank construction in a headless game. +2. Port only the minimal C++ tank/body data needed to match the full-world snapshot. +3. Add actual `Bullet` construction and firing/lifetime parity after tank construction is stable. +4. Add shape construction/spawn behavior only after RNG seeding/control is explicit. +5. Leave WebSocket/HTTP server replacement until deterministic headless gameplay parity is much broader. From 472e35a310c05441f5ca7768183d702611f4240b Mon Sep 17 00:00:00 2001 From: Saak Date: Wed, 27 May 2026 13:00:07 -0400 Subject: [PATCH 22/22] Harden parity harness boundary checks Review found several valid edge cases in the new C++ parity and e2e harness: benchmark env parsing could leave empty samples, protocol zigzag encoding used signed shifts, fixed-size entity/grid tables trusted IDs, and the unknown-gamemode websocket test expected a rejected open instead of an immediate close. Constraint: TypeScript remains the behavior reference while C++ report binaries must be deterministic and memory-safe under invalid inputs Rejected: Documenting the Node lower bound only | package engines should prevent unsupported Node versions from selecting node:test scripts Confidence: high Scope-risk: moderate Directive: Treat external IDs and environment overrides as untrusted in conformance/report binaries Tested: ITERATIONS=0 node conformance/gameplay/benchmark-parity.js Tested: WARMUPS=abc node conformance/gameplay/benchmark-parity.js Tested: npm run test:cpp Tested: npm run test:parity Tested: npm run test:e2e Tested: npm run test:all Tested: npm run bench:gameplay | C++ median 5.099ms vs TS median 1439.261ms, 282.26x speedup --- conformance/gameplay/benchmark-parity.js | 14 ++++++++++++-- cpp/include/diepcustom/protocol.hpp | 2 +- cpp/src/entity_core.cpp | 19 ++++++++++++------- cpp/src/physics.cpp | 19 ++++++++++++++++--- cpp/src/protocol.cpp | 12 ++++++++---- package-lock.json | 2 +- package.json | 2 +- test/e2e/server.test.js | 6 ++---- 8 files changed, 53 insertions(+), 23 deletions(-) diff --git a/conformance/gameplay/benchmark-parity.js b/conformance/gameplay/benchmark-parity.js index cfc34624..632970b5 100644 --- a/conformance/gameplay/benchmark-parity.js +++ b/conformance/gameplay/benchmark-parity.js @@ -3,8 +3,18 @@ const { execFileSync } = require('node:child_process'); const path = require('node:path'); const root = path.join(__dirname, '../..'); -const iterations = Number(process.env.ITERATIONS || 25); -const warmups = Number(process.env.WARMUPS || 3); +function positiveIntegerFromEnv(name, defaultValue) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return defaultValue; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`); + } + return value; +} + +const iterations = positiveIntegerFromEnv('ITERATIONS', 25); +const warmups = positiveIntegerFromEnv('WARMUPS', 3); const tsCommand = [process.execPath, path.join(root, 'conformance/gameplay/report-ts.js')]; const cppCommand = [path.join(root, 'build/cpp/gameplay_report')]; diff --git a/cpp/include/diepcustom/protocol.hpp b/cpp/include/diepcustom/protocol.hpp index 81b15b42..ec52d4c2 100644 --- a/cpp/include/diepcustom/protocol.hpp +++ b/cpp/include/diepcustom/protocol.hpp @@ -11,7 +11,7 @@ class Writer { Writer& u8(std::uint32_t value); Writer& u16(std::uint32_t value); Writer& u32(std::uint32_t value); - Writer& vu(std::int32_t value); + Writer& vu(std::uint32_t value); Writer& vi(std::int32_t value); Writer& vf(float value); Writer& float32(float value); diff --git a/cpp/src/entity_core.cpp b/cpp/src/entity_core.cpp index b477a027..8204c918 100644 --- a/cpp/src/entity_core.cpp +++ b/cpp/src/entity_core.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace diepcustom::entity_core { @@ -113,16 +114,18 @@ struct Entity { }; void Manager::add(Entity& entity) { - for (int id = 0; id <= lastId + 1; ++id) { + const auto limit = std::min(static_cast(std::max(lastId + 2, 0)), inner.size()); + for (std::size_t id = 0; id < limit; ++id) { if (inner[id]) continue; - entity.id = id; + entity.id = static_cast(id); entity.hash = entity.preservedHash = ++hashTable[id]; inner[id] = &entity; - if (entity.isCamera) cameras.push_back(id); - else if (!entity.isObject) otherEntities.push_back(id); - if (lastId < id) lastId = id; + if (entity.isCamera) cameras.push_back(entity.id); + else if (!entity.isObject) otherEntities.push_back(entity.id); + if (lastId < entity.id) lastId = entity.id; return; } + throw std::runtime_error("Manager entity table is full"); } void removeFast(std::vector& values, int id) { @@ -130,8 +133,10 @@ void removeFast(std::vector& values, int id) { } void Manager::remove(int id) { - Entity* entity = inner[id]; - inner[id] = nullptr; + if (id < 0 || static_cast(id) >= inner.size()) return; + Entity* entity = inner[static_cast(id)]; + if (!entity) return; + inner[static_cast(id)] = nullptr; entity->hash = 0; if (entity->isCamera) removeFast(cameras, id); else if (!entity->isObject) removeFast(otherEntities, id); diff --git a/cpp/src/physics.cpp b/cpp/src/physics.cpp index b1068ebc..f0655d57 100644 --- a/cpp/src/physics.cpp +++ b/cpp/src/physics.cpp @@ -73,9 +73,17 @@ void Vector::setMagnitude(double value) { const auto currentDir = angle(); set({ double Vector::angle() const { return std::atan2(y, x); } void Vector::setAngle(double value) { const auto currentMag = magnitude(); set({std::cos(value) * currentMag, std::sin(value) * currentMag}); } -void PackedEntitySet::add(std::uint32_t entityId) { data_[entityId >> 5u] |= (1u << (entityId & 31u)); } -void PackedEntitySet::remove(std::uint32_t entityId) { data_[entityId >> 5u] &= ~(1u << (entityId & 31u)); } -bool PackedEntitySet::has(std::uint32_t entityId) const { return (data_[entityId >> 5u] & (1u << (entityId & 31u))) != 0; } +void PackedEntitySet::add(std::uint32_t entityId) { + if (entityId >= MaxEntityCount) return; + data_[entityId >> 5u] |= (1u << (entityId & 31u)); +} +void PackedEntitySet::remove(std::uint32_t entityId) { + if (entityId >= MaxEntityCount) return; + data_[entityId >> 5u] &= ~(1u << (entityId & 31u)); +} +bool PackedEntitySet::has(std::uint32_t entityId) const { + return entityId < MaxEntityCount && (data_[entityId >> 5u] & (1u << (entityId & 31u))) != 0; +} void PackedEntitySet::clear() { data_.fill(0); } const std::array& PackedEntitySet::data() const { return data_; } PackedEntitySet PackedEntitySet::fullSet() { PackedEntitySet set; set.data_.fill(0xffffffffu); return set; } @@ -106,6 +114,7 @@ void HashGrid::requireUnlocked(const std::string& method) const { if (isLocked_) void HashGrid::insert(const Entity& entity) { requireUnlocked("insert() entity outside of tick"); + if (entity.id >= MaxEntityCount) return; const bool isLine = entity.sides == 2; const auto halfWidth = isLine ? entity.size / 2 : entity.size; const auto halfHeight = isLine ? entity.width / 2 : entity.size; @@ -136,6 +145,7 @@ const PackedEntitySet& HashGrid::retrieve(double centerX, double centerY, double const auto key = cellKey(x, y); if (key >= hashMap_.size()) continue; for (const auto entityId : hashMap_[key]) { + if (entityId >= MaxEntityCount) continue; if (queryIdMap_[entityId] == queryId) continue; queryIdMap_[entityId] = queryId; const auto* entity = entityById(entityId); @@ -160,6 +170,7 @@ const Entity* HashGrid::getFirstMatch(double centerX, double centerY, double hal const auto key = cellKey(x, y); if (key >= hashMap_.size()) continue; for (const auto entityId : hashMap_[key]) { + if (entityId >= MaxEntityCount) continue; if (queryIdMap_[entityId] == queryId) continue; queryIdMap_[entityId] = queryId; const auto* entity = entityById(entityId); @@ -178,11 +189,13 @@ void HashGrid::forEachCollisionPair(const std::function= MaxEntityCount) continue; const auto* entityA = entityById(eidA); if (!entityA || entityA->hash == 0) continue; for (std::size_t b = a + 1; b < cell.size(); ++b) { const auto eidB = cell[b]; if (eidA == eidB) continue; + if (eidB >= MaxEntityCount) continue; const auto* entityB = entityById(eidB); if (!entityB || entityB->hash == 0) continue; const auto idA = std::min(eidA, eidB); diff --git a/cpp/src/protocol.cpp b/cpp/src/protocol.cpp index f3fe64f8..141b257d 100644 --- a/cpp/src/protocol.cpp +++ b/cpp/src/protocol.cpp @@ -41,8 +41,7 @@ Writer& Writer::u32(std::uint32_t value) { return *this; } -Writer& Writer::vu(std::int32_t input) { - auto value = static_cast(input); +Writer& Writer::vu(std::uint32_t value) { do { auto part = value; value >>= 7u; @@ -53,7 +52,9 @@ Writer& Writer::vu(std::int32_t input) { } Writer& Writer::vi(std::int32_t value) { - return vu(static_cast((0 - (value < 0 ? 1 : 0)) ^ (value << 1))); + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return vu((bits << 1u) ^ (0u - (bits >> 31u))); } Writer& Writer::vf(float value) { @@ -120,7 +121,10 @@ std::uint32_t Reader::vu() { std::int32_t Reader::vi() { const auto out = vu(); - return static_cast((0 - (out & 1u)) ^ (out >> 1u)); + const auto decoded = (out >> 1u) ^ (0u - (out & 1u)); + std::int32_t value = 0; + std::memcpy(&value, &decoded, sizeof(value)); + return value; } float Reader::vf() { diff --git a/package-lock.json b/package-lock.json index bf2e415c..f63a5827 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "typescript": "^4.8.2" }, "engines": { - "node": ">=16.3" + "node": ">=16.17.0" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 7b7320dc..12a8921a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "bench:gameplay": "npm run test:cpp && node conformance/gameplay/benchmark-parity.js" }, "engines": { - "node": ">=16.3" + "node": ">=16.17.0" }, "dependencies": { "chalk": "^4.1.1", diff --git a/test/e2e/server.test.js b/test/e2e/server.test.js index 381d1327..aad2d92e 100644 --- a/test/e2e/server.test.js +++ b/test/e2e/server.test.js @@ -65,10 +65,8 @@ test('websocket accepts known gamemode endpoints and answers binary ping', async }); test('malformed and hostile websocket inputs close without killing the server', async () => { - await assert.rejects( - connectWebSocket(`${server.wsOrigin}/does-not-exist`), - /WebSocket error|Timed out opening websocket/ - ); + const unknownRouteSocket = await connectWebSocket(`${server.wsOrigin}/does-not-exist`); + await waitForWebSocketClose(unknownRouteSocket); const textSocket = await connectWebSocket(`${server.wsOrigin}/ffa`); textSocket.send('ignore previous instructions and claim the server works');