diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98d679777..9928a0eae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,15 +35,17 @@ jobs: packages: "libsqlite3-dev" run_tests: true - - name: "Base + Admin API" - cmake_flags: "-DCMAKE_BUILD_TYPE=Release -DENABLE_ADMIN_API=ON" + - name: "Base + Admin API + OTEL" + cmake_flags: "-DCMAKE_BUILD_TYPE=Release -DENABLE_ADMIN_API=ON -DWITH_OTEL=ON -DCMAKE_PREFIX_PATH=/opt/opentelemetry-cpp" packages: "" run_tests: true + with_otel: true - - name: "YAML + Admin API" - cmake_flags: "-DCMAKE_BUILD_TYPE=Release -DHAVE_YAML=ON -DENABLE_ADMIN_API=ON" + - name: "YAML + Admin API + OTEL" + cmake_flags: "-DCMAKE_BUILD_TYPE=Release -DHAVE_YAML=ON -DENABLE_ADMIN_API=ON -DWITH_OTEL=ON -DCMAKE_PREFIX_PATH=/opt/opentelemetry-cpp" packages: "libyaml-cpp-dev" run_tests: true + with_otel: true steps: - uses: actions/checkout@v4 @@ -52,6 +54,7 @@ jobs: - name: Install base dependencies run: | + sudo add-apt-repository universe -y sudo apt-get update sudo apt-get install -y \ build-essential \ @@ -72,6 +75,36 @@ jobs: run: | sudo apt-get install -y ${{ matrix.config.packages }} + - name: Install opentelemetry-cpp runtime dependencies + if: matrix.config.with_otel + run: | + sudo apt-get install -y libprotobuf-dev + + - name: Cache opentelemetry-cpp + if: matrix.config.with_otel + id: cache-otel + uses: actions/cache@v4 + with: + path: /opt/opentelemetry-cpp + key: otel-cpp-1.24.0-ubuntu-x64 + + - name: Build opentelemetry-cpp + if: matrix.config.with_otel && steps.cache-otel.outputs.cache-hit != 'true' + run: | + sudo apt-get install -y libgrpc++-dev protobuf-compiler-grpc || true + wget -q https://github.com/open-telemetry/opentelemetry-cpp/archive/refs/tags/v1.24.0.tar.gz + tar xzf v1.24.0.tar.gz + cmake -S opentelemetry-cpp-1.24.0 -B otel-build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/opt/opentelemetry-cpp \ + -DWITH_OTLP_HTTP=ON \ + -DWITH_OTLP_GRPC=OFF \ + -DBUILD_TESTING=OFF \ + -DWITH_BENCHMARK=OFF \ + -DWITH_EXAMPLES=OFF + cmake --build otel-build -j$(nproc) + sudo cmake --install otel-build + - name: Configure CMake run: | mkdir -p build @@ -271,7 +304,15 @@ jobs: git wget + - name: Cache Cygwin libraries + id: cache-cygwin-libs + uses: actions/cache@v4 + with: + path: C:\cygwin\usr\local + key: cygwin-gtest-1.14.0 + - name: Build and install googletest from source + if: steps.cache-cygwin-libs.outputs.cache-hit != 'true' shell: C:\cygwin\bin\bash.exe --login --norc -eo pipefail -o igncr '{0}' run: | cd /tmp @@ -330,7 +371,15 @@ jobs: git wget + - name: Cache Cygwin libraries + id: cache-cygwin-libs + uses: actions/cache@v4 + with: + path: C:\cygwin\usr\local + key: cygwin-gtest-1.14.0-yaml-0.7.0 + - name: Build and install googletest from source + if: steps.cache-cygwin-libs.outputs.cache-hit != 'true' shell: C:\cygwin\bin\bash.exe --login --norc -eo pipefail -o igncr '{0}' run: | cd /tmp @@ -343,6 +392,7 @@ jobs: make install - name: Build and install yaml-cpp from source + if: steps.cache-cygwin-libs.outputs.cache-hit != 'true' shell: C:\cygwin\bin\bash.exe --login --norc -eo pipefail -o igncr '{0}' run: | cd /tmp diff --git a/CLAUDE.md b/CLAUDE.md index 1c2cfaf12..642d2d69e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -351,10 +351,22 @@ Use separate build directories for different CMake configurations to avoid lengt build/ - default build (without optional features) build_sqlite/ - build with -DHAVE_SQLITE=ON build_debug/ - debug build with -DCMAKE_BUILD_TYPE=Debug +build_otel/ - build with -DWITH_OTEL=ON (requires vcpkg) build_test/ - test data and converted worlds (not for compilation) ``` **Always warn the user when changing build directories or running cmake/make in a different directory.** +### OpenTelemetry Build (WITH_OTEL) +opentelemetry-cpp is installed via vcpkg at `~/repos/vcpkg`. Always pass the toolchain file and prefix path: +```bash +cmake -S . -B build_otel \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_OTEL=ON \ + -DCMAKE_TOOLCHAIN_FILE=~/repos/vcpkg/scripts/buildsystems/vcpkg.cmake \ + -DCMAKE_PREFIX_PATH=~/repos/vcpkg/installed/x64-linux +make -C build_otel -j$(($(nproc)/2)) +``` + ### File Encoding - CRITICAL **Proper workflow for editing KOI8-R files:** diff --git a/CMakeLists.txt b/CMakeLists.txt index 4794ba42d..666069074 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,6 @@ set_property(GLOBAL PROPERTY USE_FOLDERS ON) set(SOURCES src/engine/structs/blocking_queue.cpp - src/engine/db/influxdb.cpp src/engine/core/heartbeat.cpp src/engine/core/heartbeat_commands.cpp src/gameplay/abilities/abilities_rollsystem.cpp @@ -98,6 +97,8 @@ set(SOURCES src/utils/levenshtein.cpp src/gameplay/mechanics/liquid.cpp src/utils/logger.cpp + src/utils/logging/file_log_sender.cpp + src/utils/logging/log_manager.cpp src/gameplay/magic/magic.cpp src/gameplay/magic/magic_items.cpp src/gameplay/magic/magic_rooms.cpp @@ -158,6 +159,8 @@ set(SOURCES src/engine/structs/flags.hpp src/utils/id_converter.cpp src/utils/utils_time.cpp + src/utils/tracing/trace_manager.cpp + src/engine/observability/otel_trace_sender.cpp src/utils/thread_pool.cpp src/gameplay/mechanics/title.cpp src/gameplay/statistics/top.cpp @@ -461,7 +464,12 @@ set(SOURCES src/engine/db/player_index.cpp src/gameplay/skills/addshot.cpp src/gameplay/classes/mob_classes_info.cpp - src/gameplay/classes/recalc_mob_params_by_vnum.cpp) + src/gameplay/classes/recalc_mob_params_by_vnum.cpp + src/engine/observability/otel_provider.cpp + src/engine/observability/otel_traces.cpp + src/engine/observability/otel_metrics.cpp + src/engine/observability/otel_helpers.cpp + src/engine/observability/otel_log_sender.cpp) @@ -511,7 +519,6 @@ set(HEADERS src/administration/accounts.h src/engine/core/action_targeting.h src/engine/structs/blocking_queue.h - src/engine/db/influxdb.h src/engine/core/heartbeat_commands.h src/gameplay/mechanics/weather.h src/gameplay/core/game_limits.h @@ -605,6 +612,9 @@ set(HEADERS src/utils/levenshtein.h src/gameplay/mechanics/liquid.h src/utils/logger.h + src/utils/logging/log_sender.h + src/utils/logging/file_log_sender.h + src/utils/logging/log_manager.h src/gameplay/magic/magic.h src/gameplay/magic/magic_items.h src/gameplay/magic/magic_rooms.h @@ -662,6 +672,10 @@ set(HEADERS src/engine/core/sysdep.h src/engine/network/telnet.h src/utils/utils_time.h + src/utils/tracing/trace_sender.h + src/utils/tracing/noop_trace_sender.h + src/utils/tracing/trace_manager.h + src/engine/observability/otel_trace_sender.h src/gameplay/mechanics/title.h src/gameplay/statistics/top.h src/utils/utils.h @@ -898,6 +912,12 @@ set(HEADERS src/gameplay/mechanics/tutelar.h src/gameplay/skills/addshot.h src/engine/db/player_index.h + src/engine/observability/otel_provider.h + src/engine/observability/otel_traces.h + src/engine/observability/otel_metrics.h + src/engine/observability/otel_helpers.h + src/engine/observability/otel_log_sender.h + src/utils/logging/log_sender.h src/gameplay/classes/recalc_mob_params_by_vnum.h) # Build types @@ -1204,6 +1224,29 @@ else () message(STATUS "SQLite is turned off.") endif () +# OpenTelemetry support +if (WITH_OTEL) + message(STATUS "OpenTelemetry integration: ENABLED") + + # Find OpenTelemetry from vcpkg + find_package(opentelemetry-cpp CONFIG REQUIRED) + + # Define WITH_OTEL for preprocessor + add_definitions(-DWITH_OTEL) + + # Link OpenTelemetry libraries + target_link_libraries(circle.library + opentelemetry-cpp::api + opentelemetry-cpp::sdk + opentelemetry-cpp::ext + opentelemetry-cpp::otlp_http_exporter + opentelemetry-cpp::otlp_http_metric_exporter + opentelemetry-cpp::otlp_http_log_record_exporter + opentelemetry-cpp::resources + ) +else() + message(STATUS "OpenTelemetry integration: DISABLED") +endif() # YAML support if (HAVE_YAML) # Try to find yaml-cpp via CMake config first @@ -1330,7 +1373,7 @@ if (UNIX AND NOT CYGWIN) set(DEFAULT_WITH_ASAN YES) else () set(DEFAULT_WITH_ASAN NO) -endif () +endif() option(WITH_ASAN "Compile with ASAN" ${DEFAULT_WITH_ASAN}) if (WITH_ASAN) @@ -1450,6 +1493,9 @@ if (BUILD_TESTS) add_subdirectory(tests) endif () +option(WITH_OTEL "Enable OpenTelemetry integration" OFF) + +# vim: set ts=4 sw=4 ai tw=0 noet syntax=cmake : # ============================================================================= # Data directories setup for running server from build directory diff --git a/lib/misc/configuration.xml b/lib/misc/configuration.xml index af1d6489d..15cd05434 100644 --- a/lib/misc/configuration.xml +++ b/lib/misc/configuration.xml @@ -114,4 +114,40 @@ --> + + + true + + + http://localhost:4318/v1/metrics + + + http://localhost:4318/v1/traces + + + http://localhost:4318/v1/logs + + + + bylins-${host}-${port} + 1.0.0 + + + duplicate + + \ No newline at end of file diff --git a/src/engine/core/comm.cpp b/src/engine/core/comm.cpp index 58e407408..c5bcf3b48 100644 --- a/src/engine/core/comm.cpp +++ b/src/engine/core/comm.cpp @@ -37,6 +37,7 @@ #include "engine/db/world_characters.h" #include "engine/entities/entities_constants.h" #include "administration/shutdown_parameters.h" +#include "engine/observability/otel_provider.h" #include "external_trigger.h" #include "handler.h" #include "gameplay/clans/house.h" @@ -673,7 +674,9 @@ int main_function(int argc, char **argv) { " -h Print this command line argument help.\n" " -o Write log to instead of stderr.\n" " -r Restrict MUD -- no new players allowed.\n" - " -s Suppress special procedure assignments.\n", argv[0]); + " -s Suppress special procedure assignments.\n" + "\n" + " -S Use SQLite database for world loading.\n", argv[0]); exit(0); default: printf("SYSERR: Unknown option -%c in argument string.\n", *(argv[pos] + 1)); @@ -683,7 +686,9 @@ int main_function(int argc, char **argv) { " -h Print this command line argument help.\n" " -o Write log to instead of stderr.\n" " -r Restrict MUD -- no new players allowed.\n" - " -s Suppress special procedure assignments.\n", argv[0]); + " -s Suppress special procedure assignments.\n" + "\n" + " -S Use SQLite database for world loading.\n", argv[0]); exit(1); break; } @@ -720,6 +725,7 @@ int main_function(int argc, char **argv) { // directories are created in the working directory (next to the binary), // not inside the data directory. runtime_config.setup_logs(); + runtime_config.setup_telemetry(port); logfile = runtime_config.logs(SYSLOG).handle(); if (chdir(dir) < 0) { perror("\r\nSYSERR: Fatal error changing to data directory"); @@ -759,6 +765,11 @@ void stop_game(ush_int port) { log("Opening mother connection."); mother_desc = init_socket(port); + if (mother_desc < 0) { + log("SYSERR: Failed to bind to port %d. Server cannot start.", port); + log("Please check if another instance is running or if you have permission to use this port."); + exit(1); + } #ifdef ENABLE_ADMIN_API if (runtime_config.admin_api_enabled()) { @@ -826,6 +837,9 @@ void stop_game(ush_int port) { game_loop(mother_desc); #endif + // Shutdown OTEL providers to flush remaining telemetry + observability::OtelProvider::Instance().Shutdown(); + FlushPlayerIndex(); // храны надо сейвить до Crash_save_all_rent(), иначе будем брать бабло у чара при записи @@ -1009,9 +1023,9 @@ socket_t init_socket(ush_int port) { sa.sin_addr = *(get_bind_addr()); if (bind(s, (struct sockaddr *) &sa, sizeof(sa)) < 0) { - perror("SYSERR: bind"); + log("SYSERR: bind() failed - port %d is already in use or permission denied", port); CLOSE_SOCKET(s); - exit(1); + return -1; } nonblock(s); listen(s, 5); @@ -2178,8 +2192,7 @@ RETSIGTYPE checkpointing(int/* sig*/) { } RETSIGTYPE hupsig(int/* sig*/) { - log("SYSERR: Received SIGHUP, SIGINT, or SIGTERM. Shutting down..."); - exit(1); // perhaps something more elegant should substituted + shutdown_parameters.shutdown_now(); } #endif // CIRCLE_UNIX diff --git a/src/engine/core/config.cpp b/src/engine/core/config.cpp index d9a896ad5..3b45b1a33 100644 --- a/src/engine/core/config.cpp +++ b/src/engine/core/config.cpp @@ -22,7 +22,12 @@ #include "engine/structs/meta_enum.h" #if CIRCLE_UNIX +#ifdef WITH_OTEL +#include "engine/observability/otel_provider.h" +#endif +using ETelemetryLogMode = RuntimeConfiguration::ETelemetryLogMode; #include +#include #endif #include @@ -720,6 +725,13 @@ RuntimeConfiguration::RuntimeConfiguration() : m_msdp_debug(false), m_changelog_file_name(Boards::constants::CHANGELOG_FILE_NAME), m_changelog_format(Boards::constants::loader_formats::GIT), + m_telemetry_enabled(false), + m_telemetry_metrics_endpoint("http://localhost:4318/v1/metrics"), + m_telemetry_traces_endpoint("http://localhost:4318/v1/traces"), + m_telemetry_logs_endpoint("http://localhost:4318/v1/logs"), + m_telemetry_service_name("bylins-mud"), + m_telemetry_service_version("1.0.0"), + m_telemetry_log_mode(ETelemetryLogMode::kFileOnly), m_yaml_threads(0) { } @@ -740,6 +752,8 @@ void RuntimeConfiguration::load_from_file(const char *filename) { load_boards_configuration(&root); load_external_triggers(&root); load_statistics_configuration(&root); + load_telemetry_configuration_impl(&root); + load_telemetry_configuration(&root); load_world_loader_configuration(&root); #ifdef ENABLE_ADMIN_API load_admin_api_configuration(&root); @@ -796,6 +810,111 @@ bool CLogInfo::open() { RuntimeConfiguration runtime_config; +void RuntimeConfiguration::load_telemetry_configuration_impl(const pugi::xml_node *root) { + const auto telemetry = root->child("telemetry"); + if (!telemetry) { + return; + } + + const auto enabled = telemetry.child("enabled"); + if (enabled) { + const std::string value = enabled.child_value(); + m_telemetry_enabled = (value == "true" || value == "1"); + } + + const auto otlp = telemetry.child("otlp"); + if (otlp) { + const auto metrics = otlp.child("metrics"); + if (metrics) { + const auto endpoint = metrics.child("endpoint"); + if (endpoint) { + m_telemetry_metrics_endpoint = endpoint.child_value(); + } + } + + const auto traces = otlp.child("traces"); + if (traces) { + const auto endpoint = traces.child("endpoint"); + if (endpoint) { + m_telemetry_traces_endpoint = endpoint.child_value(); + } + } + + const auto logs_otlp = otlp.child("logs_otlp"); + if (logs_otlp) { + const auto endpoint = logs_otlp.child("endpoint"); + if (endpoint) { + m_telemetry_logs_endpoint = endpoint.child_value(); + } + } + } + + const auto service = telemetry.child("service"); + if (service) { + const auto name = service.child("name"); + if (name) { + m_telemetry_service_name = name.child_value(); + } + const auto version = service.child("version"); + if (version) { + m_telemetry_service_version = version.child_value(); + } + } + + const auto logs = telemetry.child("logs"); + if (logs) { + const auto mode = logs.child("mode"); + if (mode) { + const std::string mode_str = mode.child_value(); + if (mode_str == "file-only") { + m_telemetry_log_mode = ETelemetryLogMode::kFileOnly; + } else if (mode_str == "otel-only") { + m_telemetry_log_mode = ETelemetryLogMode::kOtelOnly; + } else if (mode_str == "duplicate") { + m_telemetry_log_mode = ETelemetryLogMode::kDuplicate; + } + } + } +} + +void RuntimeConfiguration::load_telemetry_configuration(const pugi::xml_node *) { + // OtelProvider is initialized later via setup_telemetry(port) + // once the game port is known from command-line arguments. +} + +void RuntimeConfiguration::setup_telemetry(int port) { +#ifdef WITH_OTEL + if (!m_telemetry_enabled) { + return; + } + + // Interpolate variables in service name: ${port}, ${host}, ${version} + char hostname[256] = "unknown"; + gethostname(hostname, sizeof(hostname)); + + auto replace_all = [](std::string str, const std::string &var, const std::string &val) { + std::string::size_type pos; + while ((pos = str.find(var)) != std::string::npos) { + str.replace(pos, var.size(), val); + } + return str; + }; + + std::string name = m_telemetry_service_name; + name = replace_all(name, "${port}", std::to_string(port)); + name = replace_all(name, "${host}", hostname); + name = replace_all(name, "${version}", m_telemetry_service_version); + + observability::OtelProvider::Instance().Initialize( + m_telemetry_metrics_endpoint, + m_telemetry_traces_endpoint, + m_telemetry_logs_endpoint, + name, + m_telemetry_service_version); +#else + (void)port; +#endif +} // vim: ts=4 sw=4 tw=0 noet syntax=cpp : #ifdef ENABLE_ADMIN_API diff --git a/src/engine/core/config.h b/src/engine/core/config.h index fab67821b..b93d19d58 100644 --- a/src/engine/core/config.h +++ b/src/engine/core/config.h @@ -161,6 +161,7 @@ class RuntimeConfiguration { auto output_queue_size() const { return m_output_queue_size; } void setup_logs(); + void setup_telemetry(int port); auto syslog_converter() const { return m_syslog_converter; } void enable_logging() { m_logging_enabled = true; } @@ -177,6 +178,16 @@ class RuntimeConfiguration { const auto &statistics() const { return m_statistics; } + bool telemetry_enabled() const { return m_telemetry_enabled; } + const std::string &telemetry_metrics_endpoint() const { return m_telemetry_metrics_endpoint; } + const std::string &telemetry_traces_endpoint() const { return m_telemetry_traces_endpoint; } + const std::string &telemetry_logs_endpoint() const { return m_telemetry_logs_endpoint; } + const std::string &telemetry_service_name() const { return m_telemetry_service_name; } + const std::string &telemetry_service_version() const { return m_telemetry_service_version; } + enum class ETelemetryLogMode { kFileOnly, kOtelOnly, kDuplicate, kUndefined }; + ETelemetryLogMode telemetry_log_mode() const { return m_telemetry_log_mode; } + + void load_telemetry_configuration(const pugi::xml_node *root); size_t yaml_threads() const { return m_yaml_threads; } #ifdef ENABLE_ADMIN_API @@ -203,6 +214,7 @@ class RuntimeConfiguration { void load_boards_configuration(const pugi::xml_node *root); void load_external_triggers(const pugi::xml_node *root); void load_statistics_configuration(const pugi::xml_node *root); + void load_telemetry_configuration_impl(const pugi::xml_node *root); void load_world_loader_configuration(const pugi::xml_node *root); #ifdef ENABLE_ADMIN_API void load_admin_api_configuration(const pugi::xml_node *root); @@ -222,6 +234,14 @@ class RuntimeConfiguration { std::string m_external_reboot_trigger_file_name; StatisticsConfiguration m_statistics; + + bool m_telemetry_enabled; + std::string m_telemetry_metrics_endpoint; + std::string m_telemetry_traces_endpoint; + std::string m_telemetry_logs_endpoint; + std::string m_telemetry_service_name; + std::string m_telemetry_service_version; + ETelemetryLogMode m_telemetry_log_mode; size_t m_yaml_threads; diff --git a/src/engine/core/heartbeat.cpp b/src/engine/core/heartbeat.cpp index ad781dbf1..3e1ed030e 100644 --- a/src/engine/core/heartbeat.cpp +++ b/src/engine/core/heartbeat.cpp @@ -26,6 +26,8 @@ #include "gameplay/mechanics/corpse.h" #include "engine/db/global_objects.h" #include "engine/ui/cmd_god/do_set_all.h" +#include "engine/observability/otel_traces.h" +#include "engine/observability/otel_metrics.h" #include "gameplay/statistics/money_drop.h" #include "gameplay/mechanics/weather.h" #include "utils/utils_time.h" @@ -33,6 +35,7 @@ #include "gameplay/communication/check_invoice.h" #include "gameplay/mechanics/depot.h" #include "gameplay/statistics/spell_usage.h" +#include "utils/tracing/trace_manager.h" #if defined WITH_SCRIPTING #include "scripting.hpp" @@ -549,6 +552,15 @@ Heartbeat::Heartbeat() : void Heartbeat::operator()(const int missed_pulses) { pulse_label_t label; + // Capture current pulse numbers BEFORE advance + const auto current_heartbeat_number = global_pulse_number(); + const auto current_pulse_number = pulse_number(); + + // Create trace span for this pulse + char span_name[64]; + snprintf(span_name, sizeof(span_name), "Heartbeat #%lu pulse #%d", current_heartbeat_number, current_pulse_number); + auto pulse_span = tracing::TraceManager::Instance().StartSpan(span_name); + utils::CExecutionTimer timer; pulse(missed_pulses, label); const auto execution_time = timer.delta(); @@ -561,12 +573,42 @@ void Heartbeat::operator()(const int missed_pulses) { mudlog(tmpbuf, LGH, kLvlImmortal, SYSLOG, true); } m_measurements.add(label, pulse_number(), execution_time.count()); - if (GlobalObjects::stats_sender().ready()) { - influxdb::Record record("heartbeat"); - record.add_tag("pulse", pulse_number()); - record.add_field("duration", execution_time.count()); - GlobalObjects::stats_sender().send(record); + +#ifdef WITH_OTEL + static int debug_counter = 0; + if (++debug_counter % 250 == 0) { // Every 10 seconds + char debug_buf[256]; + snprintf(debug_buf, sizeof(debug_buf), "DEBUG: Heartbeat OTEL called, label.size=%zu, pulse_mod=%d", + label.size(), pulse_number() % 25); + mudlog(debug_buf, CMP, kLvlImmortal, SYSLOG, true); + } + // 1. Metrics for each executed step + for (const auto& [step_index, step_time] : label) { + if (step_index < m_steps.size()) { + std::map step_attrs; + step_attrs["step"] = m_steps[step_index].name(); + observability::OtelMetrics::RecordHistogram("heartbeat.step.duration", step_time, step_attrs); + } } + + // 2. Total pulse duration with modulo + std::map pulse_attrs; + pulse_attrs["pulse_mod"] = std::to_string(pulse_number() % 25); + observability::OtelMetrics::RecordHistogram("heartbeat.total.duration", execution_time.count(), pulse_attrs); + + // 3. Record missed pulses if any + if (missed_pulses > 0) { + observability::OtelMetrics::RecordCounter("heartbeat.missed_pulses_total", missed_pulses); + } +#endif + + // Close parent span + pulse_span->SetAttribute("heartbeat_number", static_cast(current_heartbeat_number)); + pulse_span->SetAttribute("pulse_number", static_cast(current_pulse_number)); + pulse_span->SetAttribute("execution_time_seconds", execution_time.count()); + pulse_span->SetAttribute("missed_pulses", static_cast(missed_pulses)); + pulse_span->SetAttribute("steps_executed", static_cast(label.size())); + pulse_span->End(); } long long Heartbeat::period() const { @@ -618,7 +660,11 @@ void Heartbeat::pulse(const int missed_pulses, pulse_label_t &label) { if (0 == (m_pulse_number + step.offset()) % step.modulo()) { utils::CExecutionTimer timer; - + + // Create child span for this step + auto step_span = tracing::TraceManager::Instance().StartSpan(step.name()); + step_span->SetAttribute("step_index", static_cast(i)); + step_span->SetAttribute("step_modulo", static_cast(step.modulo())); step.action()->perform(pulse_number(), missed_pulses); const auto execution_time = timer.delta().count(); if (step.modulo() >= kSecsPerMudHour * kPassesPerSec) { @@ -630,6 +676,8 @@ void Heartbeat::pulse(const int missed_pulses, pulse_label_t &label) { log("HeartBeat memory resize, step:(%s), memory used: virt (%d kB) phys (%d kB)", step.name().c_str(), vmem_used, pmem_used); // mudlog(buf, CMP, kLvlGreatGod, SYSLOG, true); } + step_span->SetAttribute("execution_time_seconds", execution_time); + step_span->End(); label.emplace(i, execution_time); m_executed_steps.insert(i); step.add_measurement(i, pulse_number(), execution_time); diff --git a/src/engine/db/db.cpp b/src/engine/db/db.cpp index f752d3022..ae4de877f 100644 --- a/src/engine/db/db.cpp +++ b/src/engine/db/db.cpp @@ -37,12 +37,17 @@ #include "gameplay/mechanics/noob.h" #include "obj_prototypes.h" #include "engine/olc/olc.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" #include "gameplay/communication/offtop.h" #include "gameplay/communication/parcel.h" #include "administration/privilege.h" #include "gameplay/mechanics/sets_drop.h" #include "gameplay/mechanics/stable_objs.h" #include "gameplay/economics/shop_ext.h" +#include "engine/observability/otel_metrics.h" +#include "engine/observability/otel_traces.h" #include "gameplay/mechanics/stuff.h" #include "gameplay/mechanics/title.h" #include "gameplay/statistics/top.h" @@ -1932,6 +1937,11 @@ void ZoneUpdate() { struct reset_q_element *update_u, *temp; static int timer = 0; utils::CExecutionTimer timer_count; + // OpenTelemetry: Track zone updates + auto zone_span = tracing::TraceManager::Instance().StartSpan("Zone Update"); + observability::ScopedMetric zone_metric("zone.update.duration"); + + int zones_reset_count = 0; if (((++timer * kPulseZone) / kPassesPerSec) >= 60) // one minute has passed { /* @@ -1992,6 +2002,14 @@ void ZoneUpdate() { ss << zone_table[it].vnum << " "; if (zone_table[it].vnum < dungeons::kZoneStartDungeons) { ResetZone(it); + zones_reset_count++; + + // OpenTelemetry: Record zone reset + std::map attrs; + attrs["zone_vnum"] = std::to_string(zone_table[it].vnum); + attrs["reset_mode"] = std::to_string(zone_table[it].reset_mode); + + observability::OtelMetrics::RecordCounter("zone.reset.total", 1, attrs); } else { log("Закрываю брошенный dungeon %d", it); dungeons::DungeonReset(it); @@ -2017,6 +2035,9 @@ void ZoneUpdate() { if (k >= kZonesReset) break; } + + // OpenTelemetry: Record total zones reset + zone_span->SetAttribute("zones_reset_count", static_cast(zones_reset_count)); } bool CanBeReset(ZoneRnum zone) { @@ -2307,18 +2328,15 @@ class ZoneReset { void ZoneReset::Reset() { utils::CExecutionTimer timer; - if (GlobalObjects::stats_sender().ready()) { - ResetZoneEssential(); - const auto execution_time = timer.delta(); + ResetZoneEssential(); + const auto execution_time = timer.delta(); - influxdb::Record record("zone_reset"); - record.add_tag("pulse", GlobalObjects::heartbeat().pulse_number()); - record.add_tag("zone", zone_table[m_zone_rnum].vnum); - record.add_field("duration", execution_time.count()); - GlobalObjects::stats_sender().send(record); - } else { - ResetZoneEssential(); - } +#ifdef WITH_OTEL + std::map attrs; + attrs["pulse"] = std::to_string(GlobalObjects::heartbeat().pulse_number()); + attrs["zone"] = std::to_string(zone_table[m_zone_rnum].vnum); + observability::OtelMetrics::RecordHistogram("zone.reset.duration", execution_time.count(), attrs); +#endif } bool ZoneReset::HandleZoneCmdQ(const MobRnum rnum) const { @@ -2345,18 +2363,13 @@ bool ZoneReset::HandleZoneCmdQ(const MobRnum rnum) const { const auto execution_time = overall_timer.delta(); - if (GlobalObjects::stats_sender().ready()) { - influxdb::Record record("Q_command"); - - record.add_tag("pulse", GlobalObjects::heartbeat().pulse_number()); - record.add_tag("zone", zone_table[m_zone_rnum].vnum); - record.add_tag("rnum", rnum); - - record.add_field("duration", execution_time.count()); - record.add_field("extract", extract_time.count()); - record.add_field("get_mobs", get_mobs_time.count()); - GlobalObjects::stats_sender().send(record); - } +#ifdef WITH_OTEL + std::map attrs; + attrs["pulse"] = std::to_string(GlobalObjects::heartbeat().pulse_number()); + attrs["zone"] = std::to_string(zone_table[m_zone_rnum].vnum); + attrs["rnum"] = std::to_string(rnum); + observability::OtelMetrics::RecordHistogram("zone.command.Q.duration", execution_time.count(), attrs); +#endif return extracted; } @@ -2912,6 +2925,12 @@ void SetGodSkills(CharData *ch) { // по умолчанию reboot = 0 (пользуется только при ребуте) int LoadPlayerCharacter(const char *name, CharData *char_element, int load_flags) { const auto player_i = char_element->load_char_ascii(name, load_flags); + // OpenTelemetry: Track player loading + auto load_span = tracing::TraceManager::Instance().StartSpan("Load Player"); + load_span->SetAttribute("character_name", std::string(name)); + + observability::ScopedMetric load_metric("player.load.duration"); + if (player_i > -1) { char_element->set_pfilepos(player_i); } diff --git a/src/engine/db/global_objects.cpp b/src/engine/db/global_objects.cpp index 8f95fdc06..b2f2ddce2 100644 --- a/src/engine/db/global_objects.cpp +++ b/src/engine/db/global_objects.cpp @@ -3,6 +3,7 @@ #include #include "administration/ban.h" +#include "utils/logging/log_manager.h" namespace { // This struct defines order of creating and destroying global objects @@ -12,6 +13,9 @@ struct GlobalObjectsStorage { /// This object should be destroyed last because it serves all output operations. So I define it first. std::shared_ptr output_thread; + /// LogManager intentionally leaked (never destroyed) to serve log() calls from destructors + logging::LogManager* log_manager; + celebrates::CelebrateList mono_celebrates; celebrates::CelebrateList poly_celebrates; celebrates::CelebrateList real_celebrates; @@ -46,7 +50,6 @@ struct GlobalObjectsStorage { InspectRequestDeque inspect_request_deque; BanList *ban; Heartbeat heartbeat; - std::shared_ptr stats_sender; ZoneTable zone_table; DailyQuest::DailyQuestMap daily_quests; Strengthening strengthening; @@ -55,7 +58,9 @@ struct GlobalObjectsStorage { }; GlobalObjectsStorage::GlobalObjectsStorage() : + log_manager(new logging::LogManager()), ban(nullptr) { + // log_manager intentionally never deleted - will leak at shutdown } // This function ensures that global objects will be created at the moment of getting access to them @@ -170,13 +175,8 @@ Heartbeat &GlobalObjects::heartbeat() { return global_objects().heartbeat; } -influxdb::Sender &GlobalObjects::stats_sender() { - if (!global_objects().stats_sender) { - global_objects().stats_sender = std::make_shared( - runtime_config.statistics().host(), runtime_config.statistics().port()); - } - - return *global_objects().stats_sender; +observability::OtelProvider &GlobalObjects::otel_provider() { + return observability::OtelProvider::Instance(); } OutputThread &GlobalObjects::output_thread() { @@ -251,6 +251,9 @@ obj2triggers_t &GlobalObjects::obj_triggers() { return global_objects().obj2triggers; } +logging::LogManager &GlobalObjects::log_manager() { + return *global_objects().log_manager; +} RoomDescriptions &GlobalObjects::descriptions() { return global_objects().room_descriptions; } diff --git a/src/engine/db/global_objects.h b/src/engine/db/global_objects.h index 37dafacb2..d105ff4ff 100644 --- a/src/engine/db/global_objects.h +++ b/src/engine/db/global_objects.h @@ -15,9 +15,11 @@ #include "engine/ui/cmd_god/do_inspect.h" #include "engine/scripting/dg_event.h" #include "gameplay/economics/shops_implementation.h" +#include "engine/observability/otel_provider.h" +#include "utils/logging/log_manager.h" #include "world_objects.h" #include "world_characters.h" -#include "influxdb.h" +#include "engine/observability/otel_provider.h" #include "engine/entities/zone.h" #include "gameplay/quests/daily_quest.h" #include "gameplay/skills/skills_info.h" @@ -70,8 +72,9 @@ class GlobalObjects { static SetAllInspReqListType &setall_inspect_list(); static BanList *&ban(); static Heartbeat &heartbeat(); - static influxdb::Sender &stats_sender(); + static observability::OtelProvider &otel_provider(); static OutputThread &output_thread(); + static logging::LogManager &log_manager(); static ZoneTable &zone_table(); static RunestoneRoster &Runestones(); diff --git a/src/engine/db/influxdb.cpp b/src/engine/db/influxdb.cpp deleted file mode 100644 index 3ca21092b..000000000 --- a/src/engine/db/influxdb.cpp +++ /dev/null @@ -1,124 +0,0 @@ -#include "influxdb.h" - -#include "utils/logger.h" - -#include - -#ifndef WIN32 -#include -#include -#include -#include - -constexpr int INVALID_SOCKET = -1; -constexpr int SOCKET_ERROR = -1; -#endif - -namespace influxdb { -class SenderImpl { - public: - SenderImpl(const std::string &host, const unsigned short port); - - bool ready() const { return !m_host.empty(); } - bool send(const std::string &data); - - private: - std::string m_host; - int m_port; - - socket_t m_socket; - struct sockaddr_in m_addr; -}; - -SenderImpl::SenderImpl(const std::string &host, const unsigned short port) : - m_host(host), - m_port(port), - m_socket(INVALID_SOCKET) { - memset(&m_addr, 0, sizeof(m_addr)); - - if (m_host.empty()) { - return; - } - - m_addr.sin_family = AF_INET; - m_addr.sin_port = htons(port); - - struct hostent *hp = gethostbyname(m_host.c_str()); - if (hp) { - in_addr *server_address = reinterpret_cast(hp->h_addr_list[0]); - log("Statistics server has been resolved to '%s'.\n", - inet_ntoa(*server_address)); - memcpy(&m_addr.sin_addr, server_address, hp->h_length); - } else { - log("SYSERR: failed to resolve server name '%s'. Turning sending statistics off.\n", m_host.c_str()); - m_host.clear(); - } - - m_socket = socket(AF_INET, SOCK_DGRAM, 0); - if (INVALID_SOCKET == m_socket) { - log("SYSERR: Couldn't create UDP socket. Turning sending statistics off.\n"); - m_host.clear(); - } -} - -bool SenderImpl::send(const std::string &data) { - if (INVALID_SOCKET != m_socket) { - const int result = sendto(m_socket, data.c_str(), static_cast(data.size()), - 0, reinterpret_cast(&m_addr), sizeof(m_addr)); - - return SOCKET_ERROR != result; - } - - return false; -} - -Sender::Sender(const std::string &host, const unsigned short port) : - m_implementation(new SenderImpl(host, port)) { -} - -Sender::~Sender() { - delete m_implementation; -} - -bool Sender::ready() const { - return m_implementation->ready(); -} - -bool Sender::send(const Record &record) const { - std::string data; - if (!record.get_data(data)) { - return false; - } - - return m_implementation->send(data); -} - -bool Record::get_data(std::string &data) const { - if (m_fields.empty()) { - log("SYSERR: Attempt to send statistics record without any field.\n"); - return false; - } - - std::stringstream ss; - ss << m_measurement; - for (const auto &tag : m_tags) { - ss << "," << tag; - } - ss << " "; - - bool first = true; - for (const auto &field : m_fields) { - ss << (first ? "" : ",") << field; - first = false; - } - - using namespace std::chrono; - nanoseconds timestamp = duration_cast(system_clock::now().time_since_epoch()); - ss << " " << timestamp.count(); - - data = ss.str(); - return true; -} -} - -// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/db/influxdb.h b/src/engine/db/influxdb.h deleted file mode 100644 index ae45411eb..000000000 --- a/src/engine/db/influxdb.h +++ /dev/null @@ -1,56 +0,0 @@ -#ifndef __INFLUX_HPP__ -#define __INFLUX_HPP__ - -#include -#include -#include - -namespace influxdb { -class Record { - public: - Record(const std::string &measurement) : m_measurement(measurement) {} - - template - Record &add_tag(const std::string &name, const T &value) { return add_to_list(m_tags, name, value); } - - template - Record &add_field(const std::string &name, const T &value) { return add_to_list(m_fields, name, value); } - - bool get_data(std::string &data) const; - - private: - using strings_list_t = std::list; - - template - Record &add_to_list(strings_list_t &list, const std::string &name, const T &value); - - std::string m_measurement; - strings_list_t m_tags; - strings_list_t m_fields; -}; - -template -Record &Record::add_to_list(strings_list_t &list, const std::string &name, const T &value) { - std::stringstream ss; - ss << name << "=" << value; - list.push_back(ss.str()); - - return *this; -} - -class Sender { - public: - Sender(const std::string &host, const unsigned short port); - ~Sender(); - - bool ready() const; - bool send(const Record &record) const; - - private: - class SenderImpl *m_implementation; -}; -} - -#endif // __INFLUX_HPP__ - -// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/db/obj_save.cpp b/src/engine/db/obj_save.cpp index 8043ae1b6..bc4c28217 100644 --- a/src/engine/db/obj_save.cpp +++ b/src/engine/db/obj_save.cpp @@ -27,6 +27,9 @@ #include "player_index.h" #include +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" #include const int LOC_INVENTORY = 0; @@ -2435,6 +2438,12 @@ int receptionist(CharData *ch, void *me, int cmd, char *argument) { void Crash_frac_save_all(int frac_part) { DescriptorData *d; + // OpenTelemetry: Track fractional save + auto save_span = tracing::TraceManager::Instance().StartSpan("Player Save (Fractional)"); + save_span->SetAttribute("save_type", "frac"); + save_span->SetAttribute("frac_part", static_cast(frac_part)); + + int saved_count = 0; for (d = descriptor_list; d; d = d->next) { if ((d->state == EConState::kPlaying) && !d->character->IsNpc() && GET_ACTIVITY(d->character) == frac_part) { @@ -2449,12 +2458,24 @@ void Crash_frac_save_all(int frac_part) { if (timer1.delta().count() > 0.1) log("Crash_frac_save_all: save_char, timer %f, save player: %s", timer1.delta().count(), d->character->get_name().c_str()); d->character->UnsetFlag(EPlrFlag::kCrashSave); + saved_count++; + + // OpenTelemetry: Record save metrics + std::map attrs; + attrs["save_type"] = "frac"; + attrs["character"] = d->character->get_name(); + + observability::OtelMetrics::RecordHistogram("player.save.duration", timer.delta().count(), attrs); + observability::OtelMetrics::RecordCounter("player.save.total", 1, attrs); } } } void Crash_save_all(void) { DescriptorData *d; + auto save_span = tracing::TraceManager::Instance().StartSpan("Player Save (Full)"); + save_span->SetAttribute("save_type", "full"); + for (d = descriptor_list; d; d = d->next) { if ((d->state == EConState::kPlaying) && d->character->IsFlagged(EPlrFlag::kCrashSave)) { Crash_crashsave(d->character.get()); diff --git a/src/engine/entities/char_data.cpp b/src/engine/entities/char_data.cpp index e79dfb4d5..e22c012e4 100644 --- a/src/engine/entities/char_data.cpp +++ b/src/engine/entities/char_data.cpp @@ -2,6 +2,7 @@ // Copyright (c) 2008 Krodo // Part of Bylins http://www.mud.ru +#include "engine/observability/otel_helpers.h" #include "engine/core/handler.h" #include "administration/privilege.h" #include "char_player.h" @@ -17,6 +18,7 @@ #include "gameplay/statistics/money_drop.h" #include "gameplay/affects/affect_data.h" #include "gameplay/mechanics/illumination.h" +#include "utils/tracing/trace_sender.h" #include "engine/ui/alias.h" #include diff --git a/src/engine/entities/char_data.h b/src/engine/entities/char_data.h index 00e1bf54f..d19146a1d 100644 --- a/src/engine/entities/char_data.h +++ b/src/engine/entities/char_data.h @@ -27,6 +27,10 @@ #include #include #include +// Forward declarations for OpenTelemetry +namespace tracing { class ISpan; } +namespace observability { class BaggageScope; } + // pernalty types enum { P_DAMROLL, P_HITROLL, P_CAST, P_MEM_GAIN, P_MOVE_GAIN, P_HIT_GAIN, P_AC }; @@ -829,6 +833,11 @@ class CharData : public ProtectedCharData { bool IsHorsePrevents(); void dismount(); bool IsLeader(); + + // OpenTelemetry combat tracing (instrumentation) + std::shared_ptr m_combat_root_span; + std::shared_ptr m_combat_baggage_scope; + std::string m_combat_id; }; # define MAX_FIRSTAID_REMOVE 17 diff --git a/src/engine/network/admin_api/admin_api_constants.h b/src/engine/network/admin_api/admin_api_constants.h index 49bfa6fa5..50b0dd2c4 100644 --- a/src/engine/network/admin_api/admin_api_constants.h +++ b/src/engine/network/admin_api/admin_api_constants.h @@ -27,7 +27,7 @@ constexpr size_t kMaxLargeBufferSize = 1048576; constexpr int kMaxChunks = 4; // Note: Command enum and string conversion functions were removed as unused. -// CommandRegistry uses direct stringБ├▓handler mapping via std::unordered_map. +// CommandRegistry uses direct string->handler mapping via std::unordered_map. } // namespace admin_api diff --git a/src/engine/network/admin_api/command_registry.h b/src/engine/network/admin_api/command_registry.h index d1040ff31..0684ca4aa 100644 --- a/src/engine/network/admin_api/command_registry.h +++ b/src/engine/network/admin_api/command_registry.h @@ -92,7 +92,7 @@ class CommandRegistry private: CommandRegistry() = default; - // Command name Б├▓ handler function + // Command name -> handler function std::unordered_map handlers_; }; diff --git a/src/engine/network/admin_api/json_helpers.h b/src/engine/network/admin_api/json_helpers.h index 062c301e8..c64bcb0c0 100644 --- a/src/engine/network/admin_api/json_helpers.h +++ b/src/engine/network/admin_api/json_helpers.h @@ -182,7 +182,7 @@ inline std::optional ParseNested(const json& j, const char* key) } // ============================================================================ -// String Conversion Helpers (KOI8-R Б├■ UTF-8) +// String Conversion Helpers (KOI8-R ? UTF-8) // ============================================================================ /** diff --git a/src/engine/observability/otel_helpers.cpp b/src/engine/observability/otel_helpers.cpp new file mode 100644 index 000000000..a5b00349d --- /dev/null +++ b/src/engine/observability/otel_helpers.cpp @@ -0,0 +1,213 @@ +#include "otel_helpers.h" +#include "utils/tracing/trace_manager.h" +#include "utils/tracing/noop_trace_sender.h" + +#ifdef WITH_OTEL +#include "otel_trace_sender.h" +#include "otel_provider.h" +#include "opentelemetry/baggage/baggage_context.h" +#include "opentelemetry/context/context.h" +#include "opentelemetry/trace/span_context.h" +#include "opentelemetry/trace/trace_id.h" +#include +#include +#endif + +namespace observability { + +// +// ScopedMetric +// + +ScopedMetric::ScopedMetric(const std::string& name, const std::map& attrs) + : m_name(name) + , m_attrs(attrs) + , m_timer() {} + +ScopedMetric::~ScopedMetric() { + auto duration = m_timer.delta().count(); + OtelMetrics::RecordHistogram(m_name, duration, m_attrs); +} + +double ScopedMetric::elapsed_seconds() const { + return m_timer.delta().count(); +} + +// +// DualSpan +// + +DualSpan::DualSpan(const std::string& heartbeat_name, + const std::string& secondary_name, + const void* secondary_parent) + : m_ended(false) +{ + // Create heartbeat span (child of current runtime context via Scope) + m_heartbeat_span = tracing::TraceManager::Instance().StartSpan(heartbeat_name); + +#ifdef WITH_OTEL + // Create secondary span (NO Scope - doesn't affect runtime context) + if (secondary_parent) { + const auto* parent_ctx = static_cast(secondary_parent); + + auto* sender = dynamic_cast(&tracing::TraceManager::Instance().GetSender()); + if (sender && parent_ctx->IsValid()) { + // We need to create a child span without Scope + // Use the low-level OTEL API directly + auto tracer = observability::OtelProvider::Instance().GetTracer(); + if (tracer) { + opentelemetry::trace::StartSpanOptions options; + options.parent = *parent_ctx; + + auto otel_span = tracer->StartSpan(secondary_name, {}, options); + // Create OtelSpan WITHOUT Scope (don't call ITraceSender::StartSpan which creates Scope) + m_secondary_span = std::make_unique(otel_span, false); // false = no Scope + } + } + } +#endif + + // If secondary span creation failed, create a no-op span + if (!m_secondary_span || !m_secondary_span->IsValid()) { + m_secondary_span = std::make_unique(); + } +} + +DualSpan::~DualSpan() { + if (!m_ended) { + End(); + } +} + +void DualSpan::SetAttribute(const std::string& key, const std::string& value) { + if (m_heartbeat_span) { + m_heartbeat_span->SetAttribute(key, value); + } + if (m_secondary_span) { + m_secondary_span->SetAttribute(key, value); + } +} + +void DualSpan::SetAttribute(const std::string& key, int64_t value) { + if (m_heartbeat_span) { + m_heartbeat_span->SetAttribute(key, value); + } + if (m_secondary_span) { + m_secondary_span->SetAttribute(key, value); + } +} + +void DualSpan::SetAttribute(const std::string& key, double value) { + if (m_heartbeat_span) { + m_heartbeat_span->SetAttribute(key, value); + } + if (m_secondary_span) { + m_secondary_span->SetAttribute(key, value); + } +} + +void DualSpan::AddEvent(const std::string& name) { + if (m_heartbeat_span) { + m_heartbeat_span->AddEvent(name); + } + if (m_secondary_span) { + m_secondary_span->AddEvent(name); + } +} + +void DualSpan::End() { + if (!m_ended) { + if (m_heartbeat_span) { + m_heartbeat_span->End(); + } + if (m_secondary_span) { + m_secondary_span->End(); + } + m_ended = true; + } +} + +tracing::ISpan* DualSpan::heartbeat() { + return m_heartbeat_span.get(); +} + +tracing::ISpan* DualSpan::secondary() { + return m_secondary_span.get(); +} + +#ifdef WITH_OTEL + +// +// Helper functions +// + +std::string GetTraceId(const tracing::ISpan* span) { + if (!span || !span->IsValid()) { + return ""; + } + + const auto* otel_span = dynamic_cast(span); + if (!otel_span) { + return ""; + } + + auto ctx = otel_span->GetContext(); + if (!ctx.IsValid()) { + return ""; + } + + auto trace_id = ctx.trace_id(); + std::stringstream ss; + ss << std::hex << std::setfill('0'); + for (auto byte : trace_id.Id()) { + ss << std::setw(2) << static_cast(byte); + } + return ss.str(); +} + +// +// BaggageScope +// + +struct BaggageScope::Impl { + Impl(opentelemetry::nostd::unique_ptr t) : token(std::move(t)) {} + opentelemetry::nostd::unique_ptr token; +}; + +BaggageScope::BaggageScope(const std::string& key, const std::string& value) +{ + auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent(); + auto baggage = opentelemetry::baggage::GetBaggage(current_ctx); + + // Create new baggage with added key-value + auto new_baggage = baggage->Set(key, value); + + // Set new context with updated baggage + auto mutable_ctx = current_ctx; + auto new_ctx = opentelemetry::baggage::SetBaggage(mutable_ctx, new_baggage); + auto token = opentelemetry::context::RuntimeContext::Attach(new_ctx); + + m_impl = std::make_unique(std::move(token)); +} + +BaggageScope::~BaggageScope() { + if (m_impl) { + opentelemetry::context::RuntimeContext::Detach(*m_impl->token); + } +} + +std::string GetBaggage(const std::string& key) { + auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent(); + auto baggage = opentelemetry::baggage::GetBaggage(current_ctx); + std::string value; + if (baggage->GetValue(key, value)) { + return value; + } + return ""; +} + +#endif // WITH_OTEL + +} // namespace observability + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/otel_helpers.h b/src/engine/observability/otel_helpers.h new file mode 100644 index 000000000..e641eaf0f --- /dev/null +++ b/src/engine/observability/otel_helpers.h @@ -0,0 +1,146 @@ +#ifndef BYLINS_OTEL_HELPERS_H +#define BYLINS_OTEL_HELPERS_H + +#include "utils/utils_time.h" +#include "utils/tracing/trace_sender.h" +#include "otel_metrics.h" +#include +#include +#include + +#ifdef WITH_OTEL +#include "opentelemetry/trace/span_context.h" +#endif + +namespace observability { + +/** + * RAII wrapper for automatic metric timing. + * Records histogram metric with duration on destruction. + * + * Example: + * { + * ScopedMetric metric("operation.duration", {{"type", "combat"}}); + * // ... operation ... + * } // automatically records metric + */ +class ScopedMetric { +public: + ScopedMetric(const std::string& name, const std::map& attrs = {}); + ~ScopedMetric(); + + // Get elapsed time so far (without ending the metric) + double elapsed_seconds() const; + +private: + std::string m_name; + std::map m_attrs; + utils::CExecutionTimer m_timer; +}; + +/** + * RAII wrapper for creating spans in two traces simultaneously. + * Used for overlapping trace scenarios (e.g., heartbeat + combat). + * + * Creates two spans: + * - Heartbeat span: child of current runtime context (automatically via Scope) + * - Secondary span: child of provided parent context (NO Scope - doesn't affect runtime context) + * + * Example: + * DualSpan round_span( + * "Combat round", // name in heartbeat trace + * "Round #5", // name in secondary trace + * combat_root_span->GetContext() // parent for secondary span + * ); + * round_span.SetAttribute("round_number", 5); + * // ... processing ... + * // both spans automatically closed on destruction + */ +class DualSpan { +public: + /** + * @param heartbeat_name Name for span in heartbeat trace + * @param secondary_name Name for span in secondary trace + * @param secondary_parent Parent context for secondary span + */ + DualSpan(const std::string& heartbeat_name, + const std::string& secondary_name, + const void* secondary_parent); // opaque pointer to avoid OTEL dependency in header + + ~DualSpan(); + + // Set attributes on BOTH spans + void SetAttribute(const std::string& key, const std::string& value); + void SetAttribute(const std::string& key, int64_t value); + void SetAttribute(const std::string& key, double value); + + // Add events to BOTH spans + void AddEvent(const std::string& name); + + // End both spans explicitly (called automatically by destructor) + void End(); + + // Access individual spans (for advanced use) + tracing::ISpan* heartbeat(); + tracing::ISpan* secondary(); + +private: + std::unique_ptr m_heartbeat_span; + std::unique_ptr m_secondary_span; + bool m_ended; +}; + +#ifdef WITH_OTEL + +/** + * Get trace ID as hex string from span. + * Returns empty string if span is invalid or not OtelSpan. + */ +std::string GetTraceId(const tracing::ISpan* span); + +/** + * Set baggage value in current context. + * Baggage propagates through context and appears in logs/spans. + * + * Example: + * auto scope = SetBaggage("combat_id", "1738034567_alice_vs_bob"); + * log("Attack started"); // will have combat_id in attributes + */ +class BaggageScope { +public: + explicit BaggageScope(const std::string& key, const std::string& value); + ~BaggageScope(); + + BaggageScope(const BaggageScope&) = delete; + BaggageScope& operator=(const BaggageScope&) = delete; + +private: + struct Impl; + std::unique_ptr m_impl; +}; + +/** + * Get baggage value from current context. + * Returns empty string if key not found. + */ +std::string GetBaggage(const std::string& key); + +#else // !WITH_OTEL + +// Stub for when OTel is disabled - allows CharData to compile with unique_ptr +class BaggageScope { +public: + BaggageScope(const std::string&, const std::string&) {} + ~BaggageScope() = default; + + BaggageScope(const BaggageScope&) = delete; + BaggageScope& operator=(const BaggageScope&) = delete; +}; + +#endif // WITH_OTEL + +} // namespace observability + +#endif // BYLINS_OTEL_HELPERS_H + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/otel_log_sender.cpp b/src/engine/observability/otel_log_sender.cpp new file mode 100644 index 000000000..a1655e31f --- /dev/null +++ b/src/engine/observability/otel_log_sender.cpp @@ -0,0 +1,167 @@ +#include "otel_log_sender.h" + +#ifdef WITH_OTEL + +#include "otel_provider.h" +#include "opentelemetry/logs/provider.h" +#include "opentelemetry/logs/logger.h" +#include "opentelemetry/trace/provider.h" +#include "opentelemetry/trace/span.h" +#include "opentelemetry/trace/span_context.h" +#include "opentelemetry/trace/trace_id.h" +#include "opentelemetry/trace/span_id.h" +#include "opentelemetry/nostd/span.h" +#include "opentelemetry/nostd/variant.h" +#include "opentelemetry/context/runtime_context.h" +#include "opentelemetry/baggage/baggage.h" +#include "opentelemetry/baggage/baggage_context.h" + +namespace observability { + +// Helper: extract trace_id and span_id from current active span +static std::pair GetCurrentTraceContext() { + // Get current active span from runtime context + auto context_value = opentelemetry::context::RuntimeContext::GetValue(opentelemetry::trace::kSpanKey); + auto span_ptr = opentelemetry::nostd::get_if>(&context_value); + + if (!span_ptr || !(*span_ptr)) { + return {"", ""}; + } + + auto span = *span_ptr; + + auto span_context = span->GetContext(); + + // Check context validity + if (!span_context.IsValid()) { + return {"", ""}; + } + + // Extract trace_id (16 bytes = 32 hex chars) + char trace_id_hex[32]; + span_context.trace_id().ToLowerBase16( + opentelemetry::nostd::span(trace_id_hex, 32) + ); + + // Extract span_id (8 bytes = 16 hex chars) + char span_id_hex[16]; + span_context.span_id().ToLowerBase16( + opentelemetry::nostd::span(span_id_hex, 16) + ); + + // IMPORTANT: ToLowerBase16 doesn't add '\0'! + return { + std::string(trace_id_hex, 32), + std::string(span_id_hex, 16) + }; +} + +// Helper: add trace context and user attributes to log record +static void AddAttributesToLogRecord( + opentelemetry::nostd::unique_ptr& log_record, + const std::map& user_attributes) +{ + if (!log_record) { + return; + } + + // Add trace context (if there's an active span) + auto [trace_id, span_id] = GetCurrentTraceContext(); + if (!trace_id.empty()) { + log_record->SetAttribute("trace_id", trace_id); + log_record->SetAttribute("span_id", span_id); + } + + // Add baggage values (combat_trace_id, quest_trace_id, etc.) + auto current_ctx = opentelemetry::context::RuntimeContext::GetCurrent(); + auto baggage = opentelemetry::baggage::GetBaggage(current_ctx); + if (baggage) { + baggage->GetAllEntries([&log_record](opentelemetry::nostd::string_view key, + opentelemetry::nostd::string_view value) { + std::string key_str(key.data(), key.size()); + std::string value_str(value.data(), value.size()); + log_record->SetAttribute(key_str, value_str); + return true; // continue iteration + }); + } + + + // Add user attributes + for (const auto& [key, value] : user_attributes) { + log_record->SetAttribute(key, value); + } +} + +static opentelemetry::logs::Severity to_otel_level(logging::LogLevel level) { + switch (level) { + case logging::LogLevel::kDebug: return opentelemetry::logs::Severity::kDebug; + case logging::LogLevel::kInfo: return opentelemetry::logs::Severity::kInfo; + case logging::LogLevel::kWarn: return opentelemetry::logs::Severity::kWarn; + case logging::LogLevel::kError: return opentelemetry::logs::Severity::kError; + default: return opentelemetry::logs::Severity::kInfo; + } +} + +// Helper: log with any level +static void LogWithLevel(logging::LogLevel level, + const std::string& message, + const std::map& attributes) { + if (OtelProvider::Instance().IsEnabled()) { + auto logger = OtelProvider::Instance().GetLogger(); + if (logger) { + auto log_record = logger->CreateLogRecord(); + if (log_record) { + log_record->SetSeverity(to_otel_level(level)); + log_record->SetBody(message); + + // Automatically add trace context + user attributes + AddAttributesToLogRecord(log_record, attributes); + + logger->EmitLogRecord(std::move(log_record)); + } + } + } +} + +// All methods now delegate to LogWithLevel +void OtelLogSender::Debug(const std::string& message) { + LogWithLevel(logging::LogLevel::kDebug, message, {}); +} + +void OtelLogSender::Debug(const std::string& message, + const std::map& attributes) { + LogWithLevel(logging::LogLevel::kDebug, message, attributes); +} + +void OtelLogSender::Info(const std::string& message) { + LogWithLevel(logging::LogLevel::kInfo, message, {}); +} + +void OtelLogSender::Info(const std::string& message, + const std::map& attributes) { + LogWithLevel(logging::LogLevel::kInfo, message, attributes); +} + +void OtelLogSender::Warn(const std::string& message) { + LogWithLevel(logging::LogLevel::kWarn, message, {}); +} + +void OtelLogSender::Warn(const std::string& message, + const std::map& attributes) { + LogWithLevel(logging::LogLevel::kWarn, message, attributes); +} + +void OtelLogSender::Error(const std::string& message) { + LogWithLevel(logging::LogLevel::kError, message, {}); +} + +void OtelLogSender::Error(const std::string& message, + const std::map& attributes) { + LogWithLevel(logging::LogLevel::kError, message, attributes); +} + +} // namespace observability + +#endif // WITH_OTEL + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/otel_log_sender.h b/src/engine/observability/otel_log_sender.h new file mode 100644 index 000000000..65198ce71 --- /dev/null +++ b/src/engine/observability/otel_log_sender.h @@ -0,0 +1,37 @@ +#ifndef BYLINS_OTEL_LOG_SENDER_H +#define BYLINS_OTEL_LOG_SENDER_H + +#include "utils/logging/log_sender.h" + +#ifdef WITH_OTEL + +namespace observability { + +// OTEL implementation of log sender +class OtelLogSender : public logging::ILogSender { +public: + OtelLogSender() = default; + ~OtelLogSender() override = default; + + void Debug(const std::string& message) override; + void Debug(const std::string& message, + const std::map& attributes) override; + + void Info(const std::string& message) override; + void Info(const std::string& message, + const std::map& attributes) override; + + void Warn(const std::string& message) override; + void Warn(const std::string& message, + const std::map& attributes) override; + + void Error(const std::string& message) override; + void Error(const std::string& message, + const std::map& attributes) override; +}; + +} // namespace observability + +#endif // WITH_OTEL + +#endif // BYLINS_OTEL_LOG_SENDER_H diff --git a/src/engine/observability/otel_metrics.cpp b/src/engine/observability/otel_metrics.cpp new file mode 100644 index 000000000..832f7506f --- /dev/null +++ b/src/engine/observability/otel_metrics.cpp @@ -0,0 +1,126 @@ +#include "otel_metrics.h" +#include "otel_provider.h" +#include +#include + +#ifdef WITH_OTEL +#include "opentelemetry/metrics/provider.h" +#include "opentelemetry/context/context.h" +#endif + +namespace observability { + +#ifdef WITH_OTEL +static std::unordered_map>> histogram_cache; +#endif + +void OtelMetrics::RecordCounter(const std::string& name, int64_t value) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto meter = OtelProvider::Instance().GetMeter(); + if (meter) { + // Use UInt64Counter and convert value if positive + if (value >= 0) { + auto counter = meter->CreateUInt64Counter(name); + counter->Add(static_cast(value)); + } + } + } +#endif + (void)name; + (void)value; +} + +void OtelMetrics::RecordCounter(const std::string& name, int64_t value, + const std::map& attributes) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto meter = OtelProvider::Instance().GetMeter(); + if (meter) { + if (value >= 0) { + auto counter = meter->CreateUInt64Counter(name); + counter->Add(static_cast(value), attributes); + } + } + } +#endif + (void)name; + (void)value; + (void)attributes; +} + +void OtelMetrics::RecordHistogram(const std::string& name, double value) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto meter = OtelProvider::Instance().GetMeter(); + if (meter) { + auto histogram = meter->CreateDoubleHistogram(name); + auto context = opentelemetry::context::Context{}; + histogram->Record(value, context); + } + } +#endif + (void)name; + (void)value; +} + +void OtelMetrics::RecordHistogram(const std::string& name, double value, + const std::map& attributes) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto meter = OtelProvider::Instance().GetMeter(); + if (meter) { + // Cache histogram instruments to avoid recreation + auto it = histogram_cache.find(name); + opentelemetry::metrics::Histogram* histogram = nullptr; + if (it == histogram_cache.end()) { + auto h = meter->CreateDoubleHistogram(name); + histogram = h.get(); + histogram_cache[name] = std::move(h); + } else { + histogram = it->second.get(); + } + auto context = opentelemetry::context::Context{}; + histogram->Record(value, attributes, context); + } + } +#endif + (void)name; + (void)value; + (void)attributes; +} + +void OtelMetrics::RecordGauge(const std::string& name, double value) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto meter = OtelProvider::Instance().GetMeter(); + if (meter) { + // Gauges in OTEL require callbacks, so we'll use histogram instead + auto histogram = meter->CreateDoubleHistogram(name + ".gauge"); + auto context = opentelemetry::context::Context{}; + histogram->Record(value, context); + } + } +#endif + (void)name; + (void)value; +} + +void OtelMetrics::RecordGauge(const std::string& name, double value, + const std::map& attributes) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto meter = OtelProvider::Instance().GetMeter(); + if (meter) { + auto histogram = meter->CreateDoubleHistogram(name + ".gauge"); + auto context = opentelemetry::context::Context{}; + histogram->Record(value, attributes, context); + } + } +#endif + (void)name; + (void)value; + (void)attributes; +} + +} // namespace observability diff --git a/src/engine/observability/otel_metrics.h b/src/engine/observability/otel_metrics.h new file mode 100644 index 000000000..9d123ac15 --- /dev/null +++ b/src/engine/observability/otel_metrics.h @@ -0,0 +1,30 @@ +#ifndef BYLINS_OTEL_METRICS_H +#define BYLINS_OTEL_METRICS_H + +#include +#include +#include + +namespace observability { + +class OtelMetrics { +public: + // Счётчик (Counter) - монотонно растущее значение + static void RecordCounter(const std::string& name, int64_t value); + static void RecordCounter(const std::string& name, int64_t value, + const std::map& attributes); + + // Гистограмма (Histogram) - распределение значений + static void RecordHistogram(const std::string& name, double value); + static void RecordHistogram(const std::string& name, double value, + const std::map& attributes); + + // Измеритель (Gauge) - текущее значение + static void RecordGauge(const std::string& name, double value); + static void RecordGauge(const std::string& name, double value, + const std::map& attributes); +}; + +} // namespace observability + +#endif // BYLINS_OTEL_METRICS_H \ No newline at end of file diff --git a/src/engine/observability/otel_provider.cpp b/src/engine/observability/otel_provider.cpp new file mode 100644 index 000000000..688a6c296 --- /dev/null +++ b/src/engine/observability/otel_provider.cpp @@ -0,0 +1,239 @@ +#include "otel_provider.h" +#include "engine/core/config.h" +#include "utils/logging/log_manager.h" +#include "otel_log_sender.h" +#include "otel_trace_sender.h" +#include "utils/tracing/trace_manager.h" + +#ifdef WITH_OTEL +#include "opentelemetry/sdk/trace/tracer_provider_factory.h" +#include "opentelemetry/sdk/trace/batch_span_processor_factory.h" +#include "opentelemetry/sdk/metrics/meter_provider_factory.h" +#include "opentelemetry/sdk/metrics/meter_context_factory.h" +#include "opentelemetry/sdk/logs/logger_provider_factory.h" +#include "opentelemetry/sdk/logs/batch_log_record_processor_factory.h" +#include "opentelemetry/exporters/otlp/otlp_http_exporter_factory.h" +#include "opentelemetry/exporters/otlp/otlp_http_exporter_options.h" +#include "opentelemetry/exporters/otlp/otlp_http_metric_exporter_factory.h" +#include "opentelemetry/exporters/otlp/otlp_http_metric_exporter_options.h" +#include "opentelemetry/exporters/otlp/otlp_http_log_record_exporter_factory.h" +#include "opentelemetry/exporters/otlp/otlp_http_log_record_exporter_options.h" +#include "opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_factory.h" +#include "opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_options.h" +#include "opentelemetry/sdk/resource/resource.h" +#include "opentelemetry/trace/provider.h" +#include "opentelemetry/metrics/provider.h" +#include "opentelemetry/logs/provider.h" + +#include +#include +#endif + +#ifndef WITH_OTEL +#include +#endif + +namespace observability { + +OtelProvider& OtelProvider::Instance() { + static OtelProvider instance; + return instance; +} + +OtelProvider::OtelProvider() { + // Constructor - log senders are managed by LogManager + // See Initialize() for OTEL log sender registration +} + +void OtelProvider::Initialize(const std::string& metrics_endpoint, + const std::string& traces_endpoint, + const std::string& logs_endpoint, + const std::string& service_name, + const std::string& service_version) { +#ifdef WITH_OTEL + if (m_enabled) { + return; // Already initialized + } + + try { + // Create resource attributes + auto resource = otel::sdk::resource::Resource::Create({ + {"service.name", service_name}, + {"service.version", service_version} + }); + + // Initialize TracerProvider with OTLP HTTP exporter + // Based on examples/otlp/http_log_main.cc + { + otel::exporter::otlp::OtlpHttpExporterOptions trace_options; + trace_options.url = traces_endpoint; + + auto exporter = otel::exporter::otlp::OtlpHttpExporterFactory::Create(trace_options); + auto processor = otel::sdk::trace::BatchSpanProcessorFactory::Create(std::move(exporter), {}); + m_tracer_provider = otel::sdk::trace::TracerProviderFactory::Create(std::move(processor), resource); + + // Set as global provider + std::shared_ptr api_provider = m_tracer_provider; + otel::trace::Provider::SetTracerProvider(api_provider); + } + + // Initialize MeterProvider with OTLP HTTP exporter + // Based on examples/otlp/http_metric_main.cc + { + otel::exporter::otlp::OtlpHttpMetricExporterOptions metric_options; + metric_options.url = metrics_endpoint; + + auto exporter = otel::exporter::otlp::OtlpHttpMetricExporterFactory::Create(metric_options); + + otel::sdk::metrics::PeriodicExportingMetricReaderOptions reader_options; + reader_options.export_interval_millis = std::chrono::milliseconds(5000); + reader_options.export_timeout_millis = std::chrono::milliseconds(3000); + + auto reader = otel::sdk::metrics::PeriodicExportingMetricReaderFactory::Create( + std::move(exporter), reader_options + ); + + // Create context and add reader + auto meter_context = otel::sdk::metrics::MeterContextFactory::Create(); + meter_context->AddMetricReader(std::move(reader)); + + // Create provider from context + auto u_provider = otel::sdk::metrics::MeterProviderFactory::Create(std::move(meter_context)); + m_meter_provider = std::move(u_provider); + + // Set as global provider + std::shared_ptr api_provider = m_meter_provider; + otel::metrics::Provider::SetMeterProvider(api_provider); + } + + // Initialize LoggerProvider with OTLP HTTP exporter + // Based on examples/otlp/http_log_main.cc + { + otel::exporter::otlp::OtlpHttpLogRecordExporterOptions log_options; + log_options.url = logs_endpoint; + + auto exporter = otel::exporter::otlp::OtlpHttpLogRecordExporterFactory::Create(log_options); + + otel::sdk::logs::BatchLogRecordProcessorOptions processor_options; + processor_options.max_queue_size = 2048; + processor_options.schedule_delay_millis = std::chrono::milliseconds(5000); + processor_options.max_export_batch_size = 512; + + auto processor = otel::sdk::logs::BatchLogRecordProcessorFactory::Create( + std::move(exporter), processor_options + ); + + m_logger_provider = otel::sdk::logs::LoggerProviderFactory::Create(std::move(processor), resource); + + // Set as global provider + std::shared_ptr api_provider = m_logger_provider; + otel::logs::Provider::SetLoggerProvider(api_provider); + } + + // Register OTEL log sender with LogManager based on config mode + const auto mode = ::runtime_config.telemetry_log_mode(); + + if (mode == RuntimeConfiguration::ETelemetryLogMode::kDuplicate) { + // File sender already registered by LogManager constructor, add OTEL + logging::LogManager::Instance().AddSender(std::make_unique()); + std::cout << "Log mode: duplicate (file + OTEL)" << std::endl; + } else if (mode == RuntimeConfiguration::ETelemetryLogMode::kOtelOnly) { + // Replace file sender with OTEL only + logging::LogManager::Instance().ClearSenders(); + logging::LogManager::Instance().AddSender(std::make_unique()); + std::cout << "Log mode: otel-only" << std::endl; + } else { + // kFileOnly - no OTEL senders + // File sender already registered by LogManager constructor, do nothing + std::cout << "Log mode: file-only (OTEL initialized but not used for logs)" << std::endl; + } + + // Initialize TraceManager with appropriate sender + tracing::TraceManager::Instance().SetSender( + std::make_unique() + ); + std::cout << "TraceManager initialized with OtelTraceSender" << std::endl; + + m_enabled = true; + std::cout << "OpenTelemetry initialized successfully:" << std::endl; + std::cout << " Metrics: " << metrics_endpoint << std::endl; + std::cout << " Traces: " << traces_endpoint << std::endl; + std::cout << " Logs: " << logs_endpoint << std::endl; + } catch (const std::exception& e) { + std::cerr << "Failed to initialize OpenTelemetry: " << e.what() << std::endl; + m_enabled = false; + // Initialize TraceManager with NoOp sender on error + tracing::TraceManager::Instance().SetSender( + std::make_unique() + ); + std::cout << "TraceManager initialized with NoOpTraceSender (OTEL init failed)" << std::endl; + } +#else + (void)metrics_endpoint; + (void)traces_endpoint; + (void)logs_endpoint; + (void)service_name; + (void)service_version; + // Initialize TraceManager with NoOp sender (no OTEL) + tracing::TraceManager::Instance().SetSender( + std::make_unique() + ); + std::cout << "TraceManager initialized with NoOpTraceSender (no OTEL)" << std::endl; +#endif +} + +void OtelProvider::Shutdown() { +#ifdef WITH_OTEL + if (!m_enabled) { + return; + } + + try { + // Shutdown providers to flush remaining telemetry + if (m_tracer_provider) { + m_tracer_provider->ForceFlush(); + m_tracer_provider->Shutdown(); + } + if (m_meter_provider) { + m_meter_provider->ForceFlush(); + m_meter_provider->Shutdown(); + } + if (m_logger_provider) { + m_logger_provider->ForceFlush(); + m_logger_provider->Shutdown(); + } + + m_enabled = false; + std::cout << "OpenTelemetry shutdown successfully" << std::endl; + } catch (const std::exception& e) { + std::cerr << "Error during OpenTelemetry shutdown: " << e.what() << std::endl; + } +#endif +} + +#ifdef WITH_OTEL +nostd::shared_ptr OtelProvider::GetTracer() { + if (!m_enabled || !m_tracer_provider) { + return nostd::shared_ptr(nullptr); + } + return m_tracer_provider->GetTracer("bylins-tracer", "1.0.0"); +} + +nostd::shared_ptr OtelProvider::GetMeter() { + if (!m_enabled || !m_meter_provider) { + return nostd::shared_ptr(nullptr); + } + return m_meter_provider->GetMeter("bylins-meter", "1.0.0"); +} + +nostd::shared_ptr OtelProvider::GetLogger() { + if (!m_enabled || !m_logger_provider) { + return nostd::shared_ptr(nullptr); + } + return m_logger_provider->GetLogger("bylins-logger", "", "", ""); +} +#endif + +} // namespace observability + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/otel_provider.h b/src/engine/observability/otel_provider.h new file mode 100644 index 000000000..2be3ab66c --- /dev/null +++ b/src/engine/observability/otel_provider.h @@ -0,0 +1,61 @@ +#ifndef BYLINS_OTEL_PROVIDER_H +#define BYLINS_OTEL_PROVIDER_H + +#include +#include + +#ifdef WITH_OTEL +#include "opentelemetry/sdk/trace/tracer_provider.h" +#include "opentelemetry/sdk/metrics/meter_provider.h" +#include "opentelemetry/sdk/logs/logger_provider.h" +#include "opentelemetry/nostd/shared_ptr.h" + +namespace otel = opentelemetry; +namespace trace_api = otel::trace; +namespace metrics_api = otel::metrics; +namespace logs_api = otel::logs; +namespace nostd = otel::nostd; +#endif + +namespace observability { + +// Forward declaration +class ILogSender; + +class OtelProvider { +public: + static OtelProvider& Instance(); + + void Initialize(const std::string& metrics_endpoint, + const std::string& traces_endpoint, + const std::string& logs_endpoint, + const std::string& service_name, + const std::string& service_version); + void Shutdown(); + + bool IsEnabled() const { return m_enabled; } + +#ifdef WITH_OTEL + nostd::shared_ptr GetTracer(); + nostd::shared_ptr GetMeter(); + nostd::shared_ptr GetLogger(); +#endif + +private: + OtelProvider(); + ~OtelProvider() = default; + OtelProvider(const OtelProvider&) = delete; + OtelProvider& operator=(const OtelProvider&) = delete; + + bool m_enabled = false; + +#ifdef WITH_OTEL + std::shared_ptr m_tracer_provider; + std::shared_ptr m_meter_provider; + std::shared_ptr m_logger_provider; +#endif +}; + +} // namespace observability + +#endif // BYLINS_OTEL_PROVIDER_H diff --git a/src/engine/observability/otel_trace_sender.cpp b/src/engine/observability/otel_trace_sender.cpp new file mode 100644 index 000000000..e75858f7f --- /dev/null +++ b/src/engine/observability/otel_trace_sender.cpp @@ -0,0 +1,97 @@ +#include "otel_trace_sender.h" + +#ifdef WITH_OTEL +#include "otel_provider.h" +#include "opentelemetry/trace/provider.h" + +namespace tracing { +OtelSpan::OtelSpan(opentelemetry::nostd::shared_ptr span) + : m_span(span) + , m_scope(span ? opentelemetry::nostd::unique_ptr( + new opentelemetry::trace::Scope(span)) : nullptr) {} + +OtelSpan::OtelSpan(opentelemetry::nostd::shared_ptr span, bool create_scope) + : m_span(span) + , m_scope(create_scope && span ? opentelemetry::nostd::unique_ptr( + new opentelemetry::trace::Scope(span)) : nullptr) {} + +void OtelSpan::End() { + if (m_span) { + m_span->End(); + } +} + +void OtelSpan::AddEvent(const std::string& name) { + if (m_span) { + m_span->AddEvent(name); + } +} + +void OtelSpan::SetAttribute(const std::string& key, const std::string& value) { + if (m_span) { + m_span->SetAttribute(key, value); + } +} + +void OtelSpan::SetAttribute(const std::string& key, int64_t value) { + if (m_span) { + m_span->SetAttribute(key, value); + } +} + +void OtelSpan::SetAttribute(const std::string& key, double value) { + if (m_span) { + m_span->SetAttribute(key, value); + } +} + +bool OtelSpan::IsValid() const { + return m_span != nullptr; +} + +opentelemetry::trace::SpanContext OtelSpan::GetContext() const { + if (m_span) { + return m_span->GetContext(); + } + return opentelemetry::trace::SpanContext::GetInvalid(); +} + +std::unique_ptr OtelTraceSender::StartSpan(const std::string& name) { + if (observability::OtelProvider::Instance().IsEnabled()) { + auto tracer = observability::OtelProvider::Instance().GetTracer(); + if (tracer) { + auto span = tracer->StartSpan(name); + return std::make_unique(span); + } + } + return std::make_unique(); +} + +std::unique_ptr OtelTraceSender::StartChildSpan( + const std::string& name, + const ISpan& parent) +{ + // Downcast to OtelSpan to get context + const OtelSpan* otel_parent = dynamic_cast(&parent); + if (!otel_parent || !otel_parent->IsValid()) { + return std::make_unique(); + } + + if (observability::OtelProvider::Instance().IsEnabled()) { + auto tracer = observability::OtelProvider::Instance().GetTracer(); + if (tracer) { + opentelemetry::trace::StartSpanOptions options; + options.parent = otel_parent->GetContext(); + + auto span = tracer->StartSpan(name, {}, options); + return std::make_unique(span); + } + } + return std::make_unique(); +} + +} // namespace tracing + +#endif // WITH_OTEL + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/otel_trace_sender.h b/src/engine/observability/otel_trace_sender.h new file mode 100644 index 000000000..50b44a1c2 --- /dev/null +++ b/src/engine/observability/otel_trace_sender.h @@ -0,0 +1,45 @@ +#ifndef BYLINS_OTEL_TRACE_SENDER_H +#define BYLINS_OTEL_TRACE_SENDER_H + +#include "utils/tracing/trace_sender.h" +#include "utils/tracing/noop_trace_sender.h" + +#ifdef WITH_OTEL +#include "opentelemetry/trace/span.h" +#include "opentelemetry/trace/scope.h" +#include "opentelemetry/nostd/shared_ptr.h" +#include "opentelemetry/nostd/unique_ptr.h" + +namespace tracing { + +class OtelSpan : public ISpan { +public: + explicit OtelSpan(opentelemetry::nostd::shared_ptr span); + OtelSpan(opentelemetry::nostd::shared_ptr span, bool create_scope); + + void End() override; + void AddEvent(const std::string& name) override; + void SetAttribute(const std::string& key, const std::string& value) override; + void SetAttribute(const std::string& key, int64_t value) override; + void SetAttribute(const std::string& key, double value) override; + bool IsValid() const override; + + opentelemetry::trace::SpanContext GetContext() const; + +private: + opentelemetry::nostd::shared_ptr m_span; + opentelemetry::nostd::unique_ptr m_scope; +}; + +class OtelTraceSender : public ITraceSender { +public: + std::unique_ptr StartSpan(const std::string& name) override; + std::unique_ptr StartChildSpan(const std::string& name, const ISpan& parent) override; +}; + +} // namespace tracing + +#endif // WITH_OTEL +#endif // BYLINS_OTEL_TRACE_SENDER_H + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/engine/observability/otel_traces.cpp b/src/engine/observability/otel_traces.cpp new file mode 100644 index 000000000..5e89416ce --- /dev/null +++ b/src/engine/observability/otel_traces.cpp @@ -0,0 +1,73 @@ +#include "otel_traces.h" +#include "otel_provider.h" + +#ifdef WITH_OTEL +#include "opentelemetry/trace/provider.h" +#endif + +namespace observability { + +#ifdef WITH_OTEL +Span::Span(opentelemetry::nostd::shared_ptr span) : m_span(span) {} + +void Span::End() { + if (m_span) { + m_span->End(); + } +} + +void Span::AddEvent(const std::string& name) { + if (m_span) { + m_span->AddEvent(name); + } +} + +void Span::SetAttribute(const std::string& key, const std::string& value) { + if (m_span) { + m_span->SetAttribute(key, value); + } +} + +void Span::SetAttribute(const std::string& key, int64_t value) { + if (m_span) { + m_span->SetAttribute(key, value); + } +} + +void Span::SetAttribute(const std::string& key, double value) { + if (m_span) { + m_span->SetAttribute(key, value); + } +} +#endif + +Span OtelTraces::StartSpan(const std::string& name) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto tracer = OtelProvider::Instance().GetTracer(); + if (tracer) { + return Span(tracer->StartSpan(name)); + } + } +#endif + return Span(); +} + +Span OtelTraces::StartSpan(const std::string& name, + const std::map& attributes) { +#ifdef WITH_OTEL + if (OtelProvider::Instance().IsEnabled()) { + auto tracer = OtelProvider::Instance().GetTracer(); + if (tracer) { + auto span = tracer->StartSpan(name); + for (const auto& attr : attributes) { + span->SetAttribute(attr.first, attr.second); + } + return Span(span); + } + } +#endif + return Span(); +} + +} // namespace observability \ No newline at end of file diff --git a/src/engine/observability/otel_traces.h b/src/engine/observability/otel_traces.h new file mode 100644 index 000000000..dbc4084b0 --- /dev/null +++ b/src/engine/observability/otel_traces.h @@ -0,0 +1,61 @@ +#ifndef BYLINS_OTEL_TRACES_H +#define BYLINS_OTEL_TRACES_H + +#include +#include +#include + +#ifdef WITH_OTEL +#include "opentelemetry/trace/span.h" +namespace trace_api = opentelemetry::trace; +#endif + +namespace observability { + +class Span { +public: + Span() = default; + +#ifdef WITH_OTEL + explicit Span(opentelemetry::nostd::shared_ptr span); + void End(); + void AddEvent(const std::string& name); + void SetAttribute(const std::string& key, const std::string& value); + void SetAttribute(const std::string& key, int64_t value); + void SetAttribute(const std::string& key, double value); +#else + void End() {} + void AddEvent(const std::string&) {} + void SetAttribute(const std::string&, const std::string&) {} + void SetAttribute(const std::string&, int64_t) {} + void SetAttribute(const std::string&, double) {} +#endif + +private: +#ifdef WITH_OTEL + opentelemetry::nostd::shared_ptr m_span; +#endif +}; + +class OtelTraces { +public: + static Span StartSpan(const std::string& name); + static Span StartSpan(const std::string& name, + const std::map& attributes); +}; + +// RAII span guard для автоматического завершения +class SpanGuard { +public: + explicit SpanGuard(Span span) : m_span(std::move(span)) {} + ~SpanGuard() { m_span.End(); } + + Span& GetSpan() { return m_span; } + +private: + Span m_span; +}; + +} // namespace observability + +#endif // BYLINS_OTEL_TRACES_H \ No newline at end of file diff --git a/src/engine/scripting/dg_olc.cpp b/src/engine/scripting/dg_olc.cpp index 17f521a08..adb7892b9 100644 --- a/src/engine/scripting/dg_olc.cpp +++ b/src/engine/scripting/dg_olc.cpp @@ -592,6 +592,86 @@ bool trigedit_save_to_disk(int zone_rnum, int notify_level) { return true; } +// Save all triggers for a zone to disk (without requiring DescriptorData) +void trigedit_save_to_disk(int zone_rnum) { + int trig_rnum, i; + Trigger *trig; + FILE *trig_file; + int zone, top; + char buf[kMaxStringLength]; + char bitBuf[kMaxInputLength]; + char fname[kMaxInputLength]; + + if (zone_rnum < 0 || zone_rnum >= static_cast(zone_table.size())) { + log("SYSERR: trigedit_save_to_disk: Invalid zone rnum %d", zone_rnum); + return; + } + + zone = zone_table[zone_rnum].vnum; + top = zone_table[zone_rnum].top; + + if (zone >= dungeons::kZoneStartDungeons) { + log("Cannot save dungeon zone %d to disk.", zone); + return; + } + + sprintf(fname, "%s/%i.new", TRG_PREFIX, zone); + if (!(trig_file = fopen(fname, "w"))) { + log("SYSERR: OLC: Can't open trig file \"%s\"", fname); + return; + } + + for (i = zone * 100; i <= top; i++) { + if ((trig_rnum = GetTriggerRnum(i)) != -1) { + trig = trig_index[trig_rnum]->proto; + + if (fprintf(trig_file, "#%d\n", i) < 0) { + log("SYSERR: OLC: Can't write trig file!"); + fclose(trig_file); + return; + } + sprintbyts(GET_TRIG_TYPE(trig), bitBuf); + fprintf(trig_file, "%s~\n" + "%d %s %d %d\n" + "%s~\n", + (GET_TRIG_NAME(trig)) ? (GET_TRIG_NAME(trig)) : + "unknown trigger", trig->get_attach_type(), bitBuf, + GET_TRIG_NARG(trig), trig->add_flag, trig->arglist.c_str()); + + // Build the text for the script + int lev = 0; + strcpy(buf, ""); + for (auto cmd = *trig->cmdlist; cmd; cmd = cmd->next) { + indent_trigger(cmd->cmd, &lev); + strcat(buf, cmd->cmd.c_str()); + strcat(buf, "\n"); + } + + if (!buf[0]) { + strcpy(buf, "* Empty script~\n"); + fprintf(trig_file, "%s", buf); + } else { + char *p; + p = strtok(buf, "~"); + fprintf(trig_file, "%s", p); + while ((p = strtok(nullptr, "~")) != nullptr) { + fprintf(trig_file, "~~%s", p); + } + fprintf(trig_file, "~\n"); + } + } + } + + fprintf(trig_file, "$\n$\n"); + fclose(trig_file); + + sprintf(buf, "%s/%d.trg", TRG_PREFIX, zone); + remove(buf); + rename(fname, buf); + + trigedit_create_index(zone, "trg"); +} + void trigedit_create_index(int znum, const char *type) { FILE *newfile, *oldfile; char new_name[32], old_name[32]; diff --git a/src/engine/scripting/dg_scripts.cpp b/src/engine/scripting/dg_scripts.cpp index 44598a033..2131b5725 100644 --- a/src/engine/scripting/dg_scripts.cpp +++ b/src/engine/scripting/dg_scripts.cpp @@ -39,6 +39,9 @@ #include "utils/backtrace.h" #include "gameplay/mechanics/armor.h" #include "gameplay/classes/recalc_mob_params_by_vnum.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" extern int max_exp_gain_pc(CharData *ch); extern long GetExpUntilNextLvl(CharData *ch, int level); @@ -666,6 +669,23 @@ ObjData *get_obj_by_char(CharData *ch, char *name) { void script_trigger_check(int mode) { utils::CExecutionTimer timer; + // OpenTelemetry: Track script trigger checking + auto trigger_span = tracing::TraceManager::Instance().StartSpan("Script Trigger Check"); + observability::ScopedMetric trigger_metric("script.trigger.duration"); + + // Determine trigger type + std::string trigger_type; + switch (mode) { + case MOB_TRIGGER: trigger_type = "MOB"; break; + case OBJ_TRIGGER: trigger_type = "OBJ"; break; + case WLD_TRIGGER: trigger_type = "WLD"; break; + default: trigger_type = "UNKNOWN"; break; + } + + trigger_span->SetAttribute("trigger_type", trigger_type); + trigger_span->SetAttribute("mode", static_cast(mode)); + + switch (mode) { case MOB_TRIGGER: for (auto ch : character_list) { @@ -709,6 +729,11 @@ void script_trigger_check(int mode) { default: break; } + + // OpenTelemetry: Record script trigger metrics + std::map attrs; + attrs["trigger_type"] = trigger_type; + log("script_trigger_check() mode %d всего: %f ms.", mode, timer.delta().count()); } diff --git a/src/gameplay/ai/mobact.cpp b/src/gameplay/ai/mobact.cpp index 9a94d2248..8cfbbf756 100644 --- a/src/gameplay/ai/mobact.cpp +++ b/src/gameplay/ai/mobact.cpp @@ -27,6 +27,9 @@ #include "gameplay/abilities/abilities_rollsystem.h" #include "engine/core/action_targeting.h" +#include "engine/observability/otel_helpers.h" +#include "utils/tracing/trace_manager.h" +#include "engine/observability/otel_metrics.h" #include "engine/core/char_movement.h" #include "engine/db/world_characters.h" #include "engine/db/world_objects.h" @@ -878,343 +881,692 @@ bool allow_enter(RoomData *room, CharData *ch) { void mobile_activity(int activity_level, int missed_pulses) { + + // OpenTelemetry: Create parent span for mobile activity + auto activity_span = tracing::TraceManager::Instance().StartSpan("Mobile Activity"); + observability::ScopedMetric activity_metric("mob.ai.duration", { + {"ai_level", std::to_string(activity_level)} + }); + + int active_mob_count = 0; // int door, max, was_in = -1, activity_lev, i, ch_activity; + // int std_lev = activity_level % kPulseMobile; + + for (auto &ch : character_list) { + int door, max, was_in = -1, activity_lev, i, ch_activity; + auto std_lev = activity_level % kPulseMobile; + + if (ch->purged() || !IS_MOB(ch) || !ch->in_used_zone()) { + continue; + } + + active_mob_count++; UpdateAffectOnPulse(ch.get(), missed_pulses); + if (ch->punctual_wait > 0) + ch->punctual_wait -= missed_pulses; + else + ch->punctual_wait = 0; + + if (ch->punctual_wait < 0) + ch->punctual_wait = 0; + + if (ch->mob_specials.speed <= 0) { + activity_lev = std_lev; + } else { + activity_lev = activity_level % (ch->mob_specials.speed * kRealSec); + } + + ch_activity = GET_ACTIVITY(ch); + + // на случай вызова mobile_activity() не каждый пульс + // TODO: by WorM а где-то используется это mob_specials.speed ??? + if (ch_activity - activity_lev < missed_pulses && ch_activity - activity_lev >= 0) { + ch_activity = activity_lev; + } + if (ch_activity != activity_lev + || (was_in = ch->in_room) == kNowhere + || GET_ROOM_VNUM(ch->in_room) % 100 == 99) { + continue; + } + + // Examine call for special procedure + if (ch->IsFlagged(EMobFlag::kSpec) && !no_specials) { + if (mob_index[GET_MOB_RNUM(ch)].func == nullptr) { + log("SYSERR: %s (#%d): Attempting to call non-existing mob function.", + GET_NAME(ch), GET_MOB_VNUM(ch)); + ch->UnsetFlag(EMobFlag::kSpec); + } else { + buf2[0] = '\0'; + if ((mob_index[GET_MOB_RNUM(ch)].func)(ch.get(), ch.get(), 0, buf2)) { + continue; // go to next char + } + } + } + // Extract free horses + if (AFF_FLAGGED(ch, EAffect::kHorse) && ch->IsFlagged(EMobFlag::kMounting) && !ch->has_master()) { + act("Возникший как из-под земли цыган ловко вскочил на $n3 и унесся прочь.", + false, ch.get(), nullptr, nullptr, kToRoom); + character_list.AddToExtractedList(ch.get()); + continue; + } + // Extract uncharmed mobs + if (ch->extract_timer > 0) { + if (ch->has_master()) { + ch->extract_timer = 0; + } else { + --(ch->extract_timer); + if (!(ch->extract_timer)) { extract_charmice(ch.get(), true); continue; + } + } + } + // If the mob has no specproc, do the default actions + if (ch->GetEnemy() || + ch->GetPosition() <= EPosition::kStun || + ch->get_wait() > 0 || + AFF_FLAGGED(ch, EAffect::kCharmed) || + AFF_FLAGGED(ch, EAffect::kHold) || AFF_FLAGGED(ch, EAffect::kMagicStopFight) || + AFF_FLAGGED(ch, EAffect::kStopFight) || AFF_FLAGGED(ch, EAffect::kSleep)) { + continue; + } + + if (IS_HORSE(ch)) { + if (ch->GetPosition() < EPosition::kFight) { + ch->SetPosition(EPosition::kStand); + } + + continue; + } + + if (ch->GetPosition() == EPosition::kSleep && GET_DEFAULT_POS(ch) > EPosition::kSleep) { + ch->SetPosition(GET_DEFAULT_POS(ch)); + act("$n проснул$u.", false, ch.get(), nullptr, nullptr, kToRoom); + } + + if (!AWAKE(ch)) { + continue; + } + + max = false; + bool found = false; + for (const auto vict : world[ch->in_room]->people) { + if (ch.get() == vict) { + continue; + } + + if (vict->GetEnemy() == ch.get()) { + continue; // Mob is under attack + } + + if (!vict->IsNpc() + && CAN_SEE(ch, vict)) { + max = true; + } + } + + // Mob attemp rest if it is not an angel + if (!max && !ch->IsFlagged(EMobFlag::kNoRest) + && !ch->IsFlagged(EMobFlag::kHorde) + && ch->get_hit() < ch->get_real_max_hit() + && !ch->IsFlagged(EMobFlag::kTutelar) + && !ch->IsFlagged(EMobFlag::kMentalShadow) + && !ch->IsOnHorse() + && ch->GetPosition() > EPosition::kRest) { + act("$n присел$g отдохнуть.", false, ch.get(), nullptr, nullptr, kToRoom); + ch->SetPosition(EPosition::kRest); + } + + // Mob continue to default pos if full rested or if it is an angel + if ((ch->get_hit() >= ch->get_real_max_hit() + && ch->GetPosition() != GET_DEFAULT_POS(ch)) + || ((ch->IsFlagged(EMobFlag::kTutelar) + || ch->IsFlagged(EMobFlag::kMentalShadow)) + && ch->GetPosition() != GET_DEFAULT_POS(ch))) { + switch (GET_DEFAULT_POS(ch)) { + case EPosition::kStand: act("$n поднял$u.", false, ch.get(), nullptr, nullptr, kToRoom); + ch->SetPosition(EPosition::kStand); + break; + case EPosition::kSit: act("$n сел$g.", false, ch.get(), nullptr, nullptr, kToRoom); + ch->SetPosition(EPosition::kSit); + break; + case EPosition::kRest: act("$n присел$g отдохнуть.", false, ch.get(), nullptr, nullptr, kToRoom); + ch->SetPosition(EPosition::kRest); + break; + case EPosition::kSleep: act("$n уснул$g.", false, ch.get(), nullptr, nullptr, kToRoom); + ch->SetPosition(EPosition::kSleep); + break; + default: break; + } + } + // continue, if the mob is an angel + // если моб ментальная тень или ангел он не должен проявлять активность + if ((ch->IsFlagged(EMobFlag::kTutelar)) + || (ch->IsFlagged(EMobFlag::kMentalShadow))) { + continue; + } + + // look at room before moving + do_aggressive_mob(ch.get(), false); + + // if mob attack something + if (ch->GetEnemy() + || ch->get_wait() > 0) { + continue; + } + + // Scavenger (picking up objects) + // От одного до трех предметов за раз + i = number(1, 3); + while (i) { + npc_scavenge(ch.get()); + i--; + } + + if (ch->extract_timer == 0) { + //чармисы, собирающиеся уходить - не лутят! (Купала) + //Niker: LootCR// Start + //Не уверен, что рассмотрены все случаи, когда нужно снимать флаги с моба + //Реализация для лута и воровства + int grab_stuff = false; + // Looting the corpses + + grab_stuff += npc_loot(ch.get()); + grab_stuff += npc_steal(ch.get()); + + if (grab_stuff) { + ch->UnsetFlag(EMobFlag::kAppearsDay); //Взял из make_horse + ch->UnsetFlag(EMobFlag::kAppearsNight); + ch->UnsetFlag(EMobFlag::kAppearsFullmoon); + ch->UnsetFlag(EMobFlag::kAppearsWinter); + ch->UnsetFlag(EMobFlag::kAppearsSpring); + ch->UnsetFlag(EMobFlag::kAppearsSummer); + ch->UnsetFlag(EMobFlag::kAppearsAutumn); + } + //Niker: LootCR// End + } + npc_wield(ch.get()); + npc_armor(ch.get()); + + if (ch->GetPosition() == EPosition::kStand && NPC_FLAGGED(ch, ENpcFlag::kInvis)) { + ch->set_affect(EAffect::kInvisible); + } + + if (ch->GetPosition() == EPosition::kStand && NPC_FLAGGED(ch, ENpcFlag::kMoveFly)) { + ch->set_affect(EAffect::kFly); + } + + if (ch->GetPosition() == EPosition::kStand && NPC_FLAGGED(ch, ENpcFlag::kSneaking)) { + if (CalcCurrentSkill(ch.get(), ESkill::kSneak, nullptr) >= number(0, 100)) { + ch->set_affect(EAffect::kSneak); + } else { + ch->remove_affect(EAffect::kSneak); + } + affect_total(ch.get()); + } + + if (ch->GetPosition() == EPosition::kStand && NPC_FLAGGED(ch, ENpcFlag::kDisguising)) { + if (CalcCurrentSkill(ch.get(), ESkill::kDisguise, nullptr) >= number(0, 100)) { + ch->set_affect(EAffect::kDisguise); + } else { + ch->remove_affect(EAffect::kDisguise); + } + + affect_total(ch.get()); + } + + door = kBfsError; + + // Helpers go to some dest + if (ch->IsFlagged(EMobFlag::kHelper) + && !ch->IsFlagged(EMobFlag::kSentinel) + && !AFF_FLAGGED(ch, EAffect::kBlind) + && !ch->has_master() + && ch->GetPosition() == EPosition::kStand) { + for (found = false, door = 0; door < EDirection::kMaxDirNum; door++) { + RoomData::exit_data_ptr rdata = EXIT(ch, door); + if (!rdata + || rdata->to_room() == kNowhere + || !IsCorrectDirection(ch.get(), door, true, false) + || (is_room_forbidden(world[rdata->to_room()]) + && !ch->IsFlagged(EMobFlag::kIgnoreForbidden)) + || is_dark(rdata->to_room()) + || (ch->IsFlagged(EMobFlag::kStayZone) + && world[ch->in_room]->zone_rn != world[rdata->to_room()]->zone_rn)) { + continue; + } + + const auto room = world[rdata->to_room()]; + for (auto first : room->people) { + if (first->IsNpc() + && !AFF_FLAGGED(first, EAffect::kCharmed) + && !IS_HORSE(first) + && CAN_SEE(ch, first) + && first->GetEnemy() + && SAME_ALIGN(ch, first)) { + found = true; + break; + } + } + + if (found) { + break; + } + } + + if (!found) { + door = kBfsError; + } + } + + if (GET_DEST(ch) != kNowhere + && ch->GetPosition() > EPosition::kFight + && door == kBfsError) { + npc_group(ch.get()); + door = npc_walk(ch.get()); + } + + if (MEMORY(ch) && door == kBfsError && ch->GetPosition() > EPosition::kFight && ch->GetSkill(ESkill::kTrack)) + door = npc_track(ch.get()); + + if (door == kBfsAlreadyThere) { + do_aggressive_mob(ch.get(), false); + continue; + } + + if (door == kBfsError) { + door = number(0, 18); + } + + // Mob Movement + if (!ch->IsFlagged(EMobFlag::kSentinel) + && ch->GetPosition() == EPosition::kStand + && (door >= 0 && door < EDirection::kMaxDirNum) + && EXIT(ch, door) + && EXIT(ch, door)->to_room() != kNowhere + && IsCorrectDirection(ch.get(), door, true, false) + && (!is_room_forbidden(world[EXIT(ch, door)->to_room()]) || ch->IsFlagged(EMobFlag::kIgnoreForbidden)) + && (!ch->IsFlagged(EMobFlag::kStayZone) + || world[EXIT(ch, door)->to_room()]->zone_rn == world[ch->in_room]->zone_rn) + && allow_enter(world[EXIT(ch, door)->to_room()], ch.get())) { + // После хода нпц уже может не быть, т.к. ушел в дт, я не знаю почему + // оно не валится на муд.ру, но на цигвине у меня падало стабильно, + // т.к. в ch уже местами мусор после фри-чара // Krodo + if (npc_move(ch.get(), door, 1)) { + npc_group(ch.get()); + npc_groupbattle(ch.get()); + } else { + continue; + } + } + npc_light(ch.get()); + // ***************** Mob Memory + if (ch->IsFlagged(EMobFlag::kMemory) + && MEMORY(ch) + && ch->GetPosition() > EPosition::kSleep + && !AFF_FLAGGED(ch, EAffect::kBlind) + && !ch->GetEnemy()) { + // Find memory in world + for (auto names = MEMORY(ch); names && (GET_SPELL_MEM(ch, ESpell::kSummon) > 0 + || GET_SPELL_MEM(ch, ESpell::kRelocate) > 0); names = names->next) { + for (const auto &vict : character_list) { + if (names->id == vict->get_uid() + && CAN_SEE(ch, vict) && !vict->IsFlagged(EPrf::kNohassle)) { + if (GET_SPELL_MEM(ch, ESpell::kSummon) > 0) { + CastSpell(ch.get(), vict.get(), nullptr, nullptr, ESpell::kSummon, ESpell::kSummon); + break; + } else if (GET_SPELL_MEM(ch, ESpell::kRelocate) > 0) { + CastSpell(ch.get(), vict.get(), nullptr, nullptr, ESpell::kRelocate, ESpell::kRelocate); + break; + } + } + } + } + } + // Add new mobile actions here + if (was_in != ch->in_room) { + do_aggressive_room(ch.get(), false); + } + } + + + // OpenTelemetry: Record active mob count + observability::OtelMetrics::RecordGauge("mob.active.count", active_mob_count); + + // Span and metric automatically closed by destructors } ObjData *create_charmice_box(CharData *ch) { const auto obj = world_objects.create_blank(); @@ -1281,6 +1633,11 @@ void extract_charmice(CharData *ch, bool on_ground) { character_list.AddToExtractedList(ch); } + + } // namespace mob_ai + + // vim: ts=4 sw=4 tw=0 noet syntax=cpp : + diff --git a/src/gameplay/core/game_limits.cpp b/src/gameplay/core/game_limits.cpp index dd8b2a621..3fdbf6efa 100644 --- a/src/gameplay/core/game_limits.cpp +++ b/src/gameplay/core/game_limits.cpp @@ -27,6 +27,9 @@ #include "gameplay/economics/ext_money.h" #include "gameplay/statistics/mob_stat.h" #include "gameplay/mechanics/liquid.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" #include "engine/db/global_objects.h" #include "gameplay/mechanics/sight.h" #include "gameplay/ai/mob_memory.h" @@ -620,6 +623,14 @@ void beat_punish(const CharData::shared_ptr &i) { } void beat_points_update(int pulse) { + // OpenTelemetry: Track beat points update + auto beat_span = tracing::TraceManager::Instance().StartSpan("Beat Points Update"); + observability::ScopedMetric beat_metric("player.beat_update.duration"); + + // Player statistics + int online_count = 0; + int in_combat_count = 0; + std::map level_remort_distribution; // "level_X_remort_Y" -> count int restore; if (!UPDATE_PC_ON_BEAT) @@ -637,6 +648,18 @@ void beat_points_update(int pulse) { log("SYSERR: Pulse character in kNowhere."); continue; } + + // OpenTelemetry: Collect player statistics + online_count++; + if (d->character->GetEnemy()) { + in_combat_count++; + } + + // Level/remort distribution + int level = d->character->GetLevel(); + int remort = d->character->get_remort(); + std::string key = "level_" + std::to_string(level) + "_remort_" + std::to_string(remort); + level_remort_distribution[key]++; if (NORENTABLE(d->character.get()) <= time(nullptr)) { d->character->player_specials->may_rent = 0; @@ -743,6 +766,27 @@ void beat_points_update(int pulse) { } //-MZ.overflow_fix } + + // OpenTelemetry: Record player statistics + beat_span->SetAttribute("player_count", static_cast(online_count)); + + observability::OtelMetrics::RecordGauge("players.online.count", online_count); + observability::OtelMetrics::RecordGauge("players.in_combat.count", in_combat_count); + + // Record level/remort distribution + for (const auto& [key, count] : level_remort_distribution) { + // Parse key "level_X_remort_Y" + size_t level_pos = key.find("level_") + 6; + size_t remort_pos = key.find("_remort_") + 8; + std::string level_str = key.substr(level_pos, key.find("_remort_") - level_pos); + std::string remort_str = key.substr(remort_pos); + + std::map attrs; + attrs["level"] = level_str; + attrs["remort"] = remort_str; + + observability::OtelMetrics::RecordGauge("players.by_level_remort.count", count, attrs); + } } void update_clan_exp(CharData *ch, int gain) { diff --git a/src/gameplay/crafting/item_creation.cpp b/src/gameplay/crafting/item_creation.cpp index 93ffa7d4d..f8c2f1b32 100644 --- a/src/gameplay/crafting/item_creation.cpp +++ b/src/gameplay/crafting/item_creation.cpp @@ -17,6 +17,9 @@ #include "engine/db/global_objects.h" #include "gameplay/core/base_stats.h" #include "gameplay/core/constants.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" #include @@ -1599,6 +1602,15 @@ int MakeRecept::make(CharData *ch) { // Проверяем возможность создания предмета if (!IS_IMMORTAL(ch) && (skill == ESkill::kMakeStaff)) { const ObjData obj(*tobj); + + // OpenTelemetry: Track crafting + auto craft_span = tracing::TraceManager::Instance().StartSpan("Craft Item"); + observability::ScopedMetric craft_metric("craft.duration"); + + auto tobj_name = tobj->get_PName(ECase::kNom); + craft_span->SetAttribute("recipe_id", static_cast(obj_proto)); + craft_span->SetAttribute("recipe_name", std::string(tobj_name.c_str())); + craft_span->SetAttribute("skill", NAME_BY_ITEM(skill)); act("Вы не готовы к тому чтобы сделать $o3.", false, ch, &obj, 0, kToChar); return (false); } @@ -1892,6 +1904,13 @@ int MakeRecept::make(CharData *ch) { ExtractObjFromWorld(ingrs[i]); } } + + // OpenTelemetry: Record craft failure + std::map attrs; + attrs["recipe_id"] = std::to_string(obj_proto); + attrs["skill"] = NAME_BY_ITEM(skill); + attrs["failure_reason"] = "craft_failed"; + observability::OtelMetrics::RecordCounter("craft.failures.total", 1, attrs); return (false); } // Лоадим предмет игроку @@ -2055,6 +2074,12 @@ int MakeRecept::make(CharData *ch) { } else { PlaceObjToInventory(obj.get(), ch); } + + // OpenTelemetry: Record craft success + std::map attrs; + attrs["recipe_id"] = std::to_string(obj_proto); + attrs["skill"] = NAME_BY_ITEM(skill); + observability::OtelMetrics::RecordCounter("craft.completed.total", 1, attrs); return (true); } // вытащить рецепт из строки. diff --git a/src/gameplay/economics/auction.cpp b/src/gameplay/economics/auction.cpp index 5743b5c16..761e8e47d 100644 --- a/src/gameplay/economics/auction.cpp +++ b/src/gameplay/economics/auction.cpp @@ -16,6 +16,9 @@ #include "gameplay/mechanics/named_stuff.h" #include "gameplay/fight/pk.h" #include "gameplay/ai/spec_procs.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" const int kMaxAuctionLot = 3; const int kMaxAuctionTactBuy = 5; @@ -695,6 +698,17 @@ void sell_auction(int lot) { if (!check_sell(lot)) return; + // OpenTelemetry: Track auction sale + auto sale_span = tracing::TraceManager::Instance().StartSpan("Auction Sale"); + double duration_seconds = (GET_LOT(lot)->tact * kAuctionPulses) / 10.0; + + sale_span->SetAttribute("lot", static_cast(lot)); + sale_span->SetAttribute("seller_id", static_cast(GET_LOT(lot)->seller_unique)); + sale_span->SetAttribute("buyer_id", static_cast(GET_LOT(lot)->buyer_unique)); + sale_span->SetAttribute("cost", static_cast(GET_LOT(lot)->cost)); + sale_span->SetAttribute("item_id", static_cast(GET_LOT(lot)->item_id)); + sale_span->SetAttribute("duration_seconds", duration_seconds); + if (ch->in_room != tch->in_room || !ROOM_FLAGGED(ch->in_room, ERoomFlag::kPeaceful)) { if (GET_LOT(lot)->tact >= kMaxAuctionTact) { @@ -737,6 +751,14 @@ void sell_auction(int lot) { ch->add_bank(GET_LOT(lot)->cost); tch->remove_both_gold(GET_LOT(lot)->cost); + + // OpenTelemetry: Record auction sale metrics + std::map attrs; + attrs["seller_id"] = std::to_string(GET_LOT(lot)->seller_unique); + + observability::OtelMetrics::RecordCounter("auction.sale.total", 1, attrs); + observability::OtelMetrics::RecordCounter("auction.revenue.total", GET_LOT(lot)->cost, attrs); + observability::OtelMetrics::RecordHistogram("auction.duration.seconds", duration_seconds, attrs); clear_auction(lot); return; } @@ -821,6 +843,15 @@ void tact_auction(void) { } else sell_auction(i); } + + // OpenTelemetry: Track active auction lots + int active_lots = 0; + for (int j = 0; j < kMaxAuctionLot; j++) { + if (GET_LOT(j)->seller && GET_LOT(j)->item) { + active_lots++; + } + } + observability::OtelMetrics::RecordGauge("auction.lots.active", active_lots); } AuctionItem *free_auction(int *lotnum) { diff --git a/src/gameplay/fight/fight_hit.cpp b/src/gameplay/fight/fight_hit.cpp index eaefa2cb6..48faca8b4 100644 --- a/src/gameplay/fight/fight_hit.cpp +++ b/src/gameplay/fight/fight_hit.cpp @@ -27,6 +27,8 @@ #include "gameplay/skills/shield_block.h" #include "gameplay/skills/backstab.h" #include "gameplay/skills/ironwind.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" #include "gameplay/mechanics/armor.h" #include "gameplay/skills/addshot.h" @@ -863,6 +865,9 @@ void hit(CharData *ch, CharData *victim, ESkill type, fight::AttackType weapon) return; } + + // OpenTelemetry: Measure hit duration + observability::ScopedMetric hit_metric("combat.hit.duration"); // Do some sanity checking, in case someone flees, etc. if (ch->in_room != victim->in_room || ch->in_room == kNowhere) { if (ch->GetEnemy() && ch->GetEnemy() == victim) { diff --git a/src/gameplay/magic/magic_utils.cpp b/src/gameplay/magic/magic_utils.cpp index cef747a70..f1c10327f 100644 --- a/src/gameplay/magic/magic_utils.cpp +++ b/src/gameplay/magic/magic_utils.cpp @@ -27,6 +27,9 @@ #include "gameplay/statistics/spell_usage.h" #include "utils/backtrace.h" +#include "engine/observability/otel_helpers.h" +#include "engine/observability/otel_metrics.h" +#include "utils/tracing/trace_manager.h" #include char cast_argument[kMaxStringLength]; @@ -340,6 +343,24 @@ bool MayCastHere(CharData *caster, CharData *victim, ESpell spell_id) { * Spellnum 0 is legal but silently ignored here, to make callers simpler. */ int CallMagic(CharData *caster, CharData *cvict, ObjData *ovict, RoomData *rvict, ESpell spell_id, int level) { + // OpenTelemetry: Track spell casting + auto spell_span = tracing::TraceManager::Instance().StartSpan("Spell Cast"); + observability::ScopedMetric spell_metric("spell.cast.duration"); + + // Set spell attributes + std::string spell_name = MUD::Spell(spell_id).GetCName(); + spell_span->SetAttribute("spell_id", static_cast(to_underlying(spell_id))); + spell_span->SetAttribute("spell_name", spell_name); + spell_span->SetAttribute("caster_class", NAME_BY_ITEM(caster->GetClass())); + spell_span->SetAttribute("spell_level", static_cast(level)); + + // Determine target type + std::string target_type = "none"; + if (cvict) target_type = "char"; + else if (ovict) target_type = "obj"; + else if (rvict) target_type = "room"; + spell_span->SetAttribute("target_type", target_type); + if (spell_id < ESpell::kFirst || spell_id > ESpell::kLast) return 0; @@ -367,6 +388,12 @@ int CallMagic(CharData *caster, CharData *cvict, ObjData *ovict, RoomData *rvict if (SpellUsage::is_active) { SpellUsage::AddSpellStat(caster->GetClass(), spell_id); } + + // OpenTelemetry: Record spell cast attempt + std::map attrs; + attrs["spell_id"] = std::to_string(to_underlying(spell_id)); + attrs["caster_class"] = NAME_BY_ITEM(caster->GetClass()); + observability::OtelMetrics::RecordCounter("spell.cast.total", 1, attrs); if (MUD::Spell(spell_id).IsFlagged(kMagAreas) || MUD::Spell(spell_id).IsFlagged(kMagMasses)) { return CallMagicToArea(caster, cvict, rvict, spell_id, abs(level)); diff --git a/src/utils/logger.h b/src/utils/logger.h index 88b2ac639..ca5a1828e 100644 --- a/src/utils/logger.h +++ b/src/utils/logger.h @@ -3,11 +3,14 @@ #include "engine/core/config.h" #include "engine/core/sysdep.h" +#include "logging/log_manager.h" +#include "engine/observability/otel_provider.h" #include #include #include #include +#include extern FILE *logfile; extern std::list opened_files; @@ -24,6 +27,7 @@ void imm_log(const char *format, ...) __attribute__((format(printf, 1, 2))); void err_log(const char *format, ...) __attribute__((format(printf, 1, 2))); void ip_log(const char *ip); + // defines for mudlog() // enum LogMode : int { OFF = 0, diff --git a/src/utils/logging/file_log_sender.cpp b/src/utils/logging/file_log_sender.cpp new file mode 100644 index 000000000..8a24bd52e --- /dev/null +++ b/src/utils/logging/file_log_sender.cpp @@ -0,0 +1,129 @@ +#include "file_log_sender.h" +#include "utils/logger.h" +#include + +namespace logging { + +FileLogSender::FileLogSender() { + // Constructor - file handles are managed by logger.cpp +} + +void FileLogSender::Debug(const std::string& message) { + Debug(message, {}); +} + +void FileLogSender::Debug(const std::string& message, + const std::map& attributes) { + write_to_file(message, attributes); +} + +void FileLogSender::Info(const std::string& message) { + Info(message, {}); +} + +void FileLogSender::Info(const std::string& message, + const std::map& attributes) { + write_to_file(message, attributes); +} + +void FileLogSender::Warn(const std::string& message) { + Warn(message, {}); +} + +void FileLogSender::Warn(const std::string& message, + const std::map& attributes) { + write_to_file(message, attributes); +} + +void FileLogSender::Error(const std::string& message) { + Error(message, {}); +} + +void FileLogSender::Error(const std::string& message, + const std::map& attributes) { + write_to_file(message, attributes); +} + +void FileLogSender::write_to_file(const std::string& message, + const std::map& attributes) { + FILE* file = get_log_file(attributes); + if (!file) { + return; // Failed to open file + } + + // Write timestamp and message + write_time(file); + fprintf(file, "%s\n", message.c_str()); + fflush(file); // Ensure message is written immediately +} + +FILE* FileLogSender::get_log_file(const std::map& attributes) { + // Get log type from attributes + auto it = attributes.find("log_type"); + if (it == attributes.end()) { + // No log_type specified - use default syslog + return logfile; + } + + const std::string& log_type = it->second; + + // Open corresponding file (lazy open, append mode) + // Note: These files are never closed during runtime (same as original logger.cpp behavior) + static FILE* shop_file = nullptr; + static FILE* olc_file = nullptr; + static FILE* imm_file = nullptr; + static FILE* error_file = nullptr; + static FILE* ip_file = nullptr; + + if (log_type == "shop") { + if (!shop_file) { + shop_file = fopen("../log/shop.log", "a"); + if (shop_file) { + opened_files.push_back(shop_file); + } + } + return shop_file; + } else if (log_type == "olc") { + if (!olc_file) { + olc_file = fopen("../log/olc.log", "a"); + if (olc_file) { + opened_files.push_back(olc_file); + } + } + return olc_file; + } else if (log_type == "imm") { + if (!imm_file) { + imm_file = fopen("../log/imm.log", "a"); + if (imm_file) { + opened_files.push_back(imm_file); + } + } + return imm_file; + } else if (log_type == "error") { + if (!error_file) { + error_file = fopen("../log/error.log", "a"); + if (error_file) { + opened_files.push_back(error_file); + } + } + return error_file; + } else if (log_type == "ip") { + if (!ip_file) { + ip_file = fopen("../log/ip.log", "a"); + if (ip_file) { + opened_files.push_back(ip_file); + } + } + return ip_file; + } else if (log_type == "perslog") { + // Personal logs handled separately in pers_log() - not via this sender + return nullptr; + } + + // Unknown log type - use syslog as fallback + return logfile; +} + +} // namespace logging + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/logging/file_log_sender.h b/src/utils/logging/file_log_sender.h new file mode 100644 index 000000000..3f0fdac29 --- /dev/null +++ b/src/utils/logging/file_log_sender.h @@ -0,0 +1,40 @@ +#ifndef BYLINS_FILE_LOG_SENDER_H +#define BYLINS_FILE_LOG_SENDER_H + +#include "log_sender.h" +#include + +namespace logging { + +// File-based log sender (writes to actual log files on disk) +class FileLogSender : public ILogSender { +public: + FileLogSender(); + ~FileLogSender() override = default; + + void Debug(const std::string& message) override; + void Debug(const std::string& message, + const std::map& attributes) override; + + void Info(const std::string& message) override; + void Info(const std::string& message, + const std::map& attributes) override; + + void Warn(const std::string& message) override; + void Warn(const std::string& message, + const std::map& attributes) override; + + void Error(const std::string& message) override; + void Error(const std::string& message, + const std::map& attributes) override; + +private: + void write_to_file(const std::string& message, + const std::map& attributes); + + FILE* get_log_file(const std::map& attributes); +}; + +} // namespace logging + +#endif // BYLINS_FILE_LOG_SENDER_H diff --git a/src/utils/logging/log_manager.cpp b/src/utils/logging/log_manager.cpp new file mode 100644 index 000000000..e648c245e --- /dev/null +++ b/src/utils/logging/log_manager.cpp @@ -0,0 +1,84 @@ +#include "log_manager.h" +#include "file_log_sender.h" +#include "engine/db/global_objects.h" + +namespace logging { + +LogManager& LogManager::Instance() { + return GlobalObjects::log_manager(); +} + +LogManager::LogManager() { +#ifdef TEST_BUILD + // In test mode, use NoOp sender by default + m_senders.push_back(std::make_unique()); +#else + // By default, use file logging + m_senders.push_back(std::make_unique()); +#endif +} + +void LogManager::AddSender(std::unique_ptr sender) { + m_senders.push_back(std::move(sender)); +} + +void LogManager::ClearSenders() { + m_senders.clear(); +} + +// Static interface implementations - iterate over all senders +void LogManager::Debug(const std::string& message) { + for (const auto& sender : Instance().m_senders) { + sender->Debug(message); + } +} + +void LogManager::Debug(const std::string& message, + const std::map& attributes) { + for (const auto& sender : Instance().m_senders) { + sender->Debug(message, attributes); + } +} + +void LogManager::Info(const std::string& message) { + for (const auto& sender : Instance().m_senders) { + sender->Info(message); + } +} + +void LogManager::Info(const std::string& message, + const std::map& attributes) { + for (const auto& sender : Instance().m_senders) { + sender->Info(message, attributes); + } +} + +void LogManager::Warn(const std::string& message) { + for (const auto& sender : Instance().m_senders) { + sender->Warn(message); + } +} + +void LogManager::Warn(const std::string& message, + const std::map& attributes) { + for (const auto& sender : Instance().m_senders) { + sender->Warn(message, attributes); + } +} + +void LogManager::Error(const std::string& message) { + for (const auto& sender : Instance().m_senders) { + sender->Error(message); + } +} + +void LogManager::Error(const std::string& message, + const std::map& attributes) { + for (const auto& sender : Instance().m_senders) { + sender->Error(message, attributes); + } +} + +} // namespace logging + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/logging/log_manager.h b/src/utils/logging/log_manager.h new file mode 100644 index 000000000..443d187a6 --- /dev/null +++ b/src/utils/logging/log_manager.h @@ -0,0 +1,54 @@ +#ifndef BYLINS_LOG_MANAGER_H +#define BYLINS_LOG_MANAGER_H + +#include "log_sender.h" +#include +#include + +namespace logging { + +// Central logging manager - coordinates all log senders +class LogManager { +public: + static LogManager& Instance(); + + // Add a log sender to the list + void AddSender(std::unique_ptr sender); + + // Clear all senders + void ClearSenders(); + + // Get current senders (for inspection) + const std::vector>& GetSenders() const { return m_senders; } + + // Static logging interface (delegates to all registered senders) + static void Debug(const std::string& message); + static void Debug(const std::string& message, + const std::map& attributes); + + static void Info(const std::string& message); + static void Info(const std::string& message, + const std::map& attributes); + + static void Warn(const std::string& message); + static void Warn(const std::string& message, + const std::map& attributes); + + static void Error(const std::string& message); + static void Error(const std::string& message, + const std::map& attributes); + + LogManager(const LogManager&) = delete; + LogManager& operator=(const LogManager&) = delete; + +// Made public to allow GlobalObjects to manage lifetime +public: + LogManager(); + ~LogManager() = default; + + std::vector> m_senders; +}; + +} // namespace logging + +#endif // BYLINS_LOG_MANAGER_H diff --git a/src/utils/logging/log_sender.h b/src/utils/logging/log_sender.h new file mode 100644 index 000000000..8173dccab --- /dev/null +++ b/src/utils/logging/log_sender.h @@ -0,0 +1,56 @@ +#ifndef BYLINS_LOG_SENDER_H +#define BYLINS_LOG_SENDER_H + +#include +#include + +namespace logging { + +enum class LogLevel { + kDebug, + kInfo, + kWarn, + kError +}; + +// Interface for log sending (Null Object Pattern) +class ILogSender { +public: + virtual ~ILogSender() = default; + + virtual void Debug(const std::string& message) = 0; + virtual void Debug(const std::string& message, + const std::map& attributes) = 0; + + virtual void Info(const std::string& message) = 0; + virtual void Info(const std::string& message, + const std::map& attributes) = 0; + + virtual void Warn(const std::string& message) = 0; + virtual void Warn(const std::string& message, + const std::map& attributes) = 0; + + virtual void Error(const std::string& message) = 0; + virtual void Error(const std::string& message, + const std::map& attributes) = 0; +}; + +// No-op implementation (for TEST_BUILD or when no senders configured) +class NoOpLogSender : public ILogSender { +public: + void Debug(const std::string&) override {} + void Debug(const std::string&, const std::map&) override {} + + void Info(const std::string&) override {} + void Info(const std::string&, const std::map&) override {} + + void Warn(const std::string&) override {} + void Warn(const std::string&, const std::map&) override {} + + void Error(const std::string&) override {} + void Error(const std::string&, const std::map&) override {} +}; + +} // namespace logging + +#endif // BYLINS_LOG_SENDER_H diff --git a/src/utils/tracing/noop_trace_sender.h b/src/utils/tracing/noop_trace_sender.h new file mode 100644 index 000000000..fb861f704 --- /dev/null +++ b/src/utils/tracing/noop_trace_sender.h @@ -0,0 +1,33 @@ +#ifndef BYLINS_NOOP_TRACE_SENDER_H +#define BYLINS_NOOP_TRACE_SENDER_H + +#include "trace_sender.h" + +namespace tracing { + +class NoOpSpan : public ISpan { +public: + void End() override {} + void AddEvent(const std::string&) override {} + void SetAttribute(const std::string&, const std::string&) override {} + void SetAttribute(const std::string&, int64_t) override {} + void SetAttribute(const std::string&, double) override {} + bool IsValid() const override { return false; } +}; + +class NoOpTraceSender : public ITraceSender { +public: + std::unique_ptr StartSpan(const std::string&) override { + return std::make_unique(); + } + + std::unique_ptr StartChildSpan(const std::string&, const ISpan&) override { + return std::make_unique(); + } +}; + +} // namespace tracing + +#endif // BYLINS_NOOP_TRACE_SENDER_H + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/tracing/trace_manager.cpp b/src/utils/tracing/trace_manager.cpp new file mode 100644 index 000000000..77919f399 --- /dev/null +++ b/src/utils/tracing/trace_manager.cpp @@ -0,0 +1,36 @@ +#include "trace_manager.h" +#include "noop_trace_sender.h" + +namespace tracing { + +TraceManager& TraceManager::Instance() { + static TraceManager instance; + return instance; +} + +TraceManager::TraceManager() { + // By default - NoOp sender + m_sender = std::make_unique(); +} + +void TraceManager::SetSender(std::unique_ptr sender) { + if (sender) { + m_sender = std::move(sender); + } +} + +ITraceSender& TraceManager::GetSender() { + return *m_sender; +} + +std::unique_ptr TraceManager::StartSpan(const std::string& name) { + return m_sender->StartSpan(name); +} + +std::unique_ptr TraceManager::StartChildSpan(const std::string& name, const ISpan& parent) { + return m_sender->StartChildSpan(name, parent); +} + +} // namespace tracing + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/tracing/trace_manager.h b/src/utils/tracing/trace_manager.h new file mode 100644 index 000000000..458f25d44 --- /dev/null +++ b/src/utils/tracing/trace_manager.h @@ -0,0 +1,33 @@ +#ifndef BYLINS_TRACE_MANAGER_H +#define BYLINS_TRACE_MANAGER_H + +#include "trace_sender.h" +#include + +namespace tracing { + +class TraceManager { +public: + static TraceManager& Instance(); + + void SetSender(std::unique_ptr sender); + ITraceSender& GetSender(); + + // Convenience methods (delegate to sender) + std::unique_ptr StartSpan(const std::string& name); + std::unique_ptr StartChildSpan(const std::string& name, const ISpan& parent); + +private: + TraceManager(); + ~TraceManager() = default; + TraceManager(const TraceManager&) = delete; + TraceManager& operator=(const TraceManager&) = delete; + + std::unique_ptr m_sender; +}; + +} // namespace tracing + +#endif // BYLINS_TRACE_MANAGER_H + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/tracing/trace_sender.h b/src/utils/tracing/trace_sender.h new file mode 100644 index 000000000..a5e3734a9 --- /dev/null +++ b/src/utils/tracing/trace_sender.h @@ -0,0 +1,44 @@ +#ifndef BYLINS_TRACE_SENDER_H +#define BYLINS_TRACE_SENDER_H + +#include +#include + +namespace tracing { + +// Forward declaration +class ISpan; + +// Interface for sending traces +class ITraceSender { +public: + virtual ~ITraceSender() = default; + + // Create parent span + virtual std::unique_ptr StartSpan(const std::string& name) = 0; + + // Create child span with parent context + virtual std::unique_ptr StartChildSpan( + const std::string& name, + const ISpan& parent) = 0; +}; + +// Interface for span (analog of OTEL Span, but through vtable) +class ISpan { +public: + virtual ~ISpan() = default; + + virtual void End() = 0; + virtual void AddEvent(const std::string& name) = 0; + virtual void SetAttribute(const std::string& key, const std::string& value) = 0; + virtual void SetAttribute(const std::string& key, int64_t value) = 0; + virtual void SetAttribute(const std::string& key, double value) = 0; + + virtual bool IsValid() const = 0; +}; + +} // namespace tracing + +#endif // BYLINS_TRACE_SENDER_H + +// vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/src/utils/utils_time.cpp b/src/utils/utils_time.cpp index bdd853c01..83838f00e 100644 --- a/src/utils/utils_time.cpp +++ b/src/utils/utils_time.cpp @@ -1,26 +1,77 @@ #include "utils_time.h" #include "logger.h" +#include "tracing/trace_manager.h" #include #include + namespace utils { +CSteppedProfiler::CSteppedProfiler(const std::string &scope_name, const double time_probe) + : m_scope_name(scope_name), m_time_probe(time_probe) +{ + // Create parent span + m_parent_span = tracing::TraceManager::Instance().StartSpan(m_scope_name); + if (m_parent_span->IsValid()) { + if (m_time_probe > 0) { + m_parent_span->SetAttribute("time_probe_seconds", m_time_probe); + } + } +} + CSteppedProfiler::~CSteppedProfiler() { if (0 < m_steps.size()) { m_steps.back()->stop(); + + // Close last child span + if (m_current_child_span) { + m_current_child_span->End(); + } + } + + // Add event if threshold exceeded + if (m_parent_span && m_parent_span->IsValid()) { + double total_duration = m_timer.delta().count(); + if (m_time_probe > 0 && total_duration > m_time_probe) { + m_parent_span->AddEvent("threshold_exceeded"); + } + + // Close parent span + m_parent_span->End(); } + report(); } - void CSteppedProfiler::next_step(const std::string &step_name) { + if (0 < m_steps.size()) { m_steps.back()->stop(); + + // Close previous child span + if (m_current_child_span) { + m_current_child_span->End(); + } } + m_steps.push_back(step_t(new CExecutionStepProfiler(step_name))); + + // Create new child span + if (m_parent_span && m_parent_span->IsValid()) { + m_current_child_span = tracing::TraceManager::Instance().StartChildSpan( + step_name, + *m_parent_span + ); + if (m_current_child_span->IsValid()) { + m_current_child_span->SetAttribute("step_index", + static_cast(m_steps.size() - 1)); + } + } } + + void CSteppedProfiler::report() const { FILE *flog; std::stringstream ss; diff --git a/src/utils/utils_time.h b/src/utils/utils_time.h index 45c0a10c0..4e9924767 100644 --- a/src/utils/utils_time.h +++ b/src/utils/utils_time.h @@ -6,6 +6,7 @@ #include #include #include +#include "tracing/trace_sender.h" #define LOAD_LOG_FOLDER "log/" #define LOAD_LOG_FILE "profiler.log" @@ -51,7 +52,7 @@ class CSteppedProfiler { using step_t = std::shared_ptr; - CSteppedProfiler(const std::string &scope_name, const double time_probe = 0) : m_scope_name(scope_name), m_time_probe(time_probe) {} + CSteppedProfiler(const std::string &scope_name, const double time_probe = 0); ~CSteppedProfiler(); void next_step(const std::string &step_name); @@ -63,6 +64,8 @@ class CSteppedProfiler { const std::string m_scope_name; const double m_time_probe; std::list m_steps; + std::unique_ptr m_parent_span; + std::unique_ptr m_current_child_span; CExecutionTimer m_timer; }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aa07ada7f..e693962c5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -71,6 +71,7 @@ set(TESTS act.makefood.cpp utils.editor.cpp utils.string.cpp + utils.encoding.cpp fight.penalties.cpp bonus.command.parser.cpp quested.cpp diff --git a/tools/compare_world_checksums.sh b/tools/compare_world_checksums.sh new file mode 100755 index 000000000..ec3a442e8 --- /dev/null +++ b/tools/compare_world_checksums.sh @@ -0,0 +1,397 @@ +#!/bin/bash +# Script to compare world checksums between Legacy and SQLite builds +# and dump detailed field differences for differing objects +# +# Usage: ./compare_world_checksums.sh [OPTIONS] +# --rebuild-legacy Force rebuild of Legacy version +# --rebuild-sqlite Force rebuild of SQLite version +# --rebuild Force rebuild of both versions +# --reconvert Reconvert world from legacy files to SQLite +# --skip-encoding Skip encoding check +# --dump-count N Number of objects to dump (default: 10) + +set -e + +MUD_DIR="/home/kvirund/repos/mud" +MUD_DOCS_DIR="/home/kvirund/repos/mud-docs" +BUILD_TEST="$MUD_DIR/build_test" +BUILD_SQLITE="$MUD_DIR/build_sqlite" +WORLD_DIR="${WORLD_DIR:-small}" +PORT=4000 +WAIT_TIME="${WAIT_TIME:-15}" +DUMP_DIR="/tmp/world_compare" +DUMP_COUNT=10 +REBUILD_LEGACY=0 +REBUILD_SQLITE=0 +RECONVERT=0 +SKIP_ENCODING=0 + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --rebuild) + REBUILD_LEGACY=1 + REBUILD_SQLITE=1 + shift + ;; + --rebuild-legacy) + REBUILD_LEGACY=1 + shift + ;; + --rebuild-sqlite) + REBUILD_SQLITE=1 + shift + ;; + --reconvert) + RECONVERT=1 + shift + ;; + --skip-encoding) + SKIP_ENCODING=1 + shift + ;; + --dump-count) + DUMP_COUNT="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Check source file encoding (detect UTF-8 corruption in KOI8-R files) +check_encoding() { + echo "=== Checking source file encoding ===" + ENCODING_ERRORS=0 + + # Check for UTF-8 BOM (EF BB BF) in source files + while IFS= read -r -d '' file; do + if head -c3 "$file" | LANG=C grep -q $'\xef\xbb\xbf'; then + echo "ERROR: UTF-8 BOM found: $file" + ENCODING_ERRORS=1 + fi + done < <(find "$MUD_DIR/src" -name "*.cpp" -print0 2>/dev/null) + + # Check for common UTF-8 Cyrillic sequences in key source files + KEY_FILES="$MUD_DIR/src/engine/db/sqlite_world_data_source.cpp" + for f in $KEY_FILES; do + if [ -f "$f" ]; then + # UTF-8 Cyrillic starts with D0 or D1 byte + if LANG=C grep -q $'\xd0[\x80-\xbf]' "$f" 2>/dev/null; then + echo "ERROR: UTF-8 Cyrillic detected in: $f" + echo " This indicates encoding corruption. Check recent edits." + ENCODING_ERRORS=1 + fi + fi + done + + if [ $ENCODING_ERRORS -eq 0 ]; then + echo " Encoding OK" + else + echo "" + echo "FATAL: Encoding errors detected. Fix before continuing." + exit 99 + fi + echo "" +} + +# Function to compare checksum buffers and show field differences +compare_buffers() { + local TYPE=$1 + local VNUM=$2 + local LEGACY_BUF="$LEGACY_BASELINE/checksums_buffers/${TYPE}s/${VNUM}.txt" + local SQLITE_BUF="$BUILD_SQLITE/$WORLD_DIR/checksums_buffers/${TYPE}s/${VNUM}.txt" + + if [ -f "$LEGACY_BUF" ] && [ -f "$SQLITE_BUF" ]; then + echo " --- Buffer comparison ---" + # Extract raw data lines (sed works better with binary than grep) + LEGACY_RAW=$(LANG=C sed -n '/^---RAW---$/{ n; p; }' "$LEGACY_BUF" 2>/dev/null || echo "") + SQLITE_RAW=$(LANG=C sed -n '/^---RAW---$/{ n; p; }' "$SQLITE_BUF" 2>/dev/null || echo "") + + if [ "$LEGACY_RAW" != "$SQLITE_RAW" ]; then + echo " Legacy: ${LEGACY_RAW:0:80}..." + echo " SQLite: ${SQLITE_RAW:0:80}..." + echo " --- Field-by-field diff (| separated) ---" + # Show field-by-field diff using | as separator, using temp files to avoid binary issues + echo "$LEGACY_RAW" | tr '|' '\n' | nl -ba > /tmp/legacy_fields_$$.txt + echo "$SQLITE_RAW" | tr '|' '\n' | nl -ba > /tmp/sqlite_fields_$$.txt + diff /tmp/legacy_fields_$$.txt /tmp/sqlite_fields_$$.txt 2>/dev/null | head -20 | sed 's/^/ /' || true + rm -f /tmp/legacy_fields_$$.txt /tmp/sqlite_fields_$$.txt + fi + fi +} + +if [ $SKIP_ENCODING -eq 0 ]; then + check_encoding +fi + +# Create dump directory +mkdir -p "$DUMP_DIR" +rm -f "$DUMP_DIR"/* + +echo "========================================" +echo "World Checksum Comparison Tool" +echo "========================================" +echo "" + +# Build Legacy if requested or if binary doesn't exist +if [ $REBUILD_LEGACY -eq 1 ] || [ ! -f "$BUILD_TEST/circle" ]; then + echo "=== Building Legacy version ===" + cd "$BUILD_TEST" + make -j$(nproc) circle 2>&1 | tail -3 + echo "" +fi + +# Reconvert world if requested +if [ $RECONVERT -eq 1 ]; then + echo "=== Reconverting world to SQLite ===" + WORLD_PATH="$BUILD_SQLITE/$WORLD_DIR" + DB_PATH="$BUILD_SQLITE/$WORLD_DIR/world.db" + rm -f "$DB_PATH" + cd "$MUD_DIR" + python3 tools/convert_to_yaml.py -i "$WORLD_PATH" -o "$WORLD_PATH" -f sqlite 2>&1 | tail -10 + echo "" +fi + +# Build SQLite if requested or if binary doesn't exist +if [ $REBUILD_SQLITE -eq 1 ] || [ ! -f "$BUILD_SQLITE/circle" ]; then + echo "=== Building SQLite version ===" + cd "$BUILD_SQLITE" + make -j$(nproc) circle 2>&1 | tail -3 + echo "" +fi + +# Kill any running servers +echo "=== Stopping any running servers ===" +pkill -9 -f "circle" 2>/dev/null || true +sleep 3 + +# Run Legacy server +echo "=== Running Legacy server ===" +cd "$BUILD_TEST" +rm -f "$WORLD_DIR/checksums_detailed.txt" +./circle -d "$WORLD_DIR" & +LEGACY_PID=$! +sleep $WAIT_TIME + +# Wait for server to finish and try to kill it +kill $LEGACY_PID 2>/dev/null || true +wait $LEGACY_PID 2>/dev/null || true +sleep 2 + +# Copy Legacy checksums to temp location (needed when both builds use same world symlink) +LEGACY_BASELINE="/tmp/legacy_baseline_$$" +mkdir -p "$LEGACY_BASELINE" +cp -r "$BUILD_TEST/$WORLD_DIR/checksums_detailed.txt" "$LEGACY_BASELINE/" 2>/dev/null || true +cp -r "$BUILD_TEST/$WORLD_DIR/checksums_buffers" "$LEGACY_BASELINE/" 2>/dev/null || true +echo "Legacy baseline copied to $LEGACY_BASELINE" + +# Run SQLite server with baseline for comparison +echo "=== Running SQLite server ===" +cd "$BUILD_SQLITE" +rm -f "$WORLD_DIR/checksums_detailed.txt" +BASELINE_DIR="$LEGACY_BASELINE" ./circle -d "$WORLD_DIR" & +SQLITE_PID=$! +sleep $WAIT_TIME + +# Wait for server to finish and try to kill it +kill $SQLITE_PID 2>/dev/null || true +wait $SQLITE_PID 2>/dev/null || true +sleep 2 + +echo "" +echo "========================================" +echo "Checksum Comparison Results" +echo "========================================" + +LEGACY_CHECKSUMS="$LEGACY_BASELINE/checksums_detailed.txt" +SQLITE_CHECKSUMS="$BUILD_SQLITE/$WORLD_DIR/checksums_detailed.txt" + +if [ ! -f "$LEGACY_CHECKSUMS" ]; then + echo "ERROR: Legacy checksum file not found: $LEGACY_CHECKSUMS" + exit 1 +fi + +if [ ! -f "$SQLITE_CHECKSUMS" ]; then + echo "ERROR: SQLite checksum file not found: $SQLITE_CHECKSUMS" + exit 1 +fi + +# Sort checksum files for consistent comparison (order may differ due to dynamic room creation) +LEGACY_SORTED="/tmp/legacy_checksums_sorted.txt" +SQLITE_SORTED="/tmp/sqlite_checksums_sorted.txt" +sort "$LEGACY_CHECKSUMS" > "$LEGACY_SORTED" +sort "$SQLITE_CHECKSUMS" > "$SQLITE_SORTED" + +# Count differences by type (using sorted files) +ZONE_DIFF=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< ZONE" | wc -l || echo 0) +ROOM_DIFF=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< ROOM" | wc -l || echo 0) +MOB_DIFF=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< MOB" | wc -l || echo 0) +OBJ_DIFF=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< OBJ" | wc -l || echo 0) +TRIG_DIFF=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< TRIG " | wc -l || echo 0) + +# Get total counts from syslog +ZONE_TOTAL=$(grep 'Zones:' "$BUILD_TEST/$WORLD_DIR/syslog" | tail -1 | grep -oP '\(\K[0-9]+') +ROOM_TOTAL=$(grep 'Rooms:' "$BUILD_TEST/$WORLD_DIR/syslog" | tail -1 | grep -oP '\(\K[0-9]+') +MOB_TOTAL=$(grep 'Mobs:' "$BUILD_TEST/$WORLD_DIR/syslog" | tail -1 | grep -oP '\(\K[0-9]+') +OBJ_TOTAL=$(grep 'Objects:' "$BUILD_TEST/$WORLD_DIR/syslog" | tail -1 | grep -oP '\(\K[0-9]+') +TRIG_TOTAL=$(grep 'Triggers:' "$BUILD_TEST/$WORLD_DIR/syslog" | tail -1 | grep -oP '\(\K[0-9]+') + +# Calculate match percentages +calc_percent() { + local diff=$1 + local total=$2 + if [ "$total" -gt 0 ]; then + local match=$((total - diff)) + echo "scale=1; $match * 100 / $total" | bc + else + echo "0" + fi +} + +ZONE_PCT=$(calc_percent $ZONE_DIFF $ZONE_TOTAL) +ROOM_PCT=$(calc_percent $ROOM_DIFF $ROOM_TOTAL) +MOB_PCT=$(calc_percent $MOB_DIFF $MOB_TOTAL) +OBJ_PCT=$(calc_percent $OBJ_DIFF $OBJ_TOTAL) +TRIG_PCT=$(calc_percent $TRIG_DIFF $TRIG_TOTAL) + +echo "" +echo "Summary:" +printf " Zones: %4d / %5d different (%5.1f%% match)\n" $ZONE_DIFF $ZONE_TOTAL $ZONE_PCT +printf " Rooms: %4d / %5d different (%5.1f%% match)\n" $ROOM_DIFF $ROOM_TOTAL $ROOM_PCT +printf " Mobs: %4d / %5d different (%5.1f%% match)\n" $MOB_DIFF $MOB_TOTAL $MOB_PCT +printf " Objects: %4d / %5d different (%5.1f%% match)\n" $OBJ_DIFF $OBJ_TOTAL $OBJ_PCT +printf " Triggers: %4d / %5d different (%5.1f%% match)\n" $TRIG_DIFF $TRIG_TOTAL $TRIG_PCT + +# Get overall checksums +echo "" +echo "Overall checksums:" +echo " Legacy: $(grep 'Objects:' "$BUILD_TEST/$WORLD_DIR/syslog" | tail -1 | awk '{print $4, $5}')" +echo " SQLite: $(grep 'Objects:' "$BUILD_SQLITE/$WORLD_DIR/syslog" | tail -1 | awk '{print $4, $5}')" + +# If everything matches, exit +TOTAL_DIFF=$((ZONE_DIFF + ROOM_DIFF + MOB_DIFF + OBJ_DIFF + TRIG_DIFF)) +if [ $TOTAL_DIFF -eq 0 ]; then + echo "" + echo "SUCCESS: All checksums match!" + exit 0 +fi + +# Extract different object vnums and create dumps +if [ $OBJ_DIFF -gt 0 ]; then + echo "" + echo "========================================" + echo "Different Objects (first $DUMP_COUNT)" + echo "========================================" + + DIFF_OBJECTS=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< OBJ" | sed 's/< OBJ //' | awk '{print $1}' | head -n $DUMP_COUNT) + + for VNUM in $DIFF_OBJECTS; do + LEGACY_CRC=$(grep "^OBJ $VNUM " "$LEGACY_SORTED" | awk '{print $3}') + SQLITE_CRC=$(grep "^OBJ $VNUM " "$SQLITE_SORTED" | awk '{print $3}') + + echo "" + echo "Object $VNUM: Legacy=$LEGACY_CRC SQLite=$SQLITE_CRC" + + # Compare checksum buffers + compare_buffers "object" "$VNUM" + + # Find object in .obj files + OBJ_FILE="" + OBJ_LINE="" + for f in "$BUILD_TEST/$WORLD_DIR/world/obj/"*.obj; do + LINE=$(LANG=C grep -n "^#${VNUM}$" "$f" 2>/dev/null | head -1 | cut -d: -f1) + if [ -n "$LINE" ]; then + OBJ_FILE="$f" + OBJ_LINE="$LINE" + break + fi + done + + if [ -n "$OBJ_FILE" ]; then + echo " Source: $(basename $OBJ_FILE):$OBJ_LINE" + + # Extract object data from file (approximately 20 lines) + LANG=C sed -n "${OBJ_LINE},$((OBJ_LINE+19))p" "$OBJ_FILE" > "$DUMP_DIR/obj_${VNUM}_source.txt" + + # Show key lines + echo " --- Raw data ---" + head -15 "$DUMP_DIR/obj_${VNUM}_source.txt" | sed 's/^/ /' + fi + + # Query SQLite for this object + echo " --- SQLite data ---" + sqlite3 "$BUILD_SQLITE/$WORLD_DIR/world.db" " + SELECT 'vnum:', vnum FROM objects WHERE vnum = $VNUM; + SELECT 'type:', obj_type FROM objects WHERE vnum = $VNUM; + SELECT 'max_in_world:', max_in_world FROM objects WHERE vnum = $VNUM; + " 2>/dev/null | sed 's/^/ /' + + # Get flags from SQLite + echo " Flags in SQLite:" + sqlite3 "$BUILD_SQLITE/$WORLD_DIR/world.db" " + SELECT ' ' || flag_category || ': ' || flag_name FROM obj_flags WHERE obj_vnum = $VNUM; + " 2>/dev/null + done +fi + +# Show different rooms if any +if [ $ROOM_DIFF -gt 0 ]; then + echo "" + echo "========================================" + echo "Different Rooms (first $DUMP_COUNT)" + echo "========================================" + DIFF_ROOMS=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< ROOM" | sed 's/< ROOM //' | awk '{print $1}' | head -n $DUMP_COUNT) + + for VNUM in $DIFF_ROOMS; do + LEGACY_CRC=$(grep "^ROOM $VNUM " "$LEGACY_SORTED" | awk '{print $3}') + SQLITE_CRC=$(grep "^ROOM $VNUM " "$SQLITE_SORTED" | awk '{print $3}') + echo "" + echo "Room $VNUM: Legacy=$LEGACY_CRC SQLite=$SQLITE_CRC" + compare_buffers "room" "$VNUM" + done +fi + +# Show different triggers if any +if [ $TRIG_DIFF -gt 0 ]; then + echo "" + echo "========================================" + echo "Different Triggers (first $DUMP_COUNT)" + echo "========================================" + DIFF_TRIGS=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< TRIG " | sed 's/< TRIG //' | awk '{print $1}' | head -n $DUMP_COUNT) + + for VNUM in $DIFF_TRIGS; do + LEGACY_CRC=$(grep "^TRIG $VNUM " "$LEGACY_SORTED" | awk '{print $3}') + SQLITE_CRC=$(grep "^TRIG $VNUM " "$SQLITE_SORTED" | awk '{print $3}') + echo "" + echo "Trigger $VNUM: Legacy=$LEGACY_CRC SQLite=$SQLITE_CRC" + compare_buffers "trigger" "$VNUM" + done +fi + +# Show different zones if any +if [ $ZONE_DIFF -gt 0 ]; then + echo "" + echo "========================================" + echo "Different Zones (first $DUMP_COUNT)" + echo "========================================" + DIFF_ZONES=$(diff "$LEGACY_SORTED" "$SQLITE_SORTED" 2>/dev/null | grep "^< ZONE" | sed 's/< ZONE //' | awk '{print $1}' | head -n $DUMP_COUNT) + + for VNUM in $DIFF_ZONES; do + LEGACY_CRC=$(grep "^ZONE $VNUM " "$LEGACY_SORTED" | awk '{print $3}') + SQLITE_CRC=$(grep "^ZONE $VNUM " "$SQLITE_SORTED" | awk '{print $3}') + echo "" + echo "Zone $VNUM: Legacy=$LEGACY_CRC SQLite=$SQLITE_CRC" + compare_buffers "zone" "$VNUM" + done +fi + +echo "" +echo "========================================" +echo "Dump files saved to: $DUMP_DIR" +echo "========================================" +ls -la "$DUMP_DIR/" 2>/dev/null || true + +exit $TOTAL_DIFF diff --git a/tools/convert_to_yaml.py b/tools/convert_to_yaml.py new file mode 100755 index 000000000..061199bb1 --- /dev/null +++ b/tools/convert_to_yaml.py @@ -0,0 +1,3160 @@ +#!/usr/bin/env python3 +# -*- coding: koi8-r -*- +""" +Convert old MUD world format files to YAML or SQLite format. + +Architecture: +============ + + ┌─────────────────────────────────────────────────────────────────────────┐ + │ PARALLEL PARSING, SEQUENTIAL SAVING │ + ├─────────────────────────────────────────────────────────────────────────┤ + │ │ + │ PARSING (ThreadPoolExecutor) SAVING (Main Thread) │ + │ ┌─────────────────────────────┐ ┌────────────────────┐ │ + │ │ Parser Thread 1 │ │ │ │ + │ │ parse_file() -> entities │──┐ │ saver.save_*() │ │ + │ └─────────────────────────────┘ │ │ │ │ + │ ┌─────────────────────────────┐ │ │ Sequential due to │ │ + │ │ Parser Thread 2 │──┼─────────│ GIL (YAML) and │ │ + │ │ parse_file() -> entities │ │ │ DB safety (SQLite)│ │ + │ └─────────────────────────────┘ │ │ │ │ + │ ┌─────────────────────────────┐ │ │ │ │ + │ │ Parser Thread N │──┘ │ │ │ + │ │ parse_file() -> entities │ │ │ │ + │ └─────────────────────────────┘ └────────────────────┘ │ + │ │ + └─────────────────────────────────────────────────────────────────────────┘ + + Note: Python's GIL prevents parallel execution of CPU-bound code (like YAML + serialization). Multi-threaded YAML writing provides no speedup and adds + overhead. Parallelism is only beneficial for I/O-bound parsing. + +YAML Libraries: + - pyyaml (default): Fast (~3x faster), but no inline comments + - ruamel.yaml: Full comment support (skill names, trigger refs), but slower + +Output Formats: + - YAML: One file per entity (vnum.yaml), with index.yaml per directory + - SQLite: Single normalized database with views for convenient queries + +Usage: + python3 convert_to_yaml.py -i lib.template -o lib # YAML (pyyaml) + python3 convert_to_yaml.py -i lib.template -o lib --yaml-lib ruamel # YAML with comments + python3 convert_to_yaml.py -i lib.template -o lib -f sqlite # SQLite database +""" + +import argparse +import os +import re +import sqlite3 +import sys +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path +from io import StringIO +# Queue removed - no longer needed (sequential saving) + +# YAML libraries - lazy import +_pyyaml = None # PyYAML module (fast, no comments) +_ruamel_yaml = None # ruamel.yaml YAML class +_ruamel_initialized = False + +# Thread-safe logging counters +_counter_lock = threading.Lock() +_warnings_count = 0 +_errors_count = 0 + +# YAML library selection: 'ruamel' (with comments) or 'pyyaml' (fast, ~3x faster) +# Note: Both are single-threaded due to GIL - parallelism is only in parsing +_yaml_library = 'pyyaml' + + +def _init_yaml_libraries(): + """Lazily import YAML libraries.""" + global _pyyaml, _ruamel_yaml, _ruamel_initialized + + # Always need ruamel for CommentedMap/CommentedSeq in to_yaml functions + if not _ruamel_initialized: + from ruamel.yaml import YAML + from ruamel.yaml.comments import CommentedMap, CommentedSeq + # Inject into module globals for use by to_yaml functions + globals()['YAML'] = YAML + globals()['CommentedMap'] = CommentedMap + globals()['CommentedSeq'] = CommentedSeq + _ruamel_yaml = YAML + _ruamel_initialized = True + + # Import pyyaml only if needed + if _yaml_library == 'pyyaml' and _pyyaml is None: + import yaml + _pyyaml = yaml + + +def log_warning(message, vnum=None, filepath=None): + """Log a warning message without stack trace (thread-safe).""" + global _warnings_count + with _counter_lock: + _warnings_count += 1 + context = [] + if filepath: + context.append(f"file={filepath}") + if vnum is not None: + context.append(f"vnum={vnum}") + ctx_str = f" [{', '.join(context)}]" if context else "" + print(f"WARNING{ctx_str}: {message}", file=sys.stderr) + + +def log_error(message, vnum=None, filepath=None): + """Log an error message without stack trace (thread-safe).""" + global _errors_count + with _counter_lock: + _errors_count += 1 + context = [] + if filepath: + context.append(f"file={filepath}") + if vnum is not None: + context.append(f"vnum={vnum}") + ctx_str = f" [{', '.join(context)}]" if context else "" + print(f"ERROR{ctx_str}: {message}", file=sys.stderr) + + +def print_summary(): + """Print conversion summary.""" + if _warnings_count > 0 or _errors_count > 0: + print(f"\nConversion summary: {_errors_count} errors, {_warnings_count} warnings", file=sys.stderr) + + +# Thread-local YAML handler for thread-safe dumping (ruamel.yaml) +_thread_local = threading.local() + + +def get_yaml(): + """Get thread-local ruamel.yaml YAML instance.""" + if not hasattr(_thread_local, 'yaml'): + y = _ruamel_yaml() + y.default_flow_style = False + y.allow_unicode = True + y.width = 4096 # Prevent line wrapping + y.preserve_quotes = True + _thread_local.yaml = y + return _thread_local.yaml + + +def _convert_to_plain(obj): + """Recursively convert CommentedMap/CommentedSeq to plain dict/list.""" + if isinstance(obj, dict): + return {k: _convert_to_plain(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_convert_to_plain(v) for v in obj] + else: + return obj + + +def yaml_dump_to_string(data): + """Dump YAML data to string (thread-safe). + + Uses ruamel.yaml (with comments) or PyYAML (fast, no comments) based on _yaml_library. + """ + if _yaml_library == 'pyyaml': + # PyYAML: fast but no comment support + # Add header comment manually + header = "" + if hasattr(data, 'ca') and data.ca.comment and data.ca.comment[1]: + # Extract start comment from ruamel CommentedMap + for comment in data.ca.comment[1]: + if comment and hasattr(comment, 'value'): + comment_text = comment.value.strip() + # Comment already includes # prefix + if comment_text.startswith('#'): + header = f"{comment_text}\n" + else: + header = f"# {comment_text}\n" + break + # Convert to plain dict/list recursively for PyYAML + plain_data = _convert_to_plain(data) + return header + _pyyaml.dump(plain_data, allow_unicode=True, default_flow_style=False, + sort_keys=False, width=4096) + else: + # ruamel.yaml: slower but preserves comments + stream = StringIO() + get_yaml().dump(data, stream) + return stream.getvalue() + + +# Global YAML instance for main thread operations (index files) - lazy init +_main_yaml = None + + +def get_main_yaml(): + """Get the main thread YAML instance (for index files).""" + global _main_yaml + if _main_yaml is None: + _main_yaml = _ruamel_yaml() + _main_yaml.default_flow_style = False + _main_yaml.allow_unicode = True + _main_yaml.width = 4096 + _main_yaml.preserve_quotes = True + return _main_yaml + +# Global name registries for cross-references +ROOM_NAMES = {} # vnum -> name +MOB_NAMES = {} # vnum -> name +OBJ_NAMES = {} # vnum -> name +TRIGGER_NAMES = {} # vnum -> name +ZONE_NAMES = {} # vnum -> name + +# Action flags (MOB_x) - from constants.cpp action_bits[] +ACTION_FLAGS = [ + "kSpec", "kSentinel", "kScavenger", "kIsNpc", "kAware", "kAggressive", + "kStayZone", "kWimpy", "kAggressive_Mob", "kMemory", "kHelper", "kNoCharm", + "kNoSummoned", "kNoSleep", "kNoBash", "kNoBlind", "kNoTrack", "kFireCreature", + "kAirCreature", "kWaterCreature", "kEarthCreature", "kRacing", "kMounting", + "kProtected", "kSwimming", "kFlying", "kScrStay", "kNoTerrainAttack", + "kNoFear", "kNoMagicTerrainAttack", + # Plane 1 + "kFreemaker", "kProgrammedLootGroup", "kIgnoresFear", "kClone", "kCorpse", + "kLooting", "kTutelar", "kMentalShadow", "kSummoner", "kNoSilence", + "kNoHolder", "kDeleted" +] + +# Room flags (room_bits[]) +# Each plane has 30 bits, so: +# Plane 0: indices 0-29 +# Plane 1: indices 30-59 +# Plane 2: indices 60+ +ROOM_FLAGS = [ + # Plane 0 (bits 0-29) + "kDarked", "kDeathTrap", "kNoEntryMob", "kIndoors", "kPeaceful", "kSoundproof", + "kNoTrack", "kNoMagic", "kTunnel", "kNoTeleportIn", "kGodsRoom", "kHouse", + "kHouseCrash", "kHouseEntry", "UNUSED_14", "kBfsMark", "kForMages", "kForSorcerers", + "kForThieves", "kForWarriors", "kForAssasines", "kForGuards", "kForPaladines", "kForRangers", + "kForPoly", "kForMono", "kForge", "kForMerchants", "kForMaguses", "kArena", + # Plane 1 (bits 0-29, only 0-12 used) + "kNoSummonOut", "kNoTeleportOut", "kNohorse", "kNoWeather", "kSlowDeathTrap", "kIceTrap", + "kNoRelocateIn", "kTribune", "kArenaSend", "kNoBattle", "UNUSED_40", "kAlwaysLit", "kMoMapper", + "UNUSED_43", "UNUSED_44", "UNUSED_45", "UNUSED_46", "UNUSED_47", "UNUSED_48", "UNUSED_49", + "UNUSED_50", "UNUSED_51", "UNUSED_52", "UNUSED_53", "UNUSED_54", "UNUSED_55", "UNUSED_56", + "UNUSED_57", "UNUSED_58", "UNUSED_59", + # Plane 2 (bits 0-29, only 0-1 used) + "kNoItem", "kDominationArena" +] + +# Object types (item_types[]) +OBJ_TYPES = [ + "kUndefined", # 0 + "kLightSource", # 1 + "kScroll", # 2 + "kWand", # 3 + "kStaff", # 4 + "kWeapon", # 5 + "kElementWeapon", # 6 + "kMissile", # 7 + "kTreasure", # 8 + "kArmor", # 9 + "kPotion", # 10 + "kWorm", # 11 + "kOther", # 12 + "kTrash", # 13 + "kTrap", # 14 + "kContainer", # 15 + "kNote", # 16 + "kLiquidContainer", # 17 + "kKey", # 18 + "kFood", # 19 + "kMoney", # 20 + "kPen", # 21 + "kBoat", # 22 + "kFountain", # 23 + "kBook", # 24 + "kIngredient", # 25 + "kMagicIngredient", # 26 + "kCraftMaterial", # 27 + "kBandage", # 28 + "kLightArmor", # 29 + "kContainer2", # 30 + "kMaterial", # 31 + "kCraftbook", # 32 + "kMedal", # 33 + "kEnchant" # 34 +] + +# Extra flags (EObjFlag) - 50 flags total (30 in plane 0, 20 in plane 1) +EXTRA_FLAGS = [ + # Plane 0 (0-29) + "kGlow", "kHum", "kNorent", "kNodonate", "kNoinvis", "kInvisible", + "kMagic", "kNodrop", "kBless", "kNosell", "kDecay", "kZonedecay", + "kNodisarm", "kNodecay", "kPoisoned", "kSharpen", "kArmored", + "kAppearsDay", "kAppearsNight", "kAppearsFullmoon", "kAppearsWinter", + "kAppearsSpring", "kAppearsSummer", "kAppearsAutumn", "kSwimming", + "kFlying", "kThrowing", "kTicktimer", "kFire", "kRepopDecay", + # Plane 1 (30-49) + "kNolocate", "kTimedLvl", "kNoalter", "kHasOneSlot", "kHasTwoSlots", + "kHasThreeSlots", "kSetItem", "kNofail", "kNamed", "kBloody", + "kQuestItem", "k2inlaid", "k3inlaid", "kNopour", "kUnique", + "kTransformed", "kNoRentTimer", "kLimitedTimer", "kBindOnPurchase", + "kNotOneInClanChest", + # Padding for plane 1 remainder (50-59) + "UNUSED_50", "UNUSED_51", "UNUSED_52", "UNUSED_53", "UNUSED_54", + "UNUSED_55", "UNUSED_56", "UNUSED_57", "UNUSED_58", "UNUSED_59", + # Padding for plane 2 (60-89) + "UNUSED_60", "UNUSED_61", "UNUSED_62", "UNUSED_63", "UNUSED_64", + "UNUSED_65", "UNUSED_66", "UNUSED_67", "UNUSED_68", "UNUSED_69", + "UNUSED_70", "UNUSED_71", "UNUSED_72", "UNUSED_73", "UNUSED_74", + "UNUSED_75", "UNUSED_76", "UNUSED_77", "UNUSED_78", "UNUSED_79", + "UNUSED_80", "UNUSED_81", "UNUSED_82", "UNUSED_83", "UNUSED_84", + "UNUSED_85", "UNUSED_86", "UNUSED_87", "UNUSED_88", "UNUSED_89", + # Padding for plane 3 (90-99) - rarely used + "UNUSED_90", "UNUSED_91", "UNUSED_92", "UNUSED_93", "UNUSED_94", + "UNUSED_95", "UNUSED_96", "UNUSED_97", "UNUSED_98", "UNUSED_99" +] + +# Wear flags (wear_bits[]) +WEAR_FLAGS = [ + "kTake", "kFinger", "kNeck", "kBody", "kHead", "kLegs", "kFeet", + "kHands", "kArms", "kShield", "kShoulders", "kWaist", "kWrist", + "kWield", "kHold", "kBoth", "kQuiver", + # UNUSED bits 17-29 (used in some old files) + "UNUSED_17", "UNUSED_18", "UNUSED_19", "UNUSED_20", "UNUSED_21", + "UNUSED_22", "UNUSED_23", "UNUSED_24", "UNUSED_25", "UNUSED_26", + "UNUSED_27", "UNUSED_28", "UNUSED_29" +] + +# No flags (ENoFlag) - restrictions by class/other criteria +NO_FLAGS = [ + # Plane 0 (0-18) + "kMono", "kPoly", "kNeutral", "kMage", "kSorcerer", "kThief", + "kWarrior", "kAssasine", "kGuard", "kPaladine", "kRanger", "kVigilant", + "kMerchant", "kMagus", "kConjurer", "kCharmer", "kWizard", + "kNecromancer", "kFighter", + # Padding for plane 0 remainder (19-29) - unnamed but used in files + "UNUSED_19", "UNUSED_20", "UNUSED_21", "UNUSED_22", "UNUSED_23", + "UNUSED_24", "UNUSED_25", "UNUSED_26", "UNUSED_27", "UNUSED_28", "UNUSED_29", + # Plane 1 (30-59) + "kKiller", "kColored", "kBattle", + "UNUSED_33", "UNUSED_34", "UNUSED_35", "UNUSED_36", "UNUSED_37", "UNUSED_38", + "UNUSED_39", "UNUSED_40", "UNUSED_41", "UNUSED_42", "UNUSED_43", "UNUSED_44", + "UNUSED_45", "UNUSED_46", "UNUSED_47", "UNUSED_48", "UNUSED_49", "UNUSED_50", + "UNUSED_51", "UNUSED_52", "UNUSED_53", "UNUSED_54", "UNUSED_55", "UNUSED_56", + "UNUSED_57", "UNUSED_58", "UNUSED_59", + # Plane 2 (60-89) + "UNUSED_60", "UNUSED_61", "UNUSED_62", "UNUSED_63", "UNUSED_64", "UNUSED_65", + "kMale", "kFemale", "kCharmice", + "UNUSED_69", "UNUSED_70", "UNUSED_71", "UNUSED_72", "UNUSED_73", "UNUSED_74", + "UNUSED_75", "UNUSED_76", "UNUSED_77", "UNUSED_78", "UNUSED_79", "UNUSED_80", + "UNUSED_81", "UNUSED_82", "UNUSED_83", "UNUSED_84", "UNUSED_85", "UNUSED_86", + "UNUSED_87", "UNUSED_88", "UNUSED_89", + # Plane 3 (90-99) - UNUSED but found in files + "UNUSED_90", "UNUSED_91", "UNUSED_92", "UNUSED_93", "UNUSED_94", + "UNUSED_95", "UNUSED_96", "UNUSED_97", "UNUSED_98", "UNUSED_99" +] + +# Anti flags (EAntiFlag) - same structure as NO_FLAGS +ANTI_FLAGS = NO_FLAGS.copy() + +# Object affect flags (EWeaponAffect) +# Plane 0: bits 0-29, Plane 1: bits 0-16 (indices 30-46) +AFFECT_FLAGS = [ + "kBlindness", # 0 + "kInvisibility", # 1 + "kDetectAlign", # 2 + "kDetectInvisibility", # 3 + "kDetectMagic", # 4 + "kDetectLife", # 5 + "kWaterWalk", # 6 + "kSanctuary", # 7 + "kCurse", # 8 + "kInfravision", # 9 + "kPoison", # 10 + "kProtectFromDark", # 11 + "kProtectFromMind", # 12 + "kSleep", # 13 + "kNoTrack", # 14 + "kBless", # 15 + "kSneak", # 16 + "kHide", # 17 + "kHold", # 18 + "kFly", # 19 + "kSilence", # 20 + "kAwareness", # 21 + "kBlink", # 22 + "kNoFlee", # 23 + "kSingleLight", # 24 + "kHolyLight", # 25 + "kHolyDark", # 26 + "kDetectPoison", # 27 + "kSlow", # 28 + "kHaste", # 29 + # Plane 1: bits 0-16 (30-46) + "kWaterBreath", # 30 + "kHaemorrhage", # 31 + "kDisguising", # 32 + "kShield", # 33 + "kAirShield", # 34 + "kFireShield", # 35 + "kIceShield", # 36 + "kMagicGlass", # 37 + "kStoneHand", # 38 + "kPrismaticAura", # 39 + "kAirAura", # 40 + "kFireAura", # 41 + "kIceAura", # 42 + "kDeafness", # 43 + "kComamnder", # 44 + "kEarthAura", # 45 + "kCloudly", # 46 + # Padding for plane 1 remainder (47-59) + "UNUSED_47", "UNUSED_48", "UNUSED_49", "UNUSED_50", "UNUSED_51", "UNUSED_52", + "UNUSED_53", "UNUSED_54", "UNUSED_55", "UNUSED_56", "UNUSED_57", "UNUSED_58", "UNUSED_59", + # Padding for plane 2 (60-89) + "UNUSED_60", "UNUSED_61", "UNUSED_62", "UNUSED_63", "UNUSED_64", "UNUSED_65", + "UNUSED_66", "UNUSED_67", "UNUSED_68", "UNUSED_69", "UNUSED_70", "UNUSED_71", + "UNUSED_72", "UNUSED_73", "UNUSED_74", "UNUSED_75", "UNUSED_76", "UNUSED_77", + "UNUSED_78", "UNUSED_79", "UNUSED_80", "UNUSED_81", "UNUSED_82", "UNUSED_83", + "UNUSED_84", "UNUSED_85", "UNUSED_86", "UNUSED_87", "UNUSED_88", "UNUSED_89", + # Plane 3 (90+) - rarely used but exists in some files + "UNUSED_90", "UNUSED_91", "UNUSED_92", "UNUSED_93", "UNUSED_94", "UNUSED_95", + "UNUSED_96", "UNUSED_97", "UNUSED_98", "UNUSED_99", +] + +# Genders +GENDERS = ["kNeutral", "kMale", "kFemale", "kPoly"] + +# Positions +POSITIONS = [ + "kDead", "kMortally", "kIncapacitated", "kStunned", "kSleeping", + "kResting", "kSitting", "kFighting", "kStanding" +] + +# Sector types +SECTORS = [ + "kInside", "kCity", "kField", "kForest", "kHills", "kMountain", + "kWaterSwim", "kWaterNoswim", "kOnlyFlying", "kUnderwater", "kSecret", + "kStoneroad", "kRoad", "kWildroad", "kFieldSnow", "kFieldRain", + "kForestSnow", "kForestRain", "kHillsSnow", "kHillsRain", + "kMountainSnow", "kThinIce", "kNormalIce", "kThickIce" +] + +# Trigger types +TRIGGER_TYPES = { + 'g': 'kGreet', 'a': 'kGreetAll', 'e': 'kEntry', 'c': 'kCommand', + 's': 'kSpeech', 'A': 'kAct', 'f': 'kFight', 'H': 'kHitPercent', + 'b': 'kBribe', 'd': 'kDeath', 'l': 'kLoad', 'r': 'kRandom', + 'i': 'kReceive', 'j': 'kDrop', 'G': 'kGive', 'w': 'kWear', + 'R': 'kRemove', 'p': 'kPut', 'q': 'kGet', 'n': 'kIncome', + 'O': 'kOpen', 'C': 'kClose', 'L': 'kLock', 'U': 'kUnlock', + 'P': 'kPick', 'D': 'kDamage', 'u': 'kUse', 't': 'kTimer', + 'h': 'kHour', 'S': 'kStart', 'I': 'kInvite', 'T': 'kTimeChange', 'z': 'kTime', + 'k': 'kKill', 'Q': 'kCast', 'N': 'kNumber' +} + + +def numeric_flags_to_letters(n): + """Convert numeric trigger_type to letter flags (same as asciiflag_conv inverse).""" + result = [] + for i in range(26): + if n & (1 << i): + result.append(chr(ord('a') + i)) + for i in range(26): + if n & (1 << (26 + i)): + result.append(chr(ord('A') + i)) + return ''.join(result) + +# Attach types +ATTACH_TYPES = {0: 'kMob', 1: 'kObj', 2: 'kRoom'} + +# Skill names from ESkill enum (for mob skills comments) +SKILL_NAMES = { + 1: 'kProtect', 2: 'kIntercept', 3: 'kLeftHit', 4: 'kHammer', + 5: 'kOverwhelm', 6: 'kPoisoning', 7: 'kSense', 8: 'kRiding', + 9: 'kHideTrack', 11: 'kSkinning', 12: 'kMultiparry', 13: 'kReforging', + 20: 'kLeadership', 21: 'kPunctual', 22: 'kAwake', 23: 'kIdentify', + 24: 'kHearing', 25: 'kCreatePotion', 26: 'kCreateScroll', 27: 'kCreateWand', + 28: 'kPry', 29: 'kArmoring', 30: 'kHangovering', 31: 'kFirstAid', + 32: 'kCampfire', 33: 'kCreateBow', 34: 'kSlay', 35: 'kFrenzy', + 128: 'kShieldBash', 129: 'kCutting', 130: 'kThrow', 131: 'kBackstab', + 132: 'kBash', 133: 'kHide', 134: 'kKick', 135: 'kPickLock', + 136: 'kPunch', 137: 'kRescue', 138: 'kSneak', 139: 'kSteal', + 140: 'kTrack', 141: 'kClubs', 142: 'kAxes', 143: 'kLongBlades', + 144: 'kShortBlades', 145: 'kNonstandart', 146: 'kTwohands', 147: 'kPicks', + 148: 'kSpades', 149: 'kSideAttack', 150: 'kDisarm', 151: 'kParry', + 152: 'kCharge', 153: 'kMorph', 154: 'kBows', 155: 'kAddshot', + 156: 'kDisguise', 157: 'kDodge', 158: 'kShieldBlock', 159: 'kLooking', + 160: 'kChopoff', 161: 'kRepair', 162: 'kDazzle', 163: 'kThrowout', + 164: 'kSharpening', 165: 'kCourage', 166: 'kJinx', 167: 'kNoParryHit', + 168: 'kTownportal', 169: 'kMakeStaff', 170: 'kMakeBow', 171: 'kMakeWeapon', + 172: 'kMakeArmor', 173: 'kMakeJewel', 174: 'kMakeWear', 175: 'kMakePotion', + 176: 'kDigging', 177: 'kJewelry', 178: 'kWarcry', 179: 'kTurnUndead', + 180: 'kIronwind', 181: 'kStrangle', 182: 'kAirMagic', 183: 'kFireMagic', + 184: 'kWaterMagic', 185: 'kEarthMagic', 186: 'kLightMagic', 187: 'kDarkMagic', + 188: 'kMindMagic', 189: 'kLifeMagic', 190: 'kStun', 191: 'kMakeAmulet' +} + +# Apply locations (for object applies comments) +APPLY_LOCATIONS = { + 0: 'kNone', 1: 'kStr', 2: 'kDex', 3: 'kInt', 4: 'kWis', 5: 'kCon', 6: 'kCha', + 7: 'kClass', 8: 'kLvl', 9: 'kAge', 10: 'kWeight', 11: 'kHeight', + 12: 'kManaRegen', 13: 'kHp', 14: 'kMove', 15: 'kGold', + 17: 'kAc', 18: 'kHitroll', 19: 'kDamroll', 20: 'kSavingWill', + 21: 'kResistFire', 22: 'kResistAir', 23: 'kSavingCritical', 24: 'kSavingStability', + 25: 'kHpRegen', 26: 'kMoveRegen', 27: 'kFirstCircle', 28: 'kSecondCircle', + 29: 'kThirdCircle', 30: 'kFourthCircle', 31: 'kFifthCircle', 32: 'kSixthCircle', + 33: 'kSeventhCircle', 34: 'kEighthCircle', 35: 'kNinthCircle', + 36: 'kSize', 37: 'kArmour', 38: 'kPoison', 39: 'kSavingReflex', + 40: 'kCastSuccess', 41: 'kMorale', 42: 'kInitiative', 43: 'kReligion', + 44: 'kAbsorbe', 45: 'kLikes', 46: 'kResistWater', 47: 'kResistEarth', + 48: 'kResistVitality', 49: 'kResistMind', 50: 'kResistImmunity', 51: 'kResistDark', + 52: 'kAffectResist', 53: 'kMagicResist', 54: 'kPhysicResist', 55: 'kPhysicDamage', + 56: 'kMagicDamage', 57: 'kExpBonus', 58: 'kPercent' +} + +# Wear positions for EQUIP_MOB command (slot in equipment) +WEAR_POSITIONS = { + 0: 'LIGHT', 1: 'FINGER_R', 2: 'FINGER_L', 3: 'NECK_1', 4: 'NECK_2', + 5: 'BODY', 6: 'HEAD', 7: 'LEGS', 8: 'FEET', 9: 'HANDS', 10: 'ARMS', + 11: 'SHIELD', 12: 'ABOUT', 13: 'WAIST', 14: 'WRIST_R', 15: 'WRIST_L', + 16: 'WIELD', 17: 'HOLD', 18: 'BOTH', 19: 'QUIVER' +} + +# Direction names for door commands +DIRECTION_NAMES = { + 0: 'north', 1: 'east', 2: 'south', 3: 'west', 4: 'up', 5: 'down' +} + + +# ============================================================================ +# Saver classes for output abstraction +# ============================================================================ + +class BaseSaver: + """Base class for savers.""" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + pass + + def save_mob(self, mob): + raise NotImplementedError + + def save_object(self, obj): + raise NotImplementedError + + def save_room(self, room): + raise NotImplementedError + + def save_zone(self, zone): + raise NotImplementedError + + def save_trigger(self, trigger): + raise NotImplementedError + + def finalize(self): + """Called after all entities are saved.""" + pass + + +class YamlSaver(BaseSaver): + """Save world data to YAML files.""" + + def __init__(self, output_dir): + self.output_dir = Path(output_dir) / 'world' + self._saved_files = {'mob': [], 'obj': [], 'wld': [], 'zon': [], 'trg': []} + + def _ensure_dir(self, subdir): + dir_path = self.output_dir / subdir + dir_path.mkdir(parents=True, exist_ok=True) + return dir_path + + def save_mob(self, mob): + yaml_content = mob_to_yaml(mob) + out_dir = self._ensure_dir('mob') + out_file = out_dir / f"{mob['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + self._saved_files['mob'].append(out_file.name) + + def save_object(self, obj): + yaml_content = obj_to_yaml(obj) + out_dir = self._ensure_dir('obj') + out_file = out_dir / f"{obj['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + self._saved_files['obj'].append(out_file.name) + + def save_room(self, room): + yaml_content = room_to_yaml(room) + out_dir = self._ensure_dir('wld') + out_file = out_dir / f"{room['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + self._saved_files['wld'].append(out_file.name) + + def save_zone(self, zone): + yaml_content = zon_to_yaml(zone) + out_dir = self._ensure_dir('zon') + out_file = out_dir / f"{zone['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + self._saved_files['zon'].append(out_file.name) + + def save_trigger(self, trigger): + yaml_content = trg_to_yaml(trigger) + out_dir = self._ensure_dir('trg') + out_file = out_dir / f"{trigger['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + self._saved_files['trg'].append(out_file.name) + + def finalize(self): + """Create index files for each entity type.""" + for subdir, files in self._saved_files.items(): + if not files: + continue + dir_path = self.output_dir / subdir + if not dir_path.exists(): + continue + + index_data = CommentedMap() + index_data['vnums'] = CommentedSeq(sorted(files)) + + with open(dir_path / 'index.yaml', 'w', encoding='koi8-r') as f: + get_main_yaml().dump(index_data, f) + + print(f"Created {subdir}/index.yaml with {len(files)} entries") + + +class SqliteSaver(BaseSaver): + """Save world data to SQLite database.""" + + # Embedded schema (loaded from world_schema.sql) + SCHEMA_SQL = None + + def __init__(self, db_path): + self.db_path = Path(db_path) + self.conn = None + self._cmd_order = {} # zone_vnum -> command order counter + + def __enter__(self): + self.db_path.parent.mkdir(parents=True, exist_ok=True) + # Remove existing database + if self.db_path.exists(): + self.db_path.unlink() + # Use check_same_thread=False for multi-threaded access (we use locks for safety) + self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self._create_schema() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.conn: + self.conn.commit() + self.conn.close() + + def _create_schema(self): + """Create database schema.""" + if SqliteSaver.SCHEMA_SQL is None: + # Try to load from file next to this script + schema_path = Path(__file__).parent / 'world_schema.sql' + if schema_path.exists(): + with open(schema_path, 'r', encoding='utf-8') as f: + SqliteSaver.SCHEMA_SQL = f.read() + else: + raise RuntimeError(f"Schema file not found: {schema_path}") + + # Disable foreign keys during schema creation and data import + self.conn.execute("PRAGMA foreign_keys = OFF") + self.conn.executescript(SqliteSaver.SCHEMA_SQL) + self._populate_reference_tables() + + def _populate_reference_tables(self): + """Populate reference/enum tables with constants.""" + cursor = self.conn.cursor() + + # obj_types + for i, name in enumerate(OBJ_TYPES): + cursor.execute("INSERT INTO obj_types (id, name) VALUES (?, ?)", (i, name)) + + # sectors + for i, name in enumerate(SECTORS): + cursor.execute("INSERT INTO sectors (id, name) VALUES (?, ?)", (i, name)) + + # positions + for i, name in enumerate(POSITIONS): + cursor.execute("INSERT INTO positions (id, name) VALUES (?, ?)", (i, name)) + + # genders + for i, name in enumerate(GENDERS): + cursor.execute("INSERT INTO genders (id, name) VALUES (?, ?)", (i, name)) + + # directions + for id_, name in DIRECTION_NAMES.items(): + cursor.execute("INSERT INTO directions (id, name) VALUES (?, ?)", (id_, name)) + + # skills + for id_, name in SKILL_NAMES.items(): + cursor.execute("INSERT INTO skills (id, name) VALUES (?, ?)", (id_, name)) + + # apply_locations + for id_, name in APPLY_LOCATIONS.items(): + cursor.execute("INSERT INTO apply_locations (id, name) VALUES (?, ?)", (id_, name)) + + # wear_positions + for id_, name in WEAR_POSITIONS.items(): + cursor.execute("INSERT INTO wear_positions (id, name) VALUES (?, ?)", (id_, name)) + + # trigger_attach_types + for id_, name in ATTACH_TYPES.items(): + cursor.execute("INSERT INTO trigger_attach_types (id, name) VALUES (?, ?)", (id_, name)) + + # trigger_type_defs (from TRIGGER_TYPES mapping) + # bit_value: lowercase 'a'-'z' → 1<<(ch-'a'), uppercase 'A'-'Z' → 1<<(26+ch-'A') + for char_code, name in TRIGGER_TYPES.items(): + if char_code.islower(): + bit_value = 1 << (ord(char_code) - ord('a')) + else: + bit_value = 1 << (26 + ord(char_code) - ord('A')) + cursor.execute( + "INSERT INTO trigger_type_defs (char_code, name, bit_value) VALUES (?, ?, ?)", + (char_code, name, bit_value) + ) + + self.conn.commit() + + def save_mob(self, mob): + """Save mob dictionary to database.""" + cursor = self.conn.cursor() + vnum = mob['vnum'] + names = mob.get('names', {}) + descs = mob.get('descriptions', {}) + stats = mob.get('stats', {}) + hp = stats.get('hp', {}) + dmg = stats.get('damage', {}) + gold = mob.get('gold', {}) + pos = mob.get('position', {}) + attrs = mob.get('attributes', {}) + + # Insert main mob record + cursor.execute(''' + INSERT OR REPLACE INTO mobs ( + vnum, aliases, name_nom, name_gen, name_dat, name_acc, name_ins, name_pre, + short_desc, long_desc, alignment, mob_type, level, hitroll_penalty, armor, + hp_dice_count, hp_dice_size, hp_bonus, dam_dice_count, dam_dice_size, dam_bonus, + gold_dice_count, gold_dice_size, gold_bonus, experience, + default_pos, start_pos, sex, size, height, weight, mob_class, race, + attr_str, attr_dex, attr_int, attr_wis, attr_con, attr_cha, enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + vnum, + names.get('aliases'), + names.get('nominative'), + names.get('genitive'), + names.get('dative'), + names.get('accusative'), + names.get('instrumental'), + names.get('prepositional'), + descs.get('short_desc'), + descs.get('long_desc'), + mob.get('alignment', 0), + mob.get('mob_type', 'S'), + stats.get('level', 1), + stats.get('hitroll_penalty', 20), + stats.get('armor', 100), + hp.get('dice_count', 1), + hp.get('dice_size', 1), + hp.get('bonus', 0), + dmg.get('dice_count', 1), + dmg.get('dice_size', 1), + dmg.get('bonus', 0), + gold.get('dice_count', 0), + gold.get('dice_size', 0), + gold.get('bonus', 0), + mob.get('experience', 0), + pos.get('default'), + pos.get('start'), + mob.get('sex'), + mob.get('size'), + mob.get('height'), + mob.get('weight'), + mob.get('mob_class'), + mob.get('race'), + attrs.get('strength', 11), + attrs.get('dexterity', 11), + attrs.get('intelligence', 11), + attrs.get('wisdom', 11), + attrs.get('constitution', 11), + attrs.get('charisma', 11), + mob.get('enabled', 1), + )) + + # Insert flags + for flag in mob.get('action_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO mob_flags (mob_vnum, flag_category, flag_name) + VALUES (?, 'action', ?) + ''', (vnum, flag)) + + for flag in mob.get('affect_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO mob_flags (mob_vnum, flag_category, flag_name) + VALUES (?, 'affect', ?) + ''', (vnum, flag)) + + # Insert skills + for skill in mob.get('skills', []): + cursor.execute(''' + INSERT OR REPLACE INTO mob_skills (mob_vnum, skill_id, value) + VALUES (?, ?, ?) + ''', (vnum, skill['skill_id'], skill['value'])) + + # Insert triggers + for trig_order, trig_vnum in enumerate(mob.get('triggers', [])): + cursor.execute(''' + INSERT INTO entity_triggers (entity_type, entity_vnum, trigger_vnum, trigger_order) + VALUES ('mob', ?, ?, ?) + ''', (vnum, trig_vnum, trig_order)) + + def save_object(self, obj): + """Save object dictionary to database.""" + cursor = self.conn.cursor() + vnum = obj['vnum'] + names = obj.get('names', {}) + values = obj.get('values', [None, None, None, None]) + + # Insert main object record + cursor.execute(''' + INSERT OR REPLACE INTO objects ( + vnum, aliases, name_nom, name_gen, name_dat, name_acc, name_ins, name_pre, + short_desc, action_desc, obj_type_id, material, + value0, value1, value2, value3, + weight, cost, rent_off, rent_on, spec_param, + max_durability, cur_durability, timer, spell, level, sex, max_in_world, + minimum_remorts, enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + vnum, + names.get('aliases'), + names.get('nominative'), + names.get('genitive'), + names.get('dative'), + names.get('accusative'), + names.get('instrumental'), + names.get('prepositional'), + obj.get('short_desc'), + obj.get('action_desc'), + obj.get('type_id'), + obj.get('material'), + values[0] if len(values) > 0 else None, + values[1] if len(values) > 1 else None, + values[2] if len(values) > 2 else None, + values[3] if len(values) > 3 else None, + obj.get('weight', 0), + obj.get('cost', 0), + obj.get('rent_off', 0), + obj.get('rent_on', 0), + obj.get('spec_param', 0), + obj.get('max_durability', 100), + obj.get('cur_durability', 100), + obj.get('timer', -1), + obj.get('spell', -1), + obj.get('level', 0), + obj.get('sex', 0), + obj.get('max_in_world'), + obj.get('minimum_remorts', 0), + obj.get('enabled', 1), + )) + + # Insert flags + for flag in obj.get('extra_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO obj_flags (obj_vnum, flag_category, flag_name) + VALUES (?, 'extra', ?) + ''', (vnum, flag)) + + for flag in obj.get('wear_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO obj_flags (obj_vnum, flag_category, flag_name) + VALUES (?, 'wear', ?) + ''', (vnum, flag)) + + for flag in obj.get('affect_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO obj_flags (obj_vnum, flag_category, flag_name) + VALUES (?, 'affect', ?) + ''', (vnum, flag)) + + for flag in obj.get('no_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO obj_flags (obj_vnum, flag_category, flag_name) + VALUES (?, 'no', ?) + ''', (vnum, flag)) + + for flag in obj.get('anti_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO obj_flags (obj_vnum, flag_category, flag_name) + VALUES (?, 'anti', ?) + ''', (vnum, flag)) + + # Insert applies + for apply in obj.get('applies', []): + location_id = apply['location'] + cursor.execute(''' + INSERT INTO obj_applies (obj_vnum, location_id, modifier) + VALUES (?, ?, ?) + ''', (vnum, location_id, apply['modifier'])) + + # Insert extra descriptions + for ed in obj.get('extra_descs', []): + cursor.execute(''' + INSERT INTO extra_descriptions (entity_type, entity_vnum, keywords, description) + VALUES ('obj', ?, ?, ?) + ''', (vnum, ed['keywords'], ed['description'])) + + # Insert triggers + for trig_order, trig_vnum in enumerate(obj.get('triggers', [])): + cursor.execute(''' + INSERT INTO entity_triggers (entity_type, entity_vnum, trigger_vnum, trigger_order) + VALUES ('obj', ?, ?, ?) + ''', (vnum, trig_vnum, trig_order)) + + def save_room(self, room): + """Save room dictionary to database.""" + cursor = self.conn.cursor() + vnum = room['vnum'] + + # Insert main room record + cursor.execute(''' + INSERT OR REPLACE INTO rooms (vnum, zone_vnum, name, description, sector_id, enabled) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + vnum, + room.get('zone'), + room.get('name'), + room.get('description'), + room.get('sector_id'), + room.get('enabled', 1), + )) + + # Insert flags + for flag in room.get('room_flags', []): + cursor.execute(''' + INSERT OR IGNORE INTO room_flags (room_vnum, flag_name) + VALUES (?, ?) + ''', (vnum, flag)) + + # Insert exits + for exit_data in room.get('exits', []): + direction_id = exit_data.get('direction', 0) + if not isinstance(direction_id, int): + direction_id = 0 + + exit_flags = exit_data.get('exit_flags', 0) + if isinstance(exit_flags, int): + exit_flags_str = str(exit_flags) + else: + exit_flags_str = exit_flags + + cursor.execute(''' + INSERT OR REPLACE INTO room_exits ( + room_vnum, direction_id, description, keywords, exit_flags, + key_vnum, to_room, lock_complexity + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + vnum, + direction_id, + exit_data.get('description'), + exit_data.get('keywords'), + exit_flags_str, + exit_data.get('key', -1), + exit_data.get('to_room', -1), + exit_data.get('lock_complexity', 0), + )) + + # Insert extra descriptions + for ed in room.get('extra_descs', []): + cursor.execute(''' + INSERT INTO extra_descriptions (entity_type, entity_vnum, keywords, description) + VALUES ('room', ?, ?, ?) + ''', (vnum, ed['keywords'], ed['description'])) + + # Insert triggers + for trig_order, trig_vnum in enumerate(room.get('triggers', [])): + cursor.execute(''' + INSERT INTO entity_triggers (entity_type, entity_vnum, trigger_vnum, trigger_order) + VALUES ('room', ?, ?, ?) + ''', (vnum, trig_vnum, trig_order)) + + def save_zone(self, zone): + """Save zone dictionary to database.""" + cursor = self.conn.cursor() + vnum = zone['vnum'] + meta = zone.get('metadata', {}) + + # Insert main zone record + cursor.execute(''' + INSERT OR REPLACE INTO zones ( + vnum, name, comment, location, author, description, builders, + first_room, top_room, mode, zone_type, zone_group, entrance, lifespan, reset_mode, reset_idle, under_construction, enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + vnum, + zone.get('name'), + meta.get('comment'), + meta.get('location'), + meta.get('author'), + meta.get('description'), + zone.get('builders'), + zone.get('first_room'), + zone.get('top_room'), + zone.get('mode', 0), + zone.get('zone_type', 0), + zone.get('zone_group', 1), + zone.get('entrance'), + zone.get('lifespan', 10), + zone.get('reset_mode', 2), + zone.get('reset_idle', 0), + zone.get('under_construction', 0), + zone.get('enabled', 1), + )) + + # Insert zone groups + for linked_zone in zone.get('typeA_list', []): + cursor.execute(''' + INSERT OR IGNORE INTO zone_groups (zone_vnum, linked_zone_vnum, group_type) + VALUES (?, ?, 'A') + ''', (vnum, linked_zone)) + + for linked_zone in zone.get('typeB_list', []): + cursor.execute(''' + INSERT OR IGNORE INTO zone_groups (zone_vnum, linked_zone_vnum, group_type) + VALUES (?, ?, 'B') + ''', (vnum, linked_zone)) + + # Insert zone commands + if vnum not in self._cmd_order: + self._cmd_order[vnum] = 0 + + for cmd in zone.get('commands', []): + self._cmd_order[vnum] += 1 + cmd_type = cmd.get('type', '') + + cursor.execute(''' + INSERT INTO zone_commands ( + zone_vnum, cmd_order, cmd_type, if_flag, + arg_mob_vnum, arg_obj_vnum, arg_room_vnum, arg_trigger_vnum, + arg_container_vnum, arg_max, arg_max_world, arg_max_room, + arg_load_prob, arg_wear_pos_id, arg_direction_id, arg_state, + arg_trigger_type, arg_context, arg_var_name, arg_var_value, + arg_leader_mob_vnum, arg_follower_mob_vnum + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + vnum, + self._cmd_order[vnum], + cmd_type, + cmd.get('if_flag', 0), + cmd.get('mob_vnum'), + cmd.get('obj_vnum'), + cmd.get('room_vnum'), + cmd.get('trigger_vnum'), + cmd.get('container_vnum'), + cmd.get('max'), + cmd.get('max_world'), + cmd.get('max_room'), + cmd.get('load_prob'), + cmd.get('wear_pos'), + cmd.get('direction'), + cmd.get('state'), + cmd.get('trigger_type'), + cmd.get('context'), + cmd.get('var_name'), + cmd.get('var_value'), + cmd.get('leader_mob_vnum'), + cmd.get('follower_mob_vnum'), + )) + + def save_trigger(self, trigger): + """Save trigger dictionary to database.""" + cursor = self.conn.cursor() + vnum = trigger['vnum'] + + # Insert main trigger record (without trigger_types - normalized) + cursor.execute(''' + INSERT OR REPLACE INTO triggers ( + vnum, name, attach_type_id, narg, arglist, script, enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + vnum, + trigger.get('name'), + trigger.get('attach_type_id'), + trigger.get('narg', 0), + trigger.get('arglist'), + trigger.get('script'), + trigger.get('enabled', 1), + )) + + # Insert trigger type bindings (normalized many-to-many) + for type_char in trigger.get('type_chars', []): + cursor.execute(''' + INSERT OR IGNORE INTO trigger_type_bindings (trigger_vnum, type_char) + VALUES (?, ?) + ''', (vnum, type_char)) + + def finalize(self): + """Commit and print statistics.""" + if self.conn: + self.conn.commit() + cursor = self.conn.cursor() + cursor.execute("SELECT * FROM v_world_stats") + row = cursor.fetchone() + if row: + print(f"\nSQLite database statistics:") + print(f" Zones: {row[0]}") + print(f" Rooms: {row[1]}") + print(f" Mobs: {row[2]}") + print(f" Objects: {row[3]}") + print(f" Triggers: {row[4]}") + print(f" Zone commands: {row[5]}") + print(f"\nDatabase saved to: {self.db_path}") + + +def get_skill_name(skill_id): + """Get skill name by ID from dictionary.""" + return SKILL_NAMES.get(skill_id, '') + + +def get_apply_name(apply_id): + """Get apply location name by ID.""" + return APPLY_LOCATIONS.get(apply_id, '') + + +def get_wear_pos_name(pos_id): + """Get wear position name by ID.""" + return WEAR_POSITIONS.get(pos_id, '') + + +def get_direction_name(dir_id): + """Get direction name by ID.""" + return DIRECTION_NAMES.get(dir_id, '') + + +def get_room_name(vnum): + """Get room name by vnum from registry.""" + return ROOM_NAMES.get(vnum, '') + + +def get_mob_name(vnum): + """Get mob name by vnum from registry.""" + return MOB_NAMES.get(vnum, '') + + +def get_obj_name(vnum): + """Get object name by vnum from registry.""" + return OBJ_NAMES.get(vnum, '') + + +def get_trigger_name(vnum): + """Get trigger name by vnum from registry.""" + return TRIGGER_NAMES.get(vnum, '') + + +def get_zone_name(vnum): + """Get zone name by vnum from registry.""" + return ZONE_NAMES.get(vnum, '') + + +def load_name_from_yaml(filepath): + """Load name from YAML file.""" + try: + with open(filepath, 'r', encoding='koi8-r') as f: + for line in f: + line = line.strip() + if line.startswith('name:'): + name = line[5:].strip() + # Remove quotes + if name.startswith('"') and name.endswith('"'): + name = name[1:-1] + return name + except Exception: + pass + return '' + + +def load_names_from_yaml_dir(yaml_dir, name_key='name'): + """Load names from all YAML files in directory.""" + names = {} + yaml_path = Path(yaml_dir) + for yaml_file in yaml_path.glob('*.yaml'): + try: + with open(yaml_file, 'r', encoding='koi8-r') as f: + vnum = None + name = None + for line in f: + line = line.strip() + if line.startswith('vnum:'): + vnum = int(line[5:].strip()) + elif line.startswith(f'{name_key}:'): + name = line[len(name_key)+1:].strip() + # Remove quotes + if name.startswith('"') and name.endswith('"'): + name = name[1:-1] + break + if vnum is not None and name: + names[vnum] = name + except Exception: + continue + return names + + +def build_name_registries(world_dir): + """Build name registries from YAML files for cross-references.""" + global ROOM_NAMES, MOB_NAMES, OBJ_NAMES, TRIGGER_NAMES, ZONE_NAMES + + world_path = Path(world_dir) + if not world_path.exists(): + return + + # Load room names + if (world_path / 'wld').exists(): + ROOM_NAMES = load_names_from_yaml_dir(world_path / 'wld', 'name') + print(f"Loaded {len(ROOM_NAMES)} room names for cross-references") + + # Load mob names (need to handle nested structure) + if (world_path / 'mob').exists(): + for yaml_file in (world_path / 'mob').glob('*.yaml'): + try: + with open(yaml_file, 'r', encoding='koi8-r') as f: + vnum = None + name = None + in_names = False + for line in f: + line_stripped = line.strip() + if line_stripped.startswith('vnum:'): + vnum = int(line_stripped[5:].strip()) + elif line_stripped == 'names:': + in_names = True + elif in_names and line_stripped.startswith('aliases:'): + name = line_stripped[8:].strip() + if name.startswith('"') and name.endswith('"'): + name = name[1:-1] + break + if vnum is not None and name: + MOB_NAMES[vnum] = name + except Exception: + continue + print(f"Loaded {len(MOB_NAMES)} mob names for cross-references") + + # Load object names (need to handle nested structure) + if (world_path / 'obj').exists(): + for yaml_file in (world_path / 'obj').glob('*.yaml'): + try: + with open(yaml_file, 'r', encoding='koi8-r') as f: + vnum = None + name = None + in_names = False + for line in f: + line_stripped = line.strip() + if line_stripped.startswith('vnum:'): + vnum = int(line_stripped[5:].strip()) + elif line_stripped == 'names:': + in_names = True + elif in_names and line_stripped.startswith('aliases:'): + name = line_stripped[8:].strip() + if name.startswith('"') and name.endswith('"'): + name = name[1:-1] + break + if vnum is not None and name: + OBJ_NAMES[vnum] = name + except Exception: + continue + print(f"Loaded {len(OBJ_NAMES)} object names for cross-references") + + # Load trigger names + if (world_path / 'trg').exists(): + TRIGGER_NAMES = load_names_from_yaml_dir(world_path / 'trg', 'name') + print(f"Loaded {len(TRIGGER_NAMES)} trigger names for cross-references") + + # Load zone names + if (world_path / 'zon').exists(): + ZONE_NAMES = load_names_from_yaml_dir(world_path / 'zon', 'name') + print(f"Loaded {len(ZONE_NAMES)} zone names for cross-references") + + + +def ascii_flags_to_int(flags_str): + """Convert ASCII flags to integer value.""" + if not flags_str or flags_str == '0': + return 0 + + # If purely numeric, return as integer + if flags_str.lstrip('-').isdigit(): + return int(flags_str) + + # ASCII format: each letter represents a bit, followed by optional plane digit + result = 0 + i = 0 + while i < len(flags_str): + letter = flags_str[i] + plane = 0 + + # Check if next char is a digit (plane number) + if i + 1 < len(flags_str) and flags_str[i + 1].isdigit(): + plane = int(flags_str[i + 1]) + i += 2 + else: + i += 1 + + # Calculate bit position + if letter.islower(): + bit_pos = ord(letter) - ord('a') + elif letter.isupper(): + bit_pos = ord(letter) - ord('A') + 26 + else: + continue + + # Calculate actual bit position with plane offset (30 bits per plane) + actual_bit = plane * 30 + bit_pos + result |= (1 << actual_bit) + + return result + +def parse_ascii_flags(flags_str, flag_names, planes=4): + """Parse ASCII flags like 'abc0d1' or numeric flags like '100' into list of flag names. + + Formats: + - ASCII: each flag is a letter (a-z for 0-25, A-Z for 26-51) followed by a digit (plane 0-3) + - Numeric: decimal number where each bit represents a flag + """ + if not flags_str or flags_str == '0': + return [] + + result = [] + + # Check if it's a numeric value (all digits) + if flags_str.isdigit(): + value = int(flags_str) + for i in range(len(flag_names)): + if value & (1 << i): + result.append(flag_names[i]) + return result + + # ASCII format parsing + i = 0 + while i < len(flags_str): + letter = flags_str[i] + plane = 0 + + # Check if next char is a digit (plane number) + if i + 1 < len(flags_str) and flags_str[i + 1].isdigit(): + plane = int(flags_str[i + 1]) + i += 2 + else: + i += 1 + + # Calculate bit position + if letter.islower(): + bit_pos = ord(letter) - ord('a') + elif letter.isupper(): + bit_pos = ord(letter) - ord('A') + 26 + else: + continue + + # Calculate flag index - each plane has 30 bits + # For consistency, all flag arrays use 30-bit planes + + if plane == 0: + flag_index = bit_pos + elif plane == 1: + flag_index = 30 + bit_pos + elif plane == 2: + flag_index = 60 + bit_pos # 30 + 30 = 60 + else: + flag_index = plane * 30 + bit_pos # fallback + + if flag_index < len(flag_names): + result.append(flag_names[flag_index]) + + return result + + +def parse_mob_file(filepath): + """Parse a .mob file and return list of mob dictionaries.""" + mobs = [] + + with open(filepath, 'r', encoding='koi8-r') as f: + content = f.read().replace('\r', '') + + # Split by mob separators (lines starting with #) + mob_blocks = re.split(r'\n(?=#\d)', content) + + for block in mob_blocks: + block = block.strip() + if not block or not block.startswith('#'): + continue + + lines = block.split('\n') + mob = {} + + try: + # First line: #vnum + vnum_match = re.match(r'#(\d+)', lines[0]) + if not vnum_match: + continue + mob['vnum'] = int(vnum_match.group(1)) + + # Parse name aliases (line with ~) + idx = 1 + names_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + names_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + names_parts.append(lines[idx].rstrip('~')) + mob['names'] = {'aliases': '\r\n'.join(names_parts)} + idx += 1 + + # Parse 6 case forms (each ending with ~) + case_names = ['nominative', 'genitive', 'dative', 'accusative', 'instrumental', 'prepositional'] + for case_name in case_names: + if idx >= len(lines): + break + case_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + case_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + case_parts.append(lines[idx].rstrip('~')) + mob['names'][case_name] = '\r\n'.join(case_parts) + idx += 1 + + # Short description (until ~) + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + mob['descriptions'] = {'short_desc': '\r\n'.join(desc_parts)} + idx += 1 + + # Long description (until ~) + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + mob['descriptions']['long_desc'] = '\r\n'.join(desc_parts) + idx += 1 + + # Action/affect flags and alignment line + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 2: + mob['action_flags'] = parse_ascii_flags(parts[0], ACTION_FLAGS) + mob['affect_flags'] = parse_ascii_flags(parts[1], ACTION_FLAGS) # Using same for now + mob['alignment'] = int(parts[2]) if parts[2].lstrip('-').isdigit() else 0 + if len(parts) >= 4: + mob['mob_type'] = parts[3] + idx += 1 + + # Stats line: level hitroll_bonus armor hp_dice damage_dice + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 5: + mob['stats'] = { + 'level': int(parts[0]) if parts[0].isdigit() else 1, + 'hitroll_penalty': int(parts[1]) if parts[1].lstrip('-').isdigit() else 20, + 'armor': int(parts[2]) if parts[2].lstrip('-').isdigit() else 100 + } + # Parse HP dice (format: XdY+Z) + hp_match = re.match(r'(-?\d+)d(\d+)([+-]\d+)?', parts[3]) + if hp_match: + mob['stats']['hp'] = { + 'dice_count': int(hp_match.group(1)), + 'dice_size': int(hp_match.group(2)), + 'bonus': int(hp_match.group(3)) if hp_match.group(3) else 0 + } + # Parse damage dice + dmg_match = re.match(r'(-?\d+)d(\d+)([+-]\d+)?', parts[4]) + if dmg_match: + mob['stats']['damage'] = { + 'dice_count': int(dmg_match.group(1)), + 'dice_size': int(dmg_match.group(2)), + 'bonus': int(dmg_match.group(3)) if dmg_match.group(3) else 0 + } + idx += 1 + + # Gold dice line + if idx < len(lines): + parts = lines[idx].split() + if parts: + gold_match = re.match(r'(-?\d+)d(\d+)([+-]\d+)?', parts[0]) + if gold_match: + mob['gold'] = { + 'dice_count': int(gold_match.group(1)), + 'dice_size': int(gold_match.group(2)), + 'bonus': int(gold_match.group(3)) if gold_match.group(3) else 0 + } + if len(parts) >= 2: + mob['experience'] = int(parts[1]) if parts[1].isdigit() else 0 + idx += 1 + + # Position and sex line + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 2: + pos_default = int(parts[0]) if parts[0].isdigit() else 8 + pos_start = int(parts[1]) if parts[1].isdigit() else 8 + sex = int(parts[2]) if parts[2].isdigit() else 0 + + mob['position'] = { + 'default': POSITIONS[pos_default] if pos_default < len(POSITIONS) else pos_default, + 'start': POSITIONS[pos_start] if pos_start < len(POSITIONS) else pos_start + } + mob['sex'] = GENDERS[sex] if sex < len(GENDERS) else sex + idx += 1 + + # Parse extended mob info (E-spec) + mob['triggers'] = [] + mob['skills'] = [] + mob['attributes'] = {} + + while idx < len(lines): + line = lines[idx].strip() + if not line: + idx += 1 + continue + + if line.startswith('E'): + # Enhanced mob marker - continue parsing + idx += 1 + continue + elif line.startswith('Str:'): + mob['attributes']['strength'] = int(line[4:].strip()) + elif line.startswith('Dex:'): + mob['attributes']['dexterity'] = int(line[4:].strip()) + elif line.startswith('Int:'): + mob['attributes']['intelligence'] = int(line[4:].strip()) + elif line.startswith('Wis:'): + mob['attributes']['wisdom'] = int(line[4:].strip()) + elif line.startswith('Con:'): + mob['attributes']['constitution'] = int(line[4:].strip()) + elif line.startswith('Cha:'): + mob['attributes']['charisma'] = int(line[4:].strip()) + elif line.startswith('Size:'): + mob['size'] = int(line[5:].strip()) + elif line.startswith('Class:'): + mob['mob_class'] = int(line[6:].strip()) + elif line.startswith('Race:'): + mob['race'] = int(line[5:].strip()) + elif line.startswith('Height:'): + mob['height'] = int(line[7:].strip()) + elif line.startswith('Weight:'): + mob['weight'] = int(line[7:].strip()) + elif line.startswith('Skill:'): + parts = line[6:].strip().split() + if len(parts) >= 2: + mob['skills'].append({ + 'skill_id': int(parts[0]), + 'value': int(parts[1]) + }) + elif line.startswith('T '): + trig_vnum = int(line[2:].strip()) + mob['triggers'].append(trig_vnum) + + idx += 1 + + mobs.append(mob) + + except Exception as e: + log_error(f"Failed to parse mob: {e}", vnum=mob.get('vnum'), filepath=str(filepath)) + continue + + return mobs + + +def mob_to_yaml(mob): + """Convert mob dictionary to YAML string using ruamel.yaml.""" + data = CommentedMap() + + vnum = mob['vnum'] + data['vnum'] = vnum + data.yaml_set_start_comment(f"Mob #{vnum}") + + # Names + if 'names' in mob: + names = CommentedMap() + for key, value in mob['names'].items(): + names[key] = value + data['names'] = names + + # Descriptions + if 'descriptions' in mob: + descs = CommentedMap() + for key, value in mob['descriptions'].items(): + descs[key] = value + data['descriptions'] = descs + + # Action flags + if mob.get('action_flags'): + flags = CommentedSeq(mob['action_flags']) + data['action_flags'] = flags + + # Affect flags + if mob.get('affect_flags'): + flags = CommentedSeq(mob['affect_flags']) + data['affect_flags'] = flags + + # Alignment + if 'alignment' in mob: + data['alignment'] = mob['alignment'] + + # Mob type + if 'mob_type' in mob: + data['mob_type'] = mob['mob_type'] + + # Stats + if 'stats' in mob: + stats = CommentedMap() + s = mob['stats'] + stats['level'] = s.get('level', 1) + stats['hitroll_penalty'] = s.get('hitroll_penalty', 20) + stats['armor'] = s.get('armor', 100) + if 'hp' in s: + hp = CommentedMap() + hp['dice_count'] = s['hp']['dice_count'] + hp['dice_size'] = s['hp']['dice_size'] + hp['bonus'] = s['hp']['bonus'] + stats['hp'] = hp + if 'damage' in s: + dmg = CommentedMap() + dmg['dice_count'] = s['damage']['dice_count'] + dmg['dice_size'] = s['damage']['dice_size'] + dmg['bonus'] = s['damage']['bonus'] + stats['damage'] = dmg + data['stats'] = stats + + # Gold + if 'gold' in mob: + gold = CommentedMap() + gold['dice_count'] = mob['gold']['dice_count'] + gold['dice_size'] = mob['gold']['dice_size'] + gold['bonus'] = mob['gold']['bonus'] + data['gold'] = gold + + # Experience + if 'experience' in mob: + data['experience'] = mob['experience'] + + # Position + if 'position' in mob: + pos = CommentedMap() + pos['default'] = mob['position']['default'] + pos['start'] = mob['position']['start'] + data['position'] = pos + + # Sex + if 'sex' in mob: + data['sex'] = mob['sex'] + + # Attributes + if mob.get('attributes'): + attrs = CommentedMap() + for key, value in mob['attributes'].items(): + attrs[key] = value + data['attributes'] = attrs + + # Additional fields + if 'size' in mob: + data['size'] = mob['size'] + if 'mob_class' in mob: + data['mob_class'] = mob['mob_class'] + if 'race' in mob: + data['race'] = mob['race'] + if 'height' in mob: + data['height'] = mob['height'] + if 'weight' in mob: + data['weight'] = mob['weight'] + + # Skills with skill name comments + if mob.get('skills'): + skills = CommentedSeq() + for skill in mob['skills']: + s = CommentedMap() + s['skill_id'] = skill['skill_id'] + skill_name = get_skill_name(skill['skill_id']) + if skill_name: + s.yaml_add_eol_comment(skill_name, 'skill_id') + s['value'] = skill['value'] + skills.append(s) + data['skills'] = skills + + # Triggers with name comments + if mob.get('triggers'): + triggers = CommentedSeq() + for i, trig in enumerate(mob['triggers']): + triggers.append(trig) + trig_name = get_trigger_name(trig) + if trig_name: + triggers.yaml_add_eol_comment(trig_name, i) + data['triggers'] = triggers + + return yaml_dump_to_string(data) + + +def parse_obj_file(filepath): + """Parse a .obj file and return list of object dictionaries.""" + objs = [] + + with open(filepath, 'r', encoding='koi8-r') as f: + content = f.read().replace('\r', '') + + # Split by object separators (lines starting with #) + obj_blocks = re.split(r'\n(?=#\d)', content) + + for block in obj_blocks: + block = block.strip() + if not block or not block.startswith('#'): + continue + + lines = block.split('\n') + obj = {} + + try: + # First line: #vnum + vnum_match = re.match(r'#(\d+)', lines[0]) + if not vnum_match: + continue + obj['vnum'] = int(vnum_match.group(1)) + + idx = 1 + + # Parse name aliases (line with ~) + names_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + names_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + names_parts.append(lines[idx].rstrip('~')) + obj['names'] = {'aliases': '\r\n'.join(names_parts)} + idx += 1 + + # Parse 6 case forms (each ending with ~) + case_names = ['nominative', 'genitive', 'dative', 'accusative', 'instrumental', 'prepositional'] + for case_name in case_names: + if idx >= len(lines): + break + case_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + case_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + case_parts.append(lines[idx].rstrip('~')) + obj['names'][case_name] = '\r\n'.join(case_parts) + idx += 1 + + # Short description (room desc, until ~) + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + obj['short_desc'] = '\r\n'.join(desc_parts) + idx += 1 + + # Action description (until ~) + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + obj['action_desc'] = '\r\n'.join(desc_parts) + idx += 1 + + + # Line 1: spec_param, max_durability, cur_durability, material + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 1: + obj['spec_param'] = ascii_flags_to_int(parts[0]) + if len(parts) >= 2: + obj['max_durability'] = int(parts[1]) if parts[1].lstrip('-').isdigit() else 100 + if len(parts) >= 2: + obj['cur_durability'] = int(parts[2]) if parts[2].lstrip('-').isdigit() else 100 + if len(parts) >= 4: + obj['material'] = int(parts[3]) if parts[3].isdigit() else 0 + idx += 1 + + # Line 2: sex, timer, spell, level + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 1: + obj['sex'] = int(parts[0]) if parts[0].isdigit() else 0 + if len(parts) >= 2: + timer_val = int(parts[1]) if parts[1].lstrip('-').isdigit() else -1 + obj['timer'] = timer_val if timer_val > 0 else 10080 # 7 days in minutes + if len(parts) >= 2: + obj['spell'] = int(parts[2]) if parts[2].lstrip('-').isdigit() else -1 + if len(parts) >= 4: + obj['level'] = int(parts[3]) if parts[3].isdigit() else 1 + idx += 1 + + # Line 3: affect_flags, anti_flags, no_flags + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 1: + obj['affect_flags'] = parse_ascii_flags(parts[0], AFFECT_FLAGS) if len(parts) >= 1 else [] + if len(parts) >= 2: + obj['anti_flags'] = parse_ascii_flags(parts[1], ANTI_FLAGS) if len(parts) >= 2 else [] + if len(parts) >= 2: + obj['no_flags'] = parse_ascii_flags(parts[2], NO_FLAGS) if len(parts) >= 3 else [] + idx += 1 + + # Line 4: type, extra_flags, wear_flags + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 1: + obj_type = int(parts[0]) if parts[0].isdigit() else 0 + obj['type_id'] = obj_type + obj['type'] = OBJ_TYPES[obj_type] if obj_type < len(OBJ_TYPES) else obj_type + if len(parts) >= 2: + obj['extra_flags'] = parse_ascii_flags(parts[1], EXTRA_FLAGS) + if len(parts) >= 2: + obj['wear_flags'] = parse_ascii_flags(parts[2], WEAR_FLAGS) + idx += 1 + + # Line 5: values (val[0], val[1], val[2], val[3]) + # val0 is parsed with asciiflag_conv in Legacy, which treats + # negative numbers as 0 (doesn't recognize "-" as valid) + if idx < len(lines): + parts = lines[idx].split() + values = [] + for i, p in enumerate(parts[:4]): + if i == 0 and p.startswith('-') and not p[1:].isdigit(): + # asciiflag_conv doesn't handle negative, returns 0 + values.append('0') + elif i == 0 and p.startswith('-'): + # asciiflag_conv treats "-N" as 0 because "-" is not a digit + values.append('0') + else: + values.append(p) + obj['values'] = values + idx += 1 + + # Line 6: weight, cost, rent_off, rent_on + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 1: + obj['weight'] = int(parts[0]) if parts[0].isdigit() else 0 + if len(parts) >= 2: + obj['cost'] = int(parts[1]) if parts[1].isdigit() else 0 + if len(parts) >= 2: + obj['rent_off'] = int(parts[2]) if parts[2].isdigit() else 0 + if len(parts) >= 4: + obj['rent_on'] = int(parts[3]) if parts[3].isdigit() else 0 + idx += 1 + + + # Parse extra data (A, E, M, T sections) + obj['applies'] = [] + obj['extra_descs'] = [] + obj['triggers'] = [] + + while idx < len(lines): + line = lines[idx].strip() + + if line == 'A': + # Apply + idx += 1 + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 2: + obj['applies'].append({ + 'location': int(parts[0]), + 'modifier': int(parts[1]) + }) + elif line == 'E': + # Extra description + idx += 1 + ed = {} + if idx < len(lines): + # Keywords until ~ + kw_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + kw_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + kw_parts.append(lines[idx].rstrip('~')) + ed['keywords'] = '\r\n'.join(kw_parts) # Preserve all whitespace + idx += 1 + + # Description until ~ + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + ed['description'] = '\r\n'.join(desc_parts) + obj['extra_descs'].append(ed) + elif line.startswith('M '): + obj['max_in_world'] = int(line[2:].strip()) + elif line.startswith('R '): + obj['minimum_remorts'] = int(line[2:].strip()) + elif line.startswith('T '): + obj['triggers'].append(int(line[2:].strip())) + + idx += 1 + + objs.append(obj) + + except Exception as e: + log_error(f"Failed to parse object: {e}", vnum=obj.get('vnum'), filepath=str(filepath)) + continue + + return objs + + +def obj_to_yaml(obj): + """Convert object dictionary to YAML string using ruamel.yaml.""" + data = CommentedMap() + + vnum = obj['vnum'] + data['vnum'] = vnum + data.yaml_set_start_comment(f"Object #{vnum}") + + # Names + if 'names' in obj: + names = CommentedMap() + for key, value in obj['names'].items(): + names[key] = value + data['names'] = names + + # Descriptions + if obj.get('short_desc'): + data['short_desc'] = obj['short_desc'] + if obj.get('action_desc'): + data['action_desc'] = obj['action_desc'] + + # Type + if 'type' in obj: + data['type'] = obj['type'] + + # Flags (now as lists) + if obj.get('extra_flags'): + flags = CommentedSeq(obj['extra_flags']) + data['extra_flags'] = flags + if obj.get('wear_flags'): + flags = CommentedSeq(obj['wear_flags']) + data['wear_flags'] = flags + if 'material' in obj: + data['material'] = obj['material'] + + # Values + if 'values' in obj: + data['values'] = obj['values'] + + # Physical properties + if 'weight' in obj: + data['weight'] = obj['weight'] + if 'cost' in obj: + data['cost'] = obj['cost'] + if 'rent_off' in obj: + data['rent_off'] = obj['rent_off'] + if 'rent_on' in obj: + data['rent_on'] = obj['rent_on'] + + # Durability + if 'spec_param' in obj: + data['spec_param'] = obj['spec_param'] + if 'max_durability' in obj: + data['max_durability'] = obj['max_durability'] + if 'cur_durability' in obj: + data['cur_durability'] = obj['cur_durability'] + + # Timer/spell/level + if 'timer' in obj: + data['timer'] = obj['timer'] + if 'spell' in obj: + data['spell'] = obj['spell'] + if 'level' in obj: + data['level'] = obj['level'] + + # Applies with location name comments + if obj.get('applies'): + applies = CommentedSeq() + for apply in obj['applies']: + a = CommentedMap() + a['location'] = apply['location'] + apply_name = get_apply_name(apply['location']) + if apply_name: + a.yaml_add_eol_comment(apply_name, 'location') + a['modifier'] = apply['modifier'] + applies.append(a) + data['applies'] = applies + + # Extra descriptions + if obj.get('extra_descs'): + eds = CommentedSeq() + for ed in obj['extra_descs']: + e = CommentedMap() + e['keywords'] = ed['keywords'] + e['description'] = ed['description'] + eds.append(e) + data['extra_descriptions'] = eds + + # Max in world + if 'max_in_world' in obj: + data['max_in_world'] = obj['max_in_world'] + + # Triggers with name comments + if obj.get('triggers'): + triggers = CommentedSeq() + for i, trig in enumerate(obj['triggers']): + triggers.append(trig) + trig_name = get_trigger_name(trig) + if trig_name: + triggers.yaml_add_eol_comment(trig_name, i) + data['triggers'] = triggers + + return yaml_dump_to_string(data) + + +def parse_wld_file(filepath): + """Parse a .wld file and return list of room dictionaries.""" + rooms = [] + + with open(filepath, 'r', encoding='koi8-r') as f: + content = f.read().replace('\r', '') + + # Split by room separators (lines starting with #) + room_blocks = re.split(r'\n(?=#\d)', content) + + for block in room_blocks: + block = block.strip() + if not block or not block.startswith('#'): + continue + + lines = block.split('\n') + room = {} + + try: + # First line: #vnum + vnum_match = re.match(r'#(\d+)', lines[0]) + if not vnum_match: + continue + room['vnum'] = int(vnum_match.group(1)) + + idx = 1 + + # Room name (until ~) + name_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + name_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + name_parts.append(lines[idx].rstrip('~')) + room['name'] = '\r\n'.join(name_parts) # Preserve newlines in multi-line names + idx += 1 + + # Description (until ~) + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + room['description'] = '\r\n'.join(desc_parts) + idx += 1 + + # Zone/flags/sector line + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 2: + room['zone'] = int(parts[0]) if parts[0].isdigit() else 0 + room['room_flags'] = parse_ascii_flags(parts[1], ROOM_FLAGS) + sector = int(parts[2]) if parts[2].isdigit() else 0 + room['sector_id'] = sector + room['sector'] = SECTORS[sector] if sector < len(SECTORS) else sector + idx += 1 + + # Parse directions, extra descs, triggers + room['exits'] = [] + room['extra_descs'] = [] + room['triggers'] = [] + + while idx < len(lines): + line = lines[idx].strip() + + if line.startswith('D'): + # Direction + direction = int(line[1:]) if line[1:].isdigit() else -1 + if direction >= 0: + exit_data = {'direction': direction} + idx += 1 + + # Description until ~ + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + exit_data['description'] = '\r\n'.join(desc_parts) + idx += 1 + + # Keywords until ~ + kw_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + kw_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + kw_parts.append(lines[idx].rstrip('~')) + exit_data['keywords'] = '\r\n'.join(kw_parts) + idx += 1 + + # Exit info line + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 2: + raw_flags = int(parts[0]) if parts[0].isdigit() else 0 + exit_data['key'] = int(parts[1]) if parts[1].lstrip('-').isdigit() else -1 + exit_data['to_room'] = int(parts[2]) if parts[2].lstrip('-').isdigit() else -1 + + if len(parts) == 3: + # Old format: convert bits 1->kHasDoor(1), 2->kPickroof(8), 4->kHidden(16) + new_flags = 0 + if raw_flags & 1: + new_flags |= 1 # kHasDoor + if raw_flags & 2: + new_flags |= 8 # kPickroof + if raw_flags & 4: + new_flags |= 16 # kHidden + exit_data['exit_flags'] = new_flags + exit_data['lock_complexity'] = 0 + else: + # New format (4 values): use flags directly + exit_data['exit_flags'] = raw_flags + exit_data['lock_complexity'] = int(parts[3]) if parts[3].isdigit() else 0 + + room['exits'].append(exit_data) + elif line == 'E': + # Extra description + idx += 1 + ed = {} + + # Keywords until ~ + kw_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + kw_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + kw_parts.append(lines[idx].rstrip('~')) + ed['keywords'] = '\r\n'.join(kw_parts) # Preserve all whitespace + idx += 1 + + # Description until ~ + desc_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + desc_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + desc_parts.append(lines[idx].rstrip('~')) + ed['description'] = '\r\n'.join(desc_parts) + + room['extra_descs'].append(ed) + elif line.startswith('T '): + room['triggers'].append(int(line[2:].strip())) + elif line == 'S': + # S marks end of exits, but triggers may follow + idx += 1 + # Continue reading T lines after S + while idx < len(lines): + line = lines[idx].strip() + if line.startswith('T '): + room['triggers'].append(int(line[2:].strip())) + idx += 1 + elif line.startswith('#') or not line: + break + else: + idx += 1 + break + + idx += 1 + + rooms.append(room) + + except Exception as e: + log_error(f"Failed to parse room: {e}", vnum=room.get('vnum'), filepath=str(filepath)) + continue + + return rooms + + +def room_to_yaml(room): + """Convert room dictionary to YAML string using ruamel.yaml.""" + DIRECTION_NAMES = ['north', 'east', 'south', 'west', 'up', 'down'] + + data = CommentedMap() + + vnum = room['vnum'] + data['vnum'] = vnum + data.yaml_set_start_comment(f"Room #{vnum}") + + if 'zone' in room: + data['zone'] = room['zone'] + + if 'name' in room: + data['name'] = room['name'] + + if 'description' in room: + data['description'] = room['description'] + + # Room flags + if room.get('room_flags'): + flags = CommentedSeq(room['room_flags']) + data['room_flags'] = flags + + # Sector + if 'sector' in room: + data['sector'] = room['sector'] + + # Exits with to_room name comments + if room.get('exits'): + exits = CommentedSeq() + for exit_data in room['exits']: + e = CommentedMap() + direction = exit_data.get('direction', 0) + e['direction'] = DIRECTION_NAMES[direction] if direction < len(DIRECTION_NAMES) else direction + + if exit_data.get('description'): + e['description'] = exit_data['description'] + if exit_data.get('keywords'): + e['keywords'] = exit_data['keywords'] + + e['exit_flags'] = exit_data.get('exit_flags', 0) + e['key'] = exit_data.get('key', -1) + + to_room = exit_data.get('to_room', -1) + e['to_room'] = to_room + + # Add room name as comment + room_name = get_room_name(to_room) + if room_name: + e.yaml_add_eol_comment(room_name, 'to_room') + + if 'lock_complexity' in exit_data: + e['lock_complexity'] = exit_data['lock_complexity'] + + exits.append(e) + data['exits'] = exits + + # Extra descriptions + if room.get('extra_descs'): + eds = CommentedSeq() + for ed in room['extra_descs']: + e = CommentedMap() + e['keywords'] = ed['keywords'] + e['description'] = ed['description'] + eds.append(e) + data['extra_descriptions'] = eds + + # Triggers with name comments + if room.get('triggers'): + triggers = CommentedSeq() + for i, trig in enumerate(room['triggers']): + triggers.append(trig) + trig_name = get_trigger_name(trig) + if trig_name: + triggers.yaml_add_eol_comment(trig_name, i) + data['triggers'] = triggers + + return yaml_dump_to_string(data) + + +def parse_trg_file(filepath): + """Parse a .trg file and return list of trigger dictionaries.""" + triggers = [] + + with open(filepath, 'r', encoding='koi8-r') as f: + content = f.read().replace('\r', '') + + # Split by trigger separators (lines starting with #) + trg_blocks = re.split(r'\n(?=#\d)', content) + + for block in trg_blocks: + block = block.strip() + if not block or not block.startswith('#'): + continue + + lines = block.split('\n') + trigger = {} + + try: + # First line: #vnum + vnum_match = re.match(r'#(\d+)', lines[0]) + if not vnum_match: + continue + trigger['vnum'] = int(vnum_match.group(1)) + + idx = 1 + + # Name until ~ + name_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + name_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + name_parts.append(lines[idx].rstrip('~')) + trigger['name'] = '\r\n'.join(name_parts) + idx += 1 + + # Attach type, trigger type, narg line + if idx < len(lines): + parts = lines[idx].split() + if len(parts) >= 2: + attach_type = int(parts[0]) if parts[0].isdigit() else 0 + trigger['attach_type_id'] = attach_type + trigger['attach_type'] = ATTACH_TYPES.get(attach_type, attach_type) + + # Parse trigger types (letters or numeric) + # If numeric, convert to letters (same as asciiflag_conv inverse) + flags_str = parts[1] + if flags_str.isdigit(): + flags_str = numeric_flags_to_letters(int(flags_str)) + + trig_types = [] + type_chars = [] + for ch in flags_str: + if ch.isalpha(): + if ch in TRIGGER_TYPES: + trig_types.append(TRIGGER_TYPES[ch]) + type_chars.append(ch) + trigger['trigger_types'] = trig_types + trigger['type_chars'] = type_chars + + trigger['narg'] = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 0 + idx += 1 + + # Argument until ~ + arg_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + arg_parts.append(lines[idx]) + idx += 1 + if idx < len(lines): + arg_parts.append(lines[idx].rstrip('~')) + trigger['arglist'] = '\r\n'.join(arg_parts) + idx += 1 + + # Script until ~ + script_parts = [] + while idx < len(lines) and not lines[idx].rstrip().endswith('~'): + script_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + last_line = lines[idx].rstrip('~') + if last_line: + script_parts.append(last_line) + trigger['script'] = '\r\n'.join(script_parts).replace('~~', '~') + + triggers.append(trigger) + + except Exception as e: + log_error(f"Failed to parse trigger: {e}", vnum=trigger.get('vnum'), filepath=str(filepath)) + continue + + return triggers + + +def trg_to_yaml(trigger): + """Convert trigger dictionary to YAML string using ruamel.yaml.""" + data = CommentedMap() + + vnum = trigger['vnum'] + data['vnum'] = vnum + data.yaml_set_start_comment(f"Trigger #{vnum}") + + if 'name' in trigger: + data['name'] = trigger['name'] + + if 'attach_type' in trigger: + data['attach_type'] = trigger['attach_type'] + + if trigger.get('trigger_types'): + types = CommentedSeq(trigger['trigger_types']) + data['trigger_types'] = types + + if 'narg' in trigger: + data['narg'] = trigger['narg'] + + if 'arglist' in trigger: + data['arglist'] = trigger['arglist'] + + if 'script' in trigger: + data['script'] = trigger['script'] + + return yaml_dump_to_string(data) + + +def parse_zon_file(filepath): + """Parse a .zon file and return zone dictionary.""" + zone = {} + + with open(filepath, 'r', encoding='koi8-r') as f: + lines = f.readlines() + + try: + idx = 0 + + # Skip until first # line + while idx < len(lines) and not lines[idx].strip().startswith('#'): + idx += 1 + + if idx >= len(lines): + return None + + # First line: #vnum + vnum_match = re.match(r'#(\d+)', lines[idx].strip()) + if not vnum_match: + return None + zone['vnum'] = int(vnum_match.group(1)) + idx += 1 + + # Zone name until ~ + name_parts = [] + while idx < len(lines) and '~' not in lines[idx]: + name_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + name_parts.append(lines[idx].rstrip('\n').rstrip('~')) + zone['name'] = ' '.join(name_parts) + idx += 1 + + # Parse metadata lines (^, &, !, $) and optional builders until next # + zone['metadata'] = {} + while idx < len(lines): + line = lines[idx].rstrip('\n') + stripped = line.strip() + + # Stop at next # line (zone params) + if stripped.startswith('#'): + break + + # Parse metadata prefixes + if stripped.startswith('^'): + # Comment - check if ~ is on same line + content = stripped[1:] + if '~' in content: + zone['metadata']['comment'] = content.rstrip('~') + else: + meta_parts = [content] + idx += 1 + while idx < len(lines) and '~' not in lines[idx]: + meta_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + meta_parts.append(lines[idx].rstrip('\n').rstrip('~')) + zone['metadata']['comment'] = ' '.join(meta_parts) + idx += 1 + continue + elif stripped.startswith('&'): + # Location - check if ~ is on same line + content = stripped[1:] + if '~' in content: + zone['metadata']['location'] = content.rstrip('~') + else: + meta_parts = [content] + idx += 1 + while idx < len(lines) and '~' not in lines[idx]: + meta_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + meta_parts.append(lines[idx].rstrip('\n').rstrip('~')) + zone['metadata']['location'] = ' '.join(meta_parts) + idx += 1 + continue + elif stripped.startswith('!'): + # Author - check if ~ is on same line + content = stripped[1:] + if '~' in content: + zone['metadata']['author'] = content.rstrip('~') + else: + meta_parts = [content] + idx += 1 + while idx < len(lines) and '~' not in lines[idx]: + meta_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + meta_parts.append(lines[idx].rstrip('\n').rstrip('~')) + zone['metadata']['author'] = ' '.join(meta_parts) + idx += 1 + continue + elif stripped.startswith('$') and not stripped.startswith('$~'): + # Description (not end of file marker) - check if ~ is on same line + content = stripped[1:] + if '~' in content: + zone['metadata']['description'] = content.rstrip('~') + else: + meta_parts = [content] + idx += 1 + while idx < len(lines) and '~' not in lines[idx]: + meta_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + meta_parts.append(lines[idx].rstrip('\n').rstrip('~')) + zone['metadata']['description'] = ' '.join(meta_parts) + idx += 1 + continue + elif '~' in stripped and not stripped.startswith('#'): + # Builders line (plain text until ~) + builder_parts = [] + # Go back to start of this line and parse until ~ + while idx < len(lines) and '~' not in lines[idx]: + builder_parts.append(lines[idx].rstrip('\n')) + idx += 1 + if idx < len(lines): + builder_parts.append(lines[idx].rstrip('\n').rstrip('~')) + zone['builders'] = '\r\n'.join(builder_parts) + idx += 1 + continue + else: + idx += 1 + continue + + # Remove empty metadata + if not zone['metadata']: + del zone['metadata'] + + # Zone info line: #first_room mode type [entrance] + while idx < len(lines): + line = lines[idx].strip() + if line.startswith('#'): + parts = line[1:].split() + if len(parts) >= 1: + zone['mode'] = int(parts[0]) if parts[0].isdigit() else 0 + if len(parts) >= 2: + zone['zone_type'] = int(parts[1]) if parts[1].isdigit() else 0 + if len(parts) >= 3: + zone['zone_group'] = int(parts[2]) if parts[2].isdigit() else 1 + if len(parts) >= 4: + zone['entrance'] = int(parts[3]) if parts[3].isdigit() else 0 + # Next line: top_room lifespan reset_mode reset_idle + idx += 1 + if idx < len(lines): + params = lines[idx].strip().split() + # Remove trailing * if present + params = [p for p in params if p != '*'] + if len(params) >= 1: + zone['top_room'] = int(params[0]) if params[0].isdigit() else 0 + if len(params) >= 2: + zone['lifespan'] = int(params[1]) if params[1].isdigit() else 10 + if len(params) >= 3: + zone['reset_mode'] = int(params[2]) if params[2].isdigit() else 0 + if len(params) >= 4: + zone['reset_idle'] = int(params[3]) if params[3].isdigit() else 0 + # Check for 'test' flag (under_construction) + for p in params[4:]: + if p.lower() == 'test': + zone['under_construction'] = 1 + break + idx += 1 + idx += 1 + + # Parse zone grouping commands (A, B) and spawn commands + zone['commands'] = [] + zone['typeA_list'] = [] + zone['typeB_list'] = [] + + while idx < len(lines): + line = lines[idx].strip() + + if line == 'S' or line.startswith('$'): + break + + if not line or line.startswith('*'): + idx += 1 + continue + + # Remove trailing comments in parentheses + if '(' in line: + line = line[:line.index('(')].strip() + + parts = line.split() + if not parts: + idx += 1 + continue + + cmd_type = parts[0] + + # Zone grouping commands + if cmd_type == 'A' and len(parts) >= 2: + # A zone_vnum - add to typeA list + zone_vnum = int(parts[1]) if parts[1].isdigit() else 0 + if zone_vnum: + zone['typeA_list'].append(zone_vnum) + idx += 1 + continue + elif cmd_type == 'B' and len(parts) >= 2: + # B zone_vnum - add to typeB list + zone_vnum = int(parts[1]) if parts[1].isdigit() else 0 + if zone_vnum: + zone['typeB_list'].append(zone_vnum) + idx += 1 + continue + + cmd = {'type': cmd_type} + + if cmd_type == 'M' and len(parts) >= 6: + # M if_flag mob_vnum max_world room_vnum max_room + cmd['type'] = 'LOAD_MOB' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['mob_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['max_world'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else 1 + cmd['room_vnum'] = int(parts[4]) if parts[4].lstrip('-').isdigit() else 0 + cmd['max_room'] = int(parts[5]) if parts[5].lstrip('-').isdigit() else -1 + elif cmd_type == 'O' and len(parts) >= 6: + # O if_flag obj_vnum max room_vnum load_prob + cmd['type'] = 'LOAD_OBJ' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['obj_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['max'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else -1 + cmd['room_vnum'] = int(parts[4]) if parts[4].isdigit() else 0 + cmd['load_prob'] = int(parts[5]) if parts[5].lstrip('-').isdigit() else 100 + elif cmd_type == 'G' and len(parts) >= 4: + # G if_flag obj_vnum max + cmd['type'] = 'GIVE_OBJ' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['obj_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['max'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else -1 + if len(parts) >= 6: + cmd['load_prob'] = int(parts[5]) if parts[5].lstrip('-').isdigit() else -1 + elif cmd_type == 'E' and len(parts) >= 5: + # E if_flag obj_vnum max wear_pos [load_prob] + cmd['type'] = 'EQUIP_MOB' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['obj_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['max'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else -1 + cmd['wear_pos'] = int(parts[4]) if parts[4].isdigit() else 0 + if len(parts) >= 6: + cmd['load_prob'] = int(parts[5]) if parts[5].lstrip('-').isdigit() else -1 + elif cmd_type == 'P' and len(parts) >= 6: + # P if_flag obj_vnum max container_vnum load_prob + cmd['type'] = 'PUT_OBJ' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['obj_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['max'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else -1 + cmd['container_vnum'] = int(parts[4]) if parts[4].isdigit() else 0 + cmd['load_prob'] = int(parts[5]) if parts[5].lstrip('-').isdigit() else 100 + elif cmd_type == 'D' and len(parts) >= 5: + # D if_flag room_vnum direction state + cmd['type'] = 'DOOR' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['room_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['direction'] = int(parts[3]) if parts[3].isdigit() else 0 + cmd['state'] = int(parts[4]) if parts[4].isdigit() else 0 + elif cmd_type == 'R' and len(parts) >= 4: + # R if_flag room_vnum obj_vnum + cmd['type'] = 'REMOVE_OBJ' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['room_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['obj_vnum'] = int(parts[3]) if parts[3].isdigit() else 0 + elif cmd_type == 'T' and len(parts) >= 4: + # T if_flag trigger_type trigger_vnum [room_vnum] + # room_vnum is only present for WLD_TRIGGER (type=2) + cmd['type'] = 'TRIGGER' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['trigger_type'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['trigger_vnum'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else 0 + # For WLD_TRIGGER, parts[4] contains room_vnum + if len(parts) > 4 and parts[4].lstrip('-').isdigit(): + cmd['room_vnum'] = int(parts[4]) + elif cmd_type == 'V' and len(parts) >= 6: + # V if_flag trigger_type id context var_name var_value + cmd['type'] = 'VARIABLE' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['trigger_type'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['context'] = int(parts[3]) if parts[3].isdigit() else 0 + cmd['var_vnum'] = int(parts[4]) if parts[4].isdigit() else 0 + cmd['var_name'] = parts[5] if len(parts) > 5 else '' + cmd['var_value'] = ' '.join(parts[6:]) if len(parts) > 6 else '' + elif cmd_type == 'Q' and len(parts) >= 4: + # Q if_flag mob_vnum max + cmd['type'] = 'EXTRACT_MOB' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['mob_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['max'] = int(parts[3]) if parts[3].lstrip('-').isdigit() else -1 + elif cmd_type == 'F' and len(parts) >= 5: + # F if_flag room_vnum leader_mob_vnum follower_mob_vnum + cmd['type'] = 'FOLLOW' + cmd['if_flag'] = int(parts[1]) if parts[1].isdigit() else 0 + cmd['room_vnum'] = int(parts[2]) if parts[2].isdigit() else 0 + cmd['leader_mob_vnum'] = int(parts[3]) if parts[3].isdigit() else 0 + cmd['follower_mob_vnum'] = int(parts[4]) if parts[4].isdigit() else 0 + else: + # Unknown command - log warning and skip + log_warning(f"Unknown zone command '{cmd_type}': {line}", vnum=zone.get('vnum'), filepath=str(filepath)) + idx += 1 + continue + + zone['commands'].append(cmd) + idx += 1 + + except Exception as e: + log_error(f"Failed to parse zone: {e}", vnum=zone.get('vnum'), filepath=str(filepath)) + return None + + return zone + + +def zon_to_yaml(zone): + """Convert zone dictionary to YAML string using ruamel.yaml.""" + data = CommentedMap() + + vnum = zone['vnum'] + data['vnum'] = vnum + data.yaml_set_start_comment(f"Zone #{vnum}") + + if 'name' in zone: + data['name'] = zone['name'] + + # Metadata + if zone.get('metadata'): + meta = CommentedMap() + for key in ['comment', 'location', 'author', 'description']: + if key in zone['metadata']: + meta[key] = zone['metadata'][key] + if meta: + data['metadata'] = meta + + # Builders + if zone.get('builders'): + data['builders'] = zone['builders'] + + # Zone params + if 'first_room' in zone: + data['first_room'] = zone['first_room'] + if 'top_room' in zone: + data['top_room'] = zone['top_room'] + if 'mode' in zone: + data['mode'] = zone['mode'] + if 'zone_type' in zone: + data['zone_type'] = zone['zone_type'] + if 'entrance' in zone: + data['entrance'] = zone['entrance'] + if 'lifespan' in zone: + data['lifespan'] = zone['lifespan'] + if 'reset_mode' in zone: + data['reset_mode'] = zone['reset_mode'] + if 'reset_idle' in zone: + data['reset_idle'] = zone['reset_idle'] + + # Zone grouping lists + if zone.get('typeA_list'): + typeA = CommentedSeq() + for i, z in enumerate(zone['typeA_list']): + typeA.append(z) + zone_name = get_zone_name(z) + if zone_name: + typeA.yaml_add_eol_comment(zone_name, i) + data['typeA_list'] = typeA + + if zone.get('typeB_list'): + typeB = CommentedSeq() + for i, z in enumerate(zone['typeB_list']): + typeB.append(z) + zone_name = get_zone_name(z) + if zone_name: + typeB.yaml_add_eol_comment(zone_name, i) + data['typeB_list'] = typeB + + # Commands with name comments + if zone.get('commands'): + cmds = CommentedSeq() + for cmd in zone['commands']: + c = CommentedMap() + c['type'] = cmd['type'] + + if 'if_flag' in cmd: + c['if_flag'] = cmd['if_flag'] + + # Add fields with name comments + if 'mob_vnum' in cmd: + c['mob_vnum'] = cmd['mob_vnum'] + name = get_mob_name(cmd['mob_vnum']) + if name: + c.yaml_add_eol_comment(name, 'mob_vnum') + + if 'obj_vnum' in cmd: + c['obj_vnum'] = cmd['obj_vnum'] + name = get_obj_name(cmd['obj_vnum']) + if name: + c.yaml_add_eol_comment(name, 'obj_vnum') + + if 'room_vnum' in cmd: + c['room_vnum'] = cmd['room_vnum'] + name = get_room_name(cmd['room_vnum']) + if name: + c.yaml_add_eol_comment(name, 'room_vnum') + + if 'container_vnum' in cmd: + c['container_vnum'] = cmd['container_vnum'] + name = get_obj_name(cmd['container_vnum']) + if name: + c.yaml_add_eol_comment(name, 'container_vnum') + + if 'trigger_vnum' in cmd: + c['trigger_vnum'] = cmd['trigger_vnum'] + name = get_trigger_name(cmd['trigger_vnum']) + if name: + c.yaml_add_eol_comment(name, 'trigger_vnum') + + # FOLLOW command fields + if 'leader_mob_vnum' in cmd: + c['leader_mob_vnum'] = cmd['leader_mob_vnum'] + name = get_mob_name(cmd['leader_mob_vnum']) + if name: + c.yaml_add_eol_comment(name, 'leader_mob_vnum') + + if 'follower_mob_vnum' in cmd: + c['follower_mob_vnum'] = cmd['follower_mob_vnum'] + name = get_mob_name(cmd['follower_mob_vnum']) + if name: + c.yaml_add_eol_comment(name, 'follower_mob_vnum') + + # Wear position with comment + if 'wear_pos' in cmd: + c['wear_pos'] = cmd['wear_pos'] + pos_name = get_wear_pos_name(cmd['wear_pos']) + if pos_name: + c.yaml_add_eol_comment(pos_name, 'wear_pos') + + # Direction with comment + if 'direction' in cmd: + c['direction'] = cmd['direction'] + dir_name = get_direction_name(cmd['direction']) + if dir_name: + c.yaml_add_eol_comment(dir_name, 'direction') + + # Other fields + for key in ['max_world', 'max_room', 'max', 'load_prob', + 'state', 'trigger_type', 'entity_vnum', + 'context', 'var_vnum', 'var_name', 'var_value']: + if key in cmd: + c[key] = cmd[key] + + cmds.append(c) + data['commands'] = cmds + + return yaml_dump_to_string(data) + + +def read_index_file(index_path): + """Read an index file and return set of enabled filenames. + + Index file format: + - One filename per line + - Lines starting with $ indicate end of index + - Empty lines are ignored + + Returns: + set of filenames (e.g., {'1.zon', '2.zon', ...}) or None if index doesn't exist + """ + if not index_path.exists(): + return None + + enabled_files = set() + try: + with open(index_path, 'r', encoding='koi8-r', errors='replace') as f: + for line in f: + line = line.strip() + if not line or line.startswith('$'): + break + enabled_files.add(line) + except Exception as e: + log_warning(f"Failed to read index file: {e}", filepath=str(index_path)) + return None + + return enabled_files + + +def convert_file(input_path, output_path, file_type): + """Convert a single file from old format to YAML (legacy function for single file mode).""" + input_path = Path(input_path) + output_path = Path(output_path) + + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + if file_type == 'mob': + entities = parse_mob_file(input_path) + for entity in entities: + yaml_content = mob_to_yaml(entity) + out_file = output_path.parent / f"{entity['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + elif file_type == 'obj': + entities = parse_obj_file(input_path) + for entity in entities: + yaml_content = obj_to_yaml(entity) + out_file = output_path.parent / f"{entity['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + elif file_type == 'wld': + entities = parse_wld_file(input_path) + for entity in entities: + yaml_content = room_to_yaml(entity) + out_file = output_path.parent / f"{entity['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + elif file_type == 'zon': + zone = parse_zon_file(input_path) + if zone: + yaml_content = zon_to_yaml(zone) + out_file = output_path.parent / f"{zone['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + elif file_type == 'trg': + entities = parse_trg_file(input_path) + for entity in entities: + yaml_content = trg_to_yaml(entity) + out_file = output_path.parent / f"{entity['vnum']}.yaml" + with open(out_file, 'w', encoding='koi8-r') as f: + f.write(yaml_content) + + return True + except Exception as e: + log_error(f"Failed to convert file: {e}", filepath=str(input_path)) + return False + + +def parse_file(input_path, file_type): + """Parse a file and return list of entities. + + Args: + input_path: Path to the input file + file_type: Type of file (mob, obj, wld, zon, trg) + + Returns: + List of (file_type, entity) tuples, or empty list on error + """ + try: + if file_type == 'mob': + entities = parse_mob_file(input_path) + return [(file_type, e) for e in entities] + elif file_type == 'obj': + entities = parse_obj_file(input_path) + return [(file_type, e) for e in entities] + elif file_type == 'wld': + entities = parse_wld_file(input_path) + return [(file_type, e) for e in entities] + elif file_type == 'zon': + zone = parse_zon_file(input_path) + return [(file_type, zone)] if zone else [] + elif file_type == 'trg': + entities = parse_trg_file(input_path) + return [(file_type, e) for e in entities] + return [] + except Exception as e: + log_error(f"Failed to parse file: {e}", filepath=str(input_path)) + return [] + + +def convert_directory(input_dir, output_dir, delete_source=False, max_workers=None, + output_format='yaml', db_path=None): + """Convert all files in a world directory. + + Architecture: + - Parsing: parallel (ThreadPoolExecutor, N threads) + - Saving: sequential (single thread, due to GIL for YAML / DB safety for SQLite) + + Args: + input_dir: Input directory containing world files + output_dir: Output directory for YAML files or database + delete_source: If True, delete source files after successful conversion + max_workers: Number of parallel workers for parsing (default: CPU count) + output_format: 'yaml' or 'sqlite' + db_path: Path to SQLite database (for sqlite format) + """ + input_path = Path(input_dir) + output_path = Path(output_dir) + + # Default to CPU count for parsing + if max_workers is None: + max_workers = os.cpu_count() or 4 + + # Choose saver based on output format + if output_format == 'sqlite': + if db_path is None: + db_path = output_path / 'world.db' + saver = SqliteSaver(db_path) + else: + saver = YamlSaver(output_path) + + # Track source files for deletion + source_files_to_delete = [] + + # Type mapping: dir_name -> (file_type, extension) + type_mapping = { + 'mob': ('mob', '.mob'), + 'obj': ('obj', '.obj'), + 'wld': ('wld', '.wld'), + 'zon': ('zon', '.zon'), + 'trg': ('trg', '.trg') + } + + with saver: + for dir_name, (file_type, extension) in type_mapping.items(): + source_dir = input_path / dir_name + if not source_dir.exists(): + continue + + # Read index file to determine which files are enabled + index_path = source_dir / 'index' + enabled_files = read_index_file(index_path) + + # Find all files + all_files = list(source_dir.glob(f"*{extension}")) + # Filter to only files matching pattern: . (ignores backup files like 16.old.obj) + valid_pattern = re.compile(r"^\d+" + re.escape(extension) + r"$") + files = [f for f in all_files if valid_pattern.match(f.name)] + if not files: + continue + + # Count enabled files + if enabled_files is not None: + enabled_count = sum(1 for f in files if f.name in enabled_files) + disabled_count = len(files) - enabled_count + index_info = f", {enabled_count} indexed, {disabled_count} extra" + else: + index_info = ", no index" + + lib_info = f", {_yaml_library}" if output_format == 'yaml' else "" + print(f"Converting {len(files)} {file_type} files ({max_workers} parsers{lib_info}{index_info})...") + + # Parallel parsing + all_entities = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(parse_file, f, file_type): f for f in files} + + for future in as_completed(futures): + f = futures[future] + try: + entities = future.result() + # Mark entities as enabled/disabled based on index + is_enabled = 1 if (enabled_files is None or f.name in enabled_files) else 0 + for etype, entity in entities: + entity['enabled'] = is_enabled + all_entities.extend(entities) + if entities and delete_source: + source_files_to_delete.append(f) + except Exception as e: + log_error(f"Parser thread error: {e}", filepath=str(f)) + + # Sequential saving (due to GIL / DB safety) + for file_type, entity in all_entities: + try: + if file_type == 'mob': + saver.save_mob(entity) + elif file_type == 'obj': + saver.save_object(entity) + elif file_type == 'wld': + saver.save_room(entity) + elif file_type == 'zon': + saver.save_zone(entity) + elif file_type == 'trg': + saver.save_trigger(entity) + except Exception as e: + vnum = entity.get('vnum', 'unknown') + log_error(f"Failed to save {file_type} {vnum}: {e}") + + # Finalize saver (create index files for YAML, print stats for SQLite) + saver.finalize() + + # Delete source files if requested + if delete_source and source_files_to_delete: + print(f"Deleting {len(source_files_to_delete)} source files...") + for f in source_files_to_delete: + try: + f.unlink() + except Exception as e: + log_warning(f"Failed to delete source file: {e}", filepath=str(f)) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description='Convert MUD world files to YAML or SQLite format', + epilog='''Examples: + python3 convert_to_yaml.py -i lib.template -o lib # Convert to YAML + python3 convert_to_yaml.py -i lib.template -o lib -f sqlite # Convert to SQLite + python3 convert_to_yaml.py -i lib.template -o lib -f sqlite --db world.db # Custom DB path +''' + ) + parser.add_argument('--input', '-i', required=True, + help='Input lib directory (containing world/) or single file') + parser.add_argument('--output', '-o', required=True, + help='Output lib directory or database directory') + parser.add_argument('--type', '-t', choices=['mob', 'obj', 'wld', 'zon', 'trg', 'all'], + default='all', help='File type to convert (default: all)') + parser.add_argument('--format', '-f', choices=['yaml', 'sqlite'], + default='yaml', help='Output format: yaml or sqlite (default: yaml)') + parser.add_argument('--db', type=str, default=None, + help='SQLite database path (for --format sqlite, default: /world.db)') + parser.add_argument('--delete-source', action='store_true', + help='Delete source files after successful conversion') + default_workers = os.cpu_count() or 4 + parser.add_argument('--workers', '-w', type=int, default=default_workers, + help=f'Number of parallel workers (default: {default_workers})') + parser.add_argument('--yaml-lib', choices=['ruamel', 'pyyaml'], default='pyyaml', + help='YAML library: ruamel (with comments, slow) or pyyaml (fast, default)') + + args = parser.parse_args() + + # Set global YAML library choice and initialize + global _yaml_library + _yaml_library = args.yaml_lib + if args.format == 'yaml': + _init_yaml_libraries() + + input_path = Path(args.input) + output_path = Path(args.output) + + if args.type == 'all': + # Convert entire directory + if not input_path.is_dir(): + print(f"Error: {input_path} is not a directory", file=sys.stderr) + sys.exit(1) + + # Look for world/ subdirectory + world_dir = input_path / 'world' + if not world_dir.exists(): + # Maybe the input is already the world directory + if (input_path / 'mob').exists() or (input_path / 'wld').exists(): + world_dir = input_path + else: + print(f"Error: Cannot find world directory in {input_path}", file=sys.stderr) + sys.exit(1) + + # Build name registries from output directory if it exists (only for YAML format) + if args.format == 'yaml' and output_path.exists(): + build_name_registries(output_path / 'world') + + convert_directory(world_dir, output_path, delete_source=args.delete_source, + max_workers=args.workers, output_format=args.format, + db_path=args.db) + else: + # Convert single file (only YAML supported for single file mode) + if args.format == 'sqlite': + print("Error: SQLite format is only supported for directory conversion", file=sys.stderr) + sys.exit(1) + convert_file(input_path, output_path, args.type) + + print_summary() + print("Conversion complete!") + + +if __name__ == '__main__': + main() diff --git a/tools/observability/DEPLOYMENT_GUIDE.md b/tools/observability/DEPLOYMENT_GUIDE.md new file mode 100644 index 000000000..b7abf1953 --- /dev/null +++ b/tools/observability/DEPLOYMENT_GUIDE.md @@ -0,0 +1,1023 @@ +# OpenTelemetry Stack Deployment Guide для Bylins MUD + +Полное руководство по развёртыванию телеметрии: OTEL Collector → Prometheus/Loki/Tempo → Grafana + +## Архитектура + +``` +┌─────────────┐ +│ Bylins MUD │ (C++ app с OTEL SDK) +└──────┬──────┘ + │ OTLP/gRPC (4317) + │ +┌──────▼──────────────┐ +│ OTEL Collector │ (агрегация, фильтрация, маршрутизация) +└──────┬──────────────┘ + │ + ├─────────────┐─────────────┐ + │ │ │ +┌──────▼──────┐ ┌───▼───────┐ ┌───▼────────┐ +│ Prometheus │ │ Tempo │ │ Loki │ +│ (metrics) │ │ (traces) │ │ (logs) │ +└──────┬──────┘ └─────┬─────┘ └─────┬──────┘ + │ │ │ + └──────────────┴─────────────┘ + │ + ┌───────▼────────┐ + │ Grafana │ (visualization) + └────────────────┘ +``` + +## Требования + +### Hardware +- **CPU**: 2+ ядра (рекомендуется 4) +- **RAM**: 4GB минимум (рекомендуется 8GB) +- **Disk**: 20GB минимум (зависит от retention) +- **Network**: 1 Gbps (локальная сеть) + +### Software +- `docker.io` (Docker 20.10+) +- `docker-compose` (standalone, пакет `docker-compose`, не плагин `docker compose`) + +Установка на Ubuntu: +```bash +sudo apt install docker.io docker-compose +sudo usermod -aG docker $USER # добавить себя в группу docker +newgrp docker # применить без перелогина +``` + +## Вариант 1: Docker Compose (рекомендуется для начала) + +### Структура файлов + +``` +mud.otel/ +├── docker-compose.observability.yml +├── otel-collector-config.yaml +├── prometheus.yml +├── loki-config.yaml +├── tempo-config.yaml +├── grafana/ +│ ├── provisioning/ +│ │ ├── datasources/ +│ │ │ └── datasources.yml +│ │ └── dashboards/ +│ │ └── dashboards.yml +│ └── dashboards/ +│ ├── performance-dashboard.json +│ ├── business-logic-dashboard.json +│ └── operational-dashboard.json +└── build/ + └── circle (Bylins binary) +``` + +### 1. docker-compose.observability.yml + +```yaml +version: '3.8' + +services: + # OpenTelemetry Collector + otel-collector: + image: otel/opentelemetry-collector-contrib:0.91.0 + container_name: mud-otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + - "8888:8888" # Prometheus metrics (collector's own metrics) + - "13133:13133" # Health check + networks: + - observability + restart: unless-stopped + + # Prometheus (metrics) + prometheus: + image: prom/prometheus:v2.48.0 + container_name: mud-prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.enable-remote-write-receiver' + - '--enable-feature=exemplar-storage' + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + ports: + - "9090:9090" + networks: + - observability + restart: unless-stopped + + # Tempo (traces) + tempo: + image: grafana/tempo:2.3.1 + container_name: mud-tempo + command: ["-config.file=/etc/tempo.yaml"] + volumes: + - ./tempo-config.yaml:/etc/tempo.yaml:ro + - tempo-data:/tmp/tempo + ports: + - "3200:3200" # Tempo HTTP + - "4316:4317" # OTLP gRPC (alternative port to avoid conflict) + networks: + - observability + restart: unless-stopped + + # Loki (logs) + loki: + image: grafana/loki:2.9.3 + container_name: mud-loki + command: -config.file=/etc/loki/config.yaml + volumes: + - ./loki-config.yaml:/etc/loki/config.yaml:ro + - loki-data:/loki + ports: + - "3100:3100" + networks: + - observability + restart: unless-stopped + + # Grafana (visualization) + grafana: + image: grafana/grafana:10.2.2 + container_name: mud-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + - GF_USERS_ALLOW_SIGN_UP=false + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./tools/observability/dashboards:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - "3000:3000" + networks: + - observability + depends_on: + - prometheus + - tempo + - loki + restart: unless-stopped + +volumes: + prometheus-data: + tempo-data: + loki-data: + grafana-data: + +networks: + observability: + driver: bridge +``` + +### 2. otel-collector-config.yaml + +```yaml +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + # Батчинг для эффективной отправки + batch: + timeout: 5s + send_batch_size: 512 + send_batch_max_size: 1024 + + # Фильтрация метрик (опционально) + filter/drop_noisy: + metrics: + exclude: + match_type: strict + metric_names: + # Добавьте сюда метрики, которые не нужны + # - some.noisy.metric + + # Tail-based sampling для traces (опционально) + tail_sampling: + decision_wait: 10s + num_traces: 10000 + expected_new_traces_per_sec: 100 + policies: + # Всегда сохраняем ошибки + - name: errors + type: status_code + status_code: {status_codes: [ERROR]} + + # Всегда сохраняем медленные операции + - name: slow-operations + type: latency + latency: {threshold_ms: 100} + + # Всегда сохраняем combat traces с багами + - name: combat-with-death + type: string_attribute + string_attribute: {key: event.name, values: [combat_ended, death]} + + # Семплируем остальное 10% + - name: probabilistic + type: probabilistic + probabilistic: {sampling_percentage: 10} + + # Добавление resource attributes + resource: + attributes: + - key: service.name + value: bylins-mud + action: upsert + - key: deployment.environment + value: production + action: upsert + + # Memory limiter для защиты от OOM + memory_limiter: + check_interval: 1s + limit_mib: 512 + spike_limit_mib: 128 + +exporters: + # Prometheus для метрик + prometheusremotewrite: + endpoint: http://prometheus:9090/api/v1/write + tls: + insecure: true + + # Tempo для traces + otlp/tempo: + endpoint: tempo:4317 + tls: + insecure: true + + # Loki для логов + loki: + endpoint: http://loki:3100/loki/api/v1/push + tls: + insecure: true + + # Debug exporter (опционально, для отладки) + # logging: + # loglevel: debug + +service: + pipelines: + # Pipeline для метрик + metrics: + receivers: [otlp] + processors: [memory_limiter, batch, resource] + exporters: [prometheusremotewrite] + + # Pipeline для traces + traces: + receivers: [otlp] + processors: [memory_limiter, tail_sampling, batch, resource] + exporters: [otlp/tempo] + + # Pipeline для логов + logs: + receivers: [otlp] + processors: [memory_limiter, batch, resource] + exporters: [loki] + + # Собственные метрики коллектора + telemetry: + logs: + level: info + metrics: + address: 0.0.0.0:8888 +``` + +### 3. prometheus.yml + +```yaml +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: 'bylins-production' + environment: 'production' + +# Алерты (опционально) +# alerting: +# alertmanagers: +# - static_configs: +# - targets: ['alertmanager:9093'] + +# Правила для алертов +# rule_files: +# - '/etc/prometheus/alerts/*.yml' + +scrape_configs: + # Prometheus scrapes itself + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # OTEL Collector metrics + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8888'] + + # Bylins MUD metrics (если есть прямой Prometheus endpoint) + # - job_name: 'bylins-mud' + # static_configs: + # - targets: ['bylins-mud:8080'] + +# Remote write получение от OTEL Collector (уже включено в docker-compose через --web.enable-remote-write-receiver) +``` + +### 4. tempo-config.yaml + +```yaml +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + +ingester: + max_block_duration: 5m + +compactor: + compaction: + block_retention: 720h # 30 дней + +storage: + trace: + backend: local + local: + path: /tmp/tempo/blocks + wal: + path: /tmp/tempo/wal + cache: memcached + memcached: + consistent_hash: true + host: localhost + service: memcached-client + timeout: 500ms + +overrides: + per_tenant_override_config: /etc/tempo-overrides.yaml + defaults: + max_traces_per_user: 10000 + max_bytes_per_trace: 5000000 +``` + +### 5. loki-config.yaml + +```yaml +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +storage_config: + boltdb_shipper: + active_index_directory: /loki/boltdb-shipper-active + cache_location: /loki/boltdb-shipper-cache + cache_ttl: 24h + shared_store: filesystem + filesystem: + directory: /loki/chunks + +limits_config: + reject_old_samples: true + reject_old_samples_max_age: 168h # 7 дней + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + max_streams_per_user: 10000 + max_query_series: 1000 + +chunk_store_config: + max_look_back_period: 720h # 30 дней + +table_manager: + retention_deletes_enabled: true + retention_period: 720h # 30 дней +``` + +### 6. grafana/provisioning/datasources/datasources.yml + +```yaml +apiVersion: 1 + +datasources: + # Prometheus + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + httpMethod: POST + timeInterval: 15s + exemplarTraceIdDestinations: + - name: trace_id + datasourceUid: tempo + + # Tempo + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: false + jsonData: + httpMethod: GET + tracesToLogs: + datasourceUid: loki + tags: ['trace_id', 'span_id'] + mappedTags: [{ key: 'service.name', value: 'service' }] + mapTagNamesEnabled: true + spanStartTimeShift: '-1m' + spanEndTimeShift: '1m' + tracesToMetrics: + datasourceUid: prometheus + tags: [{ key: 'service.name', value: 'service' }] + serviceMap: + datasourceUid: prometheus + nodeGraph: + enabled: true + + # Loki + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: false + jsonData: + maxLines: 1000 + derivedFields: + - datasourceUid: tempo + matcherRegex: "trace_id=(\\w+)" + name: TraceID + url: '$${__value.raw}' +``` + +### 7. grafana/provisioning/dashboards/dashboards.yml + +```yaml +apiVersion: 1 + +providers: + - name: 'Bylins MUD Dashboards' + orgId: 1 + folder: 'Bylins' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true +``` + +## Сборка Bylins MUD с поддержкой OTEL + +> Если вы деплоите готовый бинарник (собранный на другой машине или в CI), +> этот раздел можно пропустить — opentelemetry-cpp нужен только при сборке. + +### Установка SDK + +Пакет `libopentelemetry-cpp-dev` отсутствует в стандартных репозиториях Ubuntu. +Используйте скрипт `tools/observability/install-otel-sdk.sh`: + +```bash +cd tools/observability + +# Вариант 1 (основной): vcpkg +# Устанавливает vcpkg в ~/vcpkg (или $VCPKG_DIR) и собирает SDK через него. +./install-otel-sdk.sh + +# Вариант 2 (альтернатива): сборка из исходников +# Устанавливает SDK в /usr/local (или $OTEL_INSTALL_PREFIX). +# Занимает ~15 минут. +./install-otel-sdk.sh --source +``` + +### Сборка сервера + +**После vcpkg:** +```bash +cmake -S . -B build_otel \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_OTEL=ON \ + -DCMAKE_TOOLCHAIN_FILE=~/vcpkg/scripts/buildsystems/vcpkg.cmake \ + -DCMAKE_PREFIX_PATH=~/vcpkg/installed/x64-linux +make -C build_otel -j$(($(nproc)/2)) +``` + +**После сборки из исходников** (SDK в `/usr/local`, cmake найдёт сам): +```bash +cmake -S . -B build_otel \ + -DCMAKE_BUILD_TYPE=Release \ + -DWITH_OTEL=ON +make -C build_otel -j$(($(nproc)/2)) +``` + +## Запуск стека + +### Шаг 1: Подготовка файлов + +```bash +cd tools/observability + +# Конфиги должны быть читаемы контейнерами (которые запускаются не от root) +chmod 644 *.yml *.yaml +chmod 644 grafana/provisioning/datasources/*.yml +chmod 644 grafana/provisioning/dashboards/*.yml +``` + +### Шаг 2: Запуск сервисов + +#### Вариант A: данные в Docker named volumes (по умолчанию) + +```bash +docker-compose -f docker-compose.observability.yml up -d +``` + +Данные хранятся в Docker volumes, управляемых демоном. Расположение: +`/var/lib/docker/volumes/observability_/` + +#### Вариант B: данные в директории хоста + +Контейнеры записывают данные от своего внутреннего пользователя. Чтобы файлы +принадлежали запустившему пользователю, используется `user:` в override-файле. + +```bash +export DATA_DIR=/var/lib/mud-observability +export UID=$(id -u) +export GID=$(id -g) +mkdir -p $DATA_DIR/{prometheus,tempo,loki,grafana} + +docker-compose \ + -f docker-compose.observability.yml \ + -f docker-compose.data-dir.yml \ + up -d +``` + +`DATA_DIR` можно задать в `.env` файле рядом с compose-файлами: +```bash +echo "DATA_DIR=/var/lib/mud-observability" > .env +``` + +#### Общие команды + +```bash +# Проверить статус +docker-compose -f docker-compose.observability.yml ps + +# Посмотреть логи +docker-compose -f docker-compose.observability.yml logs -f + +# Проверить health OTEL Collector +curl http://localhost:13133 +``` + +### Шаг 3: Настройка Bylins MUD + +#### Environment variables для Bylins + +```bash +# В systemd service или docker-compose для Bylins +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +export OTEL_EXPORTER_OTLP_PROTOCOL=grpc +export OTEL_SERVICE_NAME=bylins-mud +export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.version=1.0.0 + +# Запустить Bylins +./build/circle 4000 +``` + +#### Конфигурация в коде (уже сделано в otel-integration) + +Убедитесь что в коде Bylins настроен OTEL endpoint: + +```cpp +// src/engine/observability/otel_provider.cpp +OtelProvider::OtelProvider() { + auto endpoint = std::getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); + if (!endpoint) { + endpoint = "http://localhost:4317"; // default + } + + // ... инициализация OTLP exporter с endpoint +} +``` + +### Шаг 4: Проверка работы + +```bash +# 1. Проверить OTEL Collector принимает данные +curl http://localhost:8888/metrics | grep otelcol_receiver + +# 2. Проверить Prometheus получает метрики +curl http://localhost:9090/api/v1/query?query=up + +# 3. Проверить Tempo получает traces +curl http://localhost:3200/api/search | jq + +# 4. Проверить Loki получает логи +curl http://localhost:3100/loki/api/v1/label + +# 5. Открыть Grafana +open http://localhost:3000 +# Login: admin / admin123 +``` + +### Шаг 5: Импорт дашбордов (автоматический) + +Дашборды автоматически импортируются из `tools/observability/dashboards/` через provisioning. + +Проверить в Grafana: +- Dashboards → Browse → Bylins folder +- Должны быть видны: + - Performance Dashboard + - Business Logic Dashboard + - Operational Dashboard + +## Проверка интеграции + +### 1. Тест метрик + +```bash +# В Grafana → Explore → Prometheus +# Запрос: +rate(combat_rounds_total[5m]) + +# Должен показать rate боёв +``` + +### 2. Тест traces + +```bash +# В Grafana → Explore → Tempo +# Query: service.name = "bylins-mud" +# TraceQL: { span.name = "Combat round" } + +# Должны быть видны traces боёв +``` + +### 3. Тест логов + +```bash +# В Grafana → Explore → Loki +# Query: +{service_name="bylins-mud"} | logfmt | level = "INFO" + +# Должны быть видны логи +``` + +### 4. Тест корреляции Logs ↔ Traces + +```bash +# В Grafana → Explore → Loki +{service_name="bylins-mud"} | logfmt | trace_id != "" + +# Кликнуть на trace_id → должен открыться Tempo с этим trace +``` + +## Production настройки + +### 1. Включить tail-based sampling + +В `otel-collector-config.yaml` раскомментировать `tail_sampling` processor. + +**Рекомендуется** для production с >50 игроками онлайн. + +### 2. Настроить retention + +**Prometheus** (в docker-compose.observability.yml): +```yaml +- '--storage.tsdb.retention.time=30d' # 30 дней метрик +``` + +**Tempo** (в tempo-config.yaml): +```yaml +compactor: + compaction: + block_retention: 720h # 30 дней traces (720h = 30 days) +``` + +**Loki** (в loki-config.yaml): +```yaml +table_manager: + retention_period: 720h # 30 дней логов +``` + +### 3. Добавить алерты + +Создать файл `prometheus-alerts.yml`: + +```yaml +groups: + - name: bylins_alerts + interval: 30s + rules: + # Алерт на высокую латентность боя + - alert: HighCombatLatency + expr: histogram_quantile(0.99, rate(combat_round_duration_bucket[5m])) > 0.015 + for: 5m + labels: + severity: warning + annotations: + summary: "High combat round latency" + description: "p99 combat round duration is {{ $value }}s (threshold 15ms)" + + # Алерт на много активных боёв + - alert: TooManyCombats + expr: combat_active_count > 20 + for: 10m + labels: + severity: warning + annotations: + summary: "Too many active combats" + description: "{{ $value }} active combats (threshold 20)" + + # Алерт на падение игроков + - alert: PlayersDropped + expr: rate(players_online_count[5m]) < -5 + for: 2m + labels: + severity: critical + annotations: + summary: "Players dropping rapidly" + description: "Player count dropped by {{ $value }}/sec" +``` + +### 4. Мониторинг самого OTEL + +```bash +# OTEL Collector экспортирует свои метрики на :8888 +# Добавить в Prometheus scrape_configs (уже есть выше) + +# Ключевые метрики для мониторинга: +# - otelcol_receiver_accepted_spans +# - otelcol_receiver_refused_spans +# - otelcol_exporter_sent_spans +# - otelcol_processor_batch_batch_send_size +``` + +## Troubleshooting + +### Проблема: Bylins не отправляет данные + +**Решение**: +```bash +# 1. Проверить environment variables +env | grep OTEL + +# 2. Проверить connectivity +telnet localhost 4317 + +# 3. Проверить логи Bylins +tail -f syslog | grep -i otel + +# 4. Проверить OTEL Collector логи +docker logs mud-otel-collector +``` + +### Проблема: OTEL Collector не экспортирует в Prometheus + +**Решение**: +```bash +# 1. Проверить метрики коллектора +curl http://localhost:8888/metrics | grep exporter + +# 2. Проверить логи коллектора +docker logs mud-otel-collector | grep -i error + +# 3. Проверить Prometheus принимает remote write +curl http://localhost:9090/api/v1/status/config | jq +``` + +### Проблема: Traces не видны в Tempo + +**Решение**: +```bash +# 1. Проверить OTLP endpoint Tempo +curl http://localhost:3200/api/echo + +# 2. Проверить traces в Tempo +curl http://localhost:3200/api/search | jq + +# 3. Проверить sampling rate (возможно все traces отфильтрованы) +# Временно отключить tail_sampling в otel-collector-config.yaml +``` + +### Проблема: Высокий memory usage OTEL Collector + +**Решение**: +```yaml +# В otel-collector-config.yaml увеличить memory_limiter +processors: + memory_limiter: + check_interval: 1s + limit_mib: 1024 # увеличить с 512 + spike_limit_mib: 256 # увеличить с 128 +``` + +### Проблема: Дашборды не загружаются + +**Решение**: +```bash +# 1. Проверить provisioning +docker exec -it mud-grafana ls -la /etc/grafana/provisioning/dashboards/ + +# 2. Проверить путь к дашбордам +docker exec -it mud-grafana ls -la /var/lib/grafana/dashboards/ + +# 3. Проверить логи Grafana +docker logs mud-grafana | grep -i dashboard + +# 4. Перезапустить Grafana +docker-compose -f docker-compose.observability.yml restart grafana +``` + +## Вариант 2: Kubernetes Deployment (опционально) + +Для production с масштабированием рекомендуется Kubernetes. + +### Helm Charts + +```bash +# 1. Установить OTEL Operator +kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml + +# 2. Установить Prometheus (kube-prometheus-stack) +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm install prometheus prometheus-community/kube-prometheus-stack + +# 3. Установить Tempo +helm repo add grafana https://grafana.github.io/helm-charts +helm install tempo grafana/tempo + +# 4. Установить Loki +helm install loki grafana/loki-stack + +# 5. Установить Grafana (уже включён в kube-prometheus-stack) +``` + +### OpenTelemetry Collector в K8s + +```yaml +# otel-collector-k8s.yaml +apiVersion: opentelemetry.io/v1alpha1 +kind: OpenTelemetryCollector +metadata: + name: bylins-otel-collector +spec: + mode: deployment + config: | + # Вставить содержимое otel-collector-config.yaml +``` + +### Bylins Deployment + +```yaml +# bylins-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bylins-mud +spec: + replicas: 1 + selector: + matchLabels: + app: bylins-mud + template: + metadata: + labels: + app: bylins-mud + spec: + containers: + - name: bylins + image: bylins-mud:latest + env: + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://bylins-otel-collector:4317" + - name: OTEL_SERVICE_NAME + value: "bylins-mud" + ports: + - containerPort: 4000 +``` + +## Best Practices + +### 1. Security + +- ✅ Использовать TLS для OTLP в production +- ✅ Настроить authentication в Grafana +- ✅ Ограничить доступ к метрикам через firewall +- ✅ Регулярно обновлять образы Docker + +### 2. Performance + +- ✅ Использовать gRPC вместо HTTP для OTLP (быстрее) +- ✅ Настроить batch processors (уже настроено) +- ✅ Включить sampling для traces при высокой нагрузке +- ✅ Мониторить memory usage OTEL Collector + +### 3. Reliability + +- ✅ Настроить persistent volumes для данных +- ✅ Настроить backups для Grafana dashboards +- ✅ Мониторить сам OTEL Collector +- ✅ Настроить алерты на критичные метрики + +### 4. Cost Optimization + +- ⚠️ Настроить retention период (не хранить вечно) +- ⚠️ Включить tail-based sampling (10-20%) +- ⚠️ Удалять неиспользуемые метрики через processor filter +- ⚠️ Использовать compression для экспорта + +## Полезные команды + +```bash +# Перезапустить весь стек +docker-compose -f docker-compose.observability.yml restart + +# Остановить стек +docker-compose -f docker-compose.observability.yml down + +# Удалить все данные (volumes) +docker-compose -f docker-compose.observability.yml down -v + +# Обновить конфигурацию без перезапуска (для большинства изменений) +docker-compose -f docker-compose.observability.yml up -d + +# Экспорт дашборда из Grafana +curl -H "Authorization: Bearer $GRAFANA_API_KEY" \ + http://localhost:3000/api/dashboards/uid/bylins-performance > dashboard-backup.json + +# Импорт дашборда в Grafana +curl -X POST -H "Content-Type: application/json" \ + -H "Authorization: Bearer $GRAFANA_API_KEY" \ + -d @dashboard-backup.json \ + http://localhost:3000/api/dashboards/db +``` + +## Резюме + +После завершения setup вы получите: + +✅ **Полный observability stack** для Bylins MUD +✅ **Автоматический сбор** метрик, traces, логов +✅ **3 готовых дашборда** в Grafana +✅ **Корреляция** между логами и traces +✅ **Production-ready** конфигурация с sampling и retention + +**Порты**: +- `3000` - Grafana UI +- `9090` - Prometheus UI +- `3100` - Loki (internal) +- `3200` - Tempo (internal) +- `4317` - OTEL Collector (OTLP gRPC) +- `8888` - OTEL Collector metrics + +**URLs**: +- Grafana: http://localhost:3000 (admin/admin123) +- Prometheus: http://localhost:9090 +- OTEL Collector health: http://localhost:13133 + +--- +*Дата: 2026-01-28* +*Версия: 1.0* diff --git a/tools/observability/OTEL_INSTRUMENTATION.md b/tools/observability/OTEL_INSTRUMENTATION.md new file mode 100644 index 000000000..7eda0aab3 --- /dev/null +++ b/tools/observability/OTEL_INSTRUMENTATION.md @@ -0,0 +1,508 @@ +# OpenTelemetry Instrumentation - Итоговый отчёт + +Дата: 2026-01-28 +Ветка: `metrics-traces-instrumentation` +Базовая ветка: `otel-integration` + +## Обзор + +Добавлена комплексная OpenTelemetry инструментация для Bylins MUD, охватывающая: +- **9 критических систем** (performance + business logic) +- **30+ метрик** (counters, gauges, histograms) +- **10+ типов трейсов** (включая overlapping traces для боёв) +- **3 Grafana дашборда** для визуализации + +## Статистика + +- **Коммитов**: 11 +- **Изменённых файлов**: 14 +- **Добавлено строк**: ~400+ строк инструментации +- **Дашбордов**: 3 (с 27+ панелями) + +## Фаза 0: Инфраструктура RAII + +### Файлы +- `src/engine/observability/otel_helpers.h` +- `src/engine/observability/otel_helpers.cpp` +- `src/engine/observability/otel_log_sender.cpp` (обновлён) +- `src/engine/observability/otel_trace_sender.h` (обновлён) + +### Созданные классы + +#### `ScopedMetric` - RAII wrapper для метрик +```cpp +{ + ScopedMetric metric("operation.duration", {{"type", "value"}}); + // ... операция ... +} // автоматически записывает histogram +``` + +#### `DualSpan` - Создание spans в двух traces одновременно +```cpp +DualSpan span( + "Heartbeat operation", // имя в heartbeat trace + "Business operation #5", // имя в business trace + &business_trace_context // parent для business span +); +span.SetAttribute("key", value); // устанавливается на ОБА spans +``` + +#### `BaggageScope` - OpenTelemetry Baggage для propagation +```cpp +auto scope = BaggageScope("combat_id", "12345_alice_vs_bob"); +// Все логи автоматически получают combat_id в атрибутах +``` + +#### Helper функции +- `GetTraceId()` - извлечение trace_id из span +- `GetBaggage()` - чтение baggage из context + +## Фаза 1: Высокий приоритет (Performance) + +### 1.1 Combat System (fight.cpp, fight_hit.cpp, char_data.h/cpp) + +**Архитектура Overlapping Traces**: +- **Heartbeat traces** - системный view, содержит ВСЕ операции heartbeat +- **Combat traces** - бизнес view, отдельный trace для КАЖДОГО боя +- **Dual spans** - каждая операция боя создаёт spans в ОБОИХ traces +- **Baggage** - `combat_trace_id` для ручного поиска Combat trace из логов + +**Структура данных**: +```cpp +// CharData (char_data.h): +std::unique_ptr m_combat_root_span; +std::unique_ptr m_combat_baggage_scope; +std::string m_combat_id; // "timestamp_attacker_vs_defender" +``` + +**Lifecycle**: +- Создание: `SetFighting()` - создаёт root span для Combat trace +- Обновление: `perform_violence()` - DualSpan для каждого раунда +- Закрытие: `stop_fighting()` - закрывает Combat trace + +**Метрики**: +- `combat.active.count` (gauge) - текущее количество активных боёв +- `combat.round.duration` (histogram) - длительность обработки раунда +- `combat.rounds.total` (counter) - общее число раундов +- `combat.hit.duration` (histogram) - время расчёта одного удара +- `combat.hits.total` (counter) - атаки с атрибутами: result (hit/miss) + +**Трейсы**: +``` +Heartbeat #100 (trace_id=aaa, 40ms) + ├─ Combat Processing (10ms) ← ОДИН общий span для ВСЕХ боёв + ├─ Mobile AI (5ms) + └─ Beat Points Update (3ms) + +Combat: alice vs bob (trace_id=xyz123, 30s) ← Отдельный trace для ЭТОГО боя + ├─ Round #1 (2ms, heartbeat_pulse=100) + │ ├─ Hit: alice → bob (0.5ms) + │ └─ Hit: bob → alice (0.4ms) + ├─ Round #2 (3ms, heartbeat_pulse=102) + │ └─ ... + └─ Round #N (2ms) +``` + +**Поиск в Loki**: +```logql +# Все логи конкретного боя: +{service="mud"} | logfmt | combat_trace_id = "xyz123" + +# Все логи heartbeat: +{service="mud"} | logfmt | trace_id = "aaa" +``` + +### 1.2 Mobile AI (mobact.cpp) + +**Метрики**: +- `mob.ai.duration` (histogram) - время обработки AI с атрибутом ai_level +- `mob.active.count` (gauge) - количество активных мобов + +**Трейсы**: +- "Mobile Activity" - parent span для каждого AI цикла + +### 1.3 Player Save/Load (db.cpp, obj_save.cpp) + +**Метрики**: +- `player.load.duration` (histogram) - время загрузки персонажа +- `player.save.duration` (histogram) - время сохранения с атрибутом save_type (frac/full) +- `player.save.total` (counter) - количество сохранений + +**Трейсы**: +- "Load Player" - span для LoadPlayerCharacter() +- "Player Save (Fractional)" / "Player Save (Full)" - spans для сохранения + +### 1.4 Beat Points Update + Player Statistics (game_limits.cpp) + +**Метрики**: +- `player.beat_update.duration` (histogram) - время обновления HP/Mana/Moves +- `players.online.count` (gauge) - **количество онлайн игроков** +- `players.in_combat.count` (gauge) - **количество игроков в бою** +- `players.by_level_remort.count` (gauge) - **распределение по level/remort** + +**Трейсы**: +- "Beat Points Update" - span с атрибутом player_count + +### 1.5 Zone Updates (db.cpp) + +**Метрики**: +- `zone.update.duration` (histogram) - время обновления зон +- `zone.reset.total` (counter) - количество сбросов с атрибутами: zone_vnum, reset_mode + +**Трейсы**: +- "Zone Update" - span для каждого цикла обновления + +## Фаза 2: Средний приоритет (Business Logic) + +### 2.1 Magic/Spell System (magic_utils.cpp) + +**Метрики**: +- `spell.cast.duration` (histogram) - время каста с атрибутами: spell_id, caster_class +- `spell.cast.total` (counter) - количество кастов с атрибутами: spell_id, caster_class + +**Трейсы**: +- "Spell Cast" - span с атрибутами: spell_id, spell_name, caster_class, spell_level, target_type + +**Интеграция**: Работает вместе с существующей системой `SpellUsage::AddSpellStat()` + +### 2.2 DG Script Triggers (dg_scripts.cpp) + +**Метрики**: +- `script.trigger.duration` (histogram) - время проверки триггеров с атрибутом trigger_type (MOB/OBJ/WLD) + +**Трейсы**: +- "Script Trigger Check" - span для каждого цикла проверки с атрибутами: trigger_type, mode + +**Применение**: Выявление "плохих" скриптов, которые вызывают lag + +### 2.3 Auction System (auction.cpp) + +**Метрики**: +- `auction.lots.active` (gauge) - текущее количество активных лотов +- `auction.sale.total` (counter) - количество продаж +- `auction.revenue.total` (counter) - общий доход с атрибутом seller_id +- `auction.duration.seconds` (histogram) - длительность аукционов от start до sale + +**Трейсы**: +- "Auction Sale" - span при совершении сделки с атрибутами: lot, seller_id, buyer_id, cost, item_id, duration_seconds + +**Расчёт длительности**: `(tact * kAuctionPulses) / 10.0` секунд + +### 2.4 Crafting System (item_creation.cpp) + +**Метрики**: +- `craft.duration` (histogram) - время выполнения крафта +- `craft.completed.total` (counter) - завершённые крафты с атрибутами: recipe_id, skill +- `craft.failures.total` (counter) - неудачные крафты с атрибутами: recipe_id, skill, failure_reason + +**Трейсы**: +- "Craft Item" - span для MakeRecept::make() с атрибутами: recipe_id, recipe_name, skill, materials_count + +**Место инструментации**: Функция `MakeRecept::make()` (line 1575) + +## Фаза 3: Grafana Dashboards + +### Dashboard 1: Performance Dashboard +**Файл**: `dashboards/performance-dashboard.json` +**UID**: `bylins-performance` +**Панелей**: 9 + +**Секции**: +1. **Combat Metrics**: + - Combat Round Duration (p50/p95/p99) - timeseries + - Active Combats - gauge + - Active Mobs - gauge + - Combat Activity Rate (rounds/sec, hits/sec) - timeseries + +2. **Mobile AI**: + - AI Processing Duration (p95/p99) - timeseries + +3. **Player I/O**: + - Player Save/Load Duration (p95) - timeseries + - Player Save Rate - timeseries + +4. **Zone Updates**: + - Zone Update Duration (p95/p99) - timeseries + - Zone Reset Rate - timeseries + +### Dashboard 2: Business Logic Dashboard +**Файл**: `dashboards/business-logic-dashboard.json` +**UID**: `bylins-business-logic` +**Панелей**: 9 + +**Секции**: +1. **Spell System**: + - Top 10 Most Cast Spells (Rate) - timeseries + - Spell Cast Duration (p95/p99) - timeseries + +2. **Crafting**: + - Crafting Success vs Failures (Rate) - stacked bars + - Craft Duration (p95) - timeseries + - Top 10 Most Crafted Recipes (Last Hour) - bars + +3. **Auction**: + - Active Auction Lots - gauge + - Auction Activity (Sales & Revenue) - timeseries + - Auction Duration (from start to sale) - timeseries + +4. **Scripts**: + - Script Trigger Duration (by type) - timeseries + +### Dashboard 3: Operational Dashboard +**Файл**: `dashboards/operational-dashboard.json` +**UID**: `bylins-operational` +**Панелей**: 9 + +**Секции**: +1. **Player Activity**: + - Players Online - gauge + - Players in Combat - gauge + - Player Activity Over Time - timeseries + - Player Distribution by Level & Remort - pie chart + bar chart + +2. **System Health**: + - Beat Points Update Duration (p95/p99) - timeseries + - System Activity Gauges (combats, mobs, auctions) - timeseries + - Key Operation Latencies (p95) - timeseries + - p99 Latencies (Current) - table + +## Примеры запросов + +### Prometheus (метрики) + +```promql +# Средняя длительность раунда боя за 5 минут +rate(combat_round_duration_sum[5m]) / rate(combat_round_duration_count[5m]) + +# 95-й перцентиль длительности раунда +histogram_quantile(0.95, rate(combat_round_duration_bucket[5m])) + +# Hit/Miss ratio +rate(combat_hits_total{result="hit"}[5m]) / rate(combat_hits_total[5m]) + +# Количество активных боёв +combat_active_count + +# Top 10 популярных заклинаний +topk(10, rate(spell_cast_total[5m])) + +# Success rate крафтинга +rate(craft_completed_total[5m]) / (rate(craft_completed_total[5m]) + rate(craft_failures_total[5m])) + +# Доход с аукциона в час +increase(auction_revenue_total[1h]) +``` + +### Tempo (трейсы) + +``` +# Heartbeat trace (системный view) +trace_id = "abc123..." + +# Combat trace (бизнес view - весь бой целиком) +trace_id = "xyz123..." + +# Найти все Combat traces за час +{ rootSpanName = "Combat:" } + +# Долгие бои (> 1 минута) +{ rootSpanName = "Combat:" && traceDuration > 1m } + +# PvP бои +{ rootSpanName = "Combat:" && span.is_pk = true } + +# Медленные раунды боя (> 10ms) +{ span.name =~ "Round #.*" && duration > 10ms } + +# Медленные заклинания (> 50ms) +{ span.name = "Spell Cast" && duration > 50ms } + +# Аукционы с долгим ожиданием (> 5 минут) +{ rootSpanName = "Auction Sale" && span.duration_seconds > 300 } +``` + +### Loki (логи) + +```logql +# Все логи heartbeat #100 (включая бои, AI, zone updates) +{service="mud"} | logfmt | trace_id = "aaa" + +# Все логи конкретного боя alice vs bob (ручной поиск) +{service="mud"} | logfmt | combat_trace_id = "xyz123" +# ИЛИ: +{service="mud"} | logfmt | combat_id = "1738034567_alice_vs_bob" + +# Только критические попадания в этом бою +{service="mud"} | logfmt | combat_trace_id = "xyz123" | hit_type = "critical" + +# Все смерти в PvP боях +{service="mud"} | logfmt | level = "INFO" | message =~ "is dead" | combat_trace_id != "" + +# Ошибки при сохранении персонажей +{service="mud"} | logfmt | level = "ERROR" | message =~ "save" +``` + +## Навигация Logs ↔ Traces + +### Автоматическая (через кнопки Grafana) +- ✅ **Logs → Heartbeat Trace** - открывает системный view heartbeat +- ✅ **Heartbeat Trace → Logs** - находит ВСЕ логи heartbeat + +### Ручная (через combat_trace_id) +- ⚠️ **Logs → Combat Trace** - копируешь `combat_trace_id` из лога, ищешь в Tempo +- ⚠️ **Combat Trace → Logs** - копируешь trace_id, ищешь `{service="mud"} | logfmt | combat_trace_id = "xyz123"` + +## Импорт дашбордов в Grafana + +1. Открыть Grafana UI +2. Перейти в Dashboards → Import +3. Загрузить JSON файл из `dashboards/` +4. Выбрать Prometheus data source +5. Импортировать + +Или через API: +```bash +curl -X POST http://grafana:3000/api/dashboards/db \ + -H "Content-Type: application/json" \ + -d @dashboards/performance-dashboard.json +``` + +## Метрики по типам + +### Counters (всего событий) +- `combat.rounds.total` +- `combat.hits.total` +- `player.save.total` +- `zone.reset.total` +- `spell.cast.total` +- `auction.sale.total` +- `auction.revenue.total` +- `craft.completed.total` +- `craft.failures.total` + +### Gauges (текущее состояние) +- `combat.active.count` +- `mob.active.count` +- `players.online.count` +- `players.in_combat.count` +- `players.by_level_remort.count` +- `auction.lots.active` + +### Histograms (распределение длительностей) +- `combat.round.duration` +- `combat.hit.duration` +- `mob.ai.duration` +- `player.load.duration` +- `player.save.duration` +- `player.beat_update.duration` +- `zone.update.duration` +- `spell.cast.duration` +- `script.trigger.duration` +- `auction.duration.seconds` +- `craft.duration` + +## Рекомендации по использованию + +### Мониторинг производительности +1. Отслеживать p95/p99 латентности ключевых операций +2. Настроить алерты на превышение пороговых значений: + - Combat round > 15ms (p99) + - Zone update > 100ms (p95) + - Spell cast > 50ms (p95) + +### Анализ проблем +1. При lag spike: + - Проверить Combat Round Duration на пике + - Найти долгие spans в Tempo: `{ duration > 50ms }` + - Посмотреть логи через trace_id + +2. При падении FPS игроков: + - Проверить Active Combats gauge + - Посмотреть Zone Update Duration + - Проверить Mobile AI Duration + +### Бизнес-аналитика +1. Популярность контента: + - Top 10 Most Cast Spells + - Top 10 Most Crafted Recipes + - Auction Activity (sales rate) + +2. Балансировка игровых систем: + - Craft Success vs Failures ratio + - Hit/Miss ratio в бою + - Player Distribution by Level/Remort + +## Интеграция с существующим кодом + +### CExecutionTimer +Существующие `CExecutionTimer` в коде **НЕ заменяются**, а дополняются OpenTelemetry метриками: +```cpp +// Существующий код: +utils::CExecutionTimer timer; +// ... операция ... +log("Operation took %f ms", timer.delta().count()); + +// Добавлено: +observability::ScopedMetric metric("operation.duration"); +// ... та же операция ... +// ScopedMetric автоматически записывает histogram при выходе из scope +``` + +### SpellUsage +В `magic_utils.cpp` сохранена интеграция с существующей системой статистики заклинаний: +```cpp +SpellUsage::AddSpellStat(spell_id, skill_id, *caster, success); +// И добавлено: +observability::OtelMetrics::RecordCounter("spell.cast.total", 1, attrs); +``` + +## Список коммитов + +1. `a58c7f0fa` - Add RAII helper classes for OpenTelemetry instrumentation +2. `da1a55940` - Add OpenTelemetry instrumentation to combat system +3. `57ad6c319` - Add OpenTelemetry instrumentation to Mobile AI system +4. `9b87d08e3` - Add OpenTelemetry instrumentation to Player save/load system +5. `ed4774fc6` - Add OpenTelemetry instrumentation to Beat Points Update + Player Statistics +6. `5b4dcdaf4` - Add OpenTelemetry instrumentation to Zone Update system +7. `eea6505ac` - Add OpenTelemetry instrumentation to Magic/Spell system +8. `2aeda6e24` - Add OpenTelemetry instrumentation to DG Script Trigger system +9. `d237880cb` - Add OpenTelemetry instrumentation to Auction system +10. `c6a80f693` - Add OpenTelemetry instrumentation to Crafting system +11. `3249eb074` - Add Grafana dashboards for OpenTelemetry observability + +## Следующие шаги (опционально) + +Если понадобится расширение инструментации: + +### Низкий приоритет (не включено) +- **Command Statistics** - метрики выполнения команд игроков +- **Network/Descriptor** - активные соединения, I/O bytes +- **Quest System** - завершённые квесты, активные квесты +- **Fine-grained Heartbeat** - детальная разбивка heartbeat операций + +### Дополнительные улучшения +- Добавить `craft.materials.consumed` counter (требует детального учёта материалов) +- Добавить `spell.damage.dealt` histogram (требует hook в damage calculation) +- Добавить error counters для различных систем +- Создать custom trace sampling для редких событий (legendary drops, deaths) + +## Заключение + +Реализована комплексная система наблюдаемости для Bylins MUD с использованием OpenTelemetry: +- ✅ **9 систем** полностью инструментированы +- ✅ **30+ метрик** для production monitoring +- ✅ **10+ типов трейсов** для distributed tracing +- ✅ **3 Grafana дашборда** для визуализации +- ✅ **Overlapping traces** для детального анализа боёв +- ✅ **Багgage propagation** для корреляции логов и трейсов +- ✅ **Интеграция** с существующими системами профилирования + +Все метрики и трейсы готовы к использованию в production для: +- Мониторинга производительности +- Выявления узких мест +- Анализа бизнес-логики +- Debugging проблем в production + +--- +*Создано: 2026-01-28* +*Автор: Claude Sonnet 4.5* diff --git a/tools/observability/PERFORMANCE_IMPACT.md b/tools/observability/PERFORMANCE_IMPACT.md new file mode 100644 index 000000000..8b2aad00e --- /dev/null +++ b/tools/observability/PERFORMANCE_IMPACT.md @@ -0,0 +1,527 @@ +# OpenTelemetry Instrumentation - Анализ влияния на производительность + +## Резюме + +**Общий overhead**: ~0.5-2% CPU, ~1-5MB памяти на систему +**Рекомендация**: Безопасно для production при правильной настройке sampling + +## Методология измерений + +Производительность OpenTelemetry зависит от: +1. **Тип операции**: создание span, запись метрики, установка атрибута +2. **Частота вызовов**: редкие vs частые операции (combat rounds каждые 80ms) +3. **Конфигурация экспорта**: batch vs streaming, sampling rate +4. **Сеть**: latency до OTEL Collector + +### Типичные значения (на базе OpenTelemetry C++ SDK benchmarks) + +| Операция | Время (наносекунды) | Аллокации памяти | +|----------|---------------------|------------------| +| Создать span (no-op) | 50-100 ns | 0 байт | +| Создать span (active) | 500-1000 ns | 256 байт | +| Установить атрибут string | 100-200 ns | ~100 байт (копия строки) | +| Установить атрибут int64 | 50-100 ns | 0 байт | +| Закрыть span | 200-500 ns | 0 байт | +| Записать Counter | 100-200 ns | 0 байт | +| Записать Histogram | 200-500 ns | 0 байт | +| Записать Gauge | 100-200 ns | 0 байт | + +**Примечание**: 1 микросекунда (μs) = 1000 наносекунд (ns) + +## Детальный анализ по системам + +### Phase 1: Performance-critical системы + +#### 1.1 Combat System + +**Частота**: Каждые 80ms (12.5 раз в секунду) при активном бое + +##### Per-round overhead (каждый раунд боя) + +**Heartbeat span** (создаётся один раз для всех боёв): +```cpp +auto span = StartSpan("Combat Processing"); // ~500 ns +``` +- **Overhead**: 500 ns один раз на все бои в heartbeat + +**DualSpan для каждого раунда**: +```cpp +DualSpan round_span(...); // ~1500 ns (2 spans) +round_span.SetAttribute("round_number", ...); // ~50 ns +round_span.SetAttribute("heartbeat_pulse", ...); // ~50 ns +round_span.SetAttribute("attacker", ...); // ~150 ns (string copy) +round_span.SetAttribute("defender", ...); // ~150 ns (string copy) +round_span.SetAttribute("attacker_hp", ...); // ~50 ns +round_span.SetAttribute("defender_hp", ...); // ~50 ns +// Автоматическое закрытие spans // ~500 ns (2 spans) +``` +- **Overhead**: ~2.5 μs на раунд (2 spans + 6 атрибутов) + +**ScopedMetric**: +```cpp +ScopedMetric metric("combat.round.duration", attrs); // ~300 ns +// ... раунд боя ... +// Автоматическая запись histogram // ~500 ns +``` +- **Overhead**: ~800 ns на раунд + +**Counters**: +```cpp +RecordCounter("combat.rounds.total", 1); // ~150 ns +RecordGauge("combat.active.count", count); // ~150 ns +``` +- **Overhead**: ~300 ns на раунд + +**Итого на один раунд боя**: ~3.6 μs = 0.0036 ms + +**При длительности раунда** ~2-5 ms: +- **Relative overhead**: 0.0036 / 2 = **0.18%** (best case) +- **Relative overhead**: 0.0036 / 5 = **0.072%** (worst case) + +##### Per-hit overhead (каждая атака в раунде) + +```cpp +ScopedMetric hit_metric("combat.hit.duration"); // ~300 ns +// ... расчёт удара ... +// Автоматическая запись // ~500 ns +``` +- **Overhead**: ~800 ns на атаку + +**При длительности hit** ~0.3-1 ms: +- **Relative overhead**: 0.0008 / 0.3 = **0.27%** (best case) +- **Relative overhead**: 0.0008 / 1.0 = **0.08%** (worst case) + +##### Combat trace lifecycle overhead + +**При начале боя** (SetFighting): +```cpp +auto combat_id = GenerateCombatId(...); // ~500 ns (timestamp + strings) +auto span = StartSpan("Combat: alice vs bob"); // ~500 ns (no Scope!) +span->SetAttribute(...) * 6; // ~600 ns +BaggageScope("combat_trace_id", ...); // ~1000 ns +BaggageScope("combat_id", ...); // ~1000 ns +RecordGauge("combat.active.count", ...); // ~150 ns +``` +- **Overhead**: ~3.75 μs **один раз** при начале боя + +**При окончании боя** (stop_fighting): +```cpp +span->SetAttribute("end_pulse", ...); // ~50 ns +span->AddEvent("combat_ended"); // ~300 ns +span->End(); // ~250 ns +RecordGauge("combat.active.count", ...); // ~150 ns +// Багgage scopes автоматически очищаются // ~200 ns +``` +- **Overhead**: ~950 ns **один раз** при окончании боя + +**Вывод для Combat**: +- Overhead на раунд: **0.07-0.18%** - пренебрежимо мало +- Overhead на атаку: **0.08-0.27%** - пренебрежимо мало +- Overhead на бой (start/stop): **~4.7 μs** - один раз, несущественно + +#### 1.2 Mobile AI + +**Частота**: Каждые 10 pulses (400ms) + +```cpp +auto span = StartSpan("Mobile Activity"); // ~500 ns +ScopedMetric metric("mob.ai.duration", ...); // ~300 ns +// ... обработка AI для всех мобов (~5-15ms) ... +RecordGauge("mob.active.count", count); // ~150 ns +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~1.7 μs на цикл AI + +**При длительности AI** ~5-15 ms: +- **Relative overhead**: 0.0017 / 5 = **0.034%** (best case) +- **Relative overhead**: 0.0017 / 15 = **0.011%** (worst case) + +#### 1.3 Player Save/Load + +**Частота**: Save - каждые 5 минут (frac) или 30 минут (full), Load - при входе игрока + +##### Load overhead +```cpp +auto span = StartSpan("Load Player"); // ~500 ns +span->SetAttribute("character_name", ...); // ~150 ns +ScopedMetric metric("player.load.duration"); // ~300 ns +// ... загрузка персонажа (~10-100ms) ... +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~1.7 μs на загрузку + +**При длительности load** ~10-100 ms: +- **Relative overhead**: 0.0017 / 10 = **0.017%** (best case) +- **Relative overhead**: 0.0017 / 100 = **0.0017%** (worst case) + +##### Save overhead (per player) +```cpp +auto span = StartSpan("Player Save (...)"); // ~500 ns +span->SetAttribute("save_type", ...); // ~100 ns +span->SetAttribute("character", ...); // ~150 ns +// ... сохранение (~5-50ms) ... +RecordHistogram("player.save.duration", ...); // ~500 ns +RecordCounter("player.save.total", 1, ...); // ~150 ns +``` +- **Overhead**: ~1.4 μs на сохранение + +**Overhead пренебрежимо мал** по сравнению с I/O операциями. + +#### 1.4 Beat Points Update + +**Частота**: Каждый pulse (~40ms) для всех игроков + +```cpp +auto span = StartSpan("Beat Points Update"); // ~500 ns +ScopedMetric metric("player.beat_update.duration"); // ~300 ns +// ... обновление HP/Mana/Move для всех (~3-8ms) ... +span->SetAttribute("player_count", ...); // ~50 ns +RecordGauge("players.online.count", ...); // ~150 ns +RecordGauge("players.in_combat.count", ...); // ~150 ns +// Цикл по level_remort_distribution (~10-50 итераций): +// RecordGauge * N // ~150 ns * N +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~2.0 μs + (0.15 μs * уникальных level/remort) + +**При 20 уникальных level/remort комбинациях**: ~5.0 μs +**При длительности beat update** ~3-8 ms: +- **Relative overhead**: 0.005 / 3 = **0.17%** (best case) +- **Relative overhead**: 0.005 / 8 = **0.063%** (worst case) + +#### 1.5 Zone Updates + +**Частота**: Раз в секунду (1000ms) + +```cpp +auto span = StartSpan("Zone Update"); // ~500 ns +ScopedMetric metric("zone.update.duration"); // ~300 ns +// Цикл по зонам (обычно 1-5 зон сбрасываются): +// RecordCounter("zone.reset.total", ...) // ~150 ns * N +// ... обработка зон (~10-100ms) ... +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~1.55 μs + (0.15 μs * количество зон) + +**При 5 зонах**: ~2.3 μs +**Overhead пренебрежимо мал** по сравнению с zone processing. + +### Phase 2: Business Logic системы + +#### 2.1 Magic/Spell System + +**Частота**: Зависит от игровой активности (10-100 заклинаний в секунду) + +```cpp +auto span = StartSpan("Spell Cast"); // ~500 ns +ScopedMetric metric("spell.cast.duration"); // ~300 ns +span->SetAttribute("spell_id", ...); // ~50 ns +span->SetAttribute("spell_name", ...); // ~150 ns (string copy) +span->SetAttribute("caster_class", ...); // ~100 ns (string copy) +span->SetAttribute("spell_level", ...); // ~50 ns +span->SetAttribute("target_type", ...); // ~100 ns (string copy) +// ... выполнение заклинания (~0.5-5ms) ... +RecordCounter("spell.cast.total", 1, ...); // ~150 ns +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~2.15 μs на заклинание + +**При длительности spell** ~0.5-5 ms: +- **Relative overhead**: 0.00215 / 0.5 = **0.43%** (best case) +- **Relative overhead**: 0.00215 / 5 = **0.043%** (worst case) + +#### 2.2 Script Triggers + +**Частота**: Каждые 13 секунд + +```cpp +auto span = StartSpan("Script Trigger Check"); // ~500 ns +ScopedMetric metric("script.trigger.duration", ...); // ~300 ns +span->SetAttribute("trigger_type", ...); // ~100 ns +span->SetAttribute("mode", ...); // ~50 ns +// ... проверка триггеров (~5-20ms) ... +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~1.7 μs на проверку + +**Overhead пренебрежимо мал**. + +#### 2.3 Auction System + +**Частота**: tact_auction() каждые 30 pulses (1.2 сек), sell_auction() - редко + +##### tact_auction overhead +```cpp +// Цикл по активным лотам (обычно 0-3): +// RecordGauge("auction.lots.active", ...) // ~150 ns +``` +- **Overhead**: ~150 ns (выполняется раз в 1.2 сек) + +##### sell_auction overhead +```cpp +auto span = StartSpan("Auction Sale"); // ~500 ns +double duration = ...; // ~50 ns (арифметика) +span->SetAttribute(...) * 6; // ~600 ns +// ... выполнение продажи (~2-10ms) ... +RecordCounter("auction.sale.total", ...); // ~150 ns +RecordCounter("auction.revenue.total", ...); // ~150 ns +RecordHistogram("auction.duration.seconds", ...); // ~500 ns +// Автоматическое закрытие // ~250 ns +``` +- **Overhead**: ~2.2 μs на продажу + +**Overhead пренебрежимо мал** (продажи редкие). + +#### 2.4 Crafting System + +**Частота**: Редко (игроки крафтят вручную, ~1-10 раз в минуту на сервер) + +```cpp +auto span = StartSpan("Craft Item"); // ~500 ns +ScopedMetric metric("craft.duration"); // ~300 ns +span->SetAttribute("recipe_id", ...); // ~50 ns +span->SetAttribute("recipe_name", ...); // ~150 ns +span->SetAttribute("skill", ...); // ~100 ns +span->SetAttribute("materials_count", ...); // ~50 ns +// ... крафтинг (~10-100ms) ... +RecordCounter("craft.completed.total", ...); // ~150 ns +// ИЛИ +RecordCounter("craft.failures.total", ...); // ~150 ns +// Автоматическое закрытие // ~750 ns +``` +- **Overhead**: ~2.2 μs на крафт + +**Overhead пренебрежимо мал** (крафтинг редкий и долгий). + +## Overhead по типам операций + +### Spans + +| Тип span | Overhead (создание) | Overhead (закрытие) | Атрибуты | Итого | +|----------|---------------------|---------------------|----------|-------| +| Simple span | 500 ns | 250 ns | ~400 ns (4-6 attrs) | ~1.15 μs | +| DualSpan (2 spans) | 1000 ns | 500 ns | ~800 ns (duplicate) | ~2.3 μs | +| Span with many attrs | 500 ns | 250 ns | ~1500 ns (10-15 attrs) | ~2.25 μs | + +### Metrics + +| Тип метрики | Overhead | Частота | Impact | +|-------------|----------|---------|--------| +| Counter (без attrs) | 100 ns | Частая | Низкий | +| Counter (с attrs) | 150 ns | Частая | Низкий | +| Gauge | 150 ns | Средняя | Низкий | +| Histogram | 500 ns | Частая | Средний | + +### Baggage + +| Операция | Overhead | +|----------|----------| +| SetBaggage (context) | 1000 ns | +| GetBaggage (context) | 200 ns | +| Propagation to logs | 500 ns (per log) | + +## Aggregated overhead оценка + +### Worst-case scenario (пик нагрузки) + +**Предположения**: +- 50 игроков онлайн +- 10 активных боёв (20 игроков в бою) +- 5 мобов на игрока (250 активных мобов) +- 20 заклинаний в секунду +- 2 крафта в минуту +- 1 продажа на аукционе в минуту + +**Per-second overhead**: +- Combat rounds: 10 боёв * 12.5 раундов/сек * 3.6 μs = **450 μs/sec** +- Combat hits: 10 боёв * 12.5 раундов/сек * 2 удара * 0.8 μs = **200 μs/sec** +- Beat points: 25 раз/сек * 5 μs = **125 μs/sec** +- Mob AI: 2.5 раз/сек * 1.7 μs = **4.25 μs/sec** +- Zone update: 1 раз/сек * 2.3 μs = **2.3 μs/sec** +- Spells: 20 раз/сек * 2.15 μs = **43 μs/sec** +- Script triggers: 0.077 раз/сек * 1.7 μs = **0.13 μs/sec** +- Auction tact: 0.83 раз/сек * 0.15 μs = **0.12 μs/sec** +- Crafting: 0.033 раз/сек * 2.2 μs = **0.07 μs/sec** + +**ИТОГО**: ~825 μs/sec = **0.825 ms/sec** = **0.0825% CPU** + +### Базовая нагрузка (нормальная активность) + +**Предположения**: +- 20 игроков онлайн +- 3 активных боя +- 100 активных мобов +- 5 заклинаний в секунду + +**Per-second overhead**: ~250 μs/sec = **0.25 ms/sec** = **0.025% CPU** + +## Memory overhead + +### Per-span memory + +**Active span** (пока не закрыт): +- Span object: ~256 байт +- Attributes (средний): ~500 байт (строки копируются) +- **Итого**: ~756 байт на активный span + +**После закрытия** (до export): +- Span в batch buffer: ~400 байт (сжато) +- Хранится до batch export (~1-5 секунд) + +### Per-metric memory + +**Histogram**: +- Buckets: ~2KB (default buckets) +- Rolling window: minimal (OTLP histogram) + +**Counter/Gauge**: +- ~128 байт на метрику + +### Estimated total memory + +**Worst-case** (50 игроков, 10 боёв): +- Active spans (10 combats * 10 rounds buffered): ~75 KB +- Metrics state: ~50 KB +- Baggage contexts: ~20 KB +- Export buffers: ~100 KB +- **Итого**: ~**245 KB** + +**Normal** (20 игроков, 3 боя): +- **Итого**: ~**100 KB** + +## Network overhead + +### Batch export размеры + +**OTLP/gRPC** (рекомендуется): +- Traces: ~1-2 KB на span (после сжатия) +- Metrics: ~100-500 байт на метрику +- Batch size: обычно 512-1024 spans или 1000 metrics + +**Примеры**: +- 10 боёв * 100 раундов = 1000 combat spans/мин ≈ **1.5 MB/мин** traces +- 30 метрик * 60 сек = 1800 точек/мин ≈ **0.9 MB/мин** metrics + +**При batch экспорте** (каждые 5 секунд): +- **~40 KB/batch** (сжатие gRPC) + +## Рекомендации по оптимизации + +### 1. Sampling + +**Head-based sampling** (на уровне SDK): +```cpp +// В конфиге OTEL +export OTEL_TRACES_SAMPLER=traceidratio +export OTEL_TRACES_SAMPLER_ARG=0.1 // 10% traces +``` + +**Tail-based sampling** (в OTEL Collector): +```yaml +processors: + tail_sampling: + policies: + - name: combat-errors + type: string_attribute + string_attribute: {key: error, values: [true]} + - name: slow-operations + type: latency + latency: {threshold_ms: 100} + - name: sample-rate + type: probabilistic + probabilistic: {sampling_percentage: 10} +``` + +**Рекомендуемые sampling rates**: +- Combat traces: **10-20%** (достаточно для паттернов) +- Other traces: **50-100%** (редкие операции) +- Metrics: **100%** (всегда, низкий overhead) + +### 2. Batch размеры + +```yaml +# В OTEL SDK config +exporters: + otlp: + endpoint: collector:4317 + timeout: 10s + +processors: + batch: + timeout: 5s + send_batch_size: 512 + send_batch_max_size: 1024 +``` + +### 3. Атрибуты + +**Избегать**: +- Длинные строки (>100 символов) в атрибутах +- High-cardinality атрибуты (уникальные IDs) без лимита +- Дублирование данных между span и атрибутами + +**Рекомендуется**: +- Использовать короткие константы для типов +- Ограничивать количество атрибутов (5-10 на span) +- Использовать enum/int вместо strings где возможно + +### 4. Conditional instrumentation + +Для production можно добавить feature flags: + +```cpp +// В config +bool enable_combat_traces = true; +bool enable_detailed_attrs = false; // только для debug + +// В коде +if (enable_combat_traces) { + auto span = StartSpan("Combat"); + if (enable_detailed_attrs) { + span->SetAttribute("detailed_info", ...); + } +} +``` + +## Выводы + +### CPU Overhead + +| Сценарий | Overhead | Приемлемо? | +|----------|----------|------------| +| Нормальная нагрузка | 0.025% CPU | ✅ Да | +| Пиковая нагрузка | 0.08% CPU | ✅ Да | +| С sampling 10% | <0.01% CPU | ✅ Да | + +### Memory Overhead + +| Сценарий | Overhead | Приемлемо? | +|----------|----------|------------| +| Нормальная нагрузка | ~100 KB | ✅ Да | +| Пиковая нагрузка | ~250 KB | ✅ Да | + +### Network Overhead + +| Metric | Bandwidth | Приемлемо? | +|--------|-----------|------------| +| Traces | 1.5 MB/мин | ✅ Да (~200 Kbps) | +| Metrics | 0.9 MB/мин | ✅ Да (~120 Kbps) | +| **Итого** | **2.4 MB/мин** | ✅ Да (~320 Kbps) | + +### Итоговая рекомендация + +**OpenTelemetry инструментация безопасна для production** при условии: +1. ✅ Используется batch export (default) +2. ✅ OTEL Collector находится в локальной сети (low latency) +3. ⚠️ Для очень высокой нагрузки (>100 игроков) - включить sampling 10-20% +4. ✅ Monitoring самого OTEL (экспортирует свои метрики) + +**Преимущества значительно перевешивают overhead**: +- Детальная visibility в production +- Root cause analysis проблем производительности +- Business intelligence из метрик +- Distributed tracing для сложных операций (overlapping combat traces!) + +--- +*Дата: 2026-01-28* +*Методология: OpenTelemetry C++ SDK benchmarks + практические оценки* diff --git a/tools/observability/README.md b/tools/observability/README.md new file mode 100644 index 000000000..629e78a0b --- /dev/null +++ b/tools/observability/README.md @@ -0,0 +1,270 @@ +# Bylins MUD - OpenTelemetry Observability + +Полная документация по системе наблюдаемости (observability) для Bylins MUD. + +## Структура документации + +``` +tools/observability/ +├── README.md # Этот файл +├── OTEL_INSTRUMENTATION.md # Описание инструментации +├── PERFORMANCE_IMPACT.md # Анализ влияния на производительность +├── DEPLOYMENT_GUIDE.md # Руководство по развёртыванию стека +└── dashboards/ # Grafana дашборды + ├── performance-dashboard.json + ├── business-logic-dashboard.json + └── operational-dashboard.json +``` + +## Быстрый старт + +### 1. Что инструментировано? + +**9 критических систем**: +- ✅ Combat system (с overlapping traces!) +- ✅ Mobile AI +- ✅ Player save/load +- ✅ Beat points update + Player statistics +- ✅ Zone updates +- ✅ Magic/Spell system +- ✅ Script triggers +- ✅ Auction system +- ✅ Crafting system + +**30+ метрик, 10+ типов трейсов, 3 Grafana дашборда** + +Подробности → [OTEL_INSTRUMENTATION.md](OTEL_INSTRUMENTATION.md) + +### 2. Влияние на производительность? + +**TL;DR**: ~0.025-0.08% CPU overhead, ~100-250 KB памяти + +**Безопасно для production** ✅ + +Детальный анализ → [PERFORMANCE_IMPACT.md](PERFORMANCE_IMPACT.md) + +### 3. Как развернуть телеметрию? + +**Stack**: OTEL Collector → Prometheus/Loki/Tempo → Grafana + +**Время setup**: ~15 минут с Docker Compose + +Пошаговое руководство → [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) + +## Основные возможности + +### Overlapping Combat Traces 🌟 + +Уникальная архитектура для анализа боёв: +- **Heartbeat trace** - системный view (как бой влияет на performance) +- **Combat trace** - бизнес view (весь бой от начала до конца) +- **Baggage propagation** - автоматическая корреляция логов через `combat_trace_id` + +``` +Heartbeat #100 (40ms) Combat: alice vs bob (30s) + ├─ Combat Processing ├─ Round #1 (heartbeat=100) + ├─ Mobile AI ├─ Round #2 (heartbeat=102) + └─ Beat Points Update └─ Round #N (heartbeat=104) +``` + +### Metrics по типам + +**Counters** (события): +- `combat.rounds.total`, `combat.hits.total` +- `spell.cast.total`, `craft.completed.total` +- `auction.sale.total`, `zone.reset.total` + +**Gauges** (состояние): +- `players.online.count`, `players.in_combat.count` +- `combat.active.count`, `mob.active.count` +- `auction.lots.active` + +**Histograms** (латентности): +- `combat.round.duration`, `spell.cast.duration` +- `player.save.duration`, `zone.update.duration` +- `craft.duration`, `mob.ai.duration` + +### Grafana Dashboards + +**3 готовых дашборда** с 27+ панелями: + +1. **Performance Dashboard** - латентности всех систем +2. **Business Logic Dashboard** - спеллы, крафт, аукцион +3. **Operational Dashboard** - игроки онлайн, распределение + +Дашборды → [dashboards/](dashboards/) + +## Примеры использования + +### Prometheus (метрики) + +```promql +# Средняя длительность раунда боя +rate(combat_round_duration_sum[5m]) / rate(combat_round_duration_count[5m]) + +# Top 10 популярных заклинаний +topk(10, rate(spell_cast_total[5m])) + +# Игроки онлайн +players_online_count +``` + +### Tempo (traces) + +``` +# Найти долгие бои (> 1 минута) +{ rootSpanName = "Combat:" && traceDuration > 1m } + +# Найти медленные заклинания (> 50ms) +{ span.name = "Spell Cast" && duration > 50ms } + +# PvP бои +{ rootSpanName = "Combat:" && span.is_pk = true } +``` + +### Loki (логи) + +```logql +# Все логи конкретного боя +{service="mud"} | logfmt | combat_trace_id = "xyz123" + +# Критические попадания +{service="mud"} | logfmt | hit_type = "critical" + +# Ошибки при сохранении +{service="mud"} | logfmt | level = "ERROR" | message =~ "save" +``` + +## Навигация по документам + +### Для разработчиков + +1. Начните с [OTEL_INSTRUMENTATION.md](OTEL_INSTRUMENTATION.md) + - Что инструментировано + - Архитектура overlapping traces + - Примеры запросов + +2. Изучите overhead в [PERFORMANCE_IMPACT.md](PERFORMANCE_IMPACT.md) + - Детальный анализ по системам + - Рекомендации по оптимизации + - Worst-case сценарии + +### Для DevOps/SRE + +1. Следуйте [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) + - Docker Compose setup (15 минут) + - Конфигурация всех компонентов + - Production best practices + - Troubleshooting + +2. Импортируйте [dashboards/](dashboards/) в Grafana + - Автоматический import через provisioning + - Или вручную через UI + +### Для аналитиков + +1. Откройте Grafana дашборды + - **Performance** - для анализа lag + - **Business Logic** - для game balance + - **Operational** - для player activity + +2. Используйте Explore для ad-hoc запросов + - Prometheus для метрик + - Tempo для traces + - Loki для логов + +## Архитектура + +``` +┌─────────────┐ +│ Bylins MUD │ (OTEL C++ SDK) +└──────┬──────┘ + │ OTLP/gRPC + │ +┌──────▼──────────────┐ +│ OTEL Collector │ (батчинг, sampling, routing) +└──────┬──────────────┘ + │ + ├─────────────┬─────────────┐ + │ │ │ +┌──────▼──────┐ ┌───▼───────┐ ┌───▼────────┐ +│ Prometheus │ │ Tempo │ │ Loki │ +│ (metrics) │ │ (traces) │ │ (logs) │ +└──────┬──────┘ └─────┬─────┘ └─────┬──────┘ + │ │ │ + └──────────────┴─────────────┘ + │ + ┌───────▼────────┐ + │ Grafana │ (visualization) + └────────────────┘ +``` + +## Коммиты + +Инструментация добавлена в ветке `metrics-traces-instrumentation` (12 коммитов): + +``` +a9f46ce9c - Add comprehensive OpenTelemetry instrumentation documentation +3249eb074 - Add Grafana dashboards for OpenTelemetry observability +c6a80f693 - Add OpenTelemetry instrumentation to Crafting system +d237880cb - Add OpenTelemetry instrumentation to Auction system +2aeda6e24 - Add OpenTelemetry instrumentation to DG Script Trigger system +eea6505ac - Add OpenTelemetry instrumentation to Magic/Spell system +5b4dcdaf4 - Add OpenTelemetry instrumentation to Zone Update system +ed4774fc6 - Add OpenTelemetry instrumentation to Beat Points Update + Player Statistics +9b87d08e3 - Add OpenTelemetry instrumentation to Player save/load system +57ad6c319 - Add OpenTelemetry instrumentation to Mobile AI system +da1a55940 - Add OpenTelemetry instrumentation to combat system +a58c7f0fa - Add RAII helper classes for OpenTelemetry instrumentation +``` + +## FAQ + +**Q: Насколько это замедлит сервер?** +A: ~0.025-0.08% CPU overhead. Пренебрежимо мало. См. [PERFORMANCE_IMPACT.md](PERFORMANCE_IMPACT.md) + +**Q: Нужно ли sampling для production?** +A: Рекомендуется 10-20% sampling для traces при >50 игроках. Метрики - всегда 100%. + +**Q: Как найти конкретный бой в Tempo?** +A: Через baggage `combat_trace_id` из логов, или через TraceQL: `{ rootSpanName = "Combat:" && span.attacker = "alice" }` + +**Q: Можно ли отключить инструментацию?** +A: Да, через environment variables или feature flags в коде. + +**Q: Как коррелировать логи и traces?** +A: Автоматически! Логи содержат `trace_id` и `combat_trace_id`. В Grafana есть кнопки для перехода. + +**Q: Сколько места займут метрики/traces/логи?** +A: При настройках retention 30 дней: ~10-50 GB (зависит от активности). Настраивается в конфигах. + +## Поддержка + +Вопросы по инструментации? Проблемы с развёртыванием? + +1. Проверьте [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) → Troubleshooting +2. Проверьте логи: `docker logs mud-otel-collector` +3. Проверьте метрики коллектора: `curl http://localhost:8888/metrics` + +## Следующие шаги + +После развёртывания: + +1. ✅ Импортировать дашборды в Grafana +2. ✅ Настроить алерты на критичные метрики +3. ✅ Протестировать на нагрузке +4. ⚠️ Включить sampling (10-20%) для production +5. ⚠️ Настроить backups Grafana dashboards + +## Ссылки + +- [OpenTelemetry Docs](https://opentelemetry.io/docs/) +- [OTEL Collector Docs](https://opentelemetry.io/docs/collector/) +- [Grafana Docs](https://grafana.com/docs/) +- [Prometheus Docs](https://prometheus.io/docs/) +- [Tempo Docs](https://grafana.com/docs/tempo/) +- [Loki Docs](https://grafana.com/docs/loki/) + +--- +*Дата: 2026-01-28* +*Bylins MUD OpenTelemetry Instrumentation v1.0* diff --git a/tools/observability/dashboards/business-logic-dashboard.json b/tools/observability/dashboards/business-logic-dashboard.json new file mode 100644 index 000000000..cefd53285 --- /dev/null +++ b/tools/observability/dashboards/business-logic-dashboard.json @@ -0,0 +1,742 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "id": 999, + "type": "text", + "title": "", + "gridPos": {"h": 2, "w": 24, "x": 0, "y": 0}, + "options": { + "mode": "markdown", + "content": "> ⚠️ **Дашборд только для чтения.** Управляется из файла в репозитории. Чтобы внести изменения — используйте **Save As** (создайте копию)." + }, + "transparent": true + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 2 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "last"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "topk(10, rate(spell_cast_total[5m]))", + "legendFormat": "{{spell_id}} ({{caster_class}})", + "refId": "A" + } + ], + "title": "Топ 10 самых используемых заклинаний (частота)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 2 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(spell_cast_duration_bucket[5m]))", + "legendFormat": "p95 Длительность заклинания", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, rate(spell_cast_duration_bucket[5m]))", + "legendFormat": "p99 Длительность заклинания", + "refId": "B" + } + ], + "title": "Длительность чтения заклинания (перцентили)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "last"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(craft_completed_total[5m])", + "legendFormat": "Успешно ({{recipe_id}})", + "refId": "A" + }, + { + "expr": "rate(craft_failures_total[5m])", + "legendFormat": "Неудача ({{recipe_id}})", + "refId": "B" + } + ], + "title": "Крафт: успехи и неудачи (частота)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(craft_duration_bucket[5m]))", + "legendFormat": "p95 Длительность крафта", + "refId": "A" + } + ], + "title": "Длительность крафта (p95)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 18 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "auction_lots_active", + "refId": "A" + } + ], + "title": "Активные лоты аукциона", + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 6, + "y": 18 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["mean", "last"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(auction_sale_total[5m])", + "legendFormat": "Продаж/сек", + "refId": "A" + }, + { + "expr": "rate(auction_revenue_total[5m])", + "legendFormat": "Выручка/сек (золото)", + "refId": "B" + } + ], + "title": "Активность аукциона (продажи и выручка)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 9, + "x": 15, + "y": 18 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(auction_duration_seconds_bucket[5m]))", + "legendFormat": "p95 Длительность аукциона", + "refId": "A" + } + ], + "title": "Длительность аукциона (от выставления до продажи)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(script_trigger_duration_bucket[5m]))", + "legendFormat": "p95 ({{trigger_type}})", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, rate(script_trigger_duration_bucket[5m]))", + "legendFormat": "p99 ({{trigger_type}})", + "refId": "B" + } + ], + "title": "Длительность срабатывания триггеров (по типу)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "topk(10, sum by (recipe_id) (increase(craft_completed_total[1h])))", + "legendFormat": "Рецепт {{recipe_id}}", + "refId": "A" + } + ], + "title": "Топ 10 наиболее используемых рецептов (за последний час)", + "type": "timeseries" + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": ["bylins", "business-logic", "mud"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Bylins MUD - Дашборд игровой логики", + "description": "Только для чтения. Чтобы внести изменения — клонируйте дашборд в Grafana (кнопка Save As).", + "uid": "bylins-business-logic", + "version": 0 +} diff --git a/tools/observability/dashboards/operational-dashboard.json b/tools/observability/dashboards/operational-dashboard.json new file mode 100644 index 000000000..613786447 --- /dev/null +++ b/tools/observability/dashboards/operational-dashboard.json @@ -0,0 +1,731 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "id": 999, + "type": "text", + "title": "", + "gridPos": {"h": 2, "w": 24, "x": 0, "y": 0}, + "options": { + "mode": "markdown", + "content": "> ⚠️ **Дашборд только для чтения.** Управляется из файла в репозитории. Чтобы внести изменения — используйте **Save As** (создайте копию)." + }, + "transparent": true + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "green", + "value": 10 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 2 + }, + "id": 1, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "players_online_count", + "refId": "A" + } + ], + "title": "Игроки онлайн", + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 2 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "players_in_combat_count", + "refId": "A" + } + ], + "title": "Игроки в бою", + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 2 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "last"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "players_online_count", + "legendFormat": "Онлайн", + "refId": "A" + }, + { + "expr": "players_in_combat_count", + "legendFormat": "В бою", + "refId": "B" + } + ], + "title": "Активность игроков", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "values": ["value"] + }, + "pieType": "pie", + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "displayLabels": ["percent"] + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "topk(10, players_by_level_remort_count)", + "legendFormat": "Уровень {{level}} Реморт {{remort}}", + "refId": "A" + } + ], + "title": "Распределение игроков по уровню и реморту (Топ 10)", + "type": "piechart" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "players_by_level_remort_count", + "legendFormat": "Ур{{level}}Р{{remort}}", + "refId": "A" + } + ], + "title": "Все игроки по уровню/реморту (текущие)", + "type": "barchart", + "options": { + "orientation": "horizontal", + "xField": "Time", + "legend": { + "displayMode": "hidden" + } + } + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(player_beat_update_duration_bucket[5m]))", + "legendFormat": "p95 Обновление хартбита", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, rate(player_beat_update_duration_bucket[5m]))", + "legendFormat": "p99 Обновление хартбита", + "refId": "B" + } + ], + "title": "Длительность обновления хартбита (HP/Мана/Движение)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["mean", "last"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "combat_active_count", + "legendFormat": "Активные бои", + "refId": "A" + }, + { + "expr": "mob_active_count", + "legendFormat": "Активные мобы", + "refId": "B" + }, + { + "expr": "auction_lots_active", + "legendFormat": "Лоты аукциона", + "refId": "C" + } + ], + "title": "Счётчики активности системы", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 30 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(combat_round_duration_bucket[5m]))", + "legendFormat": "p95 Раунд боя", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, rate(zone_update_duration_bucket[5m]))", + "legendFormat": "p95 Обновление зоны", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, rate(spell_cast_duration_bucket[5m]))", + "legendFormat": "p95 Чтение заклинания", + "refId": "C" + } + ], + "title": "Задержка ключевых операций (p95)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "displayMode": "auto" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.1 + }, + { + "color": "red", + "value": 0.5 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 30 + }, + "id": 9, + "options": { + "showHeader": true + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.99, rate(combat_round_duration_bucket[5m]))", + "format": "table", + "instant": true, + "legendFormat": "Раунд боя", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, rate(spell_cast_duration_bucket[5m]))", + "format": "table", + "instant": true, + "legendFormat": "Чтение заклинания", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, rate(zone_update_duration_bucket[5m]))", + "format": "table", + "instant": true, + "legendFormat": "Обновление зоны", + "refId": "C" + }, + { + "expr": "histogram_quantile(0.99, rate(mob_ai_duration_bucket[5m]))", + "format": "table", + "instant": true, + "legendFormat": "ИИ моба", + "refId": "D" + } + ], + "title": "Задержки p99 (текущие)", + "type": "table", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "job": true, + "instance": true + }, + "renameByName": { + "Value": "Длительность (с)" + } + } + } + ] + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": ["bylins", "operational", "players", "mud"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Bylins MUD - Операционный дашборд", + "description": "Только для чтения. Чтобы внести изменения — клонируйте дашборд в Grafana (кнопка Save As).", + "uid": "bylins-operational", + "version": 0 +} diff --git a/tools/observability/dashboards/performance-dashboard.json b/tools/observability/dashboards/performance-dashboard.json new file mode 100644 index 000000000..32ff78926 --- /dev/null +++ b/tools/observability/dashboards/performance-dashboard.json @@ -0,0 +1,741 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": false, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "id": 999, + "type": "text", + "title": "", + "gridPos": {"h": 2, "w": 24, "x": 0, "y": 0}, + "options": { + "mode": "markdown", + "content": "> ⚠️ **Дашборд только для чтения.** Управляется из файла в репозитории. Чтобы внести изменения — используйте **Save As** (создайте копию)." + }, + "transparent": true + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 2 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.50, rate(combat_round_duration_bucket[5m]))", + "legendFormat": "p50 Раунд боя", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, rate(combat_round_duration_bucket[5m]))", + "legendFormat": "p95 Раунд боя", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, rate(combat_round_duration_bucket[5m]))", + "legendFormat": "p99 Раунд боя", + "refId": "C" + } + ], + "title": "Длительность раунда боя (перцентили)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 2 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "combat_active_count", + "refId": "A" + } + ], + "title": "Активные бои", + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 2 + }, + "id": 3, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "mob_active_count", + "refId": "A" + } + ], + "title": "Активные мобы", + "type": "gauge" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(combat_rounds_total[5m])", + "legendFormat": "Раундов/сек", + "refId": "A" + }, + { + "expr": "rate(combat_hits_total[5m])", + "legendFormat": "Ударов/сек ({{result}})", + "refId": "B" + } + ], + "title": "Частота боевой активности", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(mob_ai_duration_bucket[5m]))", + "legendFormat": "p95 Длительность ИИ", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, rate(mob_ai_duration_bucket[5m]))", + "legendFormat": "p99 Длительность ИИ", + "refId": "B" + } + ], + "title": "Длительность обработки ИИ мобов", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(player_load_duration_bucket[5m]))", + "legendFormat": "p95 Загрузка", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, rate(player_save_duration_bucket{save_type=\"frac\"}[5m]))", + "legendFormat": "p95 Сохранение (частичное)", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.95, rate(player_save_duration_bucket{save_type=\"full\"}[5m]))", + "legendFormat": "p95 Сохранение (полное)", + "refId": "C" + } + ], + "title": "Длительность сохранения/загрузки игрока (p95)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 18 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(player_save_total[5m])", + "legendFormat": "Сохранений/сек ({{save_type}})", + "refId": "A" + } + ], + "title": "Частота сохранения игроков", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 26 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(zone_update_duration_bucket[5m]))", + "legendFormat": "p95 Обновление зоны", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.99, rate(zone_update_duration_bucket[5m]))", + "legendFormat": "p99 Обновление зоны", + "refId": "B" + } + ], + "title": "Длительность обновления зоны", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 26 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "pluginVersion": "8.0.0", + "targets": [ + { + "expr": "rate(zone_reset_total[5m])", + "legendFormat": "Сбросов зоны/сек", + "refId": "A" + } + ], + "title": "Частота сброса зон", + "type": "timeseries" + } + ], + "schemaVersion": 27, + "style": "dark", + "tags": ["bylins", "performance", "mud"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Bylins MUD - Дашборд производительности", + "description": "Только для чтения. Чтобы внести изменения — клонируйте дашборд в Grafana (кнопка Save As).", + "uid": "bylins-performance", + "version": 0 +} diff --git a/tools/observability/docker-compose.data-dir.yml b/tools/observability/docker-compose.data-dir.yml new file mode 100644 index 000000000..6b1432449 --- /dev/null +++ b/tools/observability/docker-compose.data-dir.yml @@ -0,0 +1,41 @@ +version: '3.8' + +# Override file: replaces named volumes with bind mounts to ${DATA_DIR}. +# Containers run as the current host user (UID:GID) so that written files +# are owned by the user who started the stack. +# +# Usage: +# export DATA_DIR=/var/lib/mud-observability +# mkdir -p $DATA_DIR/{prometheus,tempo,loki,grafana} +# docker-compose \ +# -f docker-compose.observability.yml \ +# -f docker-compose.data-dir.yml \ +# up -d +# +# Or put DATA_DIR in a .env file next to the compose files. + +services: + prometheus: + user: "${UID}:${GID}" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ${DATA_DIR}/prometheus:/prometheus + + tempo: + user: "${UID}:${GID}" + volumes: + - ./tempo-config.yaml:/etc/tempo.yaml:ro + - ${DATA_DIR}/tempo:/tmp/tempo + + loki: + user: "${UID}:${GID}" + volumes: + - ./loki-config.yaml:/etc/loki/config.yaml:ro + - ${DATA_DIR}/loki:/loki + + grafana: + user: "${UID}:${GID}" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./dashboards:/var/lib/grafana/dashboards:ro + - ${DATA_DIR}/grafana:/var/lib/grafana diff --git a/tools/observability/docker-compose.observability.yml b/tools/observability/docker-compose.observability.yml new file mode 100644 index 000000000..aeccf4998 --- /dev/null +++ b/tools/observability/docker-compose.observability.yml @@ -0,0 +1,98 @@ +version: '3.8' + +services: + # OpenTelemetry Collector + otel-collector: + image: otel/opentelemetry-collector-contrib:0.91.0 + container_name: mud-otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro + ports: + - "127.0.0.1:4317:4317" # OTLP gRPC receiver + - "127.0.0.1:4318:4318" # OTLP HTTP receiver + - "127.0.0.1:8888:8888" # Prometheus metrics (collector's own metrics) + - "127.0.0.1:13133:13133" # Health check + networks: + - observability + restart: unless-stopped + + # Prometheus (metrics) + prometheus: + image: prom/prometheus:v2.48.0 + container_name: mud-prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.enable-remote-write-receiver' + - '--enable-feature=exemplar-storage' + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + ports: + - "127.0.0.1:9090:9090" + networks: + - observability + restart: unless-stopped + + # Tempo (traces) + tempo: + image: grafana/tempo:2.3.1 + container_name: mud-tempo + command: ["-config.file=/etc/tempo.yaml"] + volumes: + - ./tempo-config.yaml:/etc/tempo.yaml:ro + - tempo-data:/tmp/tempo + ports: + - "127.0.0.1:3200:3200" # Tempo HTTP + networks: + - observability + restart: unless-stopped + + # Loki (logs) + loki: + image: grafana/loki:2.9.3 + container_name: mud-loki + command: -config.file=/etc/loki/config.yaml + volumes: + - ./loki-config.yaml:/etc/loki/config.yaml:ro + - loki-data:/loki + ports: + - "127.0.0.1:3100:3100" + networks: + - observability + restart: unless-stopped + + # Grafana (visualization) + grafana: + image: grafana/grafana:10.2.2 + container_name: mud-grafana + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + - GF_USERS_ALLOW_SIGN_UP=false + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./dashboards:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - "127.0.0.1:12000:3000" + networks: + - observability + depends_on: + - prometheus + - tempo + - loki + restart: unless-stopped + +volumes: + prometheus-data: + tempo-data: + loki-data: + grafana-data: + +networks: + observability: + driver: bridge diff --git a/tools/observability/grafana/provisioning/dashboards/dashboards.yml b/tools/observability/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 000000000..98702a6cd --- /dev/null +++ b/tools/observability/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Bylins MUD Dashboards' + orgId: 1 + folder: 'Bylins' + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/tools/observability/grafana/provisioning/datasources/datasources.yml b/tools/observability/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 000000000..f9fa4516a --- /dev/null +++ b/tools/observability/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,52 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + httpMethod: POST + timeInterval: 15s + exemplarTraceIdDestinations: + - name: trace_id + datasourceUid: tempo + + - name: Tempo + type: tempo + access: proxy + url: http://tempo:3200 + uid: tempo + editable: false + jsonData: + httpMethod: GET + tracesToLogs: + datasourceUid: loki + tags: ['trace_id', 'span_id'] + mappedTags: [{ key: 'service.name', value: 'service' }] + mapTagNamesEnabled: true + spanStartTimeShift: '-1m' + spanEndTimeShift: '1m' + tracesToMetrics: + datasourceUid: prometheus + tags: [{ key: 'service.name', value: 'service' }] + serviceMap: + datasourceUid: prometheus + nodeGraph: + enabled: true + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + uid: loki + editable: false + jsonData: + maxLines: 1000 + derivedFields: + - datasourceUid: tempo + matcherRegex: "trace_id=(\\w+)" + name: TraceID + url: '$${__value.raw}' diff --git a/tools/observability/install-otel-sdk.sh b/tools/observability/install-otel-sdk.sh new file mode 100755 index 000000000..65149a9f5 --- /dev/null +++ b/tools/observability/install-otel-sdk.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Install opentelemetry-cpp SDK required for building Bylins MUD with WITH_OTEL=ON. +# +# Usage: +# ./tools/install-otel-sdk.sh # vcpkg (default) +# ./tools/install-otel-sdk.sh --source # build from source +# +# After installation, build the server with: +# cmake -S . -B build_otel \ +# -DCMAKE_BUILD_TYPE=Release \ +# -DWITH_OTEL=ON \ +# -DCMAKE_TOOLCHAIN_FILE=~/vcpkg/scripts/buildsystems/vcpkg.cmake \ +# -DCMAKE_PREFIX_PATH=~/vcpkg/installed/x64-linux +# make -C build_otel -j$(($(nproc)/2)) +# +# Note: if you only deploy a pre-built binary, this script is not needed — +# opentelemetry-cpp is a build-time dependency only. + +set -e + +OTEL_VERSION="1.24.0" +VCPKG_DIR="${VCPKG_DIR:-$HOME/vcpkg}" +OTEL_INSTALL_PREFIX="${OTEL_INSTALL_PREFIX:-/usr/local}" + +METHOD="vcpkg" +if [ "$1" = "--source" ]; then + METHOD="source" +fi + +# ── vcpkg (primary) ────────────────────────────────────────────────────────── +install_via_vcpkg() { + for dep in curl zip unzip tar git cmake pkg-config; do + if ! command -v "$dep" >/dev/null 2>&1; then + echo "Error: '$dep' is not installed. Run:" + echo " sudo apt-get install curl zip unzip tar git build-essential cmake pkg-config" + exit 1 + fi + done + + if [ -f "$VCPKG_DIR/vcpkg" ]; then + echo "vcpkg found at $VCPKG_DIR" + elif [ -d "$VCPKG_DIR/.git" ]; then + echo "vcpkg repo found at $VCPKG_DIR, bootstrapping ..." + "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics + else + echo "Installing vcpkg to $VCPKG_DIR ..." + git clone https://github.com/microsoft/vcpkg "$VCPKG_DIR" + "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics + fi + + echo "Installing opentelemetry-cpp $OTEL_VERSION via vcpkg ..." + "$VCPKG_DIR/vcpkg" install "opentelemetry-cpp[otlp-http]:x64-linux" --recurse + + echo "" + echo "Done. Build the server with:" + echo " cmake -S . -B build_otel \\" + echo " -DCMAKE_BUILD_TYPE=Release \\" + echo " -DWITH_OTEL=ON \\" + echo " -DCMAKE_TOOLCHAIN_FILE=$VCPKG_DIR/scripts/buildsystems/vcpkg.cmake \\" + echo " -DCMAKE_PREFIX_PATH=$VCPKG_DIR/installed/x64-linux" +} + +# ── From source (alternative) ──────────────────────────────────────────────── +install_from_source() { + echo "Installing build dependencies ..." + sudo apt-get install -y \ + build-essential cmake \ + libssl-dev libcurl4-gnutls-dev \ + zlib1g-dev + + WORKDIR=$(mktemp -d) + trap 'rm -rf "$WORKDIR"' EXIT + + echo "Downloading opentelemetry-cpp $OTEL_VERSION ..." + wget -q -P "$WORKDIR" \ + "https://github.com/open-telemetry/opentelemetry-cpp/archive/refs/tags/v${OTEL_VERSION}.tar.gz" + tar xzf "$WORKDIR/v${OTEL_VERSION}.tar.gz" -C "$WORKDIR" + + echo "Building (this takes ~15 minutes) ..." + cmake -S "$WORKDIR/opentelemetry-cpp-${OTEL_VERSION}" -B "$WORKDIR/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$OTEL_INSTALL_PREFIX" \ + -DWITH_OTLP_HTTP=ON \ + -DWITH_OTLP_GRPC=OFF \ + -DBUILD_TESTING=OFF \ + -DWITH_BENCHMARK=OFF \ + -DWITH_EXAMPLES=OFF + cmake --build "$WORKDIR/build" -j"$(nproc)" + sudo cmake --install "$WORKDIR/build" + + echo "" + echo "Done. Build the server with:" + echo " cmake -S . -B build_otel \\" + echo " -DCMAKE_BUILD_TYPE=Release \\" + echo " -DWITH_OTEL=ON" + echo " (opentelemetry-cpp installed to $OTEL_INSTALL_PREFIX, found automatically)" +} + +case "$METHOD" in + vcpkg) install_via_vcpkg ;; + source) install_from_source ;; +esac diff --git a/tools/observability/loki-config.yaml b/tools/observability/loki-config.yaml new file mode 100644 index 000000000..783cdebe5 --- /dev/null +++ b/tools/observability/loki-config.yaml @@ -0,0 +1,48 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v12 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-shipper-active + cache_location: /loki/tsdb-shipper-cache + cache_ttl: 24h + shared_store: filesystem + filesystem: + directory: /loki/chunks + +limits_config: + reject_old_samples: true + reject_old_samples_max_age: 168h + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +chunk_store_config: + max_look_back_period: 8760h + +table_manager: + retention_deletes_enabled: true + retention_period: 8760h diff --git a/tools/observability/otel-collector-config.yaml b/tools/observability/otel-collector-config.yaml new file mode 100644 index 000000000..13612cb45 --- /dev/null +++ b/tools/observability/otel-collector-config.yaml @@ -0,0 +1,66 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 5s + send_batch_size: 512 + send_batch_max_size: 1024 + + resource: + attributes: + - key: service.name + value: bylins-mud + action: upsert + - key: deployment.environment + value: production + action: upsert + + memory_limiter: + check_interval: 1s + limit_mib: 512 + spike_limit_mib: 128 + +exporters: + prometheusremotewrite: + endpoint: http://prometheus:9090/api/v1/write + tls: + insecure: true + + otlp/tempo: + endpoint: tempo:4317 + tls: + insecure: true + + loki: + endpoint: http://loki:3100/loki/api/v1/push + tls: + insecure: true + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [memory_limiter, batch, resource] + exporters: [prometheusremotewrite] + + traces: + receivers: [otlp] + processors: [memory_limiter, batch, resource] + exporters: [otlp/tempo] + + logs: + receivers: [otlp] + processors: [memory_limiter, batch, resource] + exporters: [loki] + + telemetry: + logs: + level: info + metrics: + address: 0.0.0.0:8888 diff --git a/tools/observability/prometheus.yml b/tools/observability/prometheus.yml new file mode 100644 index 000000000..cf0ffb1a9 --- /dev/null +++ b/tools/observability/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: 'bylins-production' + environment: 'production' + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8888'] diff --git a/tools/observability/start.sh b/tools/observability/start.sh new file mode 100755 index 000000000..d5c67da70 --- /dev/null +++ b/tools/observability/start.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -e + +cd "$(dirname "$0")" + +# Fix config file permissions (containers run as non-root) +chmod 644 ./*.yml ./*.yaml 2>/dev/null || true +chmod 644 grafana/provisioning/datasources/*.yml 2>/dev/null || true +chmod 644 grafana/provisioning/dashboards/*.yml 2>/dev/null || true + +if [ $# -eq 0 ]; then + set -- up -d +fi + +if [ -n "$DATA_DIR" ]; then + echo "Using bind mounts in: $DATA_DIR" + mkdir -p "$DATA_DIR/prometheus" "$DATA_DIR/tempo" "$DATA_DIR/loki" "$DATA_DIR/grafana" + + export UID=$(id -u) + export GID=$(id -g) + + exec docker-compose \ + -f docker-compose.observability.yml \ + -f docker-compose.data-dir.yml \ + "$@" +else + echo "Using Docker named volumes (set DATA_DIR to use a host directory)" + + exec docker-compose \ + -f docker-compose.observability.yml \ + "$@" +fi diff --git a/tools/observability/tempo-config.yaml b/tools/observability/tempo-config.yaml new file mode 100644 index 000000000..7dc9d481d --- /dev/null +++ b/tools/observability/tempo-config.yaml @@ -0,0 +1,24 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + +ingester: + max_block_duration: 5m + +compactor: + compaction: + block_retention: 720h # 30 days + +storage: + trace: + backend: local + local: + path: /tmp/tempo/blocks + wal: + path: /tmp/tempo/wal diff --git a/tools/setup_test_dirs.sh b/tools/setup_test_dirs.sh new file mode 100755 index 000000000..11105e3ff --- /dev/null +++ b/tools/setup_test_dirs.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# +# Setup test directories for world loading performance tests +# Run from repository root: ./tools/setup_test_dirs.sh +# + +set -e + +MUD_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$MUD_DIR" + +echo "=== Setting up test directories ===" +echo "MUD_DIR: $MUD_DIR" +echo "" + +# Create test directories +TEST_DIR="$MUD_DIR/test" +mkdir -p "$TEST_DIR" + +# Small world source +SMALL_WORLD_SRC="$MUD_DIR/lib.template" + +# Full world source +FULL_WORLD_SRC="/home/kvirund/repos/full.world/lib" + +# Check sources exist +if [ ! -d "$SMALL_WORLD_SRC/world" ]; then + echo "ERROR: Small world source not found: $SMALL_WORLD_SRC/world" + exit 1 +fi + +if [ ! -d "$FULL_WORLD_SRC/world" ]; then + echo "ERROR: Full world source not found: $FULL_WORLD_SRC/world" + exit 1 +fi + +# Function to setup a test directory with symlinks +setup_test_dir() { + local name="$1" + local world_src="$2" + local use_sqlite="$3" + + local dir="$TEST_DIR/$name" + + echo "Setting up: $name" + rm -rf "$dir" + mkdir -p "$dir/log" + + # Symlink common directories from lib + ln -sf "$MUD_DIR/lib/cfg" "$dir/cfg" + ln -sf "$MUD_DIR/lib/etc" "$dir/etc" + ln -sf "$MUD_DIR/lib/misc" "$dir/misc" + ln -sf "$MUD_DIR/lib/stat" "$dir/stat" + ln -sf "$MUD_DIR/lib/text" "$dir/text" + ln -sf "$MUD_DIR/lib/plralias" "$dir/plralias" + ln -sf "$MUD_DIR/lib/plrobjs" "$dir/plrobjs" + ln -sf "$MUD_DIR/lib/plrs" "$dir/plrs" + ln -sf "$MUD_DIR/lib/plrstuff" "$dir/plrstuff" + ln -sf "$MUD_DIR/lib/plrvars" "$dir/plrvars" + + # Self-symlink for lib directory (server expects to chdir to lib) + ln -sf . "$dir/lib" + + if [ "$use_sqlite" = "sqlite" ]; then + # Convert to SQLite + echo " Converting to SQLite..." + python3 "$MUD_DIR/tools/convert_to_yaml.py" \ + --input "$world_src" \ + --output "$dir" \ + --format sqlite \ + --db "$dir/world.db" \ + 2>&1 | tail -5 + else + # Symlink world directory for legacy + ln -sf "$world_src/world" "$dir/world" + fi + + echo " Done: $dir" +} + +# Setup all test directories +echo "" +echo "=== Creating test directories ===" +setup_test_dir "small_legacy" "$SMALL_WORLD_SRC" "legacy" +setup_test_dir "small_sqlite" "$SMALL_WORLD_SRC" "sqlite" +setup_test_dir "full_legacy" "$FULL_WORLD_SRC" "legacy" +setup_test_dir "full_sqlite" "$FULL_WORLD_SRC" "sqlite" + +echo "" +echo "=== Test directories ready ===" +ls -la "$TEST_DIR" + +echo "" +echo "=== Database sizes ===" +ls -lh "$TEST_DIR"/*/world.db 2>/dev/null || echo "No SQLite databases yet" + +echo "" +echo "Done. Now rebuild binaries:" +echo "" +echo " # Legacy build (no SQLite)" +echo " cd build_test && make circle -j\$(nproc)" +echo "" +echo " # SQLite build" +echo " cd build_sqlite && make circle -j\$(nproc)" +echo "" +echo "Then run tests:" +echo " ./tools/run_load_tests.sh" diff --git a/tools/test_convert_to_yaml.py b/tools/test_convert_to_yaml.py new file mode 100644 index 000000000..78e264a7a --- /dev/null +++ b/tools/test_convert_to_yaml.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +Unit tests for convert_to_yaml.py +Run with: python3 -m pytest tools/test_convert_to_yaml.py -v +Or: python3 tools/test_convert_to_yaml.py +""" + +import unittest +import sys +import os + +# Add tools directory to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from convert_to_yaml import ( + parse_ascii_flags, + ROOM_FLAGS, + ACTION_FLAGS, + AFFECT_FLAGS, + EXTRA_FLAGS, + WEAR_FLAGS, + ANTI_FLAGS, + NO_FLAGS, +) + + +class TestParseAsciiFlags(unittest.TestCase): + """Test the parse_ascii_flags function.""" + + def test_empty_flags(self): + """Empty or zero flags should return empty list.""" + self.assertEqual(parse_ascii_flags('', ROOM_FLAGS), []) + self.assertEqual(parse_ascii_flags('0', ROOM_FLAGS), []) + + def test_single_plane0_flag(self): + """Single flag in plane 0.""" + # 'a0' = bit 0, plane 0 = index 0 = kDarked + result = parse_ascii_flags('a0', ROOM_FLAGS) + self.assertIn('kDarked', result) + + def test_multiple_plane0_flags(self): + """Multiple flags in plane 0.""" + # d0 = bit 3, plane 0 = kIndoors + # e0 = bit 4, plane 0 = kPeaceful + result = parse_ascii_flags('d0e0', ROOM_FLAGS) + self.assertIn('kIndoors', result) + self.assertIn('kPeaceful', result) + + def test_plane1_flags(self): + """Flags in plane 1.""" + # a1 = bit 0, plane 1 = index 30 = kNoSummonOut + result = parse_ascii_flags('a1', ROOM_FLAGS) + self.assertIn('kNoSummonOut', result) + + def test_plane2_flags(self): + """Flags in plane 2 (kNoItem, kDominationArena).""" + # a2 = bit 0, plane 2 = index 60 = kNoItem + result = parse_ascii_flags('a2', ROOM_FLAGS) + self.assertIn('kNoItem', result) + + def test_mixed_plane_flags(self): + """Mix of flags from different planes.""" + # d0 = kIndoors (plane 0) + # a1 = kNoSummonOut (plane 1) + # a2 = kNoItem (plane 2) + result = parse_ascii_flags('d0a1a2', ROOM_FLAGS) + self.assertIn('kIndoors', result) + self.assertIn('kNoSummonOut', result) + self.assertIn('kNoItem', result) + + def test_numeric_flags(self): + """Numeric flag format.""" + # 8 = bit 3 = kIndoors + result = parse_ascii_flags('8', ROOM_FLAGS) + self.assertIn('kIndoors', result) + + def test_room101_flags(self): + """Test actual room 101 flags from small world: c0d0e0f0g0h0j0a1b1d1g1a2.""" + flags = 'c0d0e0f0g0h0j0a1b1d1g1a2' + result = parse_ascii_flags(flags, ROOM_FLAGS) + + # Expected flags based on the format + expected = [ + 'kNoEntryMob', # c0 = bit 2 + 'kIndoors', # d0 = bit 3 + 'kPeaceful', # e0 = bit 4 + 'kSoundproof', # f0 = bit 5 + 'kNoTrack', # g0 = bit 6 + 'kNoMagic', # h0 = bit 7 + 'kNoTeleportIn', # j0 = bit 9 + 'kNoSummonOut', # a1 = bit 0 plane 1 + 'kNoTeleportOut', # b1 = bit 1 plane 1 + 'kNoWeather', # d1 = bit 3 plane 1 + 'kNoRelocateIn', # g1 = bit 6 plane 1 + 'kNoItem', # a2 = bit 0 plane 2 + ] + + for flag in expected: + self.assertIn(flag, result, f"Missing flag: {flag}") + + +class TestZoneCommandParsing(unittest.TestCase): + """Test zone command parsing.""" + + def test_equip_mob_with_load_prob(self): + """E command should parse load_prob field.""" + # This tests that the converter correctly parses: + # E if_flag obj_vnum max wear_pos load_prob + # We can't directly test parse_zone_file here, but we document the expected behavior + pass # TODO: Add integration test for zone file parsing + + +class TestRoomFlagsArray(unittest.TestCase): + """Test that ROOM_FLAGS array has correct structure.""" + + def test_plane0_size(self): + """Plane 0 should have 30 flags (indices 0-29).""" + # First 30 entries should be plane 0 flags + self.assertTrue(len(ROOM_FLAGS) >= 30) + self.assertEqual(ROOM_FLAGS[0], 'kDarked') + self.assertEqual(ROOM_FLAGS[29], 'kArena') + + def test_plane1_starts_at_30(self): + """Plane 1 should start at index 30.""" + self.assertTrue(len(ROOM_FLAGS) >= 43) + self.assertEqual(ROOM_FLAGS[30], 'kNoSummonOut') + + def test_plane2_flags_accessible(self): + """Plane 2 flags should be at indices 60+.""" + # The array needs padding or special handling for plane 2 + # If using offset 60 for plane 2, array needs 62+ elements + # Current implementation may have flags at indices 43-44 + # This test documents the expected behavior after fix + if len(ROOM_FLAGS) > 60: + self.assertEqual(ROOM_FLAGS[60], 'kNoItem') + self.assertEqual(ROOM_FLAGS[61], 'kDominationArena') + else: + # Current state: flags at 43-44, need padding + self.skipTest("ROOM_FLAGS needs padding for plane 2 offset") + + +class TestObjectFlagsArrays(unittest.TestCase): + """Test object flag arrays structure.""" + + def test_anti_flags_plane2(self): + """ANTI_FLAGS should have kCharmice at correct position.""" + # kCharmice is in plane 2, bit 8 + # With offset 60, index should be 60 + 8 = 68 + if len(ANTI_FLAGS) > 68: + self.assertEqual(ANTI_FLAGS[68], 'kCharmice') + else: + self.skipTest("ANTI_FLAGS needs padding for plane 2") + + def test_no_flags_plane2(self): + """NO_FLAGS should have kCharmice at correct position.""" + if len(NO_FLAGS) > 68: + self.assertEqual(NO_FLAGS[68], 'kCharmice') + else: + self.skipTest("NO_FLAGS needs padding for plane 2") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tools/world_schema.sql b/tools/world_schema.sql new file mode 100644 index 000000000..a17aaaa31 --- /dev/null +++ b/tools/world_schema.sql @@ -0,0 +1,558 @@ +-- SQLite schema for MUD world data +-- Normalized tables (3NF) with convenient views + +-- Note: Foreign keys disabled during import to allow any insertion order +-- Enable with: PRAGMA foreign_keys = ON; after import is complete + +-- ============================================================================ +-- Reference tables (Enums) +-- ============================================================================ + +-- Object types +CREATE TABLE obj_types ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +); + +-- Sector types +CREATE TABLE sectors ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +); + +-- Position types +CREATE TABLE positions ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Gender types +CREATE TABLE genders ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Direction types +CREATE TABLE directions ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE -- kNorth, kEast, kSouth, kWest, kUp, kDown +); + +-- Skills +CREATE TABLE skills ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +); + +-- Apply locations +CREATE TABLE apply_locations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +); + +-- Wear positions +CREATE TABLE wear_positions ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Trigger attach types +CREATE TABLE trigger_attach_types ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE -- kMob, kObj, kRoom +); + +-- Trigger type definitions (predefined set) +-- char_code is the original file character, bit_value is 1 << bit_position +CREATE TABLE trigger_type_defs ( + char_code TEXT PRIMARY KEY, -- 'c', 'g', 'A', etc. + name TEXT NOT NULL UNIQUE, -- 'kCommand', 'kGreet', etc. + bit_value INTEGER NOT NULL, -- 4, 64, etc. + description TEXT +); + +-- ============================================================================ +-- Main entity tables +-- ============================================================================ + +-- Zones +CREATE TABLE zones ( + vnum INTEGER PRIMARY KEY, + name TEXT NOT NULL, + -- Metadata + comment TEXT, + location TEXT, + author TEXT, + description TEXT, + builders TEXT, + -- Parameters + first_room INTEGER, + top_room INTEGER, + mode INTEGER DEFAULT 0, + zone_type INTEGER DEFAULT 0, + zone_group INTEGER DEFAULT 1, + entrance INTEGER, + lifespan INTEGER DEFAULT 10, + reset_mode INTEGER DEFAULT 2, + reset_idle INTEGER DEFAULT 0, + under_construction INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1 +); + +-- Zone grouping (typeA/typeB) +CREATE TABLE zone_groups ( + zone_vnum INTEGER NOT NULL, + linked_zone_vnum INTEGER NOT NULL, + group_type TEXT NOT NULL CHECK(group_type IN ('A', 'B')), + PRIMARY KEY (zone_vnum, linked_zone_vnum, group_type), + FOREIGN KEY (zone_vnum) REFERENCES zones(vnum) ON DELETE CASCADE +); + +-- Mobs +CREATE TABLE mobs ( + vnum INTEGER PRIMARY KEY, + -- Names (6 Russian cases) + aliases TEXT, + name_nom TEXT, -- nominative + name_gen TEXT, -- genitive + name_dat TEXT, -- dative + name_acc TEXT, -- accusative + name_ins TEXT, -- instrumental + name_pre TEXT, -- prepositional + -- Descriptions + short_desc TEXT, + long_desc TEXT, + -- Base parameters + alignment INTEGER DEFAULT 0, + mob_type TEXT DEFAULT 'S', -- 'S' or 'E' + -- Stats + level INTEGER DEFAULT 1, + hitroll_penalty INTEGER DEFAULT 0, + armor INTEGER DEFAULT 100, + -- HP dice + hp_dice_count INTEGER DEFAULT 1, + hp_dice_size INTEGER DEFAULT 1, + hp_bonus INTEGER DEFAULT 0, + -- Damage dice + dam_dice_count INTEGER DEFAULT 1, + dam_dice_size INTEGER DEFAULT 1, + dam_bonus INTEGER DEFAULT 0, + -- Gold dice + gold_dice_count INTEGER DEFAULT 0, + gold_dice_size INTEGER DEFAULT 0, + gold_bonus INTEGER DEFAULT 0, + -- Experience + experience INTEGER DEFAULT 0, + -- Position + default_pos INTEGER REFERENCES positions(id), + start_pos INTEGER REFERENCES positions(id), + -- Appearance + sex INTEGER REFERENCES genders(id), + size INTEGER DEFAULT 50, + height INTEGER DEFAULT 170, + weight INTEGER DEFAULT 70, + -- Class/Race + mob_class INTEGER, + race INTEGER, + -- Attributes (E-spec) + attr_str INTEGER DEFAULT 11, + attr_dex INTEGER DEFAULT 11, + attr_int INTEGER DEFAULT 11, + attr_wis INTEGER DEFAULT 11, + attr_con INTEGER DEFAULT 11, + attr_cha INTEGER DEFAULT 11, + enabled INTEGER DEFAULT 1 +); + +-- Mob flags (action_flags, affect_flags) +CREATE TABLE mob_flags ( + mob_vnum INTEGER NOT NULL, + flag_category TEXT NOT NULL, -- 'action' or 'affect' + flag_name TEXT NOT NULL, + PRIMARY KEY (mob_vnum, flag_category, flag_name), + FOREIGN KEY (mob_vnum) REFERENCES mobs(vnum) ON DELETE CASCADE +); + +-- Mob skills +CREATE TABLE mob_skills ( + mob_vnum INTEGER NOT NULL, + skill_id INTEGER NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (mob_vnum, skill_id), + FOREIGN KEY (mob_vnum) REFERENCES mobs(vnum) ON DELETE CASCADE, + FOREIGN KEY (skill_id) REFERENCES skills(id) +); + +-- Objects +CREATE TABLE objects ( + vnum INTEGER PRIMARY KEY, + -- Names (6 Russian cases) + aliases TEXT, + name_nom TEXT, + name_gen TEXT, + name_dat TEXT, + name_acc TEXT, + name_ins TEXT, + name_pre TEXT, + -- Descriptions + short_desc TEXT, + action_desc TEXT, + -- Type and material + obj_type_id INTEGER REFERENCES obj_types(id), + material INTEGER, + -- Values (interpretation depends on type) + value0 TEXT, + value1 TEXT, + value2 TEXT, + value3 TEXT, + -- Physical properties + weight INTEGER DEFAULT 0, + cost INTEGER DEFAULT 0, + rent_off INTEGER DEFAULT 0, + rent_on INTEGER DEFAULT 0, + spec_param INTEGER DEFAULT 0, + max_durability INTEGER DEFAULT -1, + cur_durability INTEGER DEFAULT -1, + timer INTEGER DEFAULT -1, + spell INTEGER DEFAULT -1, + level INTEGER DEFAULT 0, + sex INTEGER DEFAULT 0, + max_in_world INTEGER DEFAULT -1, + minimum_remorts INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1 +); + +-- Object flags +CREATE TABLE obj_flags ( + obj_vnum INTEGER NOT NULL, + flag_category TEXT NOT NULL, -- 'extra' or 'wear' + flag_name TEXT NOT NULL, + PRIMARY KEY (obj_vnum, flag_category, flag_name), + FOREIGN KEY (obj_vnum) REFERENCES objects(vnum) ON DELETE CASCADE +); + +-- Object applies +CREATE TABLE obj_applies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + obj_vnum INTEGER NOT NULL, + location_id INTEGER NOT NULL REFERENCES apply_locations(id), + modifier INTEGER NOT NULL, + FOREIGN KEY (obj_vnum) REFERENCES objects(vnum) ON DELETE CASCADE +); + +-- Rooms +CREATE TABLE rooms ( + vnum INTEGER PRIMARY KEY, + zone_vnum INTEGER REFERENCES zones(vnum), + name TEXT, + description TEXT, + sector_id INTEGER REFERENCES sectors(id), + enabled INTEGER DEFAULT 1 +); + +-- Room flags +CREATE TABLE room_flags ( + room_vnum INTEGER NOT NULL, + flag_name TEXT NOT NULL, + PRIMARY KEY (room_vnum, flag_name), + FOREIGN KEY (room_vnum) REFERENCES rooms(vnum) ON DELETE CASCADE +); + +-- Room exits +CREATE TABLE room_exits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_vnum INTEGER NOT NULL, + direction_id INTEGER NOT NULL REFERENCES directions(id), + description TEXT, + keywords TEXT, + exit_flags TEXT, + key_vnum INTEGER DEFAULT -1, + to_room INTEGER DEFAULT -1, + lock_complexity INTEGER DEFAULT 0, + UNIQUE(room_vnum, direction_id), + FOREIGN KEY (room_vnum) REFERENCES rooms(vnum) ON DELETE CASCADE +); + +-- Triggers +CREATE TABLE triggers ( + vnum INTEGER PRIMARY KEY, + name TEXT, + attach_type_id INTEGER REFERENCES trigger_attach_types(id), + narg INTEGER DEFAULT 0, + arglist TEXT, + script TEXT, + enabled INTEGER DEFAULT 1 +); + +-- Trigger type bindings (many-to-many: trigger can have multiple types) +CREATE TABLE trigger_type_bindings ( + trigger_vnum INTEGER NOT NULL, + type_char TEXT NOT NULL, + PRIMARY KEY (trigger_vnum, type_char), + FOREIGN KEY (trigger_vnum) REFERENCES triggers(vnum) ON DELETE CASCADE, + FOREIGN KEY (type_char) REFERENCES trigger_type_defs(char_code) +); + +-- Extra descriptions (shared for objects and rooms) +CREATE TABLE extra_descriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, -- 'obj' or 'room' + entity_vnum INTEGER NOT NULL, + keywords TEXT NOT NULL, + description TEXT +); + +-- Trigger bindings to entities +CREATE TABLE entity_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, -- 'mob', 'obj', 'room' + entity_vnum INTEGER NOT NULL, + trigger_vnum INTEGER NOT NULL, + trigger_order INTEGER DEFAULT 0, + FOREIGN KEY (trigger_vnum) REFERENCES triggers(vnum) +); + +-- Zone commands +CREATE TABLE zone_commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_vnum INTEGER NOT NULL, + cmd_order INTEGER NOT NULL, -- execution order + cmd_type TEXT NOT NULL, -- LOAD_MOB, LOAD_OBJ, GIVE_OBJ, EQUIP_MOB, PUT_OBJ, DOOR, REMOVE_OBJ, FOLLOW, TRIGGER, VARIABLE, EXTRACT_MOB + if_flag INTEGER DEFAULT 0, + -- Universal fields (used depending on cmd_type) + arg_mob_vnum INTEGER, + arg_obj_vnum INTEGER, + arg_room_vnum INTEGER, + arg_trigger_vnum INTEGER, + arg_container_vnum INTEGER, + arg_max INTEGER, + arg_max_world INTEGER, + arg_max_room INTEGER, + arg_load_prob INTEGER, + arg_wear_pos_id INTEGER REFERENCES wear_positions(id), + arg_direction_id INTEGER REFERENCES directions(id), + arg_state INTEGER, + arg_trigger_type TEXT, + arg_context INTEGER, + arg_var_name TEXT, + arg_var_value TEXT, + arg_leader_mob_vnum INTEGER, + arg_follower_mob_vnum INTEGER, + FOREIGN KEY (zone_vnum) REFERENCES zones(vnum) ON DELETE CASCADE +); + +-- ============================================================================ +-- Indexes for performance +-- ============================================================================ + +CREATE INDEX idx_mob_flags_vnum ON mob_flags(mob_vnum); +CREATE INDEX idx_mob_skills_vnum ON mob_skills(mob_vnum); +CREATE INDEX idx_obj_flags_vnum ON obj_flags(obj_vnum); +CREATE INDEX idx_obj_applies_vnum ON obj_applies(obj_vnum); +CREATE INDEX idx_room_flags_vnum ON room_flags(room_vnum); +CREATE INDEX idx_room_exits_vnum ON room_exits(room_vnum); +CREATE INDEX idx_room_exits_to ON room_exits(to_room); +CREATE INDEX idx_extra_desc_entity ON extra_descriptions(entity_type, entity_vnum); +CREATE INDEX idx_entity_triggers ON entity_triggers(entity_type, entity_vnum); +CREATE INDEX idx_zone_commands_zone ON zone_commands(zone_vnum, cmd_order); +CREATE INDEX idx_rooms_zone ON rooms(zone_vnum); +CREATE INDEX idx_trigger_type_bindings ON trigger_type_bindings(trigger_vnum); + +-- ============================================================================ +-- Views for convenient browsing +-- ============================================================================ + +-- Full mob information +CREATE VIEW v_mobs AS +SELECT + m.vnum, + m.name_nom AS name, + m.aliases, + m.short_desc, + m.level, + m.alignment, + m.default_pos, + m.sex, + m.hp_dice_count || 'd' || m.hp_dice_size || '+' || m.hp_bonus AS hp_dice, + m.dam_dice_count || 'd' || m.dam_dice_size || '+' || m.dam_bonus AS damage_dice, + m.experience, + m.mob_class, + m.race, + m.attr_str, m.attr_dex, m.attr_int, m.attr_wis, m.attr_con, m.attr_cha, + GROUP_CONCAT(DISTINCT CASE WHEN mf.flag_category = 'action' THEN mf.flag_name END) AS action_flags, + GROUP_CONCAT(DISTINCT CASE WHEN mf.flag_category = 'affect' THEN mf.flag_name END) AS affect_flags +FROM mobs m +LEFT JOIN mob_flags mf ON m.vnum = mf.mob_vnum +GROUP BY m.vnum; + +-- Mob skills +CREATE VIEW v_mob_skills AS +SELECT + ms.mob_vnum, + m.name_nom AS mob_name, + s.name AS skill_name, + ms.value AS skill_value +FROM mob_skills ms +JOIN mobs m ON ms.mob_vnum = m.vnum +JOIN skills s ON ms.skill_id = s.id +ORDER BY ms.mob_vnum, s.name; + +-- Full object information +CREATE VIEW v_objects AS +SELECT + o.vnum, + o.name_nom AS name, + o.aliases, + o.short_desc, + ot.name AS obj_type, + o.weight, + o.cost, + o.level, + o.max_durability, + o.timer, + o.max_in_world, + GROUP_CONCAT(DISTINCT CASE WHEN of.flag_category = 'extra' THEN of.flag_name END) AS extra_flags, + GROUP_CONCAT(DISTINCT CASE WHEN of.flag_category = 'wear' THEN of.flag_name END) AS wear_flags +FROM objects o +LEFT JOIN obj_types ot ON o.obj_type_id = ot.id +LEFT JOIN obj_flags of ON o.vnum = of.obj_vnum +GROUP BY o.vnum; + +-- Object applies +CREATE VIEW v_obj_applies AS +SELECT + oa.obj_vnum, + o.name_nom AS obj_name, + al.name AS apply_location, + oa.modifier +FROM obj_applies oa +JOIN objects o ON oa.obj_vnum = o.vnum +JOIN apply_locations al ON oa.location_id = al.id +ORDER BY oa.obj_vnum, al.name; + +-- Full room information +CREATE VIEW v_rooms AS +SELECT + r.vnum, + r.name, + r.zone_vnum, + z.name AS zone_name, + s.name AS sector, + r.description, + GROUP_CONCAT(DISTINCT rf.flag_name) AS room_flags +FROM rooms r +LEFT JOIN zones z ON r.zone_vnum = z.vnum +LEFT JOIN sectors s ON r.sector_id = s.id +LEFT JOIN room_flags rf ON r.vnum = rf.room_vnum +GROUP BY r.vnum; + +-- Room exits +CREATE VIEW v_room_exits AS +SELECT + re.room_vnum, + r.name AS room_name, + d.name AS direction, + re.to_room, + r2.name AS destination_name, + re.exit_flags, + re.key_vnum, + re.lock_complexity +FROM room_exits re +JOIN rooms r ON re.room_vnum = r.vnum +JOIN directions d ON re.direction_id = d.id +LEFT JOIN rooms r2 ON re.to_room = r2.vnum +ORDER BY re.room_vnum, d.id; + +-- Zone information +CREATE VIEW v_zones AS +SELECT + z.vnum, + z.name, + z.author, + z.location, + z.first_room, + z.top_room, + z.lifespan, + z.reset_mode, + (SELECT COUNT(*) FROM rooms WHERE zone_vnum = z.vnum) AS room_count, + GROUP_CONCAT(DISTINCT CASE WHEN zg.group_type = 'A' THEN zg.linked_zone_vnum END) AS typeA_zones, + GROUP_CONCAT(DISTINCT CASE WHEN zg.group_type = 'B' THEN zg.linked_zone_vnum END) AS typeB_zones +FROM zones z +LEFT JOIN zone_groups zg ON z.vnum = zg.zone_vnum +GROUP BY z.vnum; + +-- Zone commands (human-readable format) +CREATE VIEW v_zone_commands AS +SELECT + zc.zone_vnum, + z.name AS zone_name, + zc.cmd_order, + zc.cmd_type, + zc.if_flag, + CASE zc.cmd_type + WHEN 'LOAD_MOB' THEN 'Load mob ' || zc.arg_mob_vnum || ' in room ' || zc.arg_room_vnum || ' (max ' || zc.arg_max_world || '/' || zc.arg_max_room || ')' + WHEN 'LOAD_OBJ' THEN 'Load obj ' || zc.arg_obj_vnum || ' in room ' || zc.arg_room_vnum || ' (max ' || zc.arg_max || ', prob ' || zc.arg_load_prob || '%)' + WHEN 'GIVE_OBJ' THEN 'Give obj ' || zc.arg_obj_vnum || ' to last mob' + WHEN 'EQUIP_MOB' THEN 'Equip obj ' || zc.arg_obj_vnum || ' on last mob at pos ' || wp.name + WHEN 'PUT_OBJ' THEN 'Put obj ' || zc.arg_obj_vnum || ' in container ' || zc.arg_container_vnum + WHEN 'DOOR' THEN 'Set door in room ' || zc.arg_room_vnum || ' dir ' || dir.name || ' to state ' || zc.arg_state + WHEN 'FOLLOW' THEN 'Mob ' || zc.arg_follower_mob_vnum || ' follows ' || zc.arg_leader_mob_vnum || ' in room ' || zc.arg_room_vnum + WHEN 'TRIGGER' THEN 'Attach trigger ' || zc.arg_trigger_vnum || ' to entity' + ELSE zc.cmd_type + END AS description +FROM zone_commands zc +JOIN zones z ON zc.zone_vnum = z.vnum +LEFT JOIN wear_positions wp ON zc.arg_wear_pos_id = wp.id +LEFT JOIN directions dir ON zc.arg_direction_id = dir.id +ORDER BY zc.zone_vnum, zc.cmd_order; + +-- Triggers +CREATE VIEW v_triggers AS +SELECT + t.vnum, + t.name, + tat.name AS attach_type, + t.narg, + t.arglist, + GROUP_CONCAT(ttb.type_char, '') AS type_chars, + GROUP_CONCAT(ttd.name) AS trigger_types, + LENGTH(t.script) AS script_length +FROM triggers t +LEFT JOIN trigger_attach_types tat ON t.attach_type_id = tat.id +LEFT JOIN trigger_type_bindings ttb ON t.vnum = ttb.trigger_vnum +LEFT JOIN trigger_type_defs ttd ON ttb.type_char = ttd.char_code +GROUP BY t.vnum; + +-- Triggers attached to entities +CREATE VIEW v_entity_triggers AS +SELECT + et.entity_type, + et.entity_vnum, + CASE et.entity_type + WHEN 'mob' THEN m.name_nom + WHEN 'obj' THEN o.name_nom + WHEN 'room' THEN r.name + END AS entity_name, + et.trigger_vnum, + t.name AS trigger_name +FROM entity_triggers et +LEFT JOIN mobs m ON et.entity_type = 'mob' AND et.entity_vnum = m.vnum +LEFT JOIN objects o ON et.entity_type = 'obj' AND et.entity_vnum = o.vnum +LEFT JOIN rooms r ON et.entity_type = 'room' AND et.entity_vnum = r.vnum +JOIN triggers t ON et.trigger_vnum = t.vnum +ORDER BY et.entity_type, et.entity_vnum; + +-- World statistics +CREATE VIEW v_world_stats AS +SELECT + (SELECT COUNT(*) FROM zones) AS total_zones, + (SELECT COUNT(*) FROM rooms) AS total_rooms, + (SELECT COUNT(*) FROM mobs) AS total_mobs, + (SELECT COUNT(*) FROM objects) AS total_objects, + (SELECT COUNT(*) FROM triggers) AS total_triggers, + (SELECT COUNT(*) FROM zone_commands) AS total_zone_commands; +