Files
SEQUA-Engine/core/tests/server/test_render_channel.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

332 lines
12 KiB
C++

// SPDX-License-Identifier: MPL-2.0
// Canal de rendu (spec Core - rendu 0.1) : poignee de main sequa-render, scene complete a
// render/ready, deltas apres chaque tick, un seul rendu a la fois. Sans reseau.
#include "render_hub.hpp"
#include "sequa/core/server/live_session.hpp"
#include "sequa/core/server/protocol.hpp"
#include "sequa/core/server/runtime.hpp"
#include "sequa/core/sim/input.hpp"
#include "sequa/3d/scene.hpp"
#include "sequa/3d/wire.hpp"
#include <doctest/doctest.h>
#include <nlohmann/json.hpp>
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
using namespace sequa::core::server;
using nlohmann::json;
namespace three_d = sequa::three_d;
namespace {
struct Outbox {
std::mutex mutex;
std::vector<json> messages;
PushFn push()
{
return [this](std::string text) {
const std::lock_guard lock(mutex);
messages.push_back(json::parse(text));
};
}
std::vector<json> take()
{
const std::lock_guard lock(mutex);
std::vector<json> out;
out.swap(messages);
return out;
}
};
CoreRuntimeOptions offline()
{
CoreRuntimeOptions options;
options.real_time = false; // aucun fil de simulation
options.core_version = "test";
return options;
}
const std::string render_hello = encode_hello(render_protocol_version, render_client_name);
RenderReady ready_payload()
{
RenderReady ready;
ready.capabilities.engine_name = "debug";
ready.capabilities.engine_version = "0.1";
ready.capabilities.buffers = three_d::BufferSet{three_d::FrameBuffer::color};
ready.capabilities.headless = true;
ready.width = 320;
ready.height = 180;
return ready;
}
std::string ready_message()
{
const RenderReady ready = ready_payload();
return json{{"v", 1},
{"type", "render/ready"},
{"payload",
{{"capabilities", three_d::wire::encode_capabilities(ready.capabilities)},
{"width", ready.width},
{"height", ready.height}}}}
.dump();
}
three_d::wire::SceneDeltaMessage decode(const json& message)
{
REQUIRE(message["type"] == "scene/delta");
const auto delta = three_d::wire::decode_scene_delta(message["payload"]);
REQUIRE(delta.has_value());
return *delta;
}
} // namespace
TEST_CASE("Canal de rendu : poignee de main puis scene complete a render/ready")
{
CoreRuntime runtime(offline());
Outbox outbox;
ProtocolSession session(runtime, 7, outbox.push());
const Reply welcome = session.on_text(render_hello);
REQUIRE(welcome.messages.size() == 1);
const json w = json::parse(welcome.messages.front());
CHECK(w["type"] == "welcome");
CHECK(w["payload"]["protocolVersion"] == std::string(render_protocol_version));
CHECK(w["payload"]["renderId"].get<std::string>().size() == 32);
CHECK(outbox.take().empty()); // rien avant render/ready
CHECK(session.on_text(ready_message()).messages.empty()); // notification, sans reponse
const std::vector<json> pushed = outbox.take();
REQUIRE(pushed.size() == 1);
const auto full = decode(pushed[0]);
CHECK(full.full);
three_d::SceneState mirror;
CHECK_FALSE(mirror.apply(full.delta).has_value());
CHECK(mirror.objects().size() >= 2); // sol et joueur au moins
const RenderHub::Status status = runtime.render_hub().status();
CHECK(status.connected);
CHECK(status.ready);
CHECK(status.deltas_sent == 1);
}
TEST_CASE("Canal de rendu : un seul rendu a la fois, place liberee a la deconnexion")
{
CoreRuntime runtime(offline());
Outbox first_box;
Outbox second_box;
ProtocolSession first(runtime, 1, first_box.push());
ProtocolSession second(runtime, 2, second_box.push());
CHECK(json::parse(first.on_text(render_hello).messages.front())["type"] == "welcome");
const Reply refused = second.on_text(render_hello);
CHECK(refused.close);
CHECK(json::parse(refused.messages.front())["payload"]["code"] == "render-exists");
runtime.on_client_connected();
runtime.on_client_disconnected(1);
ProtocolSession third(runtime, 3, second_box.push());
CHECK(json::parse(third.on_text(render_hello).messages.front())["type"] == "welcome");
}
TEST_CASE("Canal de rendu : un studio ne peut pas devenir rendu sans fonction d'envoi")
{
CoreRuntime runtime(offline());
ProtocolSession session(runtime, 1); // pas de push : connexion sans envoi spontane
const Reply refused = session.on_text(render_hello);
CHECK(refused.close);
}
namespace {
std::string input_message(const nlohmann::json& payload)
{
return json{{"v", 1}, {"type", "render/input"}, {"payload", payload}}.dump();
}
std::string input_message(std::uint32_t buttons, int move_x, int move_y)
{
three_d::wire::PlayerInputMessage message;
message.input = three_d::PlayerInput{buttons, static_cast<std::int16_t>(move_x), static_cast<std::int16_t>(move_y)};
message.captured_at_us = 1;
return input_message(three_d::wire::encode_player_input(message));
}
// Session de reference sans rendu : memes entrees par session/input.
LiveSession reference_session()
{
LiveSession::Options options;
options.real_time = false;
return LiveSession(options);
}
} // namespace
TEST_CASE("Canal de rendu : render/input s'applique comme session/input et s'enregistre")
{
CoreRuntime runtime(offline());
Outbox outbox;
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
REQUIRE(render.on_text(ready_message()).messages.empty());
CHECK(render.on_text(input_message(sequa::core::sim::button::jump, 32767, 0)).messages.empty());
(void)runtime.live_session().advance(20);
CHECK(render.on_text(input_message(0, -12000, 0)).messages.empty());
(void)runtime.live_session().advance(20);
LiveSession reference = reference_session();
(void)reference.set_input(sequa::core::sim::button::jump, 32767, 0);
(void)reference.advance(20);
(void)reference.set_input(0, -12000, 0);
(void)reference.advance(20);
const SessionStateView got = runtime.session_status();
CHECK(got.tick == 40);
CHECK(got.input_count > 0);
CHECK(got.state_hash == reference.status().state_hash);
CHECK(runtime.render_hub().status().inputs_received == 2);
// Le rejeu de l'enregistrement exporte retrouve le meme etat.
const SessionExportData exported = runtime.session_export();
LiveSession replayed = reference_session();
const auto imported = replayed.import_session(exported.recording, false);
REQUIRE(imported.has_value());
CHECK(imported->state.state_hash == got.state_hash);
}
TEST_CASE("Canal de rendu : entree remise au neutre quand le rendu disparait")
{
CoreRuntime runtime(offline());
Outbox outbox;
runtime.on_client_connected();
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
REQUIRE(render.on_text(ready_message()).messages.empty());
REQUIRE(render.on_text(input_message(0, 32767, 0)).messages.empty()); // le joueur court
(void)runtime.live_session().advance(10);
runtime.on_client_disconnected(7);
(void)runtime.live_session().advance(30);
LiveSession reference = reference_session();
(void)reference.set_input(0, 32767, 0);
(void)reference.advance(10);
(void)reference.set_input(0, 0, 0);
(void)reference.advance(30);
CHECK(runtime.session_status().state_hash == reference.status().state_hash);
}
TEST_CASE("Canal de rendu : entree neutre du rendu, l'entree de Studio n'est pas ecrasee a la deconnexion")
{
CoreRuntime runtime(offline());
Outbox outbox;
runtime.on_client_connected();
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
REQUIRE(render.on_text(input_message(0, 0, 0)).messages.empty());
(void)runtime.session_input(0, 20000, 0); // Studio
runtime.on_client_disconnected(7);
(void)runtime.live_session().advance(10);
LiveSession reference = reference_session();
(void)reference.set_input(0, 20000, 0);
(void)reference.advance(10);
CHECK(runtime.session_status().state_hash == reference.status().state_hash);
}
TEST_CASE("Canal de rendu : render/input invalide refuse, entree inchangee")
{
CoreRuntime runtime(offline());
Outbox outbox;
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
const Reply refused = render.on_text(
input_message(json{{"buttons", 0}, {"moveX", 40000}, {"moveY", 0}, {"capturedAtUs", "0"}}));
REQUIRE(refused.messages.size() == 1);
const json error = json::parse(refused.messages.front());
CHECK(error["type"] == "error");
CHECK(error["payload"]["code"] == "invalid-payload");
CHECK_FALSE(refused.close);
CHECK(runtime.render_hub().status().inputs_received == 0);
(void)runtime.live_session().advance(5);
CHECK(runtime.session_status().input_count == 0);
}
TEST_CASE("Canal de rendu : render/input refuse sur une connexion Studio")
{
CoreRuntime runtime(offline());
ProtocolSession studio(runtime, 1);
REQUIRE(json::parse(studio.on_text(encode_hello(protocol_version, "sequa-studio")).messages.front())["type"] ==
"welcome");
const Reply refused = studio.on_text(input_message(0, 100, 0));
REQUIRE(refused.messages.size() == 1);
CHECK(json::parse(refused.messages.front())["type"] == "error");
CHECK(runtime.render_hub().status().inputs_received == 0);
}
TEST_CASE("Canal de rendu : entree d'un ancien canal ignoree")
{
RenderHub hub(sequa::core::sim::ArenaParams{});
Outbox outbox;
REQUIRE(hub.attach(1, outbox.push()).has_value());
CHECK(hub.accept_input(1, three_d::PlayerInput{0, 5, 0}));
CHECK(hub.disconnected(1)); // entree non neutre maintenue
REQUIRE(hub.attach(2, outbox.push()).has_value());
CHECK_FALSE(hub.accept_input(1, three_d::PlayerInput{0, 5, 0}));
CHECK_FALSE(hub.disconnected(1));
CHECK_FALSE(hub.disconnected(2)); // le nouveau canal n'a rien maintenu
}
TEST_CASE("Canal de rendu : un delta applicable par tick qui change la scene, versions croissantes")
{
LiveSession::Options options;
options.real_time = false;
LiveSession session(options);
RenderHub hub(sequa::core::sim::ArenaParams{});
session.set_world_observer(
[&hub](const sequa::core::sim::WorldState& world, std::uint64_t tick) { hub.on_world(world, tick); });
Outbox outbox;
REQUIRE(hub.attach(1, outbox.push()).has_value());
(void)session.advance(5);
CHECK(outbox.take().empty()); // pas encore pret : rien n'est pousse
REQUIRE(hub.ready(1, ready_payload()));
session.notify_world();
std::vector<json> pushed = outbox.take();
REQUIRE(pushed.size() == 1);
three_d::SceneState mirror;
const auto full = decode(pushed[0]);
CHECK(full.full);
REQUIRE_FALSE(mirror.apply(full.delta).has_value());
(void)session.set_input(0, 32767, 0); // le joueur court vers la droite
(void)session.advance(60);
pushed = outbox.take();
CHECK(pushed.size() > 10);
std::uint64_t last_tick = full.tick;
for (const json& message : pushed) {
const auto delta = decode(message);
CHECK_FALSE(delta.full);
CHECK(delta.tick > last_tick);
last_tick = delta.tick;
REQUIRE_FALSE(mirror.apply(delta.delta).has_value());
}
CHECK(hub.status().world_version == mirror.world_version());
CHECK_FALSE(hub.disconnected(1)); // aucune entree recue du rendu
(void)session.advance(10);
CHECK(outbox.take().empty()); // plus rien apres la deconnexion
}