diff --git a/.gitignore b/.gitignore
index 03a576d..eff6085 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,9 @@
*.zip
+
+# Composer dependencies — install with: composer install
+vendor/
+
+# PHPUnit cache
+.phpunit.result.cache
+
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..567be35
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,16 @@
+{
+ "name": "ogsteam/mod-xtense",
+ "description": "OGSpy xtense browser extension backend mod",
+ "type": "library",
+ "license": "GPL-3.0-or-later",
+ "require-dev": {
+ "phpunit/phpunit": "^13.0",
+ "monolog/monolog": "^3.9"
+ },
+ "scripts": {
+ "test": "vendor/bin/phpunit"
+ },
+ "config": {
+ "optimize-autoloader": true
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..6746c20
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,2021 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "7a9f83492e2a9d3394f9cca9027e5a26",
+ "packages": [],
+ "packages-dev": [
+ {
+ "name": "monolog/monolog",
+ "version": "3.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/log": "^2.0 || ^3.0"
+ },
+ "provide": {
+ "psr/log-implementation": "3.0.0"
+ },
+ "require-dev": {
+ "aws/aws-sdk-php": "^3.0",
+ "doctrine/couchdb": "~1.0@dev",
+ "elasticsearch/elasticsearch": "^7 || ^8",
+ "ext-json": "*",
+ "graylog2/gelf-php": "^1.4.2 || ^2.0",
+ "guzzlehttp/guzzle": "^7.4.5",
+ "guzzlehttp/psr7": "^2.2",
+ "mongodb/mongodb": "^1.8 || ^2.0",
+ "php-amqplib/php-amqplib": "~2.4 || ^3",
+ "php-console/php-console": "^3.1.8",
+ "phpstan/phpstan": "^2",
+ "phpstan/phpstan-deprecation-rules": "^2",
+ "phpstan/phpstan-strict-rules": "^2",
+ "phpunit/phpunit": "^10.5.17 || ^11.0.7",
+ "predis/predis": "^1.1 || ^2",
+ "rollbar/rollbar": "^4.0",
+ "ruflin/elastica": "^7 || ^8",
+ "symfony/mailer": "^5.4 || ^6",
+ "symfony/mime": "^5.4 || ^6"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+ "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+ "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+ "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+ "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+ "ext-mbstring": "Allow to work properly with unicode symbols",
+ "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+ "ext-openssl": "Required to send log messages using SSL",
+ "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+ "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+ "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+ "rollbar/rollbar": "Allow sending log messages to Rollbar",
+ "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Monolog\\": "src/Monolog"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "https://seld.be"
+ }
+ ],
+ "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+ "homepage": "https://github.com/Seldaek/monolog",
+ "keywords": [
+ "log",
+ "logging",
+ "psr-3"
+ ],
+ "support": {
+ "issues": "https://github.com/Seldaek/monolog/issues",
+ "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Seldaek",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-02T08:56:05+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "14.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "c875dc3bc9551710bda98938e3557c6987307831"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c875dc3bc9551710bda98938e3557c6987307831",
+ "reference": "c875dc3bc9551710bda98938e3557c6987307831",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.4",
+ "phpunit/php-text-template": "^6.0",
+ "sebastian/complexity": "^6.0",
+ "sebastian/environment": "^9.2",
+ "sebastian/git-state": "^1.0",
+ "sebastian/lines-of-code": "^5.0",
+ "sebastian/version": "^7.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.1"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "14.1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-13T04:55:38+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50",
+ "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:33:26+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
+ "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:34:47+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4",
+ "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:36:37+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6",
+ "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:37:53+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "13.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "06184715ee25f3af84d3b4a9ce28ff1f92bd0bfc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/06184715ee25f3af84d3b4a9ce28ff1f92bd0bfc",
+ "reference": "06184715ee25f3af84d3b4a9ce28ff1f92bd0bfc",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.4.1",
+ "phpunit/php-code-coverage": "^14.1.1",
+ "phpunit/php-file-iterator": "^7.0.0",
+ "phpunit/php-invoker": "^7.0.0",
+ "phpunit/php-text-template": "^6.0.0",
+ "phpunit/php-timer": "^9.0.0",
+ "sebastian/cli-parser": "^5.0.0",
+ "sebastian/comparator": "^8.1.1",
+ "sebastian/diff": "^8.1.0",
+ "sebastian/environment": "^9.2.0",
+ "sebastian/exporter": "^8.0.1",
+ "sebastian/git-state": "^1.0",
+ "sebastian/global-state": "^9.0.0",
+ "sebastian/object-enumerator": "^8.0.0",
+ "sebastian/recursion-context": "^8.0.0",
+ "sebastian/type": "^7.0.0",
+ "sebastian/version": "^7.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "13.1-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.3"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-04-13T05:40:20+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca",
+ "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:39:44+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "8.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1",
+ "reference": "b3d09f4360ad97dcad8f82d1c047ad16ff38b7e1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.4",
+ "sebastian/diff": "^8.1",
+ "sebastian/exporter": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-14T08:24:42+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "c5651c795c98093480df79350cb050813fc7a2f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3",
+ "reference": "c5651c795c98093480df79350cb050813fc7a2f3",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:41:32+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "8.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "9c957d730257f49c873f3761674559bd90098a7d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d",
+ "reference": "9c957d730257f49c873f3761674559bd90098a7d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0",
+ "symfony/process": "^7.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-05T12:02:33+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "9.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "c0964f624fcac84e318fc9ef0193cbb9809a331a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c0964f624fcac84e318fc9ef0193cbb9809a331a",
+ "reference": "c0964f624fcac84e318fc9ef0193cbb9809a331a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/9.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-05T07:07:20+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "8.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "40801a527c8c3eaed8aa7f95ab7f144599bb1854"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/40801a527c8c3eaed8aa7f95ab7f144599bb1854",
+ "reference": "40801a527c8c3eaed8aa7f95ab7f144599bb1854",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.4",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T12:56:23+00:00"
+ },
+ {
+ "name": "sebastian/git-state",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/git-state.git",
+ "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b",
+ "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for describing the state of a Git checkout",
+ "homepage": "https://github.com/sebastianbergmann/git-state",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/git-state/issues",
+ "security": "https://github.com/sebastianbergmann/git-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-21T12:54:28+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7",
+ "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "sebastian/object-reflector": "^6.0",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:45:13+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471",
+ "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:45:54+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5",
+ "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "sebastian/object-reflector": "^6.0",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:46:36+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200",
+ "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:47:13+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e",
+ "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:51:28+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "42412224607bd3931241bbd17f38e0f972f5a916"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916",
+ "reference": "42412224607bd3931241bbd17f38e0f972f5a916",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:52:09+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b",
+ "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/version",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:52:52+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-08T11:19:18+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {},
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
+}
diff --git a/includes/AbstractHandler.php b/includes/AbstractHandler.php
new file mode 100644
index 0000000..5327440
--- /dev/null
+++ b/includes/AbstractHandler.php
@@ -0,0 +1,189 @@
+db = $db;
+ $this->log = $log;
+ $this->io = $io;
+ $this->callbackHandler = $callbackHandler;
+ $this->server_config = $server_config;
+ $this->userData = $userData;
+ $this->database = $database;
+ $this->toolbarInfo = $toolbarInfo;
+ }
+
+ /**
+ * Process the incoming data for this handler type.
+ *
+ * @param array $data The decoded JSON data from the browser extension.
+ * @return void
+ */
+ abstract public function handle(array $data): void;
+
+ /**
+ * Get the handler type name (e.g., 'overview', 'buildings', 'system').
+ *
+ * @return string
+ */
+ abstract public function getType(): string;
+
+ /**
+ * Get the required grant for this handler (e.g., 'empire', 'system', 'ranking', 'messages').
+ *
+ * @return string
+ */
+ abstract public function getRequiredGrant(): string;
+
+ /**
+ * Check that the user has the required grant. Sets IO error and returns false if not.
+ *
+ * @param string $grant The grant type to check.
+ * @return bool True if the user has the grant, false otherwise.
+ */
+ protected function requireGrant(string $grant): bool
+ {
+ if (!$this->userData['grant'][$grant]) {
+ $this->io->set(array(
+ 'type' => 'plugin grant',
+ 'access' => $grant
+ ));
+ $this->io->status(0);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Validate and parse a coordinate string into its components.
+ *
+ * @param string $coordString Raw coordinate string (e.g., "4:252:12").
+ * @param int $exp Whether this is an expedition coordinate (position 16).
+ * @return array{coords: string, galaxy: int, system: int, row: int}
+ * @throws \InvalidArgumentException If coordinates are invalid.
+ */
+ protected function parseCoordinates(string $coordString, int $exp = 0): array
+ {
+ $coords = Check::coords($coordString, $exp);
+ list($g, $s, $r) = explode(':', $coords);
+ return [
+ 'coords' => $coords,
+ 'galaxy' => (int)$g,
+ 'system' => (int)$s,
+ 'row' => (int)$r,
+ ];
+ }
+
+ /**
+ * Resolve an integer/string planet type to the TYPE_PLANET/TYPE_MOON constant.
+ *
+ * @param int|string $type The type value from the extension data.
+ * @return string TYPE_PLANET or TYPE_MOON constant.
+ */
+ protected function resolvePlanetType($type): string
+ {
+ return ((int)$type == 0 || (int)$type == TYPE_PLANET) ? TYPE_PLANET : TYPE_MOON;
+ }
+
+ /**
+ * Get the database string representation of a planet type ('planet' or 'moon').
+ *
+ * @param string $planetTypeConstant TYPE_PLANET or TYPE_MOON.
+ * @return string 'planet' or 'moon'.
+ */
+ protected function planetTypeToString(string $planetTypeConstant): string
+ {
+ return ($planetTypeConstant === TYPE_PLANET) ? 'planet' : 'moon';
+ }
+
+ /**
+ * Build and execute an UPSERT (INSERT ... ON DUPLICATE KEY UPDATE) query.
+ *
+ * @param string $table The table constant (e.g., TABLE_USER_BUILDING).
+ * @param array $columns Ordered list of column names.
+ * @param array $values Ordered list of values matching $columns.
+ * @param array $updateColumns Column names to include in ON DUPLICATE KEY UPDATE using VALUES().
+ * @return void
+ */
+ protected function executeUpsert(string $table, array $columns, array $values, array $updateColumns): void
+ {
+ $colStr = '`' . implode('`, `', $columns) . '`';
+ $valStr = implode(', ', array_map(function ($val) {
+ return is_int($val) || is_float($val) ? $val : "'" . $val . "'";
+ }, $values));
+ $updatePairs = array_map(function ($col) {
+ return "`$col` = VALUES(`$col`)";
+ }, $updateColumns);
+
+ $query = "INSERT INTO $table ($colStr) VALUES ($valStr) ON DUPLICATE KEY UPDATE " . implode(', ', $updatePairs);
+ $this->db->sql_query($query);
+ }
+
+ /**
+ * Register a callback with the CallbackHandler.
+ *
+ * @param string $type Callback type name.
+ * @param array $params Parameters to pass to the callback.
+ * @return void
+ */
+ protected function registerCallback(string $type, array $params): void
+ {
+ $this->callbackHandler->add($type, $params);
+ }
+
+ /**
+ * Log an action via the xtense add_log function.
+ *
+ * @param string $type Log type (e.g., 'overview', 'buildings').
+ * @param array $context Additional context (coords, planet_name, etc.). toolbar is added automatically.
+ * @return void
+ */
+ protected function logAction(string $type, array $context = []): void
+ {
+ $context['toolbar'] = $this->toolbarInfo;
+ add_log($type, $context);
+ }
+
+ /**
+ * Set the IO response for a successful page update.
+ *
+ * @param string $page Page identifier (e.g., 'overview', 'buildings').
+ * @param string $coords Coordinate string.
+ * @return void
+ */
+ protected function setPageUpdatedResponse(string $page, string $coords): void
+ {
+ $this->io->set(array(
+ 'type' => 'home updated',
+ 'page' => $page,
+ 'planet' => $coords
+ ));
+ }
+}
diff --git a/includes/Check.php b/includes/Check.php
index 77173ea..a4029b4 100755
--- a/includes/Check.php
+++ b/includes/Check.php
@@ -27,12 +27,14 @@ static function player_status_forbidden($string) { //Le status "point d'honneur
* Sanitize Coordinates string
* @param $string
* @param int $exp
- * @return mixed
+ * @return string
+ * @throws \InvalidArgumentException
*/
static function coords($string, $exp = 0) {
global $server_config;
- //if ($string == "unknown") return true; //cas avec une seule planète
- if (!preg_match('!^([0-9]{1,2}):([0-9]{1,3}):([0-9]{1,2})$!Usi', $string, $match)) die("coords : Hack");
+ if (!preg_match('!^([0-9]{1,2}):([0-9]{1,3}):([0-9]{1,2})$!Usi', $string, $match)) {
+ throw new \InvalidArgumentException("Invalid coordinates format: " . substr((string)$string, 0, 30));
+ }
if (($match[1] < 1 || $match[2] < 1 || $match[3] < 1 || $match[1] > $server_config['num_of_galaxies'] || $match[2] > $server_config['num_of_systems'] || ($exp ? ($match[3] != 16) : ($match[3] > 15))) == false) return $string;
}
diff --git a/includes/Io.php b/includes/Io.php
index 2c945ef..56aa9cc 100755
--- a/includes/Io.php
+++ b/includes/Io.php
@@ -138,22 +138,15 @@ public function append_call_message($message, $type = self::SUCCESS, $callback =
*/
public function append_call_error($call, $message, ?Exception $e = null)
{
+ global $log;
+
$this->append_call($call, self::ERROR);
$this->append_call_message($message, self::ERROR, $call);
-
- echo "* CALL ERROR ({$call['root']}):\n $message\n";
-
- if ($e !== null) {
- echo " Exception Stacktrace\n";
- $stacktrace = str_replace("\n", "\n ", $e->getTraceAsString());
- if (isset($db_password)) {
- $stacktrace = str_replace($db_password, "*****", $stacktrace);
- }
- echo " " . $stacktrace;
- }
-
- echo "\n\n";
-
+ $context = ['mod' => $call['root'] ?? 'unknown'];
+ if ($e !== null) {
+ $context['exception'] = $e;
+ }
+ $log->error("Xtense callback error: $message", $context);
}
}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..3ce1727
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ ./tests/unit
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..f3366c7
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,145 @@
+ ogspy/
+
+if (file_exists($xtenseVendor)) {
+ require_once $xtenseVendor;
+} elseif (file_exists($ogspyVendor)) {
+ require_once $ogspyVendor;
+} else {
+ fwrite(STDERR, "vendor/autoload.php not found. Run 'composer install' in the mod-xtense directory.\n");
+ exit(1);
+}
+
+// ---------------------------------------------------------------------------
+// 2. Entry-point guards expected by xtense source files
+// ---------------------------------------------------------------------------
+if (!defined('IN_SPYOGAME')) define('IN_SPYOGAME', true);
+if (!defined('IN_XTENSE')) define('IN_XTENSE', true);
+
+// ---------------------------------------------------------------------------
+// 3. Globals consumed by xtense/includes/config.php and helpers
+// ---------------------------------------------------------------------------
+$GLOBALS['table_prefix'] = 'ogspy_';
+$GLOBALS['root'] = 'xtense';
+
+// ---------------------------------------------------------------------------
+// 4. TABLE_* constants (mirrors includes/config.php)
+// ---------------------------------------------------------------------------
+$p = 'ogspy_';
+foreach ([
+ 'TABLE_CONFIG' => $p . 'config',
+ 'TABLE_USER' => $p . 'user',
+ 'TABLE_MOD' => $p . 'mod',
+ 'TABLE_STATISTIC' => $p . 'statistics',
+ 'TABLE_USER_BUILDING' => $p . 'game_astro_object',
+ 'TABLE_GAME_PLAYER' => $p . 'game_player',
+ 'TABLE_GAME_ALLY' => $p . 'game_ally',
+ 'TABLE_GAME_PLAYER_DEFENSE' => $p . 'game_player_defense',
+ 'TABLE_GAME_PLAYER_FLEET' => $p . 'game_player_fleet',
+ 'TABLE_USER_TECHNOLOGY' => $p . 'game_player_technology',
+ 'TABLE_RANK_PLAYER_POINTS' => $p . 'game_rank_player_points',
+ 'TABLE_RANK_PLAYER_ECO' => $p . 'game_rank_player_economics',
+ 'TABLE_RANK_PLAYER_TECHNOLOGY' => $p . 'game_rank_player_technology',
+ 'TABLE_RANK_PLAYER_MILITARY' => $p . 'game_rank_player_military',
+ 'TABLE_RANK_PLAYER_MILITARY_BUILT' => $p . 'game_rank_player_military_built',
+ 'TABLE_RANK_PLAYER_MILITARY_LOOSE' => $p . 'game_rank_player_military_loose',
+ 'TABLE_RANK_PLAYER_MILITARY_DESTRUCT' => $p . 'game_rank_player_military_destruct',
+ 'TABLE_RANK_PLAYER_HONOR' => $p . 'game_rank_player_honor',
+ 'TABLE_RANK_ALLY_POINTS' => $p . 'game_rank_ally_points',
+ 'TABLE_RANK_ALLY_ECO' => $p . 'game_rank_ally_economics',
+ 'TABLE_RANK_ALLY_TECHNOLOGY' => $p . 'game_rank_ally_technology',
+ 'TABLE_RANK_ALLY_MILITARY' => $p . 'game_rank_ally_military',
+ 'TABLE_RANK_ALLY_MILITARY_BUILT' => $p . 'game_rank_ally_military_built',
+ 'TABLE_RANK_ALLY_MILITARY_LOOSE' => $p . 'game_rank_ally_military_loose',
+ 'TABLE_RANK_ALLY_MILITARY_DESTRUCT' => $p . 'game_rank_ally_military_destruct',
+ 'TABLE_RANK_ALLY_HONOR' => $p . 'game_rank_ally_honor',
+] as $constName => $constValue) {
+ if (!defined($constName)) define($constName, $constValue);
+}
+
+// ---------------------------------------------------------------------------
+// 5. Stub core OGSpy functions that xtense handlers call but that live outside
+// the mod directory and are therefore unavailable in the unit-test context.
+// ---------------------------------------------------------------------------
+if (!function_exists('generate_config_cache')) {
+ function generate_config_cache(): void {}
+}
+if (!function_exists('booster_encode')) {
+ function booster_encode(array $boosters): string { return ''; }
+}
+if (!function_exists('booster_encodev')) {
+ function booster_encodev(int ...$args): string { return ''; }
+}
+
+// ---------------------------------------------------------------------------
+// 6. Xtense source files (must come before test-infrastructure classes that
+// extend them, e.g. SpyCallbackHandler extends CallbackHandler)
+// ---------------------------------------------------------------------------
+$base = $xtenseRoot; // mod/xtense/
+
+// config.php reads version.txt via relative paths designed for CWD = ogspy root.
+// When running standalone from mod/xtense/, set CWD to ogspy root temporarily
+// so that "mod/xtense/version.txt" resolves correctly, then restore.
+$_ogspyRoot = dirname($xtenseRoot, 2); // mod/xtense/ → mod/ → ogspy/
+$_prevCwd = getcwd();
+if (is_dir($_ogspyRoot)) {
+ chdir($_ogspyRoot);
+}
+require_once $base . '/includes/config.php'; // TYPE_PLANET, TYPE_MOON, $database
+chdir($_prevCwd);
+require_once $base . '/includes/Io.php';
+require_once $base . '/includes/Check.php';
+require_once $base . '/includes/CallbackHandler.php';
+require_once $base . '/includes/Callback.php';
+require_once $base . '/includes/functions.php';
+require_once $base . '/includes/AbstractHandler.php';
+
+// ---------------------------------------------------------------------------
+// 7. Test-infrastructure classes (loaded after xtense sources they depend on)
+// ---------------------------------------------------------------------------
+$unitDir = __DIR__ . '/unit/';
+require_once $unitDir . 'SpyDatabase.php';
+require_once $unitDir . 'SpyCallbackHandler.php';
+
+// ---------------------------------------------------------------------------
+// 8. Phase-2+ handler files — loaded only when they exist so Phase-C tests
+// skip gracefully before the handlers are implemented.
+// ---------------------------------------------------------------------------
+$handlerDir = $base . '/Handler/';
+foreach (['Overview', 'Buildings', 'ResourceSettings', 'Defense', 'Researchs',
+ 'Fleet', 'System', 'Ranking', 'CombatReport', 'AllyList', 'Message'] as $handlerName) {
+ $handlerFile = $handlerDir . $handlerName . 'Handler.php';
+ if (file_exists($handlerFile)) {
+ require_once $handlerFile;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// 9. Base test-case class (depends on both xtense sources and spy helpers)
+// ---------------------------------------------------------------------------
+require_once $unitDir . 'XtenseTestCase.php';
diff --git a/tests/fixtures/ally_list.json b/tests/fixtures/ally_list.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ally_list.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/buildings.json b/tests/fixtures/buildings.json
new file mode 100644
index 0000000..459677e
--- /dev/null
+++ b/tests/fixtures/buildings.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "buildings",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"planet\":{\"id\":\"33717002\",\"name\":\"planète mère\",\"coords\":\"4:246:12\",\"type\":\"0\"},\"buildings\":{\"M\":\"7\",\"C\":\"5\",\"D\":\"5\",\"CES\":\"9\",\"CEF\":\"0\",\"SAT\":\"0\",\"FOR\":\"0\",\"HM\":\"1\",\"HC\":\"0\",\"HD\":\"0\"}}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/buildings_facilities.json b/tests/fixtures/buildings_facilities.json
new file mode 100644
index 0000000..a412bdd
--- /dev/null
+++ b/tests/fixtures/buildings_facilities.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "buildings",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"planet\":{\"id\":\"33717002\",\"name\":\"planète mère\",\"coords\":\"4:246:12\",\"type\":\"0\"},\"buildings\":{\"UdR\":\"1\",\"CSp\":\"0\",\"Lab\":\"1\",\"DdR\":\"0\",\"Silo\":\"0\",\"UdN\":\"0\",\"Ter\":\"0\",\"Dock\":\"0\"}}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/defense.json b/tests/fixtures/defense.json
new file mode 100644
index 0000000..199a9e1
--- /dev/null
+++ b/tests/fixtures/defense.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "defense",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"planet\":{\"id\":\"33717002\",\"name\":\"planète mère\",\"coords\":\"4:246:12\",\"type\":\"0\"},\"defense\":{\"LM\":\"0\",\"LLE\":\"0\",\"LLO\":\"0\",\"CG\":\"0\",\"AI\":\"0\",\"LP\":\"0\",\"PB\":\"0\",\"GB\":\"0\",\"MIC\":\"0\",\"MIP\":\"0\"}}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/fleet.json b/tests/fixtures/fleet.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/fleet.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/messages_ally_msg.json b/tests/fixtures/messages_ally_msg.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/messages_ally_msg.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/messages_ennemy_spy.json b/tests/fixtures/messages_ennemy_spy.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/messages_ennemy_spy.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/messages_expedition.json b/tests/fixtures/messages_expedition.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/messages_expedition.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/messages_msg.json b/tests/fixtures/messages_msg.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/messages_msg.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/messages_rc_cdr.json b/tests/fixtures/messages_rc_cdr.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/messages_rc_cdr.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/messages_spy.json b/tests/fixtures/messages_spy.json
new file mode 100644
index 0000000..7833b7c
--- /dev/null
+++ b/tests/fixtures/messages_spy.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.2",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "messages",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"type\":\"spy\",\"proba\":\"0\",\"activity\":\"15\",\"date\":\"1776372546\",\"player\":{\"name\":\"Lord Kempatek\",\"status\":\"-\",\"class\":{\"character\":{\"id\":1,\"icon\":\"miner\",\"name\":\"Le collecteur\"},\"alliance\":{\"id\":3,\"icon\":\"explorer\",\"name\":\"Chercheur (alliance)\"}}},\"planet\":{\"name\":\"KEMPA III\",\"coordinates\":\"4:246:7\",\"type\":\"1\",\"id\":\"33717423\"},\"resources\":{\"metal\":\"774266\",\"crystal\":\"152040\",\"deuterium\":\"137071\",\"loot\":\"50\"},\"buildings\":{},\"lfBuildings\":{},\"research\":{},\"lfResearch\":{},\"fleet\":{},\"defense\":{}}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/messages_spy_shared.json b/tests/fixtures/messages_spy_shared.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/messages_spy_shared.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/overview.json b/tests/fixtures/overview.json
new file mode 100644
index 0000000..7c01e27
--- /dev/null
+++ b/tests/fixtures/overview.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "overview",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"planet\":{\"id\":\"33717002\",\"name\":\"planète mère\",\"coords\":\"4:246:12\",\"type\":\"0\"},\"fields\":188,\"temperature_min\":\"-40\",\"temperature_max\":\"0\",\"ressources\":{\"metal\":20000,\"cristal\":10000,\"deut\":3271,\"antimater\":10500,\"energy\":89},\"playerdetails\":{\"player_name\":\"Marshal Galileo\",\"player_id\":\"107058\",\"playerclass_explorer\":0,\"playerclass_miner\":1,\"playerclass_warrior\":0,\"player_officer_commander\":0,\"player_officer_amiral\":0,\"player_officer_engineer\":0,\"player_officer_geologist\":0,\"player_officer_technocrate\":0},\"unidetails\":{\"uni_version\":\"12.9.0-r1\",\"uni_url\":\"s277-fr.ogame.gameforge.com\",\"uni_lang\":\"fr\",\"uni_name\":\"Veritate\",\"uni_time\":\"1776160665\",\"uni_speed\":\"8\",\"uni_speed_fleet_peaceful\":\"4\",\"uni_speed_fleet_war\":\"6\",\"uni_speed_fleet_holding\":\"6\",\"uni_donut_g\":\"1\",\"uni_donut_s\":\"1\"},\"boosters\":[]}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/ranking_ally_economy.json b/tests/fixtures/ranking_ally_economy.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_economy.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_ally_fleet_built.json b/tests/fixtures/ranking_ally_fleet_built.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_fleet_built.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_ally_fleet_destruct.json b/tests/fixtures/ranking_ally_fleet_destruct.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_fleet_destruct.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_ally_fleet_honor.json b/tests/fixtures/ranking_ally_fleet_honor.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_fleet_honor.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_ally_fleet_loose.json b/tests/fixtures/ranking_ally_fleet_loose.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_fleet_loose.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_ally_points.json b/tests/fixtures/ranking_ally_points.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_points.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_ally_research.json b/tests/fixtures/ranking_ally_research.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_ally_research.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_player_economy.json b/tests/fixtures/ranking_player_economy.json
new file mode 100644
index 0000000..3cc0e9c
--- /dev/null
+++ b/tests/fixtures/ranking_player_economy.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "ranking",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"n\":[{\"rank\":1001,\"player_id\":104789,\"player_name\":\"Saryl\",\"ally_id\":500640,\"ally_tag\":\"AdA\",\"points\":2845},{\"rank\":1002,\"player_id\":101209,\"player_name\":\"kale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2776},{\"rank\":1003,\"player_id\":106382,\"player_name\":\"Constable Ferret\",\"ally_id\":500952,\"ally_tag\":\"judeles\",\"points\":2455},{\"rank\":1004,\"player_id\":102064,\"player_name\":\"Viig\",\"ally_id\":500237,\"ally_tag\":\"NID\",\"points\":2303},{\"rank\":1005,\"player_id\":106126,\"player_name\":\"Vice Apollo\",\"ally_id\":500859,\"ally_tag\":\"ABCD\",\"points\":2183},{\"rank\":1006,\"player_id\":104738,\"player_name\":\"patriarche\",\"ally_id\":500610,\"ally_tag\":\"IOM\",\"points\":2163},{\"rank\":1007,\"player_id\":107032,\"player_name\":\"Governor Kastra\",\"ally_id\":500973,\"ally_tag\":\"TBT\",\"points\":2052},{\"rank\":1008,\"player_id\":105992,\"player_name\":\"Procurator Orionx\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2047},{\"rank\":1009,\"player_id\":102249,\"player_name\":\"Constable Flyby\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1829},{\"rank\":1010,\"player_id\":101359,\"player_name\":\"Bandit Spirit\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1775},{\"rank\":1011,\"player_id\":107151,\"player_name\":\"ynerys\",\"ally_id\":500993,\"ally_tag\":\"ELD\",\"points\":1723},{\"rank\":1012,\"player_id\":107189,\"player_name\":\"Lieutenant Pulsar\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1635},{\"rank\":1013,\"player_id\":106376,\"player_name\":\"Tenyen\",\"ally_id\":500920,\"ally_tag\":\"pkt\",\"points\":1589},{\"rank\":1014,\"player_id\":107093,\"player_name\":\"Halowolf\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1586},{\"rank\":1015,\"player_id\":100375,\"player_name\":\"Eliza\",\"ally_id\":500023,\"ally_tag\":\"RUSH\",\"points\":1579},{\"rank\":1016,\"player_id\":102183,\"player_name\":\"Engineer Uranus\",\"ally_id\":500698,\"ally_tag\":\"FAR\",\"points\":1494},{\"rank\":1017,\"player_id\":105267,\"player_name\":\"Archibald\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":1294},{\"rank\":1018,\"player_id\":105750,\"player_name\":\"kuamatt\",\"ally_id\":500805,\"ally_tag\":\"kmt\",\"points\":1258},{\"rank\":1019,\"player_id\":104506,\"player_name\":\"coolbrise\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1187},{\"rank\":1020,\"player_id\":107090,\"player_name\":\"Mon Seigneur\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1172},{\"rank\":1021,\"player_id\":107182,\"player_name\":\"Engineer Vega\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1103},{\"rank\":1022,\"player_id\":104734,\"player_name\":\"Renegade Scorpius\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":873},{\"rank\":1023,\"player_id\":106085,\"player_name\":\"Technocrat Seren\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":798},{\"rank\":1024,\"player_id\":106505,\"player_name\":\"Chambellan Aymi\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":743},{\"rank\":1025,\"player_id\":105378,\"player_name\":\"Dark revan\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":734},{\"rank\":1026,\"player_id\":101867,\"player_name\":\"Consul Columbo\",\"ally_id\":500251,\"ally_tag\":\"Mars\",\"points\":716},{\"rank\":1027,\"player_id\":100701,\"player_name\":\"Stadtholder Nebula\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":667},{\"rank\":1028,\"player_id\":106333,\"player_name\":\"Commander Nervus\",\"ally_id\":500967,\"ally_tag\":\"SGA\",\"points\":664},{\"rank\":1029,\"player_id\":101992,\"player_name\":\"Commodore Neptuno\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":650},{\"rank\":1030,\"player_id\":105123,\"player_name\":\"Lord Stardust\",\"ally_id\":500745,\"ally_tag\":\"STAR\",\"points\":587},{\"rank\":1031,\"player_id\":105224,\"player_name\":\"Geologist Stardust\",\"ally_id\":500737,\"ally_tag\":\"Fra\",\"points\":566},{\"rank\":1032,\"player_id\":100362,\"player_name\":\"Fist\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":560},{\"rank\":1033,\"player_id\":101364,\"player_name\":\"Technocrat Zenith\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":551},{\"rank\":1034,\"player_id\":104594,\"player_name\":\"Captain Seren\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":548},{\"rank\":1035,\"player_id\":103098,\"player_name\":\"Kaiman\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":473},{\"rank\":1036,\"player_id\":106048,\"player_name\":\"St-Aza\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":462},{\"rank\":1037,\"player_id\":107224,\"player_name\":\"Graham\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":442},{\"rank\":1038,\"player_id\":107154,\"player_name\":\"Procurator Hubble\",\"ally_id\":500995,\"ally_tag\":\"suisse-f\",\"points\":399},{\"rank\":1039,\"player_id\":105852,\"player_name\":\"Fengeek\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":390},{\"rank\":1040,\"player_id\":100486,\"player_name\":\"Jaya\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":386},{\"rank\":1041,\"player_id\":103579,\"player_name\":\"Magic of Chaos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":364},{\"rank\":1042,\"player_id\":106263,\"player_name\":\"Dark Lord\",\"ally_id\":500879,\"ally_tag\":\"FUN\",\"points\":362},{\"rank\":1043,\"player_id\":104647,\"player_name\":\"Marshal maxime\",\"ally_id\":500661,\"ally_tag\":\"AVISO\",\"points\":359},{\"rank\":1044,\"player_id\":106005,\"player_name\":\"Captain Xyronix\",\"ally_id\":500836,\"ally_tag\":\"TKA\",\"points\":325},{\"rank\":1045,\"player_id\":104804,\"player_name\":\"13139\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":324},{\"rank\":1046,\"player_id\":104921,\"player_name\":\"Procurator Seti\",\"ally_id\":500667,\"ally_tag\":\"ORI\",\"points\":303},{\"rank\":1047,\"player_id\":105093,\"player_name\":\"Badmood\",\"ally_id\":500808,\"ally_tag\":\"LFH\",\"points\":296},{\"rank\":1048,\"player_id\":104893,\"player_name\":\"Olsen\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":264},{\"rank\":1049,\"player_id\":101721,\"player_name\":\"Renegade Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":253},{\"rank\":1050,\"player_id\":105157,\"player_name\":\"Geologist Telesto\",\"ally_id\":500697,\"ally_tag\":\"Nexus\",\"points\":240},{\"rank\":1051,\"player_id\":107177,\"player_name\":\"Chief Interstellar\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":237},{\"rank\":1052,\"player_id\":105141,\"player_name\":\"royal__trix\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":221},{\"rank\":1053,\"player_id\":104521,\"player_name\":\"Captain Omicron\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":210},{\"rank\":1054,\"player_id\":105715,\"player_name\":\"Sagi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":157},{\"rank\":1055,\"player_id\":100196,\"player_name\":\"Diaboliks\",\"ally_id\":500037,\"ally_tag\":\"I-W\",\"points\":154},{\"rank\":1056,\"player_id\":107214,\"player_name\":\"Chancellor Pegasus\",\"ally_id\":501005,\"ally_tag\":\"chan\",\"points\":146},{\"rank\":1057,\"player_id\":107204,\"player_name\":\"NiXiD\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":146},{\"rank\":1058,\"player_id\":103773,\"player_name\":\"Commander Corvus\",\"ally_id\":500565,\"ally_tag\":\"LOL\",\"points\":136},{\"rank\":1059,\"player_id\":105027,\"player_name\":\"Capitaine flam\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":129},{\"rank\":1060,\"player_id\":105377,\"player_name\":\"Technocrat Zodiac\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":118},{\"rank\":1061,\"player_id\":101202,\"player_name\":\"Engineer Seti\",\"ally_id\":500383,\"ally_tag\":\"MIX2\",\"points\":113},{\"rank\":1062,\"player_id\":104760,\"player_name\":\"Lieutenant Nebulor\",\"ally_id\":500676,\"ally_tag\":\"FFA\",\"points\":111},{\"rank\":1063,\"player_id\":104964,\"player_name\":\"Grymstonne\",\"ally_id\":500658,\"ally_tag\":\"zogzog\",\"points\":102},{\"rank\":1064,\"player_id\":106119,\"player_name\":\"Technocrat Amo\",\"ally_id\":500857,\"ally_tag\":\"jsp\",\"points\":70},{\"rank\":1065,\"player_id\":104863,\"player_name\":\"tyrhum\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":60},{\"rank\":1066,\"player_id\":102735,\"player_name\":\"Aucter Dei\",\"ally_id\":500330,\"ally_tag\":\"Fed0\",\"points\":44},{\"rank\":1067,\"player_id\":106024,\"player_name\":\"Ogaine\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":42},{\"rank\":1068,\"player_id\":104057,\"player_name\":\"Bandit Telesto\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":41},{\"rank\":1069,\"player_id\":107131,\"player_name\":\"Bonsoir\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":36},{\"rank\":1070,\"player_id\":105104,\"player_name\":\"Bandit Eos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":35},{\"rank\":1071,\"player_id\":107136,\"player_name\":\"Emperor Horizon\",\"ally_id\":500991,\"ally_tag\":\"FRANCE\",\"points\":30},{\"rank\":1072,\"player_id\":106069,\"player_name\":\"Enflure La Raclure\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":29},{\"rank\":1073,\"player_id\":107223,\"player_name\":\"Constable Neutrino\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":26},{\"rank\":1074,\"player_id\":105602,\"player_name\":\"Satsuki\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":25},{\"rank\":1075,\"player_id\":\"107058\",\"player_name\":\"Marshal Galileo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":23},{\"rank\":1076,\"player_id\":105053,\"player_name\":\"Governor Yzaron\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":21},{\"rank\":1077,\"player_id\":106098,\"player_name\":\"Renegade Radiation\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":16},{\"rank\":1078,\"player_id\":107095,\"player_name\":\"Chief Hunter\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":13},{\"rank\":1079,\"player_id\":107083,\"player_name\":\"Shemale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":13},{\"rank\":1080,\"player_id\":107226,\"player_name\":\"Captain Cetus\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":8},{\"rank\":1081,\"player_id\":106111,\"player_name\":\"Boubane\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":8},{\"rank\":1082,\"player_id\":102724,\"player_name\":\"Admiral Columbo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":8},{\"rank\":1083,\"player_id\":107194,\"player_name\":\"The_God\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":7},{\"rank\":1084,\"player_id\":107197,\"player_name\":\"Archange\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":7},{\"rank\":1085,\"player_id\":106402,\"player_name\":\"Proconsul Pavo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":6},{\"rank\":1086,\"player_id\":105931,\"player_name\":\"Admiral Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":6},{\"rank\":1087,\"player_id\":107110,\"player_name\":\"Michel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":5},{\"rank\":1088,\"player_id\":105172,\"player_name\":\"President Orbit\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":5},{\"rank\":1089,\"player_id\":107222,\"player_name\":\"Proconsul Puck\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4},{\"rank\":1090,\"player_id\":107130,\"player_name\":\"Senator Kraz\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4},{\"rank\":1091,\"player_id\":107175,\"player_name\":\"Commodore Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4},{\"rank\":1092,\"player_id\":105061,\"player_name\":\"President Lagoon\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":3},{\"rank\":1093,\"player_id\":107219,\"player_name\":\"Procurator Draco\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1094,\"player_id\":107115,\"player_name\":\"Lord Kyions\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1095,\"player_id\":107196,\"player_name\":\"eikichi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1096,\"player_id\":107142,\"player_name\":\"Constable\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1097,\"player_id\":105963,\"player_name\":\"capt07\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1098,\"player_id\":106472,\"player_name\":\"Engineer Centauri\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1},{\"rank\":1099,\"player_id\":107125,\"player_name\":\"red astairoid\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1},{\"rank\":1100,\"player_id\":107183,\"player_name\":\"Director Universe\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1}],\"offset\":1001,\"type1\":\"player\",\"type2\":\"economy\",\"type3\":\"\",\"time\":1776160800}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/ranking_player_fleet_built.json b/tests/fixtures/ranking_player_fleet_built.json
new file mode 100644
index 0000000..e42f257
--- /dev/null
+++ b/tests/fixtures/ranking_player_fleet_built.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "ranking",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"n\":[{\"rank\":1001,\"player_id\":101209,\"player_name\":\"kale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":822,\"nb_spacecraft\":0},{\"rank\":1002,\"player_id\":101002,\"player_name\":\"Bandit Alpha\",\"ally_id\":500106,\"ally_tag\":\"ASTRO\",\"points\":801,\"nb_spacecraft\":0},{\"rank\":1003,\"player_id\":102183,\"player_name\":\"Engineer Uranus\",\"ally_id\":500698,\"ally_tag\":\"FAR\",\"points\":785,\"nb_spacecraft\":0},{\"rank\":1004,\"player_id\":105839,\"player_name\":\"Senator Osiris\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":749,\"nb_spacecraft\":0},{\"rank\":1005,\"player_id\":107199,\"player_name\":\"Constable Hubble\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":740,\"nb_spacecraft\":0},{\"rank\":1006,\"player_id\":106505,\"player_name\":\"Chambellan Aymi\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":695,\"nb_spacecraft\":0},{\"rank\":1007,\"player_id\":105141,\"player_name\":\"royal__trix\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":679,\"nb_spacecraft\":0},{\"rank\":1008,\"player_id\":107209,\"player_name\":\"Captain Mars\",\"ally_id\":501001,\"ally_tag\":\"mano\",\"points\":631,\"nb_spacecraft\":0},{\"rank\":1009,\"player_id\":101359,\"player_name\":\"Bandit Spirit\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":622,\"nb_spacecraft\":0},{\"rank\":1010,\"player_id\":107189,\"player_name\":\"Lieutenant Pulsar\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":605,\"nb_spacecraft\":0},{\"rank\":1011,\"player_id\":106059,\"player_name\":\"Altare Sanguinem\",\"ally_id\":500882,\"ally_tag\":\"EDC\",\"points\":605,\"nb_spacecraft\":0},{\"rank\":1012,\"player_id\":100634,\"player_name\":\"Floo69\",\"ally_id\":500156,\"ally_tag\":\"Flo\",\"points\":599,\"nb_spacecraft\":0},{\"rank\":1013,\"player_id\":101059,\"player_name\":\"Captain Galileo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":591,\"nb_spacecraft\":0},{\"rank\":1014,\"player_id\":103098,\"player_name\":\"Kaiman\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":542,\"nb_spacecraft\":0},{\"rank\":1015,\"player_id\":104804,\"player_name\":\"13139\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":532,\"nb_spacecraft\":0},{\"rank\":1016,\"player_id\":106005,\"player_name\":\"Captain Xyronix\",\"ally_id\":500836,\"ally_tag\":\"TKA\",\"points\":520,\"nb_spacecraft\":0},{\"rank\":1017,\"player_id\":102249,\"player_name\":\"Constable Flyby\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":504,\"nb_spacecraft\":0},{\"rank\":1018,\"player_id\":107171,\"player_name\":\"Denzel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":461,\"nb_spacecraft\":0},{\"rank\":1019,\"player_id\":103073,\"player_name\":\"Commander Oryon\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":451,\"nb_spacecraft\":0},{\"rank\":1020,\"player_id\":106085,\"player_name\":\"Technocrat Seren\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":420,\"nb_spacecraft\":0},{\"rank\":1021,\"player_id\":100701,\"player_name\":\"Stadtholder Nebula\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":407,\"nb_spacecraft\":0},{\"rank\":1022,\"player_id\":104921,\"player_name\":\"Procurator Seti\",\"ally_id\":500667,\"ally_tag\":\"ORI\",\"points\":400,\"nb_spacecraft\":0},{\"rank\":1023,\"player_id\":107177,\"player_name\":\"Chief Interstellar\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":358,\"nb_spacecraft\":0},{\"rank\":1024,\"player_id\":106333,\"player_name\":\"Commander Nervus\",\"ally_id\":500967,\"ally_tag\":\"SGA\",\"points\":330,\"nb_spacecraft\":0},{\"rank\":1025,\"player_id\":104760,\"player_name\":\"Lieutenant Nebulor\",\"ally_id\":500676,\"ally_tag\":\"FFA\",\"points\":327,\"nb_spacecraft\":0},{\"rank\":1026,\"player_id\":104734,\"player_name\":\"Renegade Scorpius\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":325,\"nb_spacecraft\":0},{\"rank\":1027,\"player_id\":105093,\"player_name\":\"Badmood\",\"ally_id\":500808,\"ally_tag\":\"LFH\",\"points\":282,\"nb_spacecraft\":0},{\"rank\":1028,\"player_id\":107224,\"player_name\":\"Graham\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":274,\"nb_spacecraft\":0},{\"rank\":1029,\"player_id\":107203,\"player_name\":\"Captain Delestor\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":244,\"nb_spacecraft\":0},{\"rank\":1030,\"player_id\":101867,\"player_name\":\"Consul Columbo\",\"ally_id\":500251,\"ally_tag\":\"Mars\",\"points\":199,\"nb_spacecraft\":0},{\"rank\":1031,\"player_id\":106376,\"player_name\":\"Tenyen\",\"ally_id\":500920,\"ally_tag\":\"pkt\",\"points\":198,\"nb_spacecraft\":0},{\"rank\":1032,\"player_id\":104647,\"player_name\":\"Marshal maxime\",\"ally_id\":500661,\"ally_tag\":\"AVISO\",\"points\":198,\"nb_spacecraft\":0},{\"rank\":1033,\"player_id\":105224,\"player_name\":\"Geologist Stardust\",\"ally_id\":500737,\"ally_tag\":\"Fra\",\"points\":182,\"nb_spacecraft\":0},{\"rank\":1034,\"player_id\":104506,\"player_name\":\"coolbrise\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":182,\"nb_spacecraft\":0},{\"rank\":1035,\"player_id\":106048,\"player_name\":\"St-Aza\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":166,\"nb_spacecraft\":0},{\"rank\":1036,\"player_id\":101721,\"player_name\":\"Renegade Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":159,\"nb_spacecraft\":0},{\"rank\":1037,\"player_id\":100862,\"player_name\":\"Commander Mercury\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":159,\"nb_spacecraft\":0},{\"rank\":1038,\"player_id\":105378,\"player_name\":\"Dark revan\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":144,\"nb_spacecraft\":0},{\"rank\":1039,\"player_id\":107090,\"player_name\":\"Mon Seigneur\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":142,\"nb_spacecraft\":0},{\"rank\":1040,\"player_id\":107151,\"player_name\":\"ynerys\",\"ally_id\":500993,\"ally_tag\":\"ELD\",\"points\":109,\"nb_spacecraft\":0},{\"rank\":1041,\"player_id\":105123,\"player_name\":\"Lord Stardust\",\"ally_id\":500745,\"ally_tag\":\"STAR\",\"points\":73,\"nb_spacecraft\":0},{\"rank\":1042,\"player_id\":103579,\"player_name\":\"Magic of Chaos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":72,\"nb_spacecraft\":0},{\"rank\":1043,\"player_id\":105377,\"player_name\":\"Technocrat Zodiac\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":70,\"nb_spacecraft\":0},{\"rank\":1044,\"player_id\":104893,\"player_name\":\"Olsen\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":67,\"nb_spacecraft\":0},{\"rank\":1045,\"player_id\":103773,\"player_name\":\"Commander Corvus\",\"ally_id\":500565,\"ally_tag\":\"LOL\",\"points\":58,\"nb_spacecraft\":0},{\"rank\":1046,\"player_id\":104964,\"player_name\":\"Grymstonne\",\"ally_id\":500658,\"ally_tag\":\"zogzog\",\"points\":52,\"nb_spacecraft\":0},{\"rank\":1047,\"player_id\":107032,\"player_name\":\"Governor Kastra\",\"ally_id\":500973,\"ally_tag\":\"TBT\",\"points\":50,\"nb_spacecraft\":0},{\"rank\":1048,\"player_id\":105715,\"player_name\":\"Sagi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":43,\"nb_spacecraft\":0},{\"rank\":1049,\"player_id\":105027,\"player_name\":\"Capitaine flam\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":34,\"nb_spacecraft\":0},{\"rank\":1050,\"player_id\":107154,\"player_name\":\"Procurator Hubble\",\"ally_id\":500995,\"ally_tag\":\"suisse-f\",\"points\":30,\"nb_spacecraft\":0},{\"rank\":1051,\"player_id\":104764,\"player_name\":\"sombreRoso\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":27,\"nb_spacecraft\":0},{\"rank\":1052,\"player_id\":106263,\"player_name\":\"Dark Lord\",\"ally_id\":500879,\"ally_tag\":\"FUN\",\"points\":27,\"nb_spacecraft\":0},{\"rank\":1053,\"player_id\":104863,\"player_name\":\"tyrhum\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":20,\"nb_spacecraft\":0},{\"rank\":1054,\"player_id\":106119,\"player_name\":\"Technocrat Amo\",\"ally_id\":500857,\"ally_tag\":\"jsp\",\"points\":19,\"nb_spacecraft\":0},{\"rank\":1055,\"player_id\":100196,\"player_name\":\"Diaboliks\",\"ally_id\":500037,\"ally_tag\":\"I-W\",\"points\":11,\"nb_spacecraft\":0},{\"rank\":1056,\"player_id\":105852,\"player_name\":\"Fengeek\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":10,\"nb_spacecraft\":0},{\"rank\":1057,\"player_id\":106069,\"player_name\":\"Enflure La Raclure\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":9,\"nb_spacecraft\":0},{\"rank\":1058,\"player_id\":102735,\"player_name\":\"Aucter Dei\",\"ally_id\":500330,\"ally_tag\":\"Fed0\",\"points\":7,\"nb_spacecraft\":0},{\"rank\":1059,\"player_id\":107214,\"player_name\":\"Chancellor Pegasus\",\"ally_id\":501005,\"ally_tag\":\"chan\",\"points\":5,\"nb_spacecraft\":0},{\"rank\":1060,\"player_id\":107136,\"player_name\":\"Emperor Horizon\",\"ally_id\":500991,\"ally_tag\":\"FRANCE\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":1060,\"player_id\":107223,\"player_name\":\"Constable Neutrino\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":1060,\"player_id\":105104,\"player_name\":\"Bandit Eos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":1061,\"player_id\":107093,\"player_name\":\"Halowolf\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1,\"nb_spacecraft\":0},{\"rank\":1061,\"player_id\":107194,\"player_name\":\"The_God\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":105931,\"player_name\":\"Admiral Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":105963,\"player_name\":\"capt07\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106024,\"player_name\":\"Ogaine\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106098,\"player_name\":\"Renegade Radiation\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106111,\"player_name\":\"Boubane\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106402,\"player_name\":\"Proconsul Pavo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106405,\"player_name\":\"Marshal Nebulon\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106472,\"player_name\":\"Engineer Centauri\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":106609,\"player_name\":\"Procurator Yakini\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":\"107058\",\"player_name\":\"Marshal Galileo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107083,\"player_name\":\"Shemale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107095,\"player_name\":\"Chief Hunter\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107110,\"player_name\":\"Michel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107115,\"player_name\":\"Lord Kyions\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107122,\"player_name\":\"Maxime\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107125,\"player_name\":\"red astairoid\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107130,\"player_name\":\"Senator Kraz\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107131,\"player_name\":\"Bonsoir\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107134,\"player_name\":\"Stadtholder Europa\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107138,\"player_name\":\"UTILITAIRE\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107142,\"player_name\":\"Constable\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107143,\"player_name\":\"Marshal Indus\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107175,\"player_name\":\"Commodore Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107182,\"player_name\":\"Engineer Vega\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107183,\"player_name\":\"Director Universe\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107196,\"player_name\":\"eikichi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107197,\"player_name\":\"Archange\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107198,\"player_name\":\"Proconsul Magellan\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107204,\"player_name\":\"NiXiD\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107207,\"player_name\":\"Albator\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107212,\"player_name\":\"Proconsul Pallas\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107215,\"player_name\":\"Emperor Erato\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107216,\"player_name\":\"Engineer Neso\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107217,\"player_name\":\"Czar Volans\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107218,\"player_name\":\"Geologist Halo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":1062,\"player_id\":107219,\"player_name\":\"Procurator Draco\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0}],\"offset\":1001,\"type1\":\"player\",\"type2\":\"fleet\",\"type3\":\"5\",\"time\":1776164400}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/ranking_player_fleet_destruct.json b/tests/fixtures/ranking_player_fleet_destruct.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_player_fleet_destruct.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_player_fleet_honor.json b/tests/fixtures/ranking_player_fleet_honor.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_player_fleet_honor.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_player_fleet_loose.json b/tests/fixtures/ranking_player_fleet_loose.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_player_fleet_loose.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/ranking_player_fleet_points.json b/tests/fixtures/ranking_player_fleet_points.json
new file mode 100644
index 0000000..647ef82
--- /dev/null
+++ b/tests/fixtures/ranking_player_fleet_points.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "ranking",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"n\":[{\"rank\":875,\"player_id\":106485,\"player_name\":\"Wartaks\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":120,\"nb_spacecraft\":0},{\"rank\":875,\"player_id\":106653,\"player_name\":\"Geologist Deimos\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":120,\"nb_spacecraft\":0},{\"rank\":876,\"player_id\":105750,\"player_name\":\"kuamatt\",\"ally_id\":500805,\"ally_tag\":\"kmt\",\"points\":114,\"nb_spacecraft\":0},{\"rank\":877,\"player_id\":104533,\"player_name\":\"Kirutochi\",\"ally_id\":500600,\"ally_tag\":\"SUN\",\"points\":110,\"nb_spacecraft\":0},{\"rank\":878,\"player_id\":101831,\"player_name\":\"Lieutenant Orinix\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":104,\"nb_spacecraft\":0},{\"rank\":879,\"player_id\":102127,\"player_name\":\"Perle noire\",\"ally_id\":500222,\"ally_tag\":\"HELLFIRE\",\"points\":100,\"nb_spacecraft\":0},{\"rank\":879,\"player_id\":106246,\"player_name\":\"Reneyh_\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":100,\"nb_spacecraft\":0},{\"rank\":879,\"player_id\":104109,\"player_name\":\"Maeko\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":100,\"nb_spacecraft\":0},{\"rank\":879,\"player_id\":104808,\"player_name\":\"Vice Serpentis\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":100,\"nb_spacecraft\":0},{\"rank\":879,\"player_id\":105115,\"player_name\":\"Planet 151\",\"ally_id\":500696,\"ally_tag\":\"PNL\",\"points\":100,\"nb_spacecraft\":0},{\"rank\":880,\"player_id\":100862,\"player_name\":\"Commander Mercury\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":91,\"nb_spacecraft\":0},{\"rank\":881,\"player_id\":105903,\"player_name\":\"Chief Hydra\",\"ally_id\":500835,\"ally_tag\":\"ABC\",\"points\":86,\"nb_spacecraft\":0},{\"rank\":882,\"player_id\":102666,\"player_name\":\"Lord Barack\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":80,\"nb_spacecraft\":0},{\"rank\":883,\"player_id\":103579,\"player_name\":\"Magic of Chaos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":72,\"nb_spacecraft\":0},{\"rank\":884,\"player_id\":105377,\"player_name\":\"Technocrat Zodiac\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":70,\"nb_spacecraft\":0},{\"rank\":885,\"player_id\":104036,\"player_name\":\"kostrid\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":64,\"nb_spacecraft\":0},{\"rank\":886,\"player_id\":103334,\"player_name\":\"Slash\",\"ally_id\":500397,\"ally_tag\":\"ICC\",\"points\":60,\"nb_spacecraft\":0},{\"rank\":887,\"player_id\":102978,\"player_name\":\"Offworlder\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":51,\"nb_spacecraft\":0},{\"rank\":888,\"player_id\":101907,\"player_name\":\"Lord Altairon\",\"ally_id\":500222,\"ally_tag\":\"HELLFIRE\",\"points\":50,\"nb_spacecraft\":0},{\"rank\":888,\"player_id\":107032,\"player_name\":\"Governor Kastra\",\"ally_id\":500973,\"ally_tag\":\"TBT\",\"points\":50,\"nb_spacecraft\":0},{\"rank\":889,\"player_id\":106327,\"player_name\":\"Wata Fack\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":46,\"nb_spacecraft\":0},{\"rank\":890,\"player_id\":103493,\"player_name\":\"Marshal Nekros\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":40,\"nb_spacecraft\":0},{\"rank\":890,\"player_id\":104506,\"player_name\":\"coolbrise\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":40,\"nb_spacecraft\":0},{\"rank\":890,\"player_id\":105302,\"player_name\":\"Senator Thorne\",\"ally_id\":500738,\"ally_tag\":\"JPB\",\"points\":40,\"nb_spacecraft\":0},{\"rank\":891,\"player_id\":106376,\"player_name\":\"Tenyen\",\"ally_id\":500920,\"ally_tag\":\"pkt\",\"points\":38,\"nb_spacecraft\":0},{\"rank\":892,\"player_id\":101867,\"player_name\":\"Consul Columbo\",\"ally_id\":500251,\"ally_tag\":\"Mars\",\"points\":32,\"nb_spacecraft\":0},{\"rank\":893,\"player_id\":102249,\"player_name\":\"Constable Flyby\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":30,\"nb_spacecraft\":0},{\"rank\":893,\"player_id\":106333,\"player_name\":\"Commander Nervus\",\"ally_id\":500967,\"ally_tag\":\"SGA\",\"points\":30,\"nb_spacecraft\":0},{\"rank\":894,\"player_id\":107038,\"player_name\":\"kikilegion\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":26,\"nb_spacecraft\":0},{\"rank\":895,\"player_id\":101992,\"player_name\":\"Commodore Neptuno\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":24,\"nb_spacecraft\":0},{\"rank\":896,\"player_id\":105989,\"player_name\":\"Maitre Acerolass\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":22,\"nb_spacecraft\":0},{\"rank\":897,\"player_id\":100344,\"player_name\":\"Constable Rigel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":20,\"nb_spacecraft\":0},{\"rank\":898,\"player_id\":106119,\"player_name\":\"Technocrat Amo\",\"ally_id\":500857,\"ally_tag\":\"jsp\",\"points\":19,\"nb_spacecraft\":0},{\"rank\":899,\"player_id\":106583,\"player_name\":\"Spirou\",\"ally_id\":500932,\"ally_tag\":\"L E\",\"points\":18,\"nb_spacecraft\":0},{\"rank\":900,\"player_id\":107151,\"player_name\":\"ynerys\",\"ally_id\":500993,\"ally_tag\":\"ELD\",\"points\":12,\"nb_spacecraft\":0},{\"rank\":901,\"player_id\":100795,\"player_name\":\"Deegroy\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":10,\"nb_spacecraft\":0},{\"rank\":901,\"player_id\":101400,\"player_name\":\"Mogul Stardust\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":10,\"nb_spacecraft\":0},{\"rank\":902,\"player_id\":106069,\"player_name\":\"Enflure La Raclure\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":9,\"nb_spacecraft\":0},{\"rank\":903,\"player_id\":104039,\"player_name\":\"President Omegion\",\"ally_id\":500276,\"ally_tag\":\"FRCO\",\"points\":8,\"nb_spacecraft\":0},{\"rank\":903,\"player_id\":105092,\"player_name\":\"Director Triton\",\"ally_id\":500694,\"ally_tag\":\"LEVEL\",\"points\":8,\"nb_spacecraft\":0},{\"rank\":904,\"player_id\":100825,\"player_name\":\"Chief Leto\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":6,\"nb_spacecraft\":0},{\"rank\":905,\"player_id\":107214,\"player_name\":\"Chancellor Pegasus\",\"ally_id\":501005,\"ally_tag\":\"chan\",\"points\":5,\"nb_spacecraft\":0},{\"rank\":906,\"player_id\":106048,\"player_name\":\"St-Aza\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":4,\"nb_spacecraft\":0},{\"rank\":906,\"player_id\":106299,\"player_name\":\"Morgan2A\",\"ally_id\":500899,\"ally_tag\":\"CRS\",\"points\":4,\"nb_spacecraft\":0},{\"rank\":906,\"player_id\":104080,\"player_name\":\"Nagawika\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4,\"nb_spacecraft\":0},{\"rank\":907,\"player_id\":104804,\"player_name\":\"13139\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":907,\"player_id\":105026,\"player_name\":\"sacha\",\"ally_id\":500353,\"ally_tag\":\"FFE\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":907,\"player_id\":105104,\"player_name\":\"Bandit Eos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":907,\"player_id\":107136,\"player_name\":\"Emperor Horizon\",\"ally_id\":500991,\"ally_tag\":\"FRANCE\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":907,\"player_id\":107223,\"player_name\":\"Constable Neutrino\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2,\"nb_spacecraft\":0},{\"rank\":908,\"player_id\":107194,\"player_name\":\"The_God\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":105806,\"player_name\":\"Astral\",\"ally_id\":500807,\"ally_tag\":\"YOLO\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":105839,\"player_name\":\"Senator Osiris\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":105852,\"player_name\":\"Fengeek\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":105895,\"player_name\":\"ReinHeardt San\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":105931,\"player_name\":\"Admiral Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":105963,\"player_name\":\"capt07\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106024,\"player_name\":\"Ogaine\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106085,\"player_name\":\"Technocrat Seren\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106098,\"player_name\":\"Renegade Radiation\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106104,\"player_name\":\"Commander Remus\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106111,\"player_name\":\"Boubane\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106126,\"player_name\":\"Vice Apollo\",\"ally_id\":500859,\"ally_tag\":\"ABCD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106253,\"player_name\":\"Technocrat Elara\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106258,\"player_name\":\"Empereur Drakiel\",\"ally_id\":500876,\"ally_tag\":\"DXD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106263,\"player_name\":\"Dark Lord\",\"ally_id\":500879,\"ally_tag\":\"FUN\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106336,\"player_name\":\"Nika\",\"ally_id\":500894,\"ally_tag\":\"OPi\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106401,\"player_name\":\"Timoko\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106402,\"player_name\":\"Proconsul Pavo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106405,\"player_name\":\"Marshal Nebulon\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106436,\"player_name\":\"Makkura\",\"ally_id\":500905,\"ally_tag\":\"Andromed\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106472,\"player_name\":\"Engineer Centauri\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106505,\"player_name\":\"Chambellan Aymi\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106609,\"player_name\":\"Procurator Yakini\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106617,\"player_name\":\"Captain Xilax\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106636,\"player_name\":\"President Midas\",\"ally_id\":500944,\"ally_tag\":\"BSF\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":106963,\"player_name\":\"Stadtholder Tauri\",\"ally_id\":500960,\"ally_tag\":\"Pegasus\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107042,\"player_name\":\"Lord Kempatek\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":\"107058\",\"player_name\":\"Marshal Galileo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107068,\"player_name\":\"Rony-BBC\",\"ally_id\":500982,\"ally_tag\":\"ADB\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107083,\"player_name\":\"Shemale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107090,\"player_name\":\"Mon Seigneur\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107093,\"player_name\":\"Halowolf\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107095,\"player_name\":\"Chief Hunter\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107110,\"player_name\":\"Michel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107115,\"player_name\":\"Lord Kyions\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107122,\"player_name\":\"Maxime\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107125,\"player_name\":\"red astairoid\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107130,\"player_name\":\"Senator Kraz\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107131,\"player_name\":\"Bonsoir\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107134,\"player_name\":\"Stadtholder Europa\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107138,\"player_name\":\"UTILITAIRE\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107142,\"player_name\":\"Constable\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107143,\"player_name\":\"Marshal Indus\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107154,\"player_name\":\"Procurator Hubble\",\"ally_id\":500995,\"ally_tag\":\"suisse-f\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107171,\"player_name\":\"Denzel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107175,\"player_name\":\"Commodore Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107182,\"player_name\":\"Engineer Vega\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107183,\"player_name\":\"Director Universe\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0},{\"rank\":909,\"player_id\":107196,\"player_name\":\"eikichi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":0,\"nb_spacecraft\":0}],\"offset\":801,\"type1\":\"player\",\"type2\":\"fleet\",\"type3\":\"\",\"time\":1776164400}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/ranking_player_points.json b/tests/fixtures/ranking_player_points.json
new file mode 100644
index 0000000..57783b8
--- /dev/null
+++ b/tests/fixtures/ranking_player_points.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "ranking",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"n\":[{\"rank\":1001,\"player_id\":105704,\"player_name\":\"Captain Roukets\",\"ally_id\":500801,\"ally_tag\":\"LVC\",\"points\":4638},{\"rank\":1002,\"player_id\":104789,\"player_name\":\"Saryl\",\"ally_id\":500640,\"ally_tag\":\"AdA\",\"points\":4275},{\"rank\":1003,\"player_id\":104192,\"player_name\":\"Horatius\",\"ally_id\":500962,\"ally_tag\":\"HRT\",\"points\":4000},{\"rank\":1004,\"player_id\":102064,\"player_name\":\"Viig\",\"ally_id\":500237,\"ally_tag\":\"NID\",\"points\":3678},{\"rank\":1005,\"player_id\":103261,\"player_name\":\"La mula phil\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":3600},{\"rank\":1006,\"player_id\":100634,\"player_name\":\"Floo69\",\"ally_id\":500156,\"ally_tag\":\"Flo\",\"points\":3549},{\"rank\":1007,\"player_id\":102183,\"player_name\":\"Engineer Uranus\",\"ally_id\":500698,\"ally_tag\":\"FAR\",\"points\":3541},{\"rank\":1008,\"player_id\":104764,\"player_name\":\"sombreRoso\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":3473},{\"rank\":1009,\"player_id\":101209,\"player_name\":\"kale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":3115},{\"rank\":1010,\"player_id\":105750,\"player_name\":\"kuamatt\",\"ally_id\":500805,\"ally_tag\":\"kmt\",\"points\":3096},{\"rank\":1011,\"player_id\":106126,\"player_name\":\"Vice Apollo\",\"ally_id\":500859,\"ally_tag\":\"ABCD\",\"points\":2929},{\"rank\":1012,\"player_id\":106382,\"player_name\":\"Constable Ferret\",\"ally_id\":500952,\"ally_tag\":\"judeles\",\"points\":2816},{\"rank\":1013,\"player_id\":104506,\"player_name\":\"coolbrise\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2694},{\"rank\":1014,\"player_id\":102249,\"player_name\":\"Constable Flyby\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2682},{\"rank\":1015,\"player_id\":107189,\"player_name\":\"Lieutenant Pulsar\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2679},{\"rank\":1016,\"player_id\":107090,\"player_name\":\"Mon Seigneur\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2523},{\"rank\":1017,\"player_id\":101359,\"player_name\":\"Bandit Spirit\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2421},{\"rank\":1018,\"player_id\":107032,\"player_name\":\"Governor Kastra\",\"ally_id\":500973,\"ally_tag\":\"TBT\",\"points\":2280},{\"rank\":1019,\"player_id\":107151,\"player_name\":\"ynerys\",\"ally_id\":500993,\"ally_tag\":\"ELD\",\"points\":2169},{\"rank\":1020,\"player_id\":107093,\"player_name\":\"Halowolf\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2124},{\"rank\":1021,\"player_id\":105267,\"player_name\":\"Archibald\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":2081},{\"rank\":1022,\"player_id\":106376,\"player_name\":\"Tenyen\",\"ally_id\":500920,\"ally_tag\":\"pkt\",\"points\":1819},{\"rank\":1023,\"player_id\":101867,\"player_name\":\"Consul Columbo\",\"ally_id\":500251,\"ally_tag\":\"Mars\",\"points\":1507},{\"rank\":1024,\"player_id\":101992,\"player_name\":\"Commodore Neptuno\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":1340},{\"rank\":1025,\"player_id\":104734,\"player_name\":\"Renegade Scorpius\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":1307},{\"rank\":1026,\"player_id\":106505,\"player_name\":\"Chambellan Aymi\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":1266},{\"rank\":1027,\"player_id\":106333,\"player_name\":\"Commander Nervus\",\"ally_id\":500967,\"ally_tag\":\"SGA\",\"points\":1258},{\"rank\":1028,\"player_id\":107182,\"player_name\":\"Engineer Vega\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1192},{\"rank\":1029,\"player_id\":104594,\"player_name\":\"Captain Seren\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1109},{\"rank\":1030,\"player_id\":100701,\"player_name\":\"Stadtholder Nebula\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1096},{\"rank\":1031,\"player_id\":106085,\"player_name\":\"Technocrat Seren\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":985},{\"rank\":1032,\"player_id\":105378,\"player_name\":\"Dark revan\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":944},{\"rank\":1033,\"player_id\":104647,\"player_name\":\"Marshal maxime\",\"ally_id\":500661,\"ally_tag\":\"AVISO\",\"points\":817},{\"rank\":1034,\"player_id\":100362,\"player_name\":\"Fist\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":812},{\"rank\":1035,\"player_id\":107154,\"player_name\":\"Procurator Hubble\",\"ally_id\":500995,\"ally_tag\":\"suisse-f\",\"points\":779},{\"rank\":1036,\"player_id\":103098,\"player_name\":\"Kaiman\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":758},{\"rank\":1037,\"player_id\":100486,\"player_name\":\"Jaya\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":753},{\"rank\":1038,\"player_id\":106048,\"player_name\":\"St-Aza\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":711},{\"rank\":1039,\"player_id\":105224,\"player_name\":\"Geologist Stardust\",\"ally_id\":500737,\"ally_tag\":\"Fra\",\"points\":689},{\"rank\":1040,\"player_id\":105123,\"player_name\":\"Lord Stardust\",\"ally_id\":500745,\"ally_tag\":\"STAR\",\"points\":686},{\"rank\":1041,\"player_id\":105157,\"player_name\":\"Geologist Telesto\",\"ally_id\":500697,\"ally_tag\":\"Nexus\",\"points\":631},{\"rank\":1042,\"player_id\":101364,\"player_name\":\"Technocrat Zenith\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":618},{\"rank\":1043,\"player_id\":104804,\"player_name\":\"13139\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":613},{\"rank\":1044,\"player_id\":103579,\"player_name\":\"Magic of Chaos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":558},{\"rank\":1045,\"player_id\":104921,\"player_name\":\"Procurator Seti\",\"ally_id\":500667,\"ally_tag\":\"ORI\",\"points\":530},{\"rank\":1046,\"player_id\":105852,\"player_name\":\"Fengeek\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":511},{\"rank\":1047,\"player_id\":105093,\"player_name\":\"Badmood\",\"ally_id\":500808,\"ally_tag\":\"LFH\",\"points\":503},{\"rank\":1048,\"player_id\":101721,\"player_name\":\"Renegade Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":488},{\"rank\":1049,\"player_id\":106263,\"player_name\":\"Dark Lord\",\"ally_id\":500879,\"ally_tag\":\"FUN\",\"points\":466},{\"rank\":1050,\"player_id\":106005,\"player_name\":\"Captain Xyronix\",\"ally_id\":500836,\"ally_tag\":\"TKA\",\"points\":465},{\"rank\":1051,\"player_id\":105141,\"player_name\":\"royal__trix\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":419},{\"rank\":1052,\"player_id\":104893,\"player_name\":\"Olsen\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":363},{\"rank\":1053,\"player_id\":104521,\"player_name\":\"Captain Omicron\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":311},{\"rank\":1054,\"player_id\":105377,\"player_name\":\"Technocrat Zodiac\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":248},{\"rank\":1055,\"player_id\":107214,\"player_name\":\"Chancellor Pegasus\",\"ally_id\":501005,\"ally_tag\":\"chan\",\"points\":243},{\"rank\":1056,\"player_id\":104760,\"player_name\":\"Lieutenant Nebulor\",\"ally_id\":500676,\"ally_tag\":\"FFA\",\"points\":209},{\"rank\":1057,\"player_id\":100196,\"player_name\":\"Diaboliks\",\"ally_id\":500037,\"ally_tag\":\"I-W\",\"points\":200},{\"rank\":1058,\"player_id\":103773,\"player_name\":\"Commander Corvus\",\"ally_id\":500565,\"ally_tag\":\"LOL\",\"points\":189},{\"rank\":1059,\"player_id\":105715,\"player_name\":\"Sagi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":177},{\"rank\":1060,\"player_id\":105027,\"player_name\":\"Capitaine flam\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":172},{\"rank\":1061,\"player_id\":107204,\"player_name\":\"NiXiD\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":146},{\"rank\":1062,\"player_id\":104964,\"player_name\":\"Grymstonne\",\"ally_id\":500658,\"ally_tag\":\"zogzog\",\"points\":134},{\"rank\":1063,\"player_id\":101202,\"player_name\":\"Engineer Seti\",\"ally_id\":500383,\"ally_tag\":\"MIX2\",\"points\":113},{\"rank\":1064,\"player_id\":106119,\"player_name\":\"Technocrat Amo\",\"ally_id\":500857,\"ally_tag\":\"jsp\",\"points\":113},{\"rank\":1065,\"player_id\":104863,\"player_name\":\"tyrhum\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":79},{\"rank\":1066,\"player_id\":102735,\"player_name\":\"Aucter Dei\",\"ally_id\":500330,\"ally_tag\":\"Fed0\",\"points\":55},{\"rank\":1067,\"player_id\":105104,\"player_name\":\"Bandit Eos\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":49},{\"rank\":1068,\"player_id\":106024,\"player_name\":\"Ogaine\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":47},{\"rank\":1069,\"player_id\":107136,\"player_name\":\"Emperor Horizon\",\"ally_id\":500991,\"ally_tag\":\"FRANCE\",\"points\":43},{\"rank\":1070,\"player_id\":104057,\"player_name\":\"Bandit Telesto\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"points\":41},{\"rank\":1071,\"player_id\":106069,\"player_name\":\"Enflure La Raclure\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":41},{\"rank\":1072,\"player_id\":107131,\"player_name\":\"Bonsoir\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":36},{\"rank\":1073,\"player_id\":107223,\"player_name\":\"Constable Neutrino\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":35},{\"rank\":1074,\"player_id\":105602,\"player_name\":\"Satsuki\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":35},{\"rank\":1075,\"player_id\":105053,\"player_name\":\"Governor Yzaron\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":26},{\"rank\":1076,\"player_id\":\"107058\",\"player_name\":\"Marshal Galileo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":24},{\"rank\":1077,\"player_id\":106098,\"player_name\":\"Renegade Radiation\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":16},{\"rank\":1078,\"player_id\":107095,\"player_name\":\"Chief Hunter\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":13},{\"rank\":1079,\"player_id\":107083,\"player_name\":\"Shemale\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":13},{\"rank\":1080,\"player_id\":106111,\"player_name\":\"Boubane\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":9},{\"rank\":1081,\"player_id\":107194,\"player_name\":\"The_God\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":8},{\"rank\":1082,\"player_id\":107226,\"player_name\":\"Captain Cetus\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":8},{\"rank\":1083,\"player_id\":102724,\"player_name\":\"Admiral Columbo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":8},{\"rank\":1084,\"player_id\":107197,\"player_name\":\"Archange\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":7},{\"rank\":1085,\"player_id\":106402,\"player_name\":\"Proconsul Pavo\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":6},{\"rank\":1086,\"player_id\":105172,\"player_name\":\"President Orbit\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":6},{\"rank\":1087,\"player_id\":105931,\"player_name\":\"Admiral Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":6},{\"rank\":1088,\"player_id\":107110,\"player_name\":\"Michel\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":5},{\"rank\":1089,\"player_id\":107222,\"player_name\":\"Proconsul Puck\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4},{\"rank\":1090,\"player_id\":107130,\"player_name\":\"Senator Kraz\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4},{\"rank\":1091,\"player_id\":107175,\"player_name\":\"Commodore Archer\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":4},{\"rank\":1092,\"player_id\":105061,\"player_name\":\"President Lagoon\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":3},{\"rank\":1093,\"player_id\":106472,\"player_name\":\"Engineer Centauri\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":3},{\"rank\":1094,\"player_id\":107219,\"player_name\":\"Procurator Draco\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1095,\"player_id\":107115,\"player_name\":\"Lord Kyions\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1096,\"player_id\":107196,\"player_name\":\"eikichi\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1097,\"player_id\":107142,\"player_name\":\"Constable\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1098,\"player_id\":105963,\"player_name\":\"capt07\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":2},{\"rank\":1099,\"player_id\":105097,\"player_name\":\"chatalere\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1},{\"rank\":1100,\"player_id\":107125,\"player_name\":\"red astairoid\",\"ally_id\":0,\"ally_tag\":\"\",\"points\":1}],\"offset\":1001,\"type1\":\"player\",\"type2\":\"points\",\"type3\":\"\",\"time\":1776160800}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/ranking_player_research.json b/tests/fixtures/ranking_player_research.json
new file mode 100644
index 0000000..2c63c08
--- /dev/null
+++ b/tests/fixtures/ranking_player_research.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/tests/fixtures/rc.json b/tests/fixtures/rc.json
new file mode 100644
index 0000000..e3f639a
--- /dev/null
+++ b/tests/fixtures/rc.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.2",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "rc",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"messageType\":\"25\",\"timestamp\":\"1776373151\",\"date\":\"2026-04-16T20:59:11.000Z\",\"ogapilnk\":\"cr-fr-277-b61d9ef31c27edaaace5f67587373e0514354618\",\"coordinates\":\"4:246:7\",\"defenderSpaceObject\":{\"id\":33717423,\"type\":\"planet\",\"name\":\"KEMPA III\",\"coordinates\":{\"galaxy\":4,\"system\":246,\"position\":7},\"owner\":{\"type\":\"player\",\"id\":107042,\"name\":\"Lord Kempatek\",\"alliance\":{\"id\":500003,\"name\":\"Suivre Les Directives\",\"tag\":\"SLD\",\"classId\":3},\"rankId\":null,\"status\":[\"inactive\",\"long-inactive\"],\"classId\":1}},\"fleets\":[{\"side\":\"defender\",\"fleetId\":0,\"player\":{\"type\":\"player\",\"id\":107042,\"name\":\"Lord Kempatek\",\"alliance\":{\"id\":500003,\"name\":\"Suivre Les Directives\",\"tag\":\"SLD\",\"classId\":3},\"rankId\":null,\"status\":[\"inactive\",\"long-inactive\"],\"classId\":1},\"spaceObject\":{\"id\":33717423,\"name\":\"KEMPA III\",\"type\":\"planet\",\"coordinates\":{\"galaxy\":4,\"system\":246,\"position\":7}},\"combatResearchPercentage\":[{\"id\":109,\"percentage\":90},{\"id\":110,\"percentage\":70},{\"id\":111,\"percentage\":120}],\"combatTechnologies\":[]},{\"side\":\"attacker\",\"fleetId\":6904593,\"player\":{\"type\":\"player\",\"id\":107058,\"name\":\"Marshal Galileo\",\"alliance\":null,\"rankId\":null,\"status\":[],\"classId\":1},\"spaceObject\":{\"id\":33717002,\"name\":\"Main\",\"type\":\"planet\",\"coordinates\":{\"galaxy\":4,\"system\":246,\"position\":12}},\"combatResearchPercentage\":[{\"id\":109,\"percentage\":0},{\"id\":110,\"percentage\":0},{\"id\":111,\"percentage\":10}],\"combatTechnologies\":[{\"technologyId\":202,\"amount\":2}]}],\"combatRounds\":[],\"result\":{\"winner\":\"attacker\",\"loot\":{\"percentage\":50,\"resources\":[{\"resource\":\"metal\",\"amount\":3810},{\"resource\":\"crystal\",\"amount\":3810},{\"resource\":\"deuterium\",\"amount\":3810},{\"resource\":\"food\",\"amount\":1070}]},\"debris\":{\"resources\":[],\"requiredShips\":[],\"shipsUsedForRecycling\":[],\"totalRequiredShips\":[]},\"totalValueOfUnitsLost\":[{\"side\":\"defender\",\"value\":0},{\"side\":\"attacker\",\"value\":0}],\"deathStarDestroyed\":false,\"repairedTechnologies\":[],\"moonCreation\":{\"chance\":0},\"honor\":null,\"tacticalRetreat\":{\"by\":\"none\",\"supremacy\":2000}},\"messageId\":\"13579260\",\"parseDate\":1776373151}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/rc_complex.json b/tests/fixtures/rc_complex.json
new file mode 100644
index 0000000..4dc9fe1
--- /dev/null
+++ b/tests/fixtures/rc_complex.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.2",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https:\/\/s277-fr.ogame.gameforge.com",
+ "type": "rc",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"messageType\":\"25\",\"timestamp\":\"1776500000\",\"date\":\"2026-04-18T10:00:00.000Z\",\"ogapilnk\":\"cr-fr-277-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\"coordinates\":\"4:246:7\",\"defenderSpaceObject\":{\"id\":33717423,\"type\":\"planet\",\"name\":\"KEMPA III\",\"coordinates\":{\"galaxy\":4,\"system\":246,\"position\":7},\"owner\":{\"type\":\"player\",\"id\":107042,\"name\":\"Lord Kempatek\",\"alliance\":{\"id\":500003,\"name\":\"Suivre Les Directives\",\"tag\":\"SLD\",\"classId\":3},\"rankId\":null,\"status\":[\"inactive\",\"long-inactive\"],\"classId\":1}},\"fleets\":[{\"side\":\"defender\",\"fleetId\":0,\"player\":{\"type\":\"player\",\"id\":107042,\"name\":\"Lord Kempatek\",\"alliance\":{\"id\":500003,\"name\":\"Suivre Les Directives\",\"tag\":\"SLD\",\"classId\":3},\"rankId\":null,\"status\":[\"inactive\",\"long-inactive\"],\"classId\":1},\"spaceObject\":{\"id\":33717423,\"name\":\"KEMPA III\",\"type\":\"planet\",\"coordinates\":{\"galaxy\":4,\"system\":246,\"position\":7}},\"combatResearchPercentage\":[{\"id\":109,\"percentage\":90},{\"id\":110,\"percentage\":70},{\"id\":111,\"percentage\":120}],\"combatTechnologies\":[{\"technologyId\":401,\"amount\":50},{\"technologyId\":402,\"amount\":25},{\"technologyId\":403,\"amount\":10},{\"technologyId\":204,\"amount\":10},{\"technologyId\":202,\"amount\":5}]},{\"side\":\"attacker\",\"fleetId\":6904593,\"player\":{\"type\":\"player\",\"id\":107058,\"name\":\"Marshal Galileo\",\"alliance\":null,\"rankId\":null,\"status\":[],\"classId\":1},\"spaceObject\":{\"id\":33717002,\"name\":\"Main\",\"type\":\"planet\",\"coordinates\":{\"galaxy\":4,\"system\":246,\"position\":12}},\"combatResearchPercentage\":[{\"id\":109,\"percentage\":70},{\"id\":110,\"percentage\":80},{\"id\":111,\"percentage\":60}],\"combatTechnologies\":[{\"technologyId\":204,\"amount\":100},{\"technologyId\":206,\"amount\":30},{\"technologyId\":207,\"amount\":5},{\"technologyId\":202,\"amount\":20}]}],\"combatRounds\":[{\"statistics\":[{\"side\":\"attacker\",\"hits\":155,\"strength\":8500000,\"absorbedDamage\":320000},{\"side\":\"defender\",\"hits\":100,\"strength\":2400000,\"absorbedDamage\":5200000}],\"fleets\":[{\"side\":\"attacker\",\"player\":{\"id\":107058,\"name\":\"Marshal Galileo\"},\"technologies\":[{\"technologyId\":204,\"remaining\":90},{\"technologyId\":206,\"remaining\":28},{\"technologyId\":207,\"remaining\":5},{\"technologyId\":202,\"remaining\":20}]},{\"side\":\"defender\",\"player\":{\"id\":107042,\"name\":\"Lord Kempatek\"},\"technologies\":[{\"technologyId\":401,\"remaining\":30},{\"technologyId\":402,\"remaining\":15},{\"technologyId\":403,\"remaining\":8},{\"technologyId\":204,\"remaining\":5},{\"technologyId\":202,\"remaining\":2}]}]},{\"statistics\":[{\"side\":\"attacker\",\"hits\":143,\"strength\":7800000,\"absorbedDamage\":145000},{\"side\":\"defender\",\"hits\":60,\"strength\":1200000,\"absorbedDamage\":5900000}],\"fleets\":[{\"side\":\"attacker\",\"player\":{\"id\":107058,\"name\":\"Marshal Galileo\"},\"technologies\":[{\"technologyId\":204,\"remaining\":85},{\"technologyId\":206,\"remaining\":27},{\"technologyId\":207,\"remaining\":5},{\"technologyId\":202,\"remaining\":20}]},{\"side\":\"defender\",\"player\":{\"id\":107042,\"name\":\"Lord Kempatek\"},\"technologies\":[{\"technologyId\":401,\"remaining\":10},{\"technologyId\":402,\"remaining\":5},{\"technologyId\":403,\"remaining\":3},{\"technologyId\":204,\"remaining\":1},{\"technologyId\":202,\"remaining\":0}]}]},{\"statistics\":[{\"side\":\"attacker\",\"hits\":136,\"strength\":7500000,\"absorbedDamage\":62000},{\"side\":\"defender\",\"hits\":19,\"strength\":360000,\"absorbedDamage\":6500000}],\"fleets\":[{\"side\":\"attacker\",\"player\":{\"id\":107058,\"name\":\"Marshal Galileo\"},\"technologies\":[{\"technologyId\":204,\"remaining\":83},{\"technologyId\":206,\"remaining\":27},{\"technologyId\":207,\"remaining\":5},{\"technologyId\":202,\"remaining\":20}]},{\"side\":\"defender\",\"player\":{\"id\":107042,\"name\":\"Lord Kempatek\"},\"technologies\":[{\"technologyId\":401,\"remaining\":0},{\"technologyId\":402,\"remaining\":0},{\"technologyId\":403,\"remaining\":0},{\"technologyId\":204,\"remaining\":0},{\"technologyId\":202,\"remaining\":0}]}]}],\"result\":{\"winner\":\"attacker\",\"loot\":{\"percentage\":50,\"resources\":[{\"resource\":\"metal\",\"amount\":200000},{\"resource\":\"crystal\",\"amount\":150000},{\"resource\":\"deuterium\",\"amount\":50000}]},\"debris\":{\"resources\":[{\"resource\":\"metal\",\"amount\":250000},{\"resource\":\"crystal\",\"amount\":120000}],\"requiredShips\":[],\"shipsUsedForRecycling\":[],\"totalRequiredShips\":[]},\"totalValueOfUnitsLost\":[{\"side\":\"defender\",\"value\":5000000},{\"side\":\"attacker\",\"value\":2500000}],\"deathStarDestroyed\":false,\"repairedTechnologies\":[],\"moonCreation\":{\"chance\":12},\"honor\":null,\"tacticalRetreat\":{\"by\":\"none\",\"supremacy\":2000}},\"messageId\":\"13579261\",\"parseDate\":1776500000}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/researchs.json b/tests/fixtures/researchs.json
new file mode 100644
index 0000000..881f363
--- /dev/null
+++ b/tests/fixtures/researchs.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "researchs",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"planet\":{\"id\":\"33717002\",\"name\":\"planète mère\",\"coords\":\"4:246:12\",\"type\":\"0\"},\"researchs\":{\"NRJ\":\"0\",\"Laser\":\"0\",\"Ions\":\"0\",\"Hyp\":\"0\",\"Plasma\":\"0\",\"RC\":\"0\",\"RI\":\"0\",\"PH\":\"0\",\"Esp\":\"0\",\"Ordi\":\"0\",\"Astrophysique\":\"0\",\"RRI\":\"0\",\"Graviton\":\"0\",\"Armes\":\"0\",\"Bouclier\":\"0\",\"Protection\":\"0\"}}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/resourceSettings.json b/tests/fixtures/resourceSettings.json
new file mode 100644
index 0000000..ff1973b
--- /dev/null
+++ b/tests/fixtures/resourceSettings.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "resourceSettings",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"planet\":{\"id\":\"33717002\",\"name\":\"planète mère\",\"coords\":\"4:246:12\",\"type\":\"0\"},\"resourceSettings\":{\"M_percentage\":100,\"C_Percentage\":100,\"D_percentage\":100,\"CES_percentage\":100,\"CEF_percentage\":100,\"SAT_percentage\":100,\"FOR_percentage\":100}}"
+}
\ No newline at end of file
diff --git a/tests/fixtures/system.json b/tests/fixtures/system.json
new file mode 100644
index 0000000..0402622
--- /dev/null
+++ b/tests/fixtures/system.json
@@ -0,0 +1,9 @@
+{
+ "toolbar_version": "3.1.4",
+ "toolbar_type": "GM-FF",
+ "mod_min_version": "2.9.0",
+ "univers": "https://s277-fr.ogame.gameforge.com",
+ "type": "system",
+ "password": "580e367364e87a117964b64a6600b0dc1c14c98d4254432de7f30cc381917389",
+ "data": "{\"rows\":[null,null,null,null,null,null,{\"player_id\":107056,\"planet_name\":\"Sun\",\"planet_id\":33716289,\"moon_id\":0,\"moon\":0,\"player_name\":\"Czar Oberon\",\"status\":\"vf\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"debris\":{\"metal\":0,\"cristal\":0,\"deuterium\":0},\"activity\":-1,\"activityMoon\":-1},{\"player_id\":107042,\"planet_name\":\"KEMPA III\",\"planet_id\":33717423,\"moon_id\":0,\"moon\":0,\"player_name\":\"Lord Kempatek\",\"status\":\"I\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"debris\":{\"metal\":1000,\"cristal\":0,\"deuterium\":0},\"activity\":0,\"activityMoon\":-1},{\"player_id\":100065,\"planet_name\":\"Balaho\",\"planet_id\":33627634,\"moon_id\":33641931,\"moon\":1,\"player_name\":\"Admiral Keyes\",\"status\":\"I\",\"ally_id\":\"0\",\"ally_tag\":\"\",\"debris\":{\"metal\":0,\"cristal\":0,\"deuterium\":0},\"activity\":0,\"activityMoon\":-1},{\"player_id\":107043,\"planet_name\":\"Colonie\",\"planet_id\":33717839,\"moon_id\":0,\"moon\":0,\"player_name\":\"nain galactique\",\"status\":\"vi\",\"ally_id\":\"0\",\"ally_tag\":\"\",\"debris\":{\"metal\":0,\"cristal\":0,\"deuterium\":0},\"activity\":-1,\"activityMoon\":-1},null,null,{\"player_id\":\"107058\",\"planet_name\":\"planète mère\",\"planet_id\":33717002,\"moon_id\":0,\"moon\":0,\"player_name\":\"Marshal Galileo\",\"status\":\"\",\"ally_id\":\"0\",\"ally_tag\":\"\",\"debris\":{\"metal\":0,\"cristal\":0,\"deuterium\":0},\"activity\":0,\"activityMoon\":-1},null,{\"player_id\":107056,\"planet_name\":\"Colonie\",\"planet_id\":33718396,\"moon_id\":0,\"moon\":0,\"player_name\":\"Czar Oberon\",\"status\":\"vf\",\"ally_id\":500003,\"ally_tag\":\"SLD\",\"debris\":{\"metal\":0,\"cristal\":0,\"deuterium\":0},\"activity\":-1,\"activityMoon\":-1},{\"player_id\":100065,\"planet_name\":\"Colonie\",\"planet_id\":33690505,\"moon_id\":33693888,\"moon\":1,\"player_name\":\"Admiral Keyes\",\"status\":\"I\",\"ally_id\":\"0\",\"ally_tag\":\"\",\"debris\":{\"metal\":0,\"cristal\":0,\"deuterium\":0},\"activity\":-1,\"activityMoon\":-1},{\"player_id\":\"\",\"planet_name\":\"Black Hole\",\"planet_id\":\"\",\"moon_id\":\"\",\"moon\":0,\"player_name\":\"Lost in space\",\"status\":\"\",\"ally_id\":\"\",\"ally_tag\":\"\",\"debris\":{\"metal\":0,\"cristal\":0},\"activity\":\"\",\"activityMoon\":\"\"}],\"galaxy\":\"4\",\"system\":\"246\"}"
+}
\ No newline at end of file
diff --git a/tests/unit/AbstractHandlerTest.php b/tests/unit/AbstractHandlerTest.php
new file mode 100644
index 0000000..d0eba66
--- /dev/null
+++ b/tests/unit/AbstractHandlerTest.php
@@ -0,0 +1,247 @@
+requireGrant($grant);
+ }
+
+ public function callParseCoordinates(string $coords, int $exp = 0): array
+ {
+ return $this->parseCoordinates($coords, $exp);
+ }
+
+ public function callResolvePlanetType($type): string
+ {
+ return $this->resolvePlanetType($type);
+ }
+
+ public function callPlanetTypeToString(string $const): string
+ {
+ return $this->planetTypeToString($const);
+ }
+
+ public function callExecuteUpsert(string $table, array $cols, array $vals, array $updateCols): void
+ {
+ $this->executeUpsert($table, $cols, $vals, $updateCols);
+ }
+
+ public function callRegisterCallback(string $type, array $params): void
+ {
+ $this->registerCallback($type, $params);
+ }
+
+ public function callLogAction(string $type, array $context = []): void
+ {
+ $this->logAction($type, $context);
+ }
+
+ public function callSetPageUpdatedResponse(string $page, string $coords): void
+ {
+ $this->setPageUpdatedResponse($page, $coords);
+ }
+}
+
+/**
+ * Unit tests for AbstractHandler shared helper methods.
+ * All tests run immediately (no handler stubs needed).
+ */
+class AbstractHandlerTest extends XtenseTestCase
+{
+ private ConcreteTestHandler $handler;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->handler = new ConcreteTestHandler(
+ $this->db,
+ $this->log,
+ $this->io,
+ $this->callbackHandler,
+ $this->serverConfig,
+ $this->userData,
+ $this->xtenseDatabase,
+ $this->toolbarInfo
+ );
+ }
+
+ // -------------------------------------------------------------------------
+ // requireGrant()
+ // -------------------------------------------------------------------------
+
+ public function testRequireGrantReturnsTrueWhenGrantPresent(): void
+ {
+ $result = $this->handler->callRequireGrant('empire');
+ $this->assertTrue($result);
+ }
+
+ public function testRequireGrantReturnsFalseWhenGrantMissing(): void
+ {
+ $userData = $this->userData;
+ $userData['grant']['empire'] = false;
+ $handler = new ConcreteTestHandler(
+ $this->db, $this->log, $this->io, $this->callbackHandler,
+ $this->serverConfig, $userData, $this->xtenseDatabase, $this->toolbarInfo
+ );
+
+ $this->assertFalse($handler->callRequireGrant('empire'));
+ }
+
+ public function testRequireGrantSetsIoErrorWhenDenied(): void
+ {
+ $userData = $this->userData;
+ $userData['grant']['empire'] = false;
+ $handler = new ConcreteTestHandler(
+ $this->db, $this->log, $this->io, $this->callbackHandler,
+ $this->serverConfig, $userData, $this->xtenseDatabase, $this->toolbarInfo
+ );
+
+ $handler->callRequireGrant('empire');
+ $response = $this->getIoResponse();
+
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame('empire', $response['access']);
+ $this->assertSame(0, $response['status']);
+ }
+
+ // -------------------------------------------------------------------------
+ // parseCoordinates()
+ // -------------------------------------------------------------------------
+
+ public function testParseCoordinatesReturnsStructuredArray(): void
+ {
+ $result = $this->handler->callParseCoordinates('4:246:12');
+
+ $this->assertSame('4:246:12', $result['coords']);
+ $this->assertSame(4, $result['galaxy']);
+ $this->assertSame(246, $result['system']);
+ $this->assertSame(12, $result['row']);
+ }
+
+ public function testParseCoordinatesInvalidFormatThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->handler->callParseCoordinates('not:a:valid:coord');
+ }
+
+ // -------------------------------------------------------------------------
+ // resolvePlanetType()
+ // -------------------------------------------------------------------------
+
+ public function testResolvePlanetTypeZeroReturnsPlanet(): void
+ {
+ $this->assertSame(TYPE_PLANET, $this->handler->callResolvePlanetType(0));
+ $this->assertSame(TYPE_PLANET, $this->handler->callResolvePlanetType('0'));
+ }
+
+ public function testResolvePlanetTypeOneReturnsMoon(): void
+ {
+ $this->assertSame(TYPE_MOON, $this->handler->callResolvePlanetType(1));
+ }
+
+ // -------------------------------------------------------------------------
+ // planetTypeToString()
+ // -------------------------------------------------------------------------
+
+ public function testPlanetTypeToStringReturnsPlanet(): void
+ {
+ $this->assertSame('planet', $this->handler->callPlanetTypeToString(TYPE_PLANET));
+ }
+
+ public function testPlanetTypeToStringReturnsMoon(): void
+ {
+ $this->assertSame('moon', $this->handler->callPlanetTypeToString(TYPE_MOON));
+ }
+
+ // -------------------------------------------------------------------------
+ // executeUpsert()
+ // -------------------------------------------------------------------------
+
+ public function testExecuteUpsertBuildsInsertOnDuplicateKeyQuery(): void
+ {
+ $this->handler->callExecuteUpsert(
+ 'ogspy_test_table',
+ ['id', 'name'],
+ [1, 'test-value'],
+ ['name']
+ );
+
+ $queries = $this->db->getQueries();
+ $this->assertCount(1, $queries);
+
+ $sql = $queries[0];
+ $this->assertStringContainsString('INSERT INTO ogspy_test_table', $sql);
+ $this->assertStringContainsString('`id`, `name`', $sql);
+ $this->assertStringContainsString("'test-value'", $sql);
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $sql);
+ $this->assertStringContainsString('`name` = VALUES(`name`)', $sql);
+ }
+
+ public function testExecuteUpsertQuotesStringsButNotIntegers(): void
+ {
+ $this->handler->callExecuteUpsert(
+ 'ogspy_test_table',
+ ['id', 'val'],
+ [42, 99],
+ ['val']
+ );
+
+ $sql = $this->db->getLastQuery();
+ // Integer 42 should appear without quotes (it is the first value after opening paren)
+ $this->assertStringContainsString('(42,', $sql);
+ // Integer 99 should appear without quotes
+ $this->assertStringContainsString(', 99)', $sql);
+ // Neither should be wrapped in quotes
+ $this->assertStringNotContainsString("'42'", $sql);
+ }
+
+ // -------------------------------------------------------------------------
+ // registerCallback()
+ // -------------------------------------------------------------------------
+
+ public function testRegisterCallbackDelegatesToCallbackHandler(): void
+ {
+ $this->handler->callRegisterCallback('buildings', ['coords' => [4, 246, 12]]);
+
+ $this->assertTrue($this->callbackHandler->hasCallForType('buildings'));
+ $calls = $this->callbackHandler->getCallsForType('buildings');
+ $this->assertSame([4, 246, 12], $calls[0]['params']['coords']);
+ }
+
+ // -------------------------------------------------------------------------
+ // logAction()
+ // -------------------------------------------------------------------------
+
+ public function testLogActionDoesNotThrow(): void
+ {
+ $this->handler->callLogAction(
+ 'buildings',
+ ['coords' => '4:246:12', 'planet_name' => 'test planet']
+ );
+ $this->addToAssertionCount(1); // explicit: reaching here without exception is the assertion
+ }
+
+ // -------------------------------------------------------------------------
+ // setPageUpdatedResponse()
+ // -------------------------------------------------------------------------
+
+ public function testSetPageUpdatedResponseSetsCorrectIoFields(): void
+ {
+ $this->handler->callSetPageUpdatedResponse('buildings', '4:246:12');
+ $response = $this->getIoResponse();
+
+ $this->assertSame('home updated', $response['type']);
+ $this->assertSame('buildings', $response['page']);
+ $this->assertSame('4:246:12', $response['planet']);
+ }
+}
diff --git a/tests/unit/BuildingsHandlerTest.php b/tests/unit/BuildingsHandlerTest.php
new file mode 100644
index 0000000..b7481bc
--- /dev/null
+++ b/tests/unit/BuildingsHandlerTest.php
@@ -0,0 +1,105 @@
+markTestSkipped('BuildingsHandler not yet implemented (Phase 2)');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Resource buildings (buildings.json)
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsResourceBuildingsIntoAstroObject(): void
+ {
+ $data = $this->getDecodedData('buildings');
+ $handler = $this->createHandler('BuildingsHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_astro_object');
+ $this->assertCount(1, $queries, 'Expected exactly one UPSERT into game_astro_object');
+
+ $sql = $queries[0];
+ $this->assertStringContainsString('INSERT INTO', $sql);
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $sql);
+ // Building codes from fixture: M=7, C=5, D=5, CES=9, …
+ $this->assertStringContainsString('`M`', $sql);
+ $this->assertStringContainsString('`C`', $sql);
+ $this->assertStringContainsString('`D`', $sql);
+ // Values cast to int
+ $this->assertStringContainsString('7', $sql); // M = 7
+ }
+
+ public function testHandleSetsCorrectIoResponseForBuildings(): void
+ {
+ $data = $this->getDecodedData('buildings');
+ $handler = $this->createHandler('BuildingsHandler');
+ $handler->handle($data);
+
+ $response = $this->getIoResponse();
+ $this->assertSame('home updated', $response['type']);
+ $this->assertSame('buildings', $response['page']);
+ $this->assertSame('4:246:12', $response['planet']);
+ }
+
+ public function testHandleRegistersBuildingsCallback(): void
+ {
+ $data = $this->getDecodedData('buildings');
+ $handler = $this->createHandler('BuildingsHandler');
+ $handler->handle($data);
+
+ $this->assertTrue($this->callbackHandler->hasCallForType('buildings'));
+ $calls = $this->callbackHandler->getCallsForType('buildings');
+ $params = $calls[0]['params'];
+ $this->assertArrayHasKey('coords', $params);
+ $this->assertArrayHasKey('buildings', $params);
+ }
+
+ // -------------------------------------------------------------------------
+ // Facilities buildings (buildings_facilities.json)
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsFacilitiesIntoAstroObject(): void
+ {
+ $data = $this->getDecodedData('buildings_facilities');
+ $handler = $this->createHandler('BuildingsHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_astro_object');
+ $this->assertCount(1, $queries);
+
+ $sql = $queries[0];
+ $this->assertStringContainsString('`UdR`', $sql); // Shipyard
+ $this->assertStringContainsString('`Lab`', $sql); // Lab
+ }
+
+ // -------------------------------------------------------------------------
+ // Grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutEmpireGrant(): void
+ {
+ $data = $this->getDecodedData('buildings');
+ $handler = $this->createHandlerWithoutGrant('BuildingsHandler', 'empire');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_astro_object'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame(0, $response['status']);
+ }
+}
diff --git a/tests/unit/CheckTest.php b/tests/unit/CheckTest.php
new file mode 100644
index 0000000..71f108f
--- /dev/null
+++ b/tests/unit/CheckTest.php
@@ -0,0 +1,106 @@
+assertSame('4:246:12', $result);
+ }
+
+ public function testCoordsInvalidFormatThrowsInvalidArgumentException(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ Check::coords('abc');
+ }
+
+ public function testCoordsEmptyStringThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ Check::coords('');
+ }
+
+ public function testCoordsGalaxyOutOfBoundsReturnsNull(): void
+ {
+ // num_of_galaxies = 9; galaxy 10 is out of bounds → method returns null implicitly
+ $result = Check::coords('10:246:12');
+ $this->assertNull($result);
+ }
+
+ public function testCoordsRowAbove15ReturnsNull(): void
+ {
+ // Non-expedition row > 15 is invalid
+ $result = Check::coords('4:246:16');
+ $this->assertNull($result);
+ }
+
+ public function testCoordsExpeditionSlot16IsValid(): void
+ {
+ // With $exp = 1, row 16 is allowed
+ $result = Check::coords('4:246:16', 1);
+ $this->assertSame('4:246:16', $result);
+ }
+
+ public function testCoordsExpeditionRequiresExactlyRow16(): void
+ {
+ // With $exp = 1, row != 16 is invalid
+ $result = Check::coords('4:246:12', 1);
+ $this->assertNull($result);
+ }
+
+ // -------------------------------------------------------------------------
+ // Check::player_status()
+ // -------------------------------------------------------------------------
+
+ public function testPlayerStatusValidPatternsReturnOne(): void
+ {
+ $this->assertSame(1, Check::player_status('vf'));
+ $this->assertSame(1, Check::player_status('I'));
+ $this->assertSame(1, Check::player_status('')); // empty string is valid
+ }
+
+ public function testPlayerStatusInvalidPatternReturnsZero(): void
+ {
+ $this->assertSame(0, Check::player_status('xyz'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Check::player_status_forbidden()
+ // -------------------------------------------------------------------------
+
+ public function testPlayerStatusForbiddenMatchesPhPattern(): void
+ {
+ $this->assertSame(1, Check::player_status_forbidden('ph'));
+ $this->assertSame(1, Check::player_status_forbidden('')); // empty also matches ^[ph]*$
+ }
+
+ public function testPlayerStatusForbiddenDoesNotMatchNormalStatus(): void
+ {
+ $this->assertSame(0, Check::player_status_forbidden('vf'));
+ $this->assertSame(0, Check::player_status_forbidden('I'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Check::universe()
+ // -------------------------------------------------------------------------
+
+ public function testUniverseExtractsUrlFromFullUri(): void
+ {
+ $result = Check::universe('https://s277-fr.ogame.gameforge.com/');
+ $this->assertSame('https://s277-fr.ogame.gameforge.com', $result);
+ }
+
+ public function testUniverseReturnsFalseForNonMatchingInput(): void
+ {
+ $this->assertFalse(Check::universe('not-a-universe-url'));
+ $this->assertFalse(Check::universe(''));
+ }
+}
diff --git a/tests/unit/CombatReportHandlerTest.php b/tests/unit/CombatReportHandlerTest.php
new file mode 100644
index 0000000..4c58462
--- /dev/null
+++ b/tests/unit/CombatReportHandlerTest.php
@@ -0,0 +1,336 @@
+markTestSkipped('CombatReportHandler not yet implemented');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Fixture sanity (no handler required — exercised via getDecodedData)
+ // -------------------------------------------------------------------------
+
+ /**
+ * @group fixture
+ */
+ public function testComplexFixtureHasThreeCombatRounds(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $this->assertCount(3, $data['combatRounds'], 'rc_complex must contain exactly 3 combat rounds');
+ }
+
+ /**
+ * @group fixture
+ */
+ public function testComplexFixtureHasDebrisField(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $debris = $data['result']['debris']['resources'];
+ $byType = array_column($debris, 'amount', 'resource');
+
+ $this->assertSame(250000, $byType['metal'], 'Debris metal should be 250 000');
+ $this->assertSame(120000, $byType['crystal'], 'Debris crystal should be 120 000');
+ }
+
+ /**
+ * @group fixture
+ */
+ public function testComplexFixtureHasMoonCreationChance(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $this->assertSame(12, $data['result']['moonCreation']['chance']);
+ }
+
+ /**
+ * @group fixture
+ */
+ public function testComplexFixtureAttackerHasFleetTechnologies(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+
+ $attacker = null;
+ foreach ($data['fleets'] as $fleet) {
+ if ($fleet['side'] === 'attacker') {
+ $attacker = $fleet;
+ break;
+ }
+ }
+
+ $this->assertNotNull($attacker);
+ $techIds = array_column($attacker['combatTechnologies'], 'technologyId');
+ // 202=PT 204=CLE 206=CR 207=VB
+ foreach ([202, 204, 206, 207] as $id) {
+ $this->assertContains($id, $techIds, "Attacker must have technologyId $id");
+ }
+ }
+
+ /**
+ * @group fixture
+ */
+ public function testComplexFixtureDefenderHasDefenseAndFleetTechnologies(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+
+ $defender = null;
+ foreach ($data['fleets'] as $fleet) {
+ if ($fleet['side'] === 'defender') {
+ $defender = $fleet;
+ break;
+ }
+ }
+
+ $this->assertNotNull($defender);
+ $techIds = array_column($defender['combatTechnologies'], 'technologyId');
+ // 202=PT 204=CLE 401=LM 402=LLE 403=LLO
+ foreach ([202, 204, 401, 402, 403] as $id) {
+ $this->assertContains($id, $techIds, "Defender must have technologyId $id");
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Handler — main RC record (TABLE_PARSEDRC)
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsMainRcRecord(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_rc');
+ $inserts = array_values(array_filter($queries, static fn(string $q) => stripos($q, 'INSERT') !== false && stripos($q, 'game_rc_round') === false));
+
+ $this->assertNotEmpty($inserts, 'Expected INSERT INTO game_rc');
+ $sql = $inserts[0];
+ $this->assertStringContainsString('1776500000', $sql, 'Timestamp must appear in RC insert');
+ }
+
+ public function testHandleRecordsWinnerAttacker(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $inserts = $this->db->getQueriesContaining('game_rc');
+ $sql = $inserts[0];
+ $this->assertStringContainsString("'A'", $sql, "Winner must be encoded as 'A'");
+ }
+
+ public function testHandleRecordsLootValues(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_rc')[0];
+ $this->assertStringContainsString('200000', $sql, 'Loot metal missing');
+ $this->assertStringContainsString('150000', $sql, 'Loot crystal missing');
+ $this->assertStringContainsString('50000', $sql, 'Loot deuterium missing');
+ }
+
+ public function testHandleRecordsDebrisValues(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_rc')[0];
+ $this->assertStringContainsString('250000', $sql, 'Debris metal missing');
+ $this->assertStringContainsString('120000', $sql, 'Debris crystal missing');
+ }
+
+ public function testHandleRecordsMoonCreationChance(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_rc')[0];
+ $this->assertStringContainsString('12', $sql, 'Moon chance (12) missing from RC insert');
+ }
+
+ public function testHandleRecordsUnitLosses(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_rc')[0];
+ $this->assertStringContainsString('2500000', $sql, 'Attacker losses missing');
+ $this->assertStringContainsString('5000000', $sql, 'Defender losses missing');
+ }
+
+ // -------------------------------------------------------------------------
+ // Handler — round records (TABLE_PARSEDRCROUND)
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsThreeRoundRecords(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $rounds = $this->db->getQueriesContaining('game_rc_round');
+ $inserts = array_values(array_filter($rounds, static fn(string $q) =>
+ stripos($q, 'INSERT') !== false &&
+ stripos($q, 'game_rc_round_attack') === false &&
+ stripos($q, 'game_rc_round_defense') === false
+ ));
+
+ $this->assertCount(3, $inserts, 'Expected exactly 3 round INSERTs');
+ }
+
+ public function testRoundRecordsContainStatistics(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $rounds = array_values(array_filter(
+ $this->db->getQueriesContaining('game_rc_round'),
+ static fn(string $q) =>
+ stripos($q, 'INSERT') !== false &&
+ stripos($q, 'game_rc_round_attack') === false &&
+ stripos($q, 'game_rc_round_defense') === false
+ ));
+
+ // Round 0: attacker hits=155, strength=8 500 000
+ $this->assertStringContainsString('155', $rounds[0], 'Round 0 attacker hits expected');
+ $this->assertStringContainsString('8500000', $rounds[0], 'Round 0 attacker strength expected');
+ }
+
+ // -------------------------------------------------------------------------
+ // Handler — per-round fleet records
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsAttackRoundRecordsPerRound(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $attacks = $this->db->getQueriesContaining('game_rc_round_attack');
+ $this->assertCount(3, $attacks, 'Expected one attack-round INSERT per combat round');
+ }
+
+ public function testHandleInsertsDefenseRoundRecordsPerRound(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $defenses = $this->db->getQueriesContaining('game_rc_round_defense');
+ $this->assertCount(3, $defenses, 'Expected one defense-round INSERT per combat round');
+ }
+
+ public function testAttackRoundRecordContainsCleColumn(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_rc_round_attack')[0];
+ $this->assertStringContainsString('`CLE`', $sql, 'CLE column expected in attack round record');
+ // Round 0 attacker: CLE remaining = 90
+ $this->assertStringContainsString('90', $sql);
+ }
+
+ public function testDefenseRoundRecordContainsLmColumn(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_rc_round_defense')[0];
+ $this->assertStringContainsString('`LM`', $sql, 'LM column expected in defense round record');
+ // Round 0 defender: LM remaining = 30
+ $this->assertStringContainsString('30', $sql);
+ }
+
+ // -------------------------------------------------------------------------
+ // Handler — IO response
+ // -------------------------------------------------------------------------
+
+ public function testHandleSetsRcIoResponse(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $response = $this->getIoResponse();
+ $this->assertSame('rc', $response['type']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Handler — grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutMessagesGrant(): void
+ {
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandlerWithoutGrant('CombatReportHandler', 'messages');
+ $handler->handle($data);
+
+ $this->assertEmpty(
+ $this->db->getQueriesContaining('INSERT'),
+ 'No INSERTs should occur when messages grant is denied'
+ );
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame('messages', $response['access']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Handler — duplicate RC (same timestamp) is skipped
+ // -------------------------------------------------------------------------
+
+ public function testHandleSkipsDuplicateCombatReport(): void
+ {
+ // Simulate the DB already having this RC by returning a non-null id
+ // Override sql_fetch_row to simulate existing record
+ $this->db = new class extends SpyDatabase {
+ public function sql_fetch_row($result): array { return [42]; } // id_rc = 42
+ };
+ $GLOBALS['db'] = $this->db;
+
+ $data = $this->getDecodedData('rc_complex');
+ $handler = $this->createHandler('CombatReportHandler');
+ $handler->handle($data);
+
+ $this->assertEmpty(
+ $this->db->getQueriesContaining('INSERT'),
+ 'Duplicate RC must not produce any INSERT'
+ );
+ }
+}
diff --git a/tests/unit/IoTest.php b/tests/unit/IoTest.php
new file mode 100644
index 0000000..0c0f368
--- /dev/null
+++ b/tests/unit/IoTest.php
@@ -0,0 +1,113 @@
+io->set('type', 'hello');
+ $output = $this->getIoResponse();
+ $this->assertSame('hello', $output['type']);
+ }
+
+ public function testSetArray(): void
+ {
+ $this->io->set(['type' => 'hello', 'page' => 'overview']);
+ $output = $this->getIoResponse();
+ $this->assertSame('hello', $output['type']);
+ $this->assertSame('overview', $output['page']);
+ }
+
+ public function testDel(): void
+ {
+ $this->io->set('type', 'hello');
+ $this->io->del('type');
+ $output = $this->getIoResponse();
+ $this->assertArrayNotHasKey('type', $output);
+ }
+
+ // -------------------------------------------------------------------------
+ // status() / send()
+ // -------------------------------------------------------------------------
+
+ public function testStatusSetsStatusField(): void
+ {
+ $this->io->status(0);
+ $output = $this->getIoResponse();
+ $this->assertSame(0, $output['status']);
+ }
+
+ public function testSendOutputsValidJson(): void
+ {
+ $this->io->set('type', 'test_type');
+ $this->io->status(4);
+
+ ob_start();
+ $this->io->send();
+ $raw = ob_get_clean();
+
+ $decoded = json_decode($raw, true);
+ $this->assertNotNull($decoded, 'send() must output valid JSON');
+ $this->assertSame('test_type', $decoded['type']);
+ $this->assertSame(4, $decoded['status']);
+ }
+
+ public function testSendWithStatusArgOverridesStoredStatus(): void
+ {
+ $this->io->status(0);
+
+ ob_start();
+ $this->io->send(4);
+ $output = json_decode(ob_get_clean(), true);
+
+ $this->assertSame(4, $output['status']);
+ }
+
+ // -------------------------------------------------------------------------
+ // append_call()
+ // -------------------------------------------------------------------------
+
+ public function testAppendCallAddsToSuccessList(): void
+ {
+ $call = ['id' => 'mod-test', 'title' => 'Test Mod'];
+ $this->io->append_call($call, Io::SUCCESS);
+ $output = $this->getIoResponse();
+ $this->assertContains('Test Mod', $output['calls']['success']);
+ }
+
+ public function testAppendCallDuplicateIdIsIgnored(): void
+ {
+ $call = ['id' => 'mod-test', 'title' => 'Test Mod'];
+ $this->io->append_call($call, Io::SUCCESS);
+ $this->io->append_call($call, Io::SUCCESS); // same id → must not be added again
+ $output = $this->getIoResponse();
+ $this->assertCount(1, $output['calls']['success']);
+ }
+
+ // -------------------------------------------------------------------------
+ // append_call_error()
+ // -------------------------------------------------------------------------
+
+ public function testAppendCallErrorAddsToErrorList(): void
+ {
+ $call = ['id' => 'mod-err', 'title' => 'Error Mod', 'root' => 'testmod'];
+ $this->io->append_call_error($call, 'Something failed');
+ $output = $this->getIoResponse();
+ $this->assertContains('Error Mod', $output['calls']['error']);
+ }
+
+ public function testAppendCallErrorDoesNotAddToSuccessList(): void
+ {
+ $call = ['id' => 'mod-err', 'title' => 'Error Mod', 'root' => 'testmod'];
+ $this->io->append_call_error($call, 'Something failed');
+ $output = $this->getIoResponse();
+ $this->assertEmpty($output['calls']['success']);
+ }
+}
diff --git a/tests/unit/OverviewHandlerTest.php b/tests/unit/OverviewHandlerTest.php
new file mode 100644
index 0000000..cec80a3
--- /dev/null
+++ b/tests/unit/OverviewHandlerTest.php
@@ -0,0 +1,126 @@
+markTestSkipped('OverviewHandler not yet implemented (Phase 2)');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Happy path
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsGamePlayerRecord(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandler('OverviewHandler');
+ $handler->handle($data);
+
+ // player_id 107058 from fixture
+ $queries = $this->db->getQueriesContaining('game_player');
+ $this->assertNotEmpty($queries, 'Expected at least one INSERT INTO game_player');
+ $this->assertStringContainsString('107058', $queries[0]);
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $queries[0]);
+ }
+
+ public function testHandleUpdatesUserPlayerIdLink(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandler('OverviewHandler');
+ $handler->handle($data);
+
+ // UPDATE ogspy_user SET player_id = 107058 WHERE id = 1
+ $queries = $this->db->getQueriesContaining('UPDATE ogspy_user');
+ $this->assertNotEmpty($queries);
+ $this->assertStringContainsString('player_id', $queries[0]);
+ $this->assertStringContainsString('107058', $queries[0]);
+ }
+
+ public function testHandleInsertsUniverseSpeedConfigs(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandler('OverviewHandler');
+ $handler->handle($data);
+
+ // 4 speed configs: speed_uni, speed_fleet_peaceful, speed_fleet_war, speed_fleet_holding
+ $queries = $this->db->getQueriesContaining('ogspy_config');
+ $configTypes = ['speed_uni', 'speed_fleet_peaceful', 'speed_fleet_war', 'speed_fleet_holding'];
+ foreach ($configTypes as $configKey) {
+ $found = $this->db->getQueriesContaining($configKey);
+ $this->assertNotEmpty($found, "Expected INSERT for config key '$configKey'");
+ }
+ }
+
+ public function testHandleInsertsAstroObject(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandler('OverviewHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_astro_object');
+ $this->assertNotEmpty($queries, 'Expected INSERT INTO game_astro_object');
+
+ $sql = $queries[0];
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $sql);
+ // coords: galaxy=4, system=246, row=12
+ $this->assertStringContainsString('4', $sql);
+ $this->assertStringContainsString('246', $sql);
+ $this->assertStringContainsString('12', $sql);
+ // planet_id 33717002
+ $this->assertStringContainsString('33717002', $sql);
+ }
+
+ public function testHandleSetsCorrectIoResponse(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandler('OverviewHandler');
+ $handler->handle($data);
+
+ $response = $this->getIoResponse();
+ $this->assertSame('home updated', $response['type']);
+ $this->assertSame('overview', $response['page']);
+ $this->assertSame('4:246:12', $response['planet']);
+ }
+
+ public function testHandleRegistersOverviewCallback(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandler('OverviewHandler');
+ $handler->handle($data);
+
+ $this->assertTrue($this->callbackHandler->hasCallForType('overview'));
+ $calls = $this->callbackHandler->getCallsForType('overview');
+ $params = $calls[0]['params'];
+ $this->assertArrayHasKey('coords', $params);
+ $this->assertArrayHasKey('planet_type', $params);
+ $this->assertArrayHasKey('ressources', $params);
+ }
+
+ // -------------------------------------------------------------------------
+ // Grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutEmpireGrant(): void
+ {
+ $data = $this->getDecodedData('overview');
+ $handler = $this->createHandlerWithoutGrant('OverviewHandler', 'empire');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_astro_object'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame(0, $response['status']);
+ }
+}
diff --git a/tests/unit/RankingHandlerTest.php b/tests/unit/RankingHandlerTest.php
new file mode 100644
index 0000000..0e1fc6a
--- /dev/null
+++ b/tests/unit/RankingHandlerTest.php
@@ -0,0 +1,161 @@
+markTestSkipped('RankingHandler not yet implemented (Phase 3)');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Table routing — correct table is selected based on type2/type3
+ // -------------------------------------------------------------------------
+
+ public function testPlayerFleetBuiltRoutesToMilitaryBuiltTable(): void
+ {
+ // type2=fleet, type3=5 → TABLE_RANK_PLAYER_MILITARY_BUILT
+ $data = $this->getDecodedData('ranking_player_fleet_built');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_rank_player_military_built');
+ $this->assertNotEmpty($queries,
+ 'Expected REPLACE INTO game_rank_player_military_built for type3=5');
+ }
+
+ public function testPlayerPointsRoutesToPointsTable(): void
+ {
+ // type2=points → TABLE_RANK_PLAYER_POINTS
+ $data = $this->getDecodedData('ranking_player_points');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_rank_player_points');
+ $this->assertNotEmpty($queries,
+ 'Expected REPLACE INTO game_rank_player_points for type2=points');
+ }
+
+ public function testPlayerEconomyRoutesToEconomicsTable(): void
+ {
+ // type2=economy → TABLE_RANK_PLAYER_ECO
+ $data = $this->getDecodedData('ranking_player_economy');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_rank_player_economics');
+ $this->assertNotEmpty($queries,
+ 'Expected REPLACE INTO game_rank_player_economics for type2=economy');
+ }
+
+ // -------------------------------------------------------------------------
+ // Query structure
+ // -------------------------------------------------------------------------
+
+ public function testRankingQueryUsesReplaceInto(): void
+ {
+ $data = $this->getDecodedData('ranking_player_points');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('REPLACE INTO');
+ $this->assertNotEmpty($queries, 'Ranking handler must use REPLACE INTO');
+ }
+
+ public function testRankingInsertsGamePlayerForEachEntry(): void
+ {
+ $data = $this->getDecodedData('ranking_player_fleet_built');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ // Every ranked player should get a game_player UPSERT
+ $playerQueries = $this->db->getQueriesContaining('game_player');
+ $this->assertNotEmpty($playerQueries,
+ 'Expected game_player UPSERTs for ranked players');
+ }
+
+ public function testRankingInsertsGameAllyForEntriesWithAlliance(): void
+ {
+ $data = $this->getDecodedData('ranking_player_fleet_built');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ // At least some entries have non-zero ally_id (e.g. ASTRO, FAR, SLD …)
+ $allyQueries = $this->db->getQueriesContaining('game_ally');
+ $this->assertNotEmpty($allyQueries,
+ 'Expected game_ally UPSERTs for players with an alliance');
+ }
+
+ // -------------------------------------------------------------------------
+ // fleet/military table — special nb_spacecraft column
+ // -------------------------------------------------------------------------
+
+ public function testFleetMilitaryTableQueryIncludesNbSpacecraft(): void
+ {
+ // When the target table is TABLE_RANK_PLAYER_MILITARY the nb_spacecraft
+ // column must be present in the REPLACE INTO query.
+ // For fleet_built (type3=5) it must NOT be present.
+ $data = $this->getDecodedData('ranking_player_fleet_built');
+ $handler = $this->createHandler('RankingHandler');
+ $handler->handle($data);
+
+ $builtQueries = $this->db->getQueriesContaining('game_rank_player_military_built');
+ // fleet_built uses the regular columns (no nb_spacecraft)
+ $this->assertStringNotContainsString('nb_spacecraft', $builtQueries[0],
+ 'game_rank_player_military_built should not include nb_spacecraft');
+ }
+
+ // -------------------------------------------------------------------------
+ // Validation
+ // -------------------------------------------------------------------------
+
+ public function testRankingThrowsOnInvalidOffset(): void
+ {
+ $data = $this->getDecodedData('ranking_player_points');
+ $data['offset'] = '50'; // valid offsets: 1, 101, 201, … → (offset-1) % 100 == 0
+ $handler = $this->createHandler('RankingHandler');
+
+ $this->expectException(\UnexpectedValueException::class);
+ $handler->handle($data);
+ }
+
+ public function testRankingThrowsOnUnknownType2(): void
+ {
+ $data = $this->getDecodedData('ranking_player_points');
+ $data['type2'] = 'nonexistent';
+ $handler = $this->createHandler('RankingHandler');
+
+ $this->expectException(\UnexpectedValueException::class);
+ $handler->handle($data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutRankingGrant(): void
+ {
+ $data = $this->getDecodedData('ranking_player_points');
+ $handler = $this->createHandlerWithoutGrant('RankingHandler', 'ranking');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_rank_player_points'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame(0, $response['status']);
+ }
+}
diff --git a/tests/unit/ResearchsHandlerTest.php b/tests/unit/ResearchsHandlerTest.php
new file mode 100644
index 0000000..058b04b
--- /dev/null
+++ b/tests/unit/ResearchsHandlerTest.php
@@ -0,0 +1,106 @@
+markTestSkipped('ResearchsHandler not yet implemented (Phase 2)');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Happy path
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsTechnologyTableKeyedByPlayerId(): void
+ {
+ $data = $this->getDecodedData('researchs');
+ $handler = $this->createHandler('ResearchsHandler');
+ $handler->handle($data);
+
+ // Target table is game_player_technology, NOT game_astro_object
+ $this->assertEmpty(
+ $this->db->getQueriesContaining('game_astro_object'),
+ 'ResearchsHandler must not write to game_astro_object'
+ );
+
+ $queries = $this->db->getQueriesContaining('game_player_technology');
+ $this->assertNotEmpty($queries, 'Expected INSERT INTO game_player_technology');
+
+ $sql = $queries[0];
+ $this->assertStringContainsString('INSERT INTO', $sql);
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $sql);
+ // userData['player_id'] = 42 (from XtenseTestCase)
+ $this->assertStringContainsString('42', $sql);
+ }
+
+ public function testHandleIncludesAllSixteenResearchColumns(): void
+ {
+ $data = $this->getDecodedData('researchs');
+ $handler = $this->createHandler('ResearchsHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_player_technology')[0];
+
+ // All 16 labo codes from $database['labo'] that appear in the fixture
+ $expectedCodes = ['Esp', 'Ordi', 'Armes', 'Bouclier', 'Protection',
+ 'NRJ', 'Hyp', 'RC', 'RI', 'PH',
+ 'Laser', 'Ions', 'Plasma', 'RRI', 'Graviton', 'Astrophysique'];
+
+ foreach ($expectedCodes as $code) {
+ $this->assertStringContainsString("`$code`", $sql, "Column '$code' missing from query");
+ }
+ }
+
+ public function testHandleSetsCorrectIoResponse(): void
+ {
+ $data = $this->getDecodedData('researchs');
+ $handler = $this->createHandler('ResearchsHandler');
+ $handler->handle($data);
+
+ $response = $this->getIoResponse();
+ $this->assertSame('home updated', $response['type']);
+ $this->assertSame('labo', $response['page']);
+ }
+
+ public function testHandleRegistersResearchCallback(): void
+ {
+ $data = $this->getDecodedData('researchs');
+ $handler = $this->createHandler('ResearchsHandler');
+ $handler->handle($data);
+
+ $this->assertTrue($this->callbackHandler->hasCallForType('research'));
+ $calls = $this->callbackHandler->getCallsForType('research');
+ $this->assertArrayHasKey('research', $calls[0]['params']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutEmpireGrant(): void
+ {
+ $data = $this->getDecodedData('researchs');
+ $handler = $this->createHandlerWithoutGrant('ResearchsHandler', 'empire');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_player_technology'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame(0, $response['status']);
+ }
+}
diff --git a/tests/unit/ResourceSettingsHandlerTest.php b/tests/unit/ResourceSettingsHandlerTest.php
new file mode 100644
index 0000000..6d3e103
--- /dev/null
+++ b/tests/unit/ResourceSettingsHandlerTest.php
@@ -0,0 +1,100 @@
+markTestSkipped('ResourceSettingsHandler not yet implemented (Phase 2)');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Happy path
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsPercentageColumnsIntoAstroObject(): void
+ {
+ $data = $this->getDecodedData('resourceSettings');
+ $handler = $this->createHandler('ResourceSettingsHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('game_astro_object');
+ $this->assertCount(1, $queries, 'Expected exactly one UPSERT into game_astro_object');
+
+ $sql = $queries[0];
+ $this->assertStringContainsString('INSERT INTO', $sql);
+ $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $sql);
+ // All 7 percentage columns must be present
+ $this->assertStringContainsString('`M_percentage`', $sql);
+ $this->assertStringContainsString('`C_Percentage`', $sql);
+ $this->assertStringContainsString('`D_percentage`', $sql);
+ $this->assertStringContainsString('`CES_percentage`', $sql);
+ $this->assertStringContainsString('`CEF_percentage`', $sql);
+ // SAT_percentage is stored as Sat_percentage in the DB (case mapping)
+ $this->assertStringContainsString('percentage', $sql);
+ $this->assertStringContainsString('`FOR_percentage`', $sql);
+ }
+
+ public function testHandleIncludesPlanetCoordinates(): void
+ {
+ $data = $this->getDecodedData('resourceSettings');
+ $handler = $this->createHandler('ResourceSettingsHandler');
+ $handler->handle($data);
+
+ $sql = $this->db->getQueriesContaining('game_astro_object')[0];
+ // planet_id 33717002, coords 4:246:12
+ $this->assertStringContainsString('33717002', $sql);
+ $this->assertStringContainsString('246', $sql);
+ }
+
+ public function testHandleSetsIoResponseWithBuildingsPage(): void
+ {
+ // The existing production code reports page='buildings' for resourceSettings.
+ $data = $this->getDecodedData('resourceSettings');
+ $handler = $this->createHandler('ResourceSettingsHandler');
+ $handler->handle($data);
+
+ $response = $this->getIoResponse();
+ $this->assertSame('home updated', $response['type']);
+ $this->assertSame('buildings', $response['page']); // intentional: matches prod
+ $this->assertSame('4:246:12', $response['planet']);
+ }
+
+ public function testHandleRegistersResourceSettingsCallback(): void
+ {
+ $data = $this->getDecodedData('resourceSettings');
+ $handler = $this->createHandler('ResourceSettingsHandler');
+ $handler->handle($data);
+
+ $this->assertTrue($this->callbackHandler->hasCallForType('resourceSettings'));
+ }
+
+ // -------------------------------------------------------------------------
+ // Grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutEmpireGrant(): void
+ {
+ $data = $this->getDecodedData('resourceSettings');
+ $handler = $this->createHandlerWithoutGrant('ResourceSettingsHandler', 'empire');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_astro_object'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame(0, $response['status']);
+ }
+}
diff --git a/tests/unit/SpyCallbackHandler.php b/tests/unit/SpyCallbackHandler.php
new file mode 100644
index 0000000..6a9a2d4
--- /dev/null
+++ b/tests/unit/SpyCallbackHandler.php
@@ -0,0 +1,38 @@
+ */
+ public array $calls = [];
+
+ /** @param mixed $type @param mixed $params */
+ public function add($type, $params): void
+ {
+ if (empty($params)) {
+ return;
+ }
+ $this->calls[] = ['type' => $type, 'params' => $params];
+ }
+
+ /**
+ * Returns all recorded calls for a given callback type.
+ * @return array
+ */
+ public function getCallsForType(string $type): array
+ {
+ return array_values(
+ array_filter($this->calls, fn(array $c): bool => $c['type'] === $type)
+ );
+ }
+
+ public function hasCallForType(string $type): bool
+ {
+ return !empty($this->getCallsForType($type));
+ }
+}
diff --git a/tests/unit/SpyDatabase.php b/tests/unit/SpyDatabase.php
new file mode 100644
index 0000000..8fdf140
--- /dev/null
+++ b/tests/unit/SpyDatabase.php
@@ -0,0 +1,68 @@
+queries[] = $query;
+ return true;
+ }
+
+ public function sql_escape_string(string $string): string
+ {
+ return addslashes($string);
+ }
+
+ public function sql_fetch_assoc($result): array
+ {
+ return [];
+ }
+
+ public function sql_fetch_row($result): array
+ {
+ return [null];
+ }
+
+ private int $lastInsertId = 0;
+
+ public function sql_insertid(): int
+ {
+ return ++$this->lastInsertId;
+ }
+
+ /** @return string[] All captured queries in order. */
+ public function getQueries(): array
+ {
+ return $this->queries;
+ }
+
+ public function getLastQuery(): ?string
+ {
+ return empty($this->queries) ? null : end($this->queries);
+ }
+
+ /**
+ * Returns all captured queries whose text contains $substring (case-insensitive).
+ * @return string[]
+ */
+ public function getQueriesContaining(string $substring): array
+ {
+ return array_values(array_filter(
+ $this->queries,
+ fn(string $q): bool => stripos($q, $substring) !== false
+ ));
+ }
+
+ public function reset(): void
+ {
+ $this->queries = [];
+ }
+}
diff --git a/tests/unit/SystemHandlerTest.php b/tests/unit/SystemHandlerTest.php
new file mode 100644
index 0000000..752c36b
--- /dev/null
+++ b/tests/unit/SystemHandlerTest.php
@@ -0,0 +1,178 @@
+markTestSkipped('SystemHandler not yet implemented (Phase 3)');
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Core system writes
+ // -------------------------------------------------------------------------
+
+ public function testHandleWritesAllFifteenPositionsToAstroObject(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ // One INSERT per position (rows 1-15, including empty rows with planet_id=0)
+ $queries = $this->db->getQueriesContaining('game_astro_object');
+ $this->assertGreaterThanOrEqual(15, count($queries),
+ 'Expected at least 15 UPSERTs into game_astro_object (one per row)');
+ }
+
+ public function testHandleWritesMoonRowsForPositionsWithMoon(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ // Rows 8 (moon_id=33641931) and 15 (moon_id=33693888) have moons
+ $moonQueries = $this->db->getQueriesContaining('33641931');
+ $this->assertNotEmpty($moonQueries, 'Expected moon INSERT for row 8 (moon_id=33641931)');
+
+ $moonQueries2 = $this->db->getQueriesContaining('33693888');
+ $this->assertNotEmpty($moonQueries2, 'Expected moon INSERT for row 15 (moon_id=33693888)');
+ }
+
+ public function testHandleWritesKnownPlanetIdToAstroObject(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ // planet_id 33717002 from row 12 (Marshal Galileo)
+ $queries = $this->db->getQueriesContaining('33717002');
+ $this->assertNotEmpty($queries, 'Expected planet_id 33717002 in an astro_object UPSERT');
+ }
+
+ // -------------------------------------------------------------------------
+ // Game-player / ally upserts
+ // -------------------------------------------------------------------------
+
+ public function testHandleInsertsGamePlayerForOccupiedPositions(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ // player_id 107056 (Czar Oberon) appears in rows 6 and 14
+ $queries = $this->db->getQueriesContaining('game_player');
+ $this->assertNotEmpty($queries, 'Expected at least one INSERT into game_player');
+
+ $czarQueries = $this->db->getQueriesContaining('107056');
+ $this->assertNotEmpty($czarQueries, 'Expected game_player UPSERT for player 107056');
+ }
+
+ public function testHandleInsertsGameAllyForPositionsWithAlliance(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ // ally_id 500003 (SLD) appears in rows 6, 7, 14
+ $allyQueries = $this->db->getQueriesContaining('game_ally');
+ $this->assertNotEmpty($allyQueries, 'Expected at least one INSERT into game_ally');
+
+ $sldQueries = $this->db->getQueriesContaining('500003');
+ $this->assertNotEmpty($sldQueries, 'Expected game_ally UPSERT for ally_id 500003');
+ }
+
+ // -------------------------------------------------------------------------
+ // Statistics and counter update
+ // -------------------------------------------------------------------------
+
+ public function testHandleUpdatesPlanetImportsCounter(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ $queries = $this->db->getQueriesContaining('planet_imports');
+ $this->assertNotEmpty($queries, 'Expected UPDATE for planet_imports counter');
+ }
+
+ // -------------------------------------------------------------------------
+ // IO response and callback
+ // -------------------------------------------------------------------------
+
+ public function testHandleSetsCorrectIoResponse(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ $response = $this->getIoResponse();
+ $this->assertSame('system', $response['type']);
+ $this->assertSame('4', (string)$response['galaxy']);
+ $this->assertSame('246', (string)$response['system']);
+ }
+
+ public function testHandleRegistersSystemCallback(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ $this->assertTrue($this->callbackHandler->hasCallForType('system'));
+ $calls = $this->callbackHandler->getCallsForType('system');
+ $params = $calls[0]['params'];
+ $this->assertArrayHasKey('galaxy', $params);
+ $this->assertArrayHasKey('system', $params);
+ $this->assertArrayHasKey('data', $params);
+ }
+
+ // -------------------------------------------------------------------------
+ // Grant denial
+ // -------------------------------------------------------------------------
+
+ public function testHandleDeniedWithoutSystemGrant(): void
+ {
+ $data = $this->getDecodedData('system');
+ $handler = $this->createHandlerWithoutGrant('SystemHandler', 'system');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_astro_object'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame('plugin grant', $response['type']);
+ $this->assertSame(0, $response['status']);
+ }
+
+ public function testHandleRejectsGalaxyOutOfBounds(): void
+ {
+ $data = $this->getDecodedData('system');
+ $data['galaxy'] = '99'; // > num_of_galaxies (9)
+ $handler = $this->createHandler('SystemHandler');
+ $handler->handle($data);
+
+ $this->assertEmpty($this->db->getQueriesContaining('game_astro_object'));
+
+ $response = $this->getIoResponse();
+ $this->assertSame(0, $response['status']);
+ }
+}
diff --git a/tests/unit/XtenseTestCase.php b/tests/unit/XtenseTestCase.php
new file mode 100644
index 0000000..ca9369c
--- /dev/null
+++ b/tests/unit/XtenseTestCase.php
@@ -0,0 +1,259 @@
+ 9,
+ 'num_of_systems' => 499,
+ ];
+
+ protected array $userData = [
+ 'id' => 1,
+ 'name' => 'TestUser',
+ 'player_id' => 42,
+ 'grant' => [
+ 'empire' => true,
+ 'system' => true,
+ 'ranking' => true,
+ 'messages' => true,
+ ],
+ ];
+
+ /**
+ * Mirrors the $database array from mod/xtense/includes/config.php.
+ */
+ protected array $xtenseDatabase = [
+ 'ressources' => ['metal', 'cristal', 'deuterium', 'energie', 'activite'],
+ 'ressources_p' => ['M_percentage', 'C_Percentage', 'D_percentage', 'CES_percentage', 'CEF_percentage', 'SAT_percentage', 'FOR_percentage'],
+ 'buildings' => ['M', 'C', 'D', 'CES', 'CEF', 'UdR', 'UdN', 'CSp', 'SAT', 'HM', 'HC', 'HD', 'FOR', 'Lab', 'Ter', 'Dock', 'Silo', 'DdR', 'BaLu', 'Pha', 'PoSa'],
+ 'labo' => ['Esp', 'Ordi', 'Armes', 'Bouclier', 'Protection', 'NRJ', 'Hyp', 'RC', 'RI', 'PH', 'Laser', 'Ions', 'Plasma', 'RRI', 'Graviton', 'Astrophysique'],
+ 'defense' => ['LM', 'LLE', 'LLO', 'CG', 'LP', 'AI', 'PB', 'GB', 'MIC', 'MIP'],
+ 'fleet' => ['PT', 'GT', 'CLE', 'CLO', 'CR', 'VB', 'VC', 'REC', 'SE', 'BMD', 'DST', 'EDLM', 'TRA', 'FAU', 'ECL'],
+ 'fleet_production' => ['SAT', 'FOR'],
+ ];
+
+ protected string $toolbarInfo = 'GM-FF V3.1.4';
+
+ // -------------------------------------------------------------------------
+ // Class-level bootstrap — runs once per test class
+ // -------------------------------------------------------------------------
+
+ public static function setUpBeforeClass(): void
+ {
+ parent::setUpBeforeClass();
+
+ // Guard: files only need to be loaded once across all test classes.
+ if (class_exists('Io', false)) {
+ return;
+ }
+
+ // --- Stub core OGSpy functions not available in unit-test context ----
+ // These must be defined BEFORE xtense files are required so that any
+ // file that references them via require_once won't cause a fatal error.
+
+ if (!function_exists('generate_config_cache')) {
+ // phpcs:ignore
+ function generate_config_cache(): void {}
+ }
+ if (!function_exists('booster_encode')) {
+ // phpcs:ignore
+ function booster_encode(array $boosters): string { return ''; }
+ }
+ if (!function_exists('booster_encodev')) {
+ // phpcs:ignore
+ function booster_encodev(int ...$args): string { return ''; }
+ }
+
+ // --- Constants required by xtense guards ----------------------------
+ if (!defined('IN_SPYOGAME')) define('IN_SPYOGAME', true);
+ if (!defined('IN_XTENSE')) define('IN_XTENSE', true);
+
+ // --- Globals needed by xtense/includes/config.php -------------------
+ $GLOBALS['table_prefix'] = 'ogspy_';
+ $GLOBALS['root'] = 'xtense';
+
+ // --- Define TABLE_* constants (mirrors includes/config.php) ---------
+ $p = 'ogspy_';
+ foreach ([
+ 'TABLE_CONFIG' => $p . 'config',
+ 'TABLE_USER' => $p . 'user',
+ 'TABLE_MOD' => $p . 'mod',
+ 'TABLE_STATISTIC' => $p . 'statistics',
+ 'TABLE_USER_BUILDING' => $p . 'game_astro_object',
+ 'TABLE_GAME_PLAYER' => $p . 'game_player',
+ 'TABLE_GAME_ALLY' => $p . 'game_ally',
+ 'TABLE_GAME_PLAYER_DEFENSE' => $p . 'game_player_defense',
+ 'TABLE_GAME_PLAYER_FLEET' => $p . 'game_player_fleet',
+ 'TABLE_USER_TECHNOLOGY' => $p . 'game_player_technology',
+ 'TABLE_RANK_PLAYER_POINTS' => $p . 'game_rank_player_points',
+ 'TABLE_RANK_PLAYER_ECO' => $p . 'game_rank_player_economics',
+ 'TABLE_RANK_PLAYER_TECHNOLOGY' => $p . 'game_rank_player_technology',
+ 'TABLE_RANK_PLAYER_MILITARY' => $p . 'game_rank_player_military',
+ 'TABLE_RANK_PLAYER_MILITARY_BUILT' => $p . 'game_rank_player_military_built',
+ 'TABLE_RANK_PLAYER_MILITARY_LOOSE' => $p . 'game_rank_player_military_loose',
+ 'TABLE_RANK_PLAYER_MILITARY_DESTRUCT' => $p . 'game_rank_player_military_destruct',
+ 'TABLE_RANK_PLAYER_HONOR' => $p . 'game_rank_player_honor',
+ 'TABLE_RANK_ALLY_POINTS' => $p . 'game_rank_ally_points',
+ 'TABLE_RANK_ALLY_ECO' => $p . 'game_rank_ally_economics',
+ 'TABLE_RANK_ALLY_TECHNOLOGY' => $p . 'game_rank_ally_technology',
+ 'TABLE_RANK_ALLY_MILITARY' => $p . 'game_rank_ally_military',
+ 'TABLE_RANK_ALLY_MILITARY_BUILT' => $p . 'game_rank_ally_military_built',
+ 'TABLE_RANK_ALLY_MILITARY_LOOSE' => $p . 'game_rank_ally_military_loose',
+ 'TABLE_RANK_ALLY_MILITARY_DESTRUCT' => $p . 'game_rank_ally_military_destruct',
+ 'TABLE_RANK_ALLY_HONOR' => $p . 'game_rank_ally_honor',
+ 'TABLE_PARSEDRC' => $p . 'game_rc',
+ 'TABLE_PARSEDRCROUND' => $p . 'game_rc_round',
+ 'TABLE_ROUND_ATTACK' => $p . 'game_rc_round_attack',
+ 'TABLE_ROUND_DEFENSE' => $p . 'game_rc_round_defense',
+ ] as $name => $value) {
+ if (!defined($name)) define($name, $value);
+ }
+
+ // --- Load xtense source files ----------------------------------------
+ $base = dirname(__DIR__, 2); // resolves to mod/xtense/ from tests/unit/
+
+ // Test infrastructure (must come before xtense source files that use them)
+ require_once __DIR__ . '/SpyDatabase.php';
+ require_once __DIR__ . '/SpyCallbackHandler.php';
+
+ // Xtense includes
+ require_once $base . '/includes/config.php'; // TYPE_PLANET, TYPE_MOON, $database
+ require_once $base . '/includes/Io.php';
+ require_once $base . '/includes/Check.php';
+ require_once $base . '/includes/CallbackHandler.php';
+ require_once $base . '/includes/Callback.php';
+ require_once $base . '/includes/functions.php';
+ require_once $base . '/includes/AbstractHandler.php';
+
+ // Load handler files if they already exist (Phase 2+)
+ $handlerDir = $base . '/Handler/';
+ foreach (['Overview', 'Buildings', 'ResourceSettings', 'Defense', 'Researchs',
+ 'Fleet', 'System', 'Ranking', 'CombatReport', 'AllyList', 'Message'] as $name) {
+ $file = $handlerDir . $name . 'Handler.php';
+ if (file_exists($file)) {
+ require_once $file;
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Per-test setup
+ // -------------------------------------------------------------------------
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->db = new SpyDatabase();
+ $this->log = $this->createStub(\Monolog\Logger::class);
+ $this->io = new Io();
+ $this->callbackHandler = new SpyCallbackHandler();
+
+ // Globals consumed by add_log(), update_statistic(), Check::coords()
+ $GLOBALS['db'] = $this->db;
+ $GLOBALS['log'] = $this->log;
+ $GLOBALS['server_config'] = $this->serverConfig;
+ $GLOBALS['xtense_user_data'] = $this->userData;
+
+ // Fixed timestamp for ranking handler tests
+ $GLOBALS['timestamp'] = mktime(0, 0, 0, 1, 1, 2026);
+ }
+
+ // -------------------------------------------------------------------------
+ // Fixture helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Load a fixture JSON file from tests/fixtures/ and return its decoded array.
+ */
+ protected function loadFixture(string $name): array
+ {
+ $path = __DIR__ . '/../fixtures/' . $name . '.json';
+ $content = file_get_contents($path);
+ $decoded = json_decode($content, true);
+ $this->assertIsArray($decoded, "Fixture '$name.json' is not valid JSON");
+ return $decoded;
+ }
+
+ /**
+ * Load a fixture and decode its inner 'data' field — the actual game payload
+ * sent by the browser extension.
+ */
+ protected function getDecodedData(string $fixtureName): array
+ {
+ $fixture = $this->loadFixture($fixtureName);
+ $data = json_decode($fixture['data'], true);
+ $this->assertIsArray($data, "Fixture '$fixtureName.json' 'data' field is not valid JSON");
+ return $data;
+ }
+
+ // -------------------------------------------------------------------------
+ // Response / assertion helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Capture the current IO state as a decoded array.
+ * Calls Io::send() internally; use only once per test to avoid double-output.
+ */
+ protected function getIoResponse(): array
+ {
+ ob_start();
+ $this->io->send();
+ return json_decode(ob_get_clean(), true) ?? [];
+ }
+
+ /**
+ * Instantiate a handler class with the standard test dependencies.
+ */
+ protected function createHandler(string $className): AbstractHandler
+ {
+ return new $className(
+ $this->db,
+ $this->log,
+ $this->io,
+ $this->callbackHandler,
+ $this->serverConfig,
+ $this->userData,
+ $this->xtenseDatabase,
+ $this->toolbarInfo
+ );
+ }
+
+ /**
+ * Instantiate a handler with a specific grant disabled, for grant-denial tests.
+ */
+ protected function createHandlerWithoutGrant(string $className, string $grant): AbstractHandler
+ {
+ $userData = $this->userData;
+ $userData['grant'][$grant] = false;
+ return new $className(
+ $this->db,
+ $this->log,
+ $this->io,
+ $this->callbackHandler,
+ $this->serverConfig,
+ $userData,
+ $this->xtenseDatabase,
+ $this->toolbarInfo
+ );
+ }
+}
diff --git a/xtense.php b/xtense.php
index c4b9c15..d763e9e 100755
--- a/xtense.php
+++ b/xtense.php
@@ -111,6 +111,7 @@
// Meilleur Endroit pour voir ce que l'on récupère de l'extension :-)
//print_r($data);
+try {
switch ($received_game_data['type']) {
case 'overview':
@@ -918,6 +919,7 @@
'economy' => TABLE_RANK_PLAYER_ECO,
'research' => TABLE_RANK_PLAYER_TECHNOLOGY,
'fleet' => match ($type3) {
+ '' => TABLE_RANK_PLAYER_MILITARY,
'5' => TABLE_RANK_PLAYER_MILITARY_BUILT,
'6' => TABLE_RANK_PLAYER_MILITARY_DESTRUCT,
'4' => TABLE_RANK_PLAYER_MILITARY_LOOSE,
@@ -932,6 +934,7 @@
'economy' => TABLE_RANK_ALLY_ECO,
'research' => TABLE_RANK_ALLY_TECHNOLOGY,
'fleet' => match ($type3) {
+ '' => TABLE_RANK_ALLY_MILITARY,
'5' => TABLE_RANK_ALLY_MILITARY_BUILT,
'6' => TABLE_RANK_ALLY_MILITARY_DESTRUCT,
'4' => TABLE_RANK_ALLY_MILITARY_LOOSE,
@@ -1871,6 +1874,12 @@
$call->apply();
+} catch (\Throwable $e) {
+ $log->error("Xtense error: " . $e->getMessage(), ['exception' => $e]);
+ $io->set('error', $e->getMessage());
+ $io->status(0);
+}
+
$io->set('execution', str_replace(',', '.', round((microtime(true) - $start_time) * 1000, 2)));
$io->send();
$db->sql_close();