Files
SEQUA-Engine/core/tests/server/test_protocol.cpp
T
Ange-Marie MAURIN - A2M Systèmes 98c778aa28 feat(input): entrees du joueur captees par l'adaptateur (ADR 0011)
L'adaptateur traduit clavier et manette en actions SEQUA (PlayerInput) ;
le Core ne recoit et n'enregistre que ces actions, le rejeu reste exact.

- SEQUA 3D : PlayerInput, poll_input(), capacite player_input
  (interface des adaptateurs en version 2)
- protocole Core - rendu 0.2 : render/input, envoye quand les actions
  changent ; entree remise au neutre si le rendu disparait
- Godot : InputMap du projet (ZQSD, fleches, espace, manette) ou script
  SequaInput.sequa_player_input() pour une correspondance avancee ;
  entree neutre sans focus ; exemple examples/sequa_input.gd
- adaptateur debug : entrees scriptees par numero d'image
- Studio : ligne "Entrees du joueur" du panneau Rendu
2026-11-07 20:48:26 +01:00

525 lines
21 KiB
C++

// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/server/protocol.hpp"
#include "sequa/core/server/services.hpp"
#include <doctest/doctest.h>
#include <nlohmann/json.hpp>
#include <string>
using namespace sequa::core::server;
using nlohmann::json;
namespace three_d = sequa::three_d;
namespace {
// Services factices : mémorisent les appels, réponses prévisibles.
class FakeServices final : public CoreServices {
public:
CoreStatus core_status() const override
{
CoreStatus status;
status.core_version = "1.2.3";
status.protocol_version = std::string(protocol_version);
status.instance_id = "0123456789abcdef0123456789abcdef";
status.pid = 4242;
status.linked = linked;
status.controller_attached = controller.has_value();
return status;
}
AttachResult attach(ConnectionId id, bool force) override
{
if (controller && *controller != id && !force) {
return AttachResult::controller_exists;
}
controller = id;
linked = true;
return AttachResult::attached;
}
DetachResult detach(ConnectionId id) override
{
if (controller != id) {
return DetachResult::not_controller;
}
linked = false;
return DetachResult::detached;
}
void request_shutdown() override { shutdown = true; }
SessionStateView session_start(std::uint64_t new_seed) override
{
state.seed = new_seed;
state.tick = 0;
return state;
}
SessionStateView session_pause() override
{
state.running = false;
return state;
}
SessionStateView session_resume() override
{
state.running = true;
return state;
}
SessionStateView session_status() const override { return state; }
std::uint64_t session_input(std::uint32_t b, std::int32_t x, std::int32_t y) override
{
buttons = b;
move_x = x;
move_y = y;
return state.tick;
}
SessionExportData session_export() const override
{
SessionExportData data;
data.recording = {std::byte{'S'}, std::byte{'Q'}};
data.tick = 2;
data.state_hash = 0xABCDEFull;
data.hashes = {1, 0x0102030405060708ull};
return data;
}
std::optional<SessionImportData> session_import(std::span<const std::byte> recording, bool resume) override
{
if (recording.size() != 2) {
return std::nullopt;
}
SessionImportData data;
data.state = state;
data.state.running = resume;
data.replay_ms = 1.5;
return data;
}
std::optional<std::string> render_attach(ConnectionId, PushFn) override
{
return std::string(32, 'a');
}
void on_client_connected() override {}
void on_client_disconnected(ConnectionId) override {}
// --- Rendu (protocole 0.3) ---
RenderStartResult render_start(const RenderStartRequest& request) override
{
last_render_start_request = request;
if (render_running) {
return RenderStartResult{std::nullopt, "render-running", "Un rendu est déjà en cours."};
}
if (render_unavailable) {
return RenderStartResult{std::nullopt, "render-unavailable", "Rendu non disponible."};
}
render_running = true;
RenderStatusView view;
view.state = RenderRunState::starting;
view.adapter = request.adapter;
view.restarts = 0;
view.world_version = last_world_version;
return RenderStartResult{view, "", ""};
}
RenderStatusView render_stop() override
{
render_running = false;
RenderStatusView view;
view.state = RenderRunState::stopped;
view.restarts = last_restarts;
view.world_version = last_world_version;
view.last_failure = last_failure.empty() ? std::nullopt : std::optional<std::string>(last_failure);
return view;
}
RenderStatusView render_status() const override
{
RenderStatusView view;
if (!render_running) {
view.state = RenderRunState::stopped;
view.restarts = last_restarts;
view.world_version = last_world_version;
view.last_failure = last_failure.empty() ? std::nullopt : std::optional<std::string>(last_failure);
return view;
}
view.state = RenderRunState::running;
view.adapter = last_adapter;
view.pid = 1234;
view.restarts = last_restarts;
view.engine = three_d::EngineCapabilities{"debug", "0.1", three_d::BufferSet{three_d::FrameBuffer::color}, true, false};
view.width = 1280;
view.height = 720;
view.measurements = RenderFrames{180, 0x1D5, 6.1, 6.1, 340, 340, 5900, 5900};
view.world_version = last_world_version;
view.last_failure = last_failure.empty() ? std::nullopt : std::optional<std::string>(last_failure);
return view;
}
// État simulé pour les tests.
bool render_running = false;
bool render_unavailable = false;
std::string last_adapter;
std::uint32_t last_restarts = 0;
std::uint64_t last_world_version = 0x1D5;
std::string last_failure;
RenderStartRequest last_render_start_request;
SessionStateView state{0x2Aull, 60, 10, 0xFFull, true, 3};
std::optional<ConnectionId> controller;
bool linked = false;
bool shutdown = false;
std::uint32_t buttons = 0;
std::int32_t move_x = 0;
std::int32_t move_y = 0;
};
const std::string hello = encode_hello(protocol_version, "sequa-studio-host");
const std::string render_hello = encode_hello(render_protocol_version, render_client_name);
json single(const Reply& reply)
{
REQUIRE(reply.messages.size() == 1);
return json::parse(reply.messages.front());
}
json request(ProtocolSession& session, const std::string& type, json payload = json::object(), std::string id = "r1")
{
const json message{{"v", 1}, {"type", type}, {"id", id}, {"payload", std::move(payload)}};
const Reply reply = session.on_text(message.dump());
CHECK_FALSE(reply.close);
return single(reply);
}
ProtocolSession ready_session(FakeServices& services, ConnectionId id = 1)
{
ProtocolSession session(services, id);
const json welcome = single(session.on_text(hello));
REQUIRE(welcome["type"] == "welcome");
return session;
}
ProtocolSession render_session(FakeServices& services, ConnectionId id = 1)
{
ProtocolSession session(services, id, [](std::string) {}); // envoi spontané ignoré
const json welcome = single(session.on_text(render_hello));
REQUIRE(welcome["type"] == "welcome");
return session;
}
void check_rejected(ProtocolSession& session, const std::string& text, const std::string& expected_code)
{
const Reply reply = session.on_text(text);
CHECK(single(reply)["payload"]["code"] == expected_code);
CHECK(reply.close);
CHECK(reply.close_reason.size() <= 123);
CHECK(session.state() == ProtocolSession::State::closed);
}
} // namespace
TEST_CASE("Protocole : compatibilite des versions 0.3")
{
CHECK(is_compatible_protocol_version("0.3.0"));
CHECK(is_compatible_protocol_version("0.3.7-rc.1"));
CHECK_FALSE(is_compatible_protocol_version("0.1.0"));
CHECK_FALSE(is_compatible_protocol_version("0.2.0"));
CHECK_FALSE(is_compatible_protocol_version("1.2.0"));
CHECK_FALSE(is_compatible_protocol_version("0.3"));
CHECK_FALSE(is_compatible_protocol_version("0.3.0x"));
}
TEST_CASE("Protocole : welcome porte instanceId, pid et linked")
{
FakeServices services;
ProtocolSession session(services, 1);
const json welcome = single(session.on_text(encode_hello(protocol_version, "sequa-studio")));
CHECK(welcome["payload"]["protocolVersion"] == "0.3.0");
CHECK(welcome["payload"]["coreVersion"] == "1.2.3");
CHECK(welcome["payload"]["instanceId"] == "0123456789abcdef0123456789abcdef");
CHECK(welcome["payload"]["pid"] == 4242);
CHECK(welcome["payload"]["linked"] == false);
}
TEST_CASE("Protocole : poignee de main refusee")
{
FakeServices services;
ProtocolSession session(services, 1);
SUBCASE("JSON invalide") { check_rejected(session, "{pas du json", "invalid-json"); }
SUBCASE("autre message avant hello") { check_rejected(session, R"({"v":1,"type":"core/ping","payload":{}})", "handshake-required"); }
SUBCASE("client inconnu") { check_rejected(session, encode_hello(protocol_version, "autre"), "invalid-payload"); }
SUBCASE("version 0.1 incompatible") { check_rejected(session, encode_hello("0.1.0", "sequa-studio"), "incompatible-protocol"); }
SUBCASE("version 0.2 incompatible") { check_rejected(session, encode_hello("0.2.0", "sequa-studio"), "incompatible-protocol"); }
SUBCASE("id non textuel") { check_rejected(session, R"({"v":1,"type":"hello","id":5,"payload":{}})", "invalid-envelope"); }
}
TEST_CASE("Protocole : id recopie, reponse du meme type")
{
FakeServices services;
auto session = ready_session(services);
const json pong = request(session, "core/ping", json::object(), "abc-17");
CHECK(pong["type"] == "core/ping");
CHECK(pong["id"] == "abc-17");
// Sans id : réponse sans id.
const json no_id = single(session.on_text(R"({"v":1,"type":"core/ping","payload":{}})"));
CHECK_FALSE(no_id.contains("id"));
}
TEST_CASE("Protocole : erreurs apres la poignee de main sans fermeture")
{
FakeServices services;
auto session = ready_session(services);
const json unknown = request(session, "scene/get");
CHECK(unknown["type"] == "error");
CHECK(unknown["id"] == "r1");
CHECK(unknown["payload"]["code"] == "unknown-type");
CHECK(request(session, "session/start", {{"seed", "xyz"}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/start", {{"seed", "12345678901234567"}})["payload"]["code"] == "invalid-payload");
const Reply garbage = session.on_text("pas du json");
CHECK_FALSE(garbage.close);
CHECK(single(garbage)["payload"]["code"] == "invalid-json");
CHECK(session.state() == ProtocolSession::State::ready);
}
TEST_CASE("Protocole : core/status et core/shutdown")
{
FakeServices services;
auto session = ready_session(services);
const json status = request(session, "core/status")["payload"];
CHECK(status["instanceId"] == "0123456789abcdef0123456789abcdef");
CHECK(status["controllerAttached"] == false);
CHECK(request(session, "core/shutdown")["type"] == "core/shutdown");
CHECK(services.shutdown);
}
TEST_CASE("Protocole : controleur, attach force et detach")
{
FakeServices services;
auto first = ready_session(services, 1);
auto second = ready_session(services, 2);
CHECK(request(second, "core/detach")["payload"]["code"] == "not-controller");
CHECK(request(first, "core/attach")["payload"]["linked"] == true);
CHECK(request(second, "core/attach")["payload"]["code"] == "controller-exists");
CHECK(request(second, "core/attach", {{"force", "oui"}})["payload"]["code"] == "invalid-payload");
CHECK(request(second, "core/attach", {{"force", true}})["payload"]["linked"] == true);
CHECK(services.controller == 2u);
CHECK(request(first, "core/detach")["payload"]["code"] == "not-controller");
CHECK(request(second, "core/detach")["payload"]["linked"] == false);
}
TEST_CASE("Protocole : etat de session en hexadecimal")
{
FakeServices services;
auto session = ready_session(services);
const json state = request(session, "session/status")["payload"];
CHECK(state["seed"] == "000000000000002a");
CHECK(state["stateHash"] == "00000000000000ff");
CHECK(state["tickRateHz"] == 60);
CHECK(state["tick"] == 10);
CHECK(state["inputCount"] == 3);
CHECK(request(session, "session/start", {{"seed", "FF"}})["payload"]["seed"] == "00000000000000ff");
CHECK(request(session, "session/pause")["payload"]["running"] == false);
CHECK(request(session, "session/resume")["payload"]["running"] == true);
}
TEST_CASE("Protocole : session/input et ses bornes")
{
FakeServices services;
auto session = ready_session(services);
CHECK(request(session, "session/input", {{"buttons", 4294967295u}, {"moveX", -32767}, {"moveY", 32767}})["payload"]["tick"] == 10);
CHECK(services.buttons == 4294967295u);
CHECK(services.move_x == -32767);
CHECK(request(session, "session/input", {{"buttons", 1}, {"moveX", 32768}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/input", {{"buttons", -1}, {"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/input", {{"buttons", 4294967296u}, {"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
CHECK(request(session, "session/input", {{"moveX", 0}, {"moveY", 0}})["payload"]["code"] == "invalid-payload");
}
TEST_CASE("Protocole : session/export et session/import en base64")
{
FakeServices services;
auto session = ready_session(services);
const json exported = request(session, "session/export", {{"includeHashes", true}})["payload"];
CHECK(exported["recording"] == "U1E=");
CHECK(exported["tick"] == 2);
CHECK(exported["stateHash"] == "0000000000abcdef");
CHECK(exported["hashes"] == "AQAAAAAAAAAIBwYFBAMCAQ=="); // 1 puis 0x0102030405060708, petit-boutiste
CHECK_FALSE(request(session, "session/export")["payload"].contains("hashes"));
const json imported = request(session, "session/import", {{"recording", "U1E="}, {"resume", false}})["payload"];
CHECK(imported["running"] == false);
CHECK(imported["replayMs"] == 1.5);
CHECK(request(session, "session/import", {{"recording", "U1E"}, {"resume", true}})["payload"]["code"] == "invalid-recording");
CHECK(request(session, "session/import", {{"recording", "AAAA"}, {"resume", true}})["payload"]["code"] == "invalid-recording");
CHECK(request(session, "session/import", {{"recording", "U1E="}})["payload"]["code"] == "invalid-payload");
}
// --- Tests du rendu (protocole 0.3, §9) ----------------------------------------
TEST_CASE("Protocole : render/start valide")
{
FakeServices services;
auto session = ready_session(services);
const json result = request(session, "render/start",
{{"adapter", "debug"}, {"width", 1280}, {"height", 720}, {"headless", true}, {"fps", 0}},
"rs1");
CHECK(result["type"] == "render/start");
CHECK(result["id"] == "rs1");
const json& payload = result["payload"];
CHECK(payload["state"] == "starting");
CHECK(payload["adapter"] == "debug");
CHECK(payload["restarts"] == 0);
CHECK(payload["worldVersion"] == "00000000000001d5");
CHECK(services.last_render_start_request.adapter == "debug");
CHECK(services.last_render_start_request.width == 1280);
CHECK(services.last_render_start_request.height == 720);
CHECK(services.last_render_start_request.headless == true);
CHECK(services.last_render_start_request.fps == 0);
}
TEST_CASE("Protocole : render/start payloads invalides")
{
FakeServices services;
auto session = ready_session(services);
// adapter absent
CHECK(request(session, "render/start", {{"width", 1280}}, "id1")["payload"]["code"] == "invalid-payload");
// adapter inconnu
CHECK(request(session, "render/start", {{"adapter", "inconnu"}}, "id2")["payload"]["code"] == "invalid-payload");
// width hors bornes (< 64)
CHECK(request(session, "render/start", {{"adapter", "debug"}, {"width", 32}}, "id3")["payload"]["code"] == "invalid-payload");
// width hors bornes (> 7680)
CHECK(request(session, "render/start", {{"adapter", "debug"}, {"width", 8000}}, "id4")["payload"]["code"] == "invalid-payload");
// height hors bornes
CHECK(request(session, "render/start", {{"adapter", "debug"}, {"height", 32}}, "id5")["payload"]["code"] == "invalid-payload");
// headless non booléen
CHECK(request(session, "render/start", {{"adapter", "debug"}, {"headless", "oui"}}, "id6")["payload"]["code"] == "invalid-payload");
// fps non entier
CHECK(request(session, "render/start", {{"adapter", "debug"}, {"fps", 3.14}}, "id7")["payload"]["code"] == "invalid-payload");
// fps hors bornes
CHECK(request(session, "render/start", {{"adapter", "debug"}, {"fps", 250}}, "id8")["payload"]["code"] == "invalid-payload");
}
TEST_CASE("Protocole : render/start erreur render-running")
{
FakeServices services;
services.render_running = true;
auto session = ready_session(services);
const json result = request(session, "render/start", {{"adapter", "debug"}}, "rr1");
CHECK(result["type"] == "error");
CHECK(result["id"] == "rr1");
CHECK(result["payload"]["code"] == "render-running");
CHECK(result["payload"]["message"] == "Un rendu est déjà en cours.");
}
TEST_CASE("Protocole : render/start erreur render-unavailable")
{
FakeServices services;
services.render_unavailable = true;
auto session = ready_session(services);
const json result = request(session, "render/start", {{"adapter", "debug"}}, "ru1");
CHECK(result["type"] == "error");
CHECK(result["id"] == "ru1");
CHECK(result["payload"]["code"] == "render-unavailable");
CHECK(result["payload"]["message"] == "Rendu non disponible.");
}
TEST_CASE("Protocole : render/stop")
{
FakeServices services;
services.render_running = true;
services.last_restarts = 2;
services.last_world_version = 0xABCD;
services.last_failure = "adapter-start : timeout";
auto session = ready_session(services);
const json result = request(session, "render/stop", json::object(), "stop1");
CHECK(result["type"] == "render/stop");
CHECK(result["id"] == "stop1");
const json& payload = result["payload"];
CHECK(payload["state"] == "stopped");
CHECK(payload["restarts"] == 2);
CHECK(payload["worldVersion"] == "000000000000abcd");
CHECK(payload["lastFailure"] == "adapter-start : timeout");
// adapter, pid, engine, width, height, measurements absents
CHECK_FALSE(payload.contains("adapter"));
CHECK_FALSE(payload.contains("pid"));
CHECK_FALSE(payload.contains("engine"));
CHECK_FALSE(payload.contains("width"));
CHECK_FALSE(payload.contains("height"));
CHECK_FALSE(payload.contains("measurements"));
}
TEST_CASE("Protocole : render/status stopped par defaut")
{
FakeServices services;
auto session = ready_session(services);
const json result = request(session, "render/status", json::object(), "st1");
CHECK(result["type"] == "render/status");
CHECK(result["id"] == "st1");
const json& payload = result["payload"];
CHECK(payload["state"] == "stopped");
CHECK(payload["restarts"] == 0);
CHECK(payload["worldVersion"] == "00000000000001d5");
CHECK_FALSE(payload.contains("adapter"));
CHECK_FALSE(payload.contains("pid"));
CHECK_FALSE(payload.contains("engine"));
CHECK_FALSE(payload.contains("width"));
CHECK_FALSE(payload.contains("height"));
CHECK_FALSE(payload.contains("measurements"));
CHECK_FALSE(payload.contains("lastFailure"));
}
TEST_CASE("Protocole : render/status running avec tous les champs")
{
FakeServices services;
services.render_running = true;
services.last_adapter = "debug";
services.last_restarts = 1;
services.last_world_version = 0x1D5;
auto session = ready_session(services);
const json result = request(session, "render/status", json::object(), "st2");
CHECK(result["type"] == "render/status");
CHECK(result["id"] == "st2");
const json& payload = result["payload"];
CHECK(payload["state"] == "running");
CHECK(payload["adapter"] == "debug");
CHECK(payload["pid"] == 1234);
CHECK(payload["restarts"] == 1);
CHECK(payload["engine"]["name"] == "debug");
CHECK(payload["engine"]["version"] == "0.1");
CHECK(payload["engine"]["buffers"] == json::array({"color"}));
CHECK(payload["engine"]["windowed"] == true);
CHECK(payload["engine"]["headless"] == false);
CHECK(payload["engine"]["playerInput"] == false);
CHECK(payload["width"] == 1280);
CHECK(payload["height"] == 720);
CHECK(payload["measurements"]["framesPerSecond"] == 180);
CHECK(payload["measurements"]["cpuMsP95"] == 6.1);
CHECK(payload["measurements"]["latencyUsP95"] == 340);
CHECK(payload["measurements"]["applyUsP95"] == 5900);
CHECK(payload["worldVersion"] == "00000000000001d5");
CHECK_FALSE(payload.contains("lastFailure"));
}
TEST_CASE("Protocole : render/status running avec lastFailure")
{
FakeServices services;
services.render_running = true;
services.last_failure = "adapter-start : bibliothèque introuvable";
auto session = ready_session(services);
const json result = request(session, "render/status", json::object(), "st3");
const json& payload = result["payload"];
CHECK(payload["lastFailure"] == "adapter-start : bibliothèque introuvable");
}
TEST_CASE("Protocole : render/* sur le canal sequa-render -> unknown-type")
{
FakeServices services;
auto session = render_session(services);
CHECK(request(session, "render/start", {{"adapter", "debug"}})["payload"]["code"] == "unknown-type");
CHECK(request(session, "render/stop")["payload"]["code"] == "unknown-type");
CHECK(request(session, "render/status")["payload"]["code"] == "unknown-type");
// Les messages du rendu (render/ready, render/frames, etc.) fonctionnent toujours.
// Mais core/ping devrait aussi être unknown-type sur le canal de rendu.
CHECK(request(session, "core/ping")["payload"]["code"] == "unknown-type");
}