Files
Faset_Engine/src/editor/session.cpp
T
emil28092005 4cc58d8c59
Native and manual checks / native (ubuntu-24.04) (push) Canceled after 0s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Native and manual checks / manual (push) Canceled after 0s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Configure LuaLS for single-file script workspaces
2026-09-18 11:57:23 +03:00

629 lines
32 KiB
C++

#include <algorithm>
#include <chrono>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/editor/session.hpp>
#include <faset/scripting/project.hpp>
namespace faset::editor {
namespace {
std::string executable_name(const std::string& name) {
#ifdef _WIN32
return name + ".exe";
#else
return name;
#endif
}
BuildConfig build_config(const SessionConfig& config) {
BuildConfig result;
result.project_root = config.project_root;
result.engine_root = config.engine_root;
result.build_directory = config.project_root / ".faset/build";
result.cache_root = config.project_root / ".faset/cache";
return result;
}
Json resolved_or_throw(Commands& commands, const std::string& id) {
const auto resolved = commands.resolved_scene(id);
if (!resolved.at("conflicts").empty())
throw Error("template.conflicts", "Resolve template conflicts before Play or export",
{{"conflicts", resolved.at("conflicts")}});
return resolved.at("scene");
}
} // namespace
struct Session::ImportTask {
std::string id;
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
mutable std::mutex mutex;
std::string state = "queued", error;
Json result = Json::object(), request = Json::object();
Json json() const {
std::lock_guard lock(mutex);
const auto progress = job->progress();
return {{"id", id},
{"kind", "import"},
{"state", state},
{"stage", progress.stage},
{"progress", progress.fraction},
{"error", error},
{"request", request},
{"result", result}};
}
};
Session::Session(SessionConfig config)
: config_(std::move(config)), authoring_(config_.project_root), commands_(authoring_),
assets_(config_.project_root / ".faset/cache"), builds_(build_config(config_)) {
register_commands();
plugins_ = std::make_unique<PluginManager>(
commands_, [this](std::string message) { log(std::move(message)); });
plugins_->load(config_.project_root / "Plugins");
const auto schema = config_.project_root / ".faset/schema.json";
if (std::filesystem::exists(schema))
try {
load_schema(schema);
const auto state = config_.project_root / ".faset/schema-state.json";
if (std::filesystem::exists(state))
schema_source_signature_ =
read_json(state).value("source_signature", std::string());
} catch (const std::exception& error) {
log(std::string("Schema load failed: ") + error.what());
}
}
Session::~Session() {
for (const auto& [id, task] : imports_)
task->job->cancel();
workers_.clear();
stop_player();
}
void Session::log(std::string value) {
if (value.empty())
return;
logs_.push_back(std::move(value));
if (logs_.size() > 1000)
logs_.erase(logs_.begin(), logs_.begin() + 100);
}
Json Session::project() const {
const auto path = config_.project_root / "project.faset.json";
if (std::filesystem::exists(path)) {
const auto value = read_json(path);
require(value.is_object() && value.value("format", "") == "faset.project" &&
value.value("version", 0) == 1,
"project.version", "Unsupported project format or version");
require(value.contains("name") && value.at("name").is_string() &&
!value.at("name").get<std::string>().empty(),
"project.name", "Project name must be a nonempty string");
const auto dimension = value.value("dimension", 3);
require(dimension == 2 || dimension == 3, "project.dimension",
"Project dimension must be 2 or 3");
if (value.contains("start_scene")) {
require(value.at("start_scene").is_string(), "project.start_scene",
"Project start_scene must be a relative path");
const auto scene = value.at("start_scene").get<std::string>();
if (!scene.empty())
project_path(config_.project_root, path_from_utf8(scene));
}
return value;
}
return {{"format", "faset.project"},
{"version", 1},
{"name", path_to_utf8(config_.project_root.filename())},
{"dimension", 3}};
}
void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension);
log("Created project: " + name);
}
Json Session::assets_list() const {
Json list = Json::array();
const auto directory = assets_.cache_root() / "assets";
if (std::filesystem::exists(directory))
for (const auto& entry : std::filesystem::directory_iterator(directory))
if (entry.is_directory()) {
try {
const auto id = path_to_utf8(entry.path().filename());
const auto manifest = assets_.current_manifest(id);
list.push_back(
{{"id", id}, {"manifest", manifest}, {"freshness", assets_.freshness(id)}});
} catch (const std::exception& error) {
list.push_back(
{{"id", path_to_utf8(entry.path().filename())}, {"error", error.what()}});
}
}
return {{"assets", list}};
}
std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root);
const auto directory = config_.project_root / "Scripts";
std::vector<std::filesystem::path> files;
if (std::filesystem::exists(directory))
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua"))
files.push_back(file.path());
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
sha256_file(file) + "\n";
if (lua.enabled())
contents += "lua:" + lua.fingerprint + "\n";
return sha256(contents);
}
Json Session::schema_status() const {
try {
const auto signature = source_signature();
return {{"loaded", schema_loaded_},
{"stale", !schema_loaded_ || schema_source_signature_ != signature ||
!schema_error_.empty()},
{"error", schema_error_}};
} catch (const std::exception& error) {
return {{"loaded", schema_loaded_}, {"stale", true}, {"error", error.what()}};
}
}
Json Session::jobs() const {
Json list = Json::array();
for (const auto& item : builds_.jobs())
list.push_back(item.json());
for (const auto& [id, task] : imports_)
list.push_back(task->json());
return {{"jobs", list}};
}
Json Session::job(const std::string& id) const {
if (imports_.contains(id))
return imports_.at(id)->json();
return builds_.job(id).json();
}
void Session::load_schema(const std::filesystem::path& path) {
const auto value = read_json(path);
authoring_.replace_external_schemas(value);
const auto output = config_.project_root / ".faset/schema.json";
if (std::filesystem::weakly_canonical(path) != std::filesystem::weakly_canonical(output))
atomic_write_json(output, value);
schema_loaded_ = true;
schema_error_.clear();
log("Gameplay schema loaded");
}
void Session::launch_player(Json scene, const std::filesystem::path& executable) {
require(std::filesystem::is_regular_file(executable), "play.missing_player",
"Build the Player before starting Play");
const auto directory = config_.project_root / ".faset/play" / new_id();
std::filesystem::create_directories(directory);
const auto snapshot = directory / "scene.fscene";
write_cooked_scene(snapshot, scene);
control_path_ = directory / "control.json";
control_sequence_ = 0;
ProcessOptions options;
options.arguments = {
path_to_utf8(executable), "--scene", path_to_utf8(snapshot), "--assets",
path_to_utf8(assets_.cache_root()), "--control", path_to_utf8(control_path_)};
if (scripting::loadLuaProject(config_.project_root).enabled()) {
options.arguments.insert(options.arguments.end(),
{"--project", path_to_utf8(config_.project_root), "--watch-lua"});
}
options.working_directory = config_.project_root;
player_ = std::make_unique<Process>(options);
log("Play started in a separate Player process; Lua projects reload changed scripts and reset "
"simulation state automatically");
}
void Session::stop_player() {
if (!pending_play_job_.empty()) {
builds_.cancel(pending_play_job_);
pending_play_job_.clear();
pending_play_scene_ = nullptr;
}
if (player_) {
// Allow the normal Player shutdown path (including gameplay OnDestroy) first.
// Keep Stop synchronous and bounded so starting a new Play cannot overlap this one.
bool finished = false;
try {
atomic_write_json(control_path_,
{{"sequence", ++control_sequence_}, {"command", "stop"}});
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
for (;;) {
const auto result = player_->poll();
log(result.output);
if (!result.running) {
finished = true;
break;
}
if (std::chrono::steady_clock::now() >= deadline)
break;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
} catch (const std::exception& error) {
log(std::string("Graceful Player stop failed: ") + error.what());
}
if (!finished) {
log("Player did not finish graceful shutdown; terminating the process");
player_->cancel();
log(player_->poll().output);
}
player_.reset();
control_path_.clear();
log("Play stopped; authoring scene unchanged");
}
}
void Session::poll() {
if (player_) {
const auto result = player_->poll();
log(result.output);
if (!result.running) {
log("Player exited with code " + std::to_string(result.exit_code.value_or(-1)));
player_.reset();
}
}
for (const auto& value : builds_.jobs()) {
if (observed_jobs_[value.id] == value.state)
continue;
observed_jobs_[value.id] = value.state;
bool schema_valid = true;
if (value.state == "failed") {
schema_error_ = value.error;
log(value.kind + " failed: " + value.error +
"; previous gameplay metadata remains available and is marked stale");
}
if (value.state == "succeeded") {
log(value.kind + " completed");
if (value.result.contains("schema"))
try {
load_schema(path_from_utf8(value.result.at("schema").get<std::string>()));
// This signature represents the sources submitted with this job, not later
// edits.
if (value.result.contains("source_signature"))
schema_source_signature_ =
value.result.at("source_signature").get<std::string>();
else if (submitted_sources_.contains(value.id))
schema_source_signature_ = submitted_sources_.at(value.id);
atomic_write_json(config_.project_root / ".faset/schema-state.json",
{{"source_signature", schema_source_signature_}});
} catch (const std::exception& error) {
schema_valid = false;
schema_error_ = error.what();
log(std::string("Schema update failed: ") + error.what());
}
}
if (value.id == pending_play_job_ && value.finished()) {
pending_play_job_.clear();
if (value.state == "succeeded" && schema_valid)
try {
const auto executable =
value.result.value("player", path_to_utf8(builds_.config().build_directory /
executable_name("faset_player")));
launch_player(pending_play_scene_, path_from_utf8(executable));
} catch (const std::exception& error) {
log(std::string("Play failed: ") + error.what());
}
pending_play_scene_ = nullptr;
}
}
for (const auto& [id, task] : imports_) {
const auto value = task->json();
const auto status = value.at("state").get<std::string>();
if (observed_jobs_[id] == status)
continue;
observed_jobs_[id] = status;
if (status == "succeeded")
log("Asset import completed: " + value["result"].value("asset_id", std::string()));
if (status == "failed" || status == "conflict")
log("Asset import " + status + ": " + value.value("error", std::string()));
}
}
void Session::register_commands() {
const Json text = {{"type", "string"}}, boolean = {{"type", "boolean"}};
auto schema = [](Json properties, Json required = Json::array()) {
return Commands::object_schema(std::move(properties), std::move(required));
};
commands_.add(
"faset_capabilities", "Inspect available Editor services and rendering capabilities.",
schema(Json::object()),
[&](const Json&) {
bool screenshot = false;
for (const auto& command : commands_.list())
if (command.at("name") == "faset_editor_capture")
screenshot = true;
return Json{{"authoring", true},
{"build", true},
{"import", true},
{"plugins", true},
{"viewport_capture", screenshot},
{"runtime_entity_access", false},
{"protocol", "2025-06-18"}};
},
true);
commands_.add(
"faset_plugins", "Inspect startup-loaded Editor plugins and exact SDK compatibility.",
schema(Json::object()),
[&](const Json&) {
return Json{{"sdk_fingerprint", PluginManager::fingerprint()},
{"plugins", plugins_->status()},
{"panels", plugins_->panels()}};
},
true);
commands_.add(
"faset_project", "Read the authoring project's settings.", schema(Json::object()),
[&](const Json&) { return project(); }, true);
commands_.add(
"faset_project_settings_get", "Read project settings and their content revision.",
schema(Json::object()),
[&](const Json&) {
const auto value = project();
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
},
true);
commands_.add(
"faset_project_settings_set",
"Save project name, initial scene dimension or start scene with an expected content "
"revision. Applies on the next project open; does not change the active scene or its Undo "
"history.",
schema({{"revision", text}, {"settings", {{"type", "object"}}}}, {"revision", "settings"}),
[&](const Json& args) {
auto value = project();
require(args.at("revision") == sha256(value.dump()), "revision.conflict",
"Project settings changed; reload them before saving");
const auto& changes = args.at("settings");
for (const auto& [key, field] : changes.items()) {
require(key == "name" || key == "dimension" || key == "start_scene",
"project.setting", "Unknown editable project setting: " + key);
if (key == "name")
require(field.is_string() && !field.get<std::string>().empty(), "project.name",
"Project name must be a nonempty string");
else if (key == "dimension")
require(field.is_number_integer() && (field == 2 || field == 3),
"project.dimension", "Initial scene dimension must be 2 or 3");
else {
require(field.is_string() && !field.get<std::string>().empty(),
"project.start_scene", "Choose a saved scene inside the project");
const auto file = project_path(config_.project_root,
path_from_utf8(field.get<std::string>()));
require(std::filesystem::is_regular_file(file), "project.start_scene",
"Save the start scene before selecting it in Project settings");
const auto scene = read_json(file);
require(scene.value("format", "") == "faset.scene" &&
scene.value("version", 0) == 1,
"project.start_scene",
"The start scene must be a supported Faset scene");
}
value[key] = field;
}
if (!value.contains("id"))
value["id"] = new_id();
atomic_write_json(config_.project_root / "project.faset.json", value);
log("Project settings saved; changes apply on next project open");
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
});
commands_.add(
"faset_assets", "List imported assets with source/dependency/recipe freshness and reasons.",
schema(Json::object()), [&](const Json&) { return assets_list(); }, true);
commands_.add(
"faset_import",
"Import PNG/JPEG, GLB/glTF, or a Blender export manifest relative to this project. Returns "
"a cancellable job ID; failure retains the last successful generation.",
schema({{"path", text},
{"settings", {{"type", "object"}}},
{"allow_removed_outputs", boolean},
{"expected_generation", text},
{"expected_active_generation", text}},
{"path"}),
[&](const Json& args) {
assets::ImportRequest request;
request.source = project_path(config_.project_root,
path_from_utf8(args.at("path").get<std::string>()));
request.settings = args.value("settings", Json(nullptr));
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
request.expected_generation = args.value("expected_generation", std::string());
request.expected_active_generation =
args.value("expected_active_generation", std::string());
auto task = std::make_shared<ImportTask>();
task->id = "import-" + new_id();
task->request = {{"path", generic_path_to_utf8(std::filesystem::relative(
request.source, config_.project_root))},
{"settings", request.settings},
{"allow_removed_outputs", request.allow_removed_outputs}};
imports_[task->id] = task;
workers_.emplace_back([this, task, request] {
{
std::lock_guard lock(task->mutex);
task->state = "running";
}
try {
const auto result = assets_.import_asset(request, *task->job);
std::lock_guard lock(task->mutex);
task->state = result.status == assets::ImportStatus::succeeded ? "succeeded"
: result.status == assets::ImportStatus::cancelled ? "cancelled"
: result.status == assets::ImportStatus::conflict ? "conflict"
: "failed";
task->result = {{"asset_id", result.asset_id},
{"generation", result.generation},
{"previous_generation", result.previous_generation},
{"removed_output_ids", result.removed_output_ids},
{"diagnostics", result.diagnostics},
{"cache_hit", result.cache_hit},
{"manifest", result.manifest}};
for (const auto& message : result.diagnostics)
task->error += message + "\n";
} catch (const std::exception& error) {
std::lock_guard lock(task->mutex);
task->state = "failed";
task->error = error.what();
}
});
return Json{{"job", task->id}};
});
commands_.add(
"faset_schema_status",
"Report whether the last successful gameplay schema matches current project scripts. "
"Failed builds retain metadata but mark it stale.",
schema(Json::object()), [&](const Json&) { return schema_status(); }, true);
commands_.add("faset_build",
"Incrementally compile gameplay and export C++/Lua metadata in separate native "
"processes. Returns a job ID.",
schema(Json::object()), [&](const Json&) {
const auto signature = source_signature();
const auto id = builds_.start_build();
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
commands_.add(
"faset_lua_refresh",
"Validate Lua behavior schemas in a separate process and refresh Inspector metadata. "
"Uses the incremental build; script edits do not require C++ compilation. Returns a job "
"ID.",
schema(Json::object()), [&](const Json&) {
require(scripting::loadLuaProject(config_.project_root).enabled(), "lua.disabled",
"Declare scripting.lua.scripts in project.faset.json first");
const auto signature = source_signature();
const auto id = builds_.start_build();
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
commands_.add("faset_lua_reload",
"Reload Lua in the running development Player. Successful reload resets the Play "
"snapshot and script state; invalid edits keep the current running version.",
schema(Json::object()), [&](const Json&) {
require(bool(player_), "play.not_running", "Player is not running");
require(scripting::loadLuaProject(config_.project_root).enabled(),
"lua.disabled", "The current project has no Lua behaviors");
atomic_write_json(control_path_, {{"sequence", ++control_sequence_},
{"command", "reload-lua"}});
return Json{{"queued", true}};
});
commands_.add(
"faset_lua_setup",
"Install Faset LuaLS type annotations in .faset/lua and create .luarc.json only if absent. "
"Existing user language-server configuration is never overwritten.",
schema(Json::object()), [&](const Json&) {
const auto source = config_.engine_root / "tools/lua";
const auto annotations = project_path(config_.project_root, ".faset/lua/faset.lua");
const auto configuration = project_path(config_.project_root, ".luarc.json");
const auto scripts_configuration =
project_path(config_.project_root, "Scripts/.luarc.json");
atomic_write(annotations, read_text(source / "faset.lua"));
const bool create_configuration = !std::filesystem::exists(configuration);
if (create_configuration)
atomic_write_json(configuration, read_json(source / "luarc.json"));
// Zed can open a script as a single-file workspace rooted at Scripts.
// Give that workspace its own relative path to the API declarations.
const bool create_scripts_configuration =
!std::filesystem::exists(scripts_configuration);
if (create_scripts_configuration)
atomic_write_json(scripts_configuration,
read_json(source / "luarc-scripts.json"));
log(create_configuration
? "LuaLS configured: .luarc.json and .faset/lua/faset.lua"
: "LuaLS annotations updated; existing .luarc.json preserved. Add .faset/lua "
"to workspace.library if needed");
return Json{{"annotations", ".faset/lua/faset.lua"},
{"configuration_created", create_configuration},
{"scripts_configuration_created", create_scripts_configuration}};
});
commands_.add(
"faset_script_open",
"Open a project Lua source in an external editor, never as an executable. Optional editor "
"is an argv array (default: project editor.script_editor, then zed). Exact {file} and "
"{project} arguments are replaced; no shell expansion is performed.",
schema({{"path", text}, {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
{"path"}),
[&](const Json& args) {
const auto file = project_path(config_.project_root,
path_from_utf8(args.at("path").get<std::string>()));
require(file.extension() == ".lua" && std::filesystem::is_regular_file(file),
"lua.source", "Select an existing .lua file inside the project");
Json command = Json::array({"zed", "{file}"});
const auto settings = project();
if (settings.contains("editor") && settings.at("editor").is_object() &&
settings.at("editor").contains("script_editor"))
command = settings.at("editor").at("script_editor");
if (args.contains("editor"))
command = args.at("editor");
require(command.is_array() && !command.empty(), "lua.editor",
"Configure editor.script_editor as a nonempty executable/argument array");
std::vector<std::string> arguments;
bool has_file = false;
for (const auto& part : command) {
require(part.is_string(), "lua.editor", "Editor arguments must be strings");
auto argument = part.get<std::string>();
require(argument.find('\0') == std::string::npos, "lua.editor",
"Editor arguments cannot contain NUL");
if (argument == "{file}") {
require(!arguments.empty(), "lua.editor", "The first argument is the editor");
argument = path_to_utf8(file);
has_file = true;
} else if (argument == "{project}") {
require(!arguments.empty(), "lua.editor", "The first argument is the editor");
argument = path_to_utf8(std::filesystem::absolute(config_.project_root));
}
arguments.push_back(std::move(argument));
}
require(!arguments.front().empty(), "lua.editor", "Choose an editor executable");
if (!has_file)
arguments.push_back(path_to_utf8(file));
launch_detached(arguments, config_.project_root);
log("Opened Lua source in external editor: " + args.at("path").get<std::string>());
return Json{{"opened", args.at("path")}};
});
commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.",
schema({{"document", text}, {"output", text}}, {"document", "output"}),
[&](const Json& args) {
const auto signature = source_signature();
const auto id = builds_.start_export(
resolved_or_throw(commands_, args.at("document")),
project_path(config_.project_root,
path_from_utf8(args.at("output").get<std::string>())));
submitted_sources_[id] = signature;
return Json{{"job", id}};
});
commands_.add(
"faset_jobs", "List editor import/build/export jobs and their progress.",
schema(Json::object()), [&](const Json&) { return jobs(); }, true);
commands_.add(
"faset_job", "Read an editor job's progress, result and diagnostics.",
schema({{"id", text}}, {"id"}), [&](const Json& args) { return job(args.at("id")); }, true);
commands_.add("faset_job_cancel",
"Cancel an editor job. Cancellation is separate from authoring Undo.",
schema({{"id", text}}, {"id"}), [&](const Json& args) {
const auto id = args.at("id").get<std::string>();
if (imports_.contains(id))
imports_.at(id)->job->cancel();
else
builds_.cancel(id);
return Json{{"cancel_requested", true}};
});
commands_.add("faset_play",
"Build gameplay, then start a separate Player from an immutable snapshot of the "
"current authoring document. Returns the build job ID.",
schema({{"document", text}}, {"document"}), [&](const Json& args) {
stop_player();
pending_play_scene_ = resolved_or_throw(commands_, args.at("document"));
const auto signature = source_signature();
pending_play_job_ = builds_.start_build();
submitted_sources_[pending_play_job_] = signature;
return Json{{"job", pending_play_job_}, {"play_pending", true}};
});
commands_.add(
"faset_stop",
"Stop editor Play or cancel its pending build. Does not modify the authoring scene.",
schema(Json::object()), [&](const Json&) {
stop_player();
return Json{{"stopped", true}};
});
commands_.add("faset_play_control",
"Pause, resume, or single-step the Editor's Player session. No game entities or "
"state are exposed.",
schema({{"command", {{"type", "string"}, {"enum", {"pause", "resume", "step"}}}}},
{"command"}),
[&](const Json& args) {
require(bool(player_), "play.not_running", "Player is not running");
const auto command = args.at("command").get<std::string>();
require(command == "pause" || command == "resume" || command == "step",
"play.command", "Unknown Play control");
atomic_write_json(control_path_,
{{"sequence", ++control_sequence_}, {"command", command}});
return Json{{"queued", true}};
});
commands_.add(
"faset_editor_logs",
"Read compiler, importer and process diagnostics collected by this Editor session.",
schema(Json::object()), [&](const Json&) { return Json{{"logs", logs_}}; }, true);
}
} // namespace faset::editor