From af8f69da1307d2ef9f199a48cd279ea783cfcec0 Mon Sep 17 00:00:00 2001 From: CabinetOnFire Date: Wed, 13 May 2026 14:37:56 +0200 Subject: [PATCH] Adds "Event Logging", or EVLogging, A new debug system that allows us to track individual datums and log events on a timeline (#96035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## About The Pull Request This Pull Request adds a new logging system that uses a timeline to track and visualize important events for specific datums. This is done via a new window in which you can select a datum for tracking, which adds it to the timeline. If this datum implements the EVLOGGING macros, it can track important events onto this timeline. As an example, we can log whenever an AI is deciding to make a new path, if it decides to generate a new decisionmaking plan, it finishes an action, or it decides to target someone/something. We can select these events to see more information, and optionally get a snapshot of important variables at the time this event was logged (like the blackboard and current plan for AI controllers). You can also filter out specific events / track info, which is done via categories. Each event / piece of track info is given a category and if you disable a category all events / track info in that category is hidden. This lets you filter out things you might not care about. image _whoever thinks we didnt need advanced cleanbot logging is a noob_ In the video below I showcase how this works; https://file.house/7nsOiqdvmSTxlsk3fs-e8g==.mp4 A cleanbot is roaming the halls, I turn on the event logger, click the "pick target" button and click on the datum I'd like to track (the cleanbot). This results in the cleanbot now tracking its events. I spawn some dirt and the cleanbot decides to clean it, and I go through the events; You can see theres different events being listed, such as when the cleanbot starts targetting the dirt, when it cleans plan, when it makes it JPS path and every time it moves over it. The macros I've currently implemented are as follows: **EVLOG_TEXT(DATUM, CATEGORY, INFO)** Only adds text to the event logger window, no world-visuals EVLOG_LOCATION(DATUM, CATEGORY, INFO, TURF) Adds text to the event logger and adds an image to where that turf is. EVLOG_TURFS(DATUM, CATEGORY, INFO, TURFS) Adds text to the event logger and adds an image to each turf in the TURFS list EVLOG_LINES(DATUM, CATEGORY, INFO, TURF_A, TURF_B) Adds text to the event logger and adds a line from turf_a to turf_B EVLOG_PATH(DATUM, CATEGORY, INFO, TURFS) Adds text to the event logger and visualizes a path from A to B (same way as the pathfinding debugger, of which I moved the visualization before to SSPathfinder) In terms of performance, the logger is a singleton, and events are ONLY logged if 1. The logger is running 2. The datum has the DF_EVLOGGING flag. This means most of the time, logging an event is a single var lookup (Since the runner is off by default). The DF_EVLOGGING flag is off by default as well and has to be enabled by the event logger, or set temporarily by a dev in code. This system can easily be extended with more event types / visualization types as well. (I'm thinking of datumizing the ones I have now) The TGUI is still a bit of a mess, I would love some pointers because I'm not really good at react so I just kind of hit it with a hammer until it did what I wanted 😎 Also, all of this is based on VisLogging from Unreal Engine, so it will have some likeness https://unreal-garden.com/tutorials/visual-logger/ ## Why It's Good For The Game This system allows us to debug more complex systems (like basic AI) in an understandable and clear way. While the implementation cases are not super common right now, extending this system could make debugging these systems much more comprehensible, and hopefully lets more developers help us with improving these systems. (plus, we LOVE timelines) ## Changelog :cl: CabinetOnFire refactor: Implements "Event Logging" an improved way for programmers to debug specific datums. /:cl: --------- Co-authored-by: Lucy --- code/__DEFINES/_flags.dm | 2 + code/__DEFINES/dcs/signals/signals_datum.dm | 8 + code/__DEFINES/event_logger.dm | 61 ++ .../subsystem/movement/movement_types.dm | 9 + code/controllers/subsystem/pathfinder.dm | 33 ++ code/datums/ai/_ai_behavior.dm | 1 + code/datums/ai/_ai_controller.dm | 64 +- .../basic_ai_behaviors/targeting.dm | 6 + .../basic_mobs/basic_subtrees/mine_walls.dm | 1 + code/datums/ai/generic/find_and_set.dm | 3 + code/datums/ai/generic/generic_behaviors.dm | 1 + .../ai/hunting_behavior/hunting_behaviors.dm | 2 + code/datums/ai/monkey/monkey_behaviors.dm | 7 +- code/modules/admin/admin_verbs.dm | 4 + code/modules/admin/event_logger.dm | 551 ++++++++++++++++++ code/modules/admin/verbs/path_debugger.dm | 16 +- code/modules/asset_cache/assets/tgui.dm | 6 + code/modules/mob/living/basic/bots/bot_ai.dm | 4 + icons/turf/debug.dmi | Bin 1359 -> 1412 bytes tgstation.dme | 2 + tgui/packages/tgui-chat-dark/index.ts | 1 + .../interfaces/EventLogger/CategoryBar.tsx | 35 ++ .../interfaces/EventLogger/ControlBar.tsx | 63 ++ .../tgui/interfaces/EventLogger/InfoPanel.tsx | 150 +++++ .../EventLogger/SelectedEventsPanel.tsx | 117 ++++ .../tgui/interfaces/EventLogger/Timeline.tsx | 432 ++++++++++++++ .../tgui/interfaces/EventLogger/index.tsx | 253 ++++++++ .../tgui/interfaces/EventLogger/types.ts | 140 +++++ .../tgui/styles/interfaces/EventLogger.scss | 16 + tgui/packages/tgui/styles/main.scss | 1 + tgui/rspack.config.ts | 1 + 31 files changed, 1974 insertions(+), 16 deletions(-) create mode 100644 code/__DEFINES/event_logger.dm create mode 100644 code/modules/admin/event_logger.dm create mode 100644 tgui/packages/tgui-chat-dark/index.ts create mode 100644 tgui/packages/tgui/interfaces/EventLogger/CategoryBar.tsx create mode 100644 tgui/packages/tgui/interfaces/EventLogger/ControlBar.tsx create mode 100644 tgui/packages/tgui/interfaces/EventLogger/InfoPanel.tsx create mode 100644 tgui/packages/tgui/interfaces/EventLogger/SelectedEventsPanel.tsx create mode 100644 tgui/packages/tgui/interfaces/EventLogger/Timeline.tsx create mode 100644 tgui/packages/tgui/interfaces/EventLogger/index.tsx create mode 100644 tgui/packages/tgui/interfaces/EventLogger/types.ts create mode 100644 tgui/packages/tgui/styles/interfaces/EventLogger.scss diff --git a/code/__DEFINES/_flags.dm b/code/__DEFINES/_flags.dm index e54b939404c..a0019a66cba 100644 --- a/code/__DEFINES/_flags.dm +++ b/code/__DEFINES/_flags.dm @@ -13,6 +13,8 @@ GLOBAL_LIST_INIT(bitflags, list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 204 #define DF_ISPROCESSING (1<<2) /// Placed on datums that have a static, constant reference. Primarily only used for turfs. #define DF_STATIC_OBJECT (1<<3) +/// Set on datums that should be tracked by the event logger. +#define DF_EVLOGGING (1<<4) //FLAGS BITMASK // scroll down before changing the numbers on these diff --git a/code/__DEFINES/dcs/signals/signals_datum.dm b/code/__DEFINES/dcs/signals/signals_datum.dm index 3668d202a7f..e381979907d 100644 --- a/code/__DEFINES/dcs/signals/signals_datum.dm +++ b/code/__DEFINES/dcs/signals/signals_datum.dm @@ -26,6 +26,14 @@ /// from datum/tgui/get_payload(user, list/data) #define COMSIG_UI_DATA "ui_data" +/// fired on a datum when event-logger tracking is enabled on it [/datum/proc/enable_evlogging]: () +#define COMSIG_EVLOGGING_ENABLED "evlogging_enabled" +/// fired on a datum when event-logger tracking is disabled on it [/datum/proc/disable_evlogging]: () +#define COMSIG_EVLOGGING_DISABLED "evlogging_disabled" + +/// fired on the source datum whenever an event is added for it in the event logger: (/datum/event_logger_track/track, list/event_data) +#define COMSIG_EVLOG_EVENT_ADDED "evlog_event_added" + /// fires on the target datum when an element is attached to it (/datum/element) #define COMSIG_ELEMENT_ATTACH "element_attach" /// fires on the target datum when an element is detached from it (/datum/element) diff --git a/code/__DEFINES/event_logger.dm b/code/__DEFINES/event_logger.dm new file mode 100644 index 00000000000..42cd0a21271 --- /dev/null +++ b/code/__DEFINES/event_logger.dm @@ -0,0 +1,61 @@ +// Event Logger macros. The logging functions only run if the logger is active and the datum being logged has the DF_EVLOGGING flag set. This makes logging itself relatively cheap when not in use. +/// Log a plain text event. +#define EVLOG_TEXT(DATUM, CATEGORY, INFO) \ + if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \ + { GLOB.event_logger.log_event_text(DATUM, CATEGORY, INFO) } + +/// Log a location event that highlights a single tile when selected. +/// TURF is a /turf. DATUM must have DF_EVLOGGING set. +#define EVLOG_LOCATION(DATUM, CATEGORY, INFO, TURF) \ + if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \ + { GLOB.event_logger.log_event_location(DATUM, CATEGORY, INFO, TURF) } + +/// Log a turfs event that highlights a set of tiles when selected. +/// TURFS is a list of /turf. DATUM must have DF_EVLOGGING set. +#define EVLOG_TURFS(DATUM, CATEGORY, INFO, TURFS) \ + if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \ + { GLOB.event_logger.log_event_turfs(DATUM, CATEGORY, INFO, TURFS) } + +/// Log a line event that highlights a path from one tile to another when selected. +/// TURF_A and TURF_B are /turf. DATUM must have DF_EVLOGGING set. +#define EVLOG_LINES(DATUM, CATEGORY, INFO, TURF_A, TURF_B) \ + if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \ + { GLOB.event_logger.log_event_lines(DATUM, CATEGORY, INFO, TURF_A, TURF_B) } + +/// Log a path event that renders directional arrows + start/end markers when selected. +/// TURFS is an ordered list of /turf. DATUM must have DF_EVLOGGING set. +#define EVLOG_PATH(DATUM, CATEGORY, INFO, TURFS) \ + if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \ + { GLOB.event_logger.log_event_path(DATUM, CATEGORY, INFO, TURFS) } + +/// Log a maptext event that renders a floating text string at a turf when selected. +/// TURF is a /turf. TEXT is the string to display. DATUM must have DF_EVLOGGING set. +#define EVLOG_MAPTEXT(DATUM, CATEGORY, INFO, TURF, TEXT) \ + if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \ + { GLOB.event_logger.log_event_maptext(DATUM, CATEGORY, INFO, TURF, TEXT) } + + +/// Append a categorized title and entry to a track_info snapshot list. +#define EVLOG_TRACK_INFO_ENTRY(LIST, CATEGORY, KEY, VALUE) \ + LIST += list(list("category" = CATEGORY, "title" = KEY, "entry" = VALUE)) + +#define IS_EVLOGGING GLOB.event_logger.running + + +///All the types of log entries we have. + +#define EVLOG_TYPE_TEXT "text" +#define EVLOG_TYPE_LOCATION "location" +#define EVLOG_TYPE_TURFS "turfs" +#define EVLOG_TYPE_LINES "lines" +#define EVLOG_TYPE_PATH "path" +#define EVLOG_TYPE_MAPTEXT "maptext" + + + +///Categories go here. Put sane names in the string since they get displayed. +#define EVLOG_CATEGORY_MOVELOOPS "Moveloops" +#define EVLOG_CATEGORY_JPS "JPS" +#define EVLOG_CATEGORY_AI_DECISIONMAKING "AI Decisionmaking" +#define EVLOG_CATEGORY_AI_BEHAVIORS "AI Behaviors" +#define EVLOG_CATEGORY_AI_TARGETING "AI Targeting" diff --git a/code/controllers/subsystem/movement/movement_types.dm b/code/controllers/subsystem/movement/movement_types.dm index 25a2b88e12b..e83f68d22db 100644 --- a/code/controllers/subsystem/movement/movement_types.dm +++ b/code/controllers/subsystem/movement/movement_types.dm @@ -74,6 +74,7 @@ /datum/move_loop/proc/loop_stopped() SHOULD_CALL_PARENT(TRUE) status &= ~MOVELOOP_STATUS_RUNNING + EVLOG_TEXT(moving, EVLOG_CATEGORY_MOVELOOPS, "Moveloop stopped") SEND_SIGNAL(src, COMSIG_MOVELOOP_STOP) /datum/move_loop/proc/info_deleted(datum/source) @@ -127,6 +128,10 @@ owner?.processing_move_loop_flags = flags var/result = move() //Result is an enum value. Enums defined in __DEFINES/movement.dm + + if(result) + EVLOG_PATH(moving, EVLOG_CATEGORY_MOVELOOPS, "Moved using [src]", list(old_loc, moving.loc)) //You might think, this runs a lot; but if not logging, it only does a lookup on the event logger. + if(moving) var/direction = get_dir(old_loc, moving.loc) SEND_SIGNAL(moving, COMSIG_MOVABLE_MOVED_FROM_LOOP, src, old_dir, direction) @@ -414,6 +419,7 @@ . = ..() movement_path = null + /datum/move_loop/has_target/jps/Destroy() avoid = null on_finish_callbacks = null @@ -432,6 +438,7 @@ /datum/move_loop/has_target/jps/proc/on_finish_pathing(list/path) movement_path = path is_pathing = FALSE + EVLOG_PATH(moving, EVLOG_CATEGORY_JPS, "Planned AI path", movement_path) SEND_SIGNAL(src, COMSIG_MOVELOOP_JPS_FINISHED_PATHING, path) /datum/move_loop/has_target/jps/move() @@ -439,6 +446,7 @@ if(is_pathing) return MOVELOOP_NOT_READY else + EVLOG_TEXT(moving, EVLOG_CATEGORY_JPS, "Path recalculating due to lack of path") INVOKE_ASYNC(src, PROC_REF(recalculate_path)) return MOVELOOP_FAILURE @@ -453,6 +461,7 @@ if(length(movement_path)) movement_path.Cut(1,2) else + EVLOG_TEXT(moving, EVLOG_CATEGORY_MOVELOOPS, "Path recalculating due to obstruction") INVOKE_ASYNC(src, PROC_REF(recalculate_path)) return MOVELOOP_FAILURE diff --git a/code/controllers/subsystem/pathfinder.dm b/code/controllers/subsystem/pathfinder.dm index b67260853ba..bcb9861fbb6 100644 --- a/code/controllers/subsystem/pathfinder.dm +++ b/code/controllers/subsystem/pathfinder.dm @@ -185,6 +185,39 @@ SUBSYSTEM_DEF(pathfinder) return constructing return null +/// Builds a single directional arrow image for a path step visualization +/datum/controller/subsystem/pathfinder/proc/render_path_arrow(turf/draw, direction) + var/image/arrow = image('icons/turf/debug.dmi', draw, "arrow", PATH_ARROW_DEBUG_LAYER, direction) + SET_PLANE_EXPLICIT(arrow, BALLOON_CHAT_PLANE, draw) + return arrow + +///Renders a full path of arrows, showing the direction taken from each tile. +/datum/controller/subsystem/pathfinder/proc/render_path_images(list/turf/draw_list) + if(!length(draw_list)) + return list() + var/list/image/turf_images = list() + for(var/i in 1 to (length(draw_list) - 1)) + var/turf/T = draw_list[i] + var/turf/next = draw_list[i + 1] + turf_images += render_path_arrow(T, get_dir(T, next)) + return turf_images + +/// Renders a full path visualisation: start marker, directional arrows along the path, end marker. +/datum/controller/subsystem/pathfinder/proc/render_path_images_full(list/turf/draw_list) + if(!length(draw_list)) + return list() + var/list/image/turf_images = list() + var/turf/first = draw_list[1] + var/image/start = image('icons/turf/debug.dmi', first, "start", PATH_DEBUG_LAYER) + SET_PLANE_EXPLICIT(start, BALLOON_CHAT_PLANE, first) + turf_images += start + turf_images += render_path_images(draw_list) + var/turf/last = draw_list[length(draw_list)] + var/image/end = image('icons/turf/debug.dmi', last, "end", PATH_DEBUG_LAYER) + SET_PLANE_EXPLICIT(end, BALLOON_CHAT_PLANE, last) + turf_images += end + return turf_images + /// Takes a set of pathfind info, returns all valid pathmaps that would work /// Takes an optional minimum range arg /datum/controller/subsystem/pathfinder/proc/get_valid_maps(datum/can_pass_info/pass_info, turf/target, simulated_only = TRUE, turf/exclude, age = MAP_REUSE_INSTANT, min_range = -INFINITY, include_building = FALSE) diff --git a/code/datums/ai/_ai_behavior.dm b/code/datums/ai/_ai_behavior.dm index 4a277c0e861..a73ca167214 100644 --- a/code/datums/ai/_ai_behavior.dm +++ b/code/datums/ai/_ai_behavior.dm @@ -33,6 +33,7 @@ return clear_movement_target(controller) controller.ai_movement.stop_moving_towards(controller) + EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_BEHAVIORS, "[controller.pawn] has [succeeded ? "succeeded" : "failed"] at performing [src]", get_turf(controller.pawn), "Behavior finished: [succeeded ? "Success" : "Failure"]") /// Helper proc to ensure consistency in setting the source of the movement target /datum/ai_behavior/proc/set_movement_target(datum/ai_controller/controller, atom/target, datum/ai_movement/new_movement) diff --git a/code/datums/ai/_ai_controller.dm b/code/datums/ai/_ai_controller.dm index 509d86668b9..dc14b7633cd 100644 --- a/code/datums/ai/_ai_controller.dm +++ b/code/datums/ai/_ai_controller.dm @@ -70,6 +70,7 @@ multiple modular subtrees with behaviors /// are we currently on failed planning timeout? var/on_failed_planning_timeout = FALSE + /datum/ai_controller/New(atom/new_pawn) change_ai_movement_type(ai_movement) init_subtrees() @@ -156,6 +157,8 @@ multiple modular subtrees with behaviors RegisterSignal(pawn, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_changed)) RegisterSignal(pawn, COMSIG_MOB_LOGIN, PROC_REF(on_sentience_gained)) RegisterSignal(pawn, COMSIG_QDELETING, PROC_REF(on_pawn_qdeleted)) + RegisterSignal(pawn, COMSIG_EVLOGGING_ENABLED, PROC_REF(on_pawn_evlogging_enabled)) + RegisterSignal(pawn, COMSIG_EVLOGGING_DISABLED, PROC_REF(on_pawn_evlogging_disabled)) update_able_to_run() setup_able_to_run() @@ -305,7 +308,7 @@ multiple modular subtrees with behaviors SEND_SIGNAL(src, COMSIG_AI_CONTROLLER_UNPOSSESSED_PAWN) set_ai_status(AI_STATUS_OFF) - UnregisterSignal(pawn, list(COMSIG_MOVABLE_Z_CHANGED, COMSIG_MOB_LOGIN, COMSIG_MOB_LOGOUT, COMSIG_MOB_STATCHANGE, COMSIG_QDELETING)) + UnregisterSignal(pawn, list(COMSIG_MOVABLE_Z_CHANGED, COMSIG_MOB_LOGIN, COMSIG_MOB_LOGOUT, COMSIG_MOB_STATCHANGE, COMSIG_QDELETING, COMSIG_EVLOGGING_ENABLED)) clear_able_to_run() if(ai_movement.moving_controllers[src]) ai_movement.stop_moving_towards(src) @@ -427,6 +430,15 @@ multiple modular subtrees with behaviors arguments += stored_arguments forgotten_behavior.finish_action(arglist(arguments)) + if(IS_EVLOGGING) + var/list/event_text = list() + event_text += "New plan starting!" + + for(var/datum/ai_behavior/behavior in planned_behaviors) + event_text += "Queued behavior [behavior.type]" + + EVLOG_TEXT(src, EVLOG_CATEGORY_AI_DECISIONMAKING, jointext(event_text, "\n")) + ///This proc handles changing ai status, and starts/stops processing if required. /datum/ai_controller/proc/set_ai_status(new_ai_status, additional_flags = NONE) if(ai_status == new_ai_status) @@ -563,6 +575,7 @@ multiple modular subtrees with behaviors return for(var/datum/ai_behavior/current_behavior as anything in current_behaviors) fail_behavior(current_behavior) + EVLOG_TEXT(src, EVLOG_CATEGORY_AI_DECISIONMAKING, "Actions were cancelled!") /datum/ai_controller/proc/fail_behavior(datum/ai_behavior/current_behavior) var/list/arguments = list(src, FALSE) @@ -944,6 +957,55 @@ multiple modular subtrees with behaviors index += 1 +/// When the pawn gets DF_EVLOGGING, propagate it to this controller too. +/datum/ai_controller/proc/on_pawn_evlogging_enabled(datum/source) + SIGNAL_HANDLER + enable_evlogging(pawn) + +/// When the pawn gets DF_EVLOGGING disabled, propagate it to this controller too. +/datum/ai_controller/proc/on_pawn_evlogging_disabled(datum/source) + SIGNAL_HANDLER + disable_evlogging(pawn) + +///Register for an event being added so we can update track info +/datum/ai_controller/enable_evlogging() + . = ..() + RegisterSignal(src, COMSIG_EVLOG_EVENT_ADDED, PROC_REF(on_evlog_event_added)) + +///Unregister the evlog event added event, as we're no longer updating track info +/datum/ai_controller/disable_evlogging() + . = ..() + UnregisterSignal(src, COMSIG_EVLOG_EVENT_ADDED) + +/// Called whenever an event is logged for this controller. Attaches a snapshot of current behaviors and blackboard state to the event via track_info. +/datum/ai_controller/proc/on_evlog_event_added(datum/source, datum/event_logger_track/track, list/event_data) + SIGNAL_HANDLER + var/list/track_info = list() + + var/list/current_behavior_info = list() + for(var/datum/ai_behavior/behavior in planned_behaviors) + if(behavior in current_behaviors) //Not really ideal; we should find a better way to do this. + current_behavior_info += "ACTIVE: [span_bold("[behavior.type]")]" + else + current_behavior_info += "[behavior.type]" + EVLOG_TRACK_INFO_ENTRY(track_info, "Behaviors", "Current Behavior", jointext(current_behavior_info, "\n")) + + for(var/blackboard_key_name, blackboard_value in blackboard) + var/value_string + if(isatom(blackboard_value)) + value_string = "[blackboard_value]" + else if(islist(blackboard_value)) + var/list/blackboard_list = blackboard_value + value_string = length(blackboard_list) ? jointext(blackboard_list, "\n") : "Empty List" + else if(isnull(blackboard_value)) + value_string = "null" + else // I think I covered all cases? + value_string = "[blackboard_value]" + EVLOG_TRACK_INFO_ENTRY(track_info, "Blackboard", blackboard_key_name, value_string) + + event_data["track_info"] = track_info + + #undef TRACK_AI_DATUM_TARGET #undef CLEAR_AI_DATUM_TARGET #undef TRAIT_AI_TRACKING diff --git a/code/datums/ai/basic_mobs/basic_ai_behaviors/targeting.dm b/code/datums/ai/basic_mobs/basic_ai_behaviors/targeting.dm index de9697902d2..bf7d128fef9 100644 --- a/code/datums/ai/basic_mobs/basic_ai_behaviors/targeting.dm +++ b/code/datums/ai/basic_mobs/basic_ai_behaviors/targeting.dm @@ -73,6 +73,10 @@ GLOBAL_LIST_INIT(target_interested_atoms, typecacheof(list(/mob, /obj/machinery/ return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED var/atom/target = pick_final_target(controller, filtered_targets) + + EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [target] as a target for blackboard key [target_key]! Behavior: [src]", get_turf(target), "Target: [target]") + EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(target)) + controller.set_blackboard_key(target_key, target) controller.set_blackboard_key(BB_BASIC_MOB_TARGET_REFRESH_COOLDOWN, world.time + priority_refresh_cooldown) @@ -147,6 +151,8 @@ GLOBAL_LIST_INIT(target_interested_atoms, typecacheof(list(/mob, /obj/machinery/ // Alright, we found something acceptable, let's use it yeah? var/atom/target = pick_final_target(controller, accepted_targets) + EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [target] as a target for blackboard key [target_key]! Behavior: [src]", get_turf(target), "Target: [target]") + EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(target)) controller.set_blackboard_key(target_key, target) var/atom/potential_hiding_location = strategy.find_hidden_mobs(pawn, target) diff --git a/code/datums/ai/basic_mobs/basic_subtrees/mine_walls.dm b/code/datums/ai/basic_mobs/basic_subtrees/mine_walls.dm index 12875f9a3f3..6edc631c7a1 100644 --- a/code/datums/ai/basic_mobs/basic_subtrees/mine_walls.dm +++ b/code/datums/ai/basic_mobs/basic_subtrees/mine_walls.dm @@ -21,6 +21,7 @@ set_movement_target(controller, target) /datum/ai_behavior/mine_wall/perform(seconds_per_tick, datum/ai_controller/controller, target_key) + . = ..() var/mob/living/basic/living_pawn = controller.pawn var/turf/closed/mineral/target = controller.blackboard[target_key] var/is_gibtonite_turf = istype(target, /turf/closed/mineral/gibtonite) diff --git a/code/datums/ai/generic/find_and_set.dm b/code/datums/ai/generic/find_and_set.dm index 9ec033e0402..ced56912c91 100644 --- a/code/datums/ai/generic/find_and_set.dm +++ b/code/datums/ai/generic/find_and_set.dm @@ -14,6 +14,9 @@ var/find_this_thing = search_tactic(controller, locate_path, search_range) if(isnull(find_this_thing)) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED + + EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [find_this_thing] as a target for blackboard key [set_key]! Behavior: [src]", get_turf(find_this_thing), "Target: [find_this_thing]") + EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(find_this_thing)) controller.set_blackboard_key(set_key, find_this_thing) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED diff --git a/code/datums/ai/generic/generic_behaviors.dm b/code/datums/ai/generic/generic_behaviors.dm index da338fa45cb..d0e8c25c9d7 100644 --- a/code/datums/ai/generic/generic_behaviors.dm +++ b/code/datums/ai/generic/generic_behaviors.dm @@ -301,6 +301,7 @@ /datum/ai_behavior/perform_emote /datum/ai_behavior/perform_emote/perform(seconds_per_tick, datum/ai_controller/controller, emote, speech_sound) + . = ..() var/mob/living/living_pawn = controller.pawn if(!istype(living_pawn)) return AI_BEHAVIOR_INSTANT diff --git a/code/datums/ai/hunting_behavior/hunting_behaviors.dm b/code/datums/ai/hunting_behavior/hunting_behaviors.dm index db684921281..1095919c058 100644 --- a/code/datums/ai/hunting_behavior/hunting_behaviors.dm +++ b/code/datums/ai/hunting_behavior/hunting_behaviors.dm @@ -55,6 +55,8 @@ if(!valid_dinner(living_mob, possible_dinner, hunt_range, controller, seconds_per_tick)) continue controller.set_blackboard_key(hunting_target_key, possible_dinner) + EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [possible_dinner] as a target for blackboard key [hunting_target_key]! Behavior: [src]", get_turf(possible_dinner), "Target: [possible_dinner]") + EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(possible_dinner)) return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED diff --git a/code/datums/ai/monkey/monkey_behaviors.dm b/code/datums/ai/monkey/monkey_behaviors.dm index c21d15e8a19..865f6a069ea 100644 --- a/code/datums/ai/monkey/monkey_behaviors.dm +++ b/code/datums/ai/monkey/monkey_behaviors.dm @@ -296,5 +296,10 @@ if(!length(valids)) return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED - controller.set_blackboard_key(set_key, pick_weight(valids)) + var/mob/living/target = pick_weight(valids) + + EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [target] as a target for blackboard key [set_key]! Behavior: [src]", get_turf(target), "Target: [target]") + EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(target)) + + controller.set_blackboard_key(set_key, target) return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index b7b3f874325..62f07be0679 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -865,6 +865,9 @@ ADMIN_VERB(give_ai_speech, R_FUN, "Give Random AI Speech", ADMIN_VERB_NO_DESCRIP return our_controller.planning_subtrees = list(GLOB.ai_subtrees[/datum/ai_planning_subtree/random_speech/blackboard]) + our_controller.planning_subtrees +ADMIN_VERB(open_event_logger, R_DEBUG, "Open Event Logger", "Open the event logger interface.", ADMIN_CATEGORY_DEBUG) + GLOB.event_logger.ui_interact(user.mob) + ADMIN_VERB(new_blackmarket_item, R_BUILD, "Create Black Market Item", "Add an item to the black market for purchase.", ADMIN_CATEGORY_EVENTS, object as text) if(!object) to_chat(user, span_boldwarning("Failed! Provide a full or partial typepath!")) @@ -912,4 +915,5 @@ ADMIN_VERB(new_blackmarket_item, R_BUILD, "Create Black Market Item", "Add an it BLACKBOX_LOG_ADMIN_VERB("Create Black Market Item") + #undef STEALTH_MODE_TRAIT diff --git a/code/modules/admin/event_logger.dm b/code/modules/admin/event_logger.dm new file mode 100644 index 00000000000..cd943db8d30 --- /dev/null +++ b/code/modules/admin/event_logger.dm @@ -0,0 +1,551 @@ +/// Global event logger datum, accessible anywhere for debug logging. +GLOBAL_DATUM_INIT(event_logger, /datum/event_logger, new()) + +/// Enables event logging for a datum. If display_on is supplied then it will report the events as if theyre happening on that datum +/datum/proc/enable_evlogging(datum/display_on = null) + if(display_on) + GLOB.event_logger.display_map[REF(src)] = REF(display_on) + if(datum_flags & DF_EVLOGGING) + return + datum_flags |= DF_EVLOGGING + SEND_SIGNAL(src, COMSIG_EVLOGGING_ENABLED) + +/// Disables event logging for a datum. If display_on was supplied during enable_evlogging, it will also remove the display mapping. +/datum/proc/disable_evlogging() + GLOB.event_logger.display_map -= REF(src) + if(!(datum_flags & DF_EVLOGGING)) + return + datum_flags &= ~DF_EVLOGGING + SEND_SIGNAL(src, COMSIG_EVLOGGING_DISABLED) + + +///Datum that holds info for a single track of events in the logger. Each track represents a datum that is being tracked. This track is shown in the timeline and when selected shows information on the track. +/datum/event_logger_track + /// Display name for this track row. + var/name + /// String ref key used to identify this track. + var/ref + /// List of event assoc lists: id, tick, category, kind, info, + kind-specific fields. + var/list/events = list() + /// Assoc list of string (Title) -> string (Info) displayed in the info panel when this track is selected. + var/list/info = list() + +/datum/event_logger_track/New(track_ref, track_name, list/info_data = list()) + ref = track_ref + name = track_name + info = info_data + +/datum/event_logger_track/Destroy() + events = null + info = null + return ..() + +///Datum for the event logger. One of these is made, and everyone shares it. So be nice. This keeps tracks of everything related to the logger +/datum/event_logger + /// Whether the logger is actively recording. + var/running = FALSE + /// world.time at which logging first started (null until first start). + var/time_start = null + /// Assoc list: category name -> enabled (TRUE/FALSE). + var/list/categories = list() + /// Assoc list: category name -> hex color, assigned when a category is first seen. + var/list/category_colors = list() + + /// List of colors we use for categories. We loop around once we run out + var/list/category_palette = list( + "#4fc3f7", "#81c784", "#ffb74d", "#e57373", "#ba68c8", + "#4dd0e1", "#fff176", "#f06292", "#a1887f", "#90a4ae", + ) + /// Assoc list: ref string -> /datum/event_logger_track + var/list/tracks = list() + /// Assoc list: string event id -> event assoc list, for quick look-up when selecting events + var/list/events_by_id = list() + /// Incrementing id assigned to each logged event. + var/next_event_id = 0 + /// Ref string of the currently selected track (for the info panel). + var/selected_ref = null + /// Assoc list: mob -> list of /image, for overlay cleanup. + var/list/user_overlays = list() + /// The mob currently waiting to click a target for pick-target mode, or null. + var/mob/awaiting_pick_user = null + /// Assoc list: REF(source datum) -> REF(display datum). Events from the source appear under the display datum's track. + var/list/display_map = list() + +/datum/event_logger/Destroy() + _clear_all_overlays() + QDEL_LIST_ASSOC_VAL(tracks) + tracks = null + categories = null + user_overlays = null + display_map = null + return ..() + +/datum/event_logger/proc/start() + if(running) + return + running = TRUE + if(isnull(time_start)) + time_start = world.time + +/datum/event_logger/proc/stop() + running = FALSE + +/// Enter pick-target mode: the next atom the user clicks gets DF_EVLOGGING set. +/datum/event_logger/proc/toggle_pick_target(mob/user) + if(awaiting_pick_user) + _end_pick_target() + else + awaiting_pick_user = user + RegisterSignal(user, COMSIG_MOB_CLICKON, PROC_REF(on_pick_target_click)) + +/// Signal handler: fired when the user clicks something while in pick-target mode. +/datum/event_logger/proc/on_pick_target_click(mob/source, atom/clicked, list/modifiers) + SIGNAL_HANDLER + clicked.enable_evlogging() + _end_pick_target() + return NONE + +/// Ends pick-target mode, unregistering the click signal and clearing the awaiting_pick_user var. +/datum/event_logger/proc/_end_pick_target() + if(isnull(awaiting_pick_user)) + return + UnregisterSignal(awaiting_pick_user, COMSIG_MOB_CLICKON) + awaiting_pick_user = null + +/// Ensures a track exists for ref_string. Safe to call multiple times. +/datum/event_logger/proc/add_track(ref_string, track_name, list/info_data) + if(tracks[ref_string]) + return + tracks[ref_string] = new /datum/event_logger_track(ref_string, track_name, info_data) + +/// Internal: Sets the event up, making a track if necesary. Also turns the instance into a REF() at this point +/datum/event_logger/proc/_add_event(datum/source, list/event_data) + if(!running) + return + // Resolve display routing: if source has a display_on target, log under that datum's track instead + var/datum/display_target + var/display_ref = display_map[REF(source)] + if(display_ref) + display_target = locate(display_ref) + if(!display_target) // display_on was deleted, remove the stale entry. Yeah this is a bit lame, but I felt like registering QDEL might be more of a hassle if we're using REF() anyway. Feel free to @ me over it + display_map -= REF(source) + + var/datum/track_datum = display_target || source + var/ref_string = REF(track_datum) + if(!tracks[ref_string]) + var/track_name + if(isatom(track_datum)) + var/atom/resolved_atom = track_datum + track_name = "[ref_string] [resolved_atom] [track_datum.type]" + else + track_name = "[ref_string] [track_datum.type]" + add_track(ref_string, track_name, null) + var/datum/event_logger_track/track = tracks[ref_string] + event_data["id"] = ++next_event_id + event_data["tick"] = world.time + var/category = event_data["category"] + if(!isnull(category) && isnull(categories[category])) + categories[category] = TRUE + + var/selected_index = (length(category_colors) + 1) % (length(category_palette) + 1) + + category_colors[category] = category_palette[selected_index] + track.events += list(event_data) + events_by_id["[event_data["id"]]"] = event_data + SEND_SIGNAL(source, COMSIG_EVLOG_EVENT_ADDED, track, event_data) + if(display_target) + SEND_SIGNAL(display_target, COMSIG_EVLOG_EVENT_ADDED, track, event_data) + +/// Log a plain text event. Has no world-visuals, just puts text into the menu +/datum/event_logger/proc/log_event_text(datum/source, category, info_string) + _add_event(source, list( + "log_type" = EVLOG_TYPE_TEXT, + "category" = category, + "info" = info_string, + )) + +/// Log a location event (highlights a single tile). I should remove this one as turfs does the same thing essentially +/datum/event_logger/proc/log_event_location(datum/source, category, info_string, turf/T) + _add_event(source, list( + "log_type" = EVLOG_TYPE_LOCATION, + "category" = category, + "info" = info_string, + "x" = T.x, + "y" = T.y, + "z" = T.z, + )) + +/// Log a turfs event (highlights a set of tiles). +/datum/event_logger/proc/log_event_turfs(datum/source, category, info_string, list/turfs) + var/list/coords = list() + for(var/turf/T as anything in turfs) + coords += list(list("x" = T.x, "y" = T.y, "z" = T.z)) + _add_event(source, list( + "log_type" = EVLOG_TYPE_TURFS, + "category" = category, + "info" = info_string, + "coords" = coords, + )) + +/// Log a line event (draws a line between 2 turfs). +/datum/event_logger/proc/log_event_lines(datum/source, category, info_string, turf/A, turf/B) + _add_event(source, list( + "log_type" = EVLOG_TYPE_LINES, + "category" = category, + "info" = info_string, + "x1" = A.x, + "y1" = A.y, + "z1" = A.z, + "x2" = B.x, + "y2" = B.y, + "z2" = B.z, + )) + +/// Log a path event (renders directional arrows + start/end markers for a list of turfs in order from start to finish). +/datum/event_logger/proc/log_event_path(datum/source, category, info_string, list/turfs) + var/list/coords = list() + for(var/turf/T as anything in turfs) + coords += list(list("x" = T.x, "y" = T.y, "z" = T.z)) + _add_event(source, list( + "log_type" = EVLOG_TYPE_PATH, + "category" = category, + "info" = info_string, + "coords" = coords, + )) + +/// Log a maptext event (renders a floating text label at the turf when selected). text_string is the string to display at the turf. +/datum/event_logger/proc/log_event_maptext(datum/source, category, info_string, turf/T, text_string) + _add_event(source, list( + "log_type" = EVLOG_TYPE_MAPTEXT, + "category" = category, + "info" = info_string, + "x" = T.x, + "y" = T.y, + "z" = T.z, + "text" = text_string, + )) + +/// Remove all overlays for a specific user. +/datum/event_logger/proc/_clear_user_overlays(mob/user) + if(!user_overlays[user]) + return + if(user.client) + user.client.images -= user_overlays[user] + user_overlays -= user + +/// Remove all overlays for all users. +/datum/event_logger/proc/_clear_all_overlays() + for(var/mob/user as anything in user_overlays) + _clear_user_overlays(user) + +/// Push world-space highlight overlays to the user's client for the given events. +/datum/event_logger/proc/_apply_event_overlays(mob/user, list/events_to_show) + _clear_user_overlays(user) + if(!user.client || !length(events_to_show)) + return + + var/list/images = list() + + for(var/list/evt as anything in events_to_show) + var/color = get_category_color(evt["category"]) + var/log_type = evt["log_type"] + + if(log_type == EVLOG_TYPE_LOCATION) + var/turf/found_turf = locate(evt["x"], evt["y"], evt["z"]) + if(found_turf) + images += _make_tile_image(found_turf, "box_overlay", color, 0.3) + + else if(log_type == EVLOG_TYPE_TURFS) + var/list/coords = evt["coords"] + for(var/list/coord as anything in coords) + var/turf/found_turf = locate(coord["x"], coord["y"], coord["z"]) + if(found_turf) + images += _make_tile_image(found_turf, "box_overlay", color, 0.3) + + else if(log_type == EVLOG_TYPE_LINES) + var/turf/turf_A = locate(evt["x1"], evt["y1"], evt["z1"]) + var/turf/turf_B = locate(evt["x2"], evt["y2"], evt["z2"]) + if(turf_A && turf_B && turf_A != turf_B) + images += _make_line_images(turf_A, turf_B, color) + + //Use SSPathfinder to render the full path + else if(log_type == EVLOG_TYPE_PATH) + var/list/path_turfs = list() + var/list/coords = evt["coords"] + for(var/list/coord as anything in coords) + var/turf/found_turf = locate(coord["x"], coord["y"], coord["z"]) + if(found_turf) + path_turfs += found_turf + images += SSpathfinder.render_path_images_full(path_turfs) + + //Draw maptext on a turf which lets us show important events in-world + else if(log_type == EVLOG_TYPE_MAPTEXT) + var/turf/found_turf = locate(evt["x"], evt["y"], evt["z"]) + if(found_turf) + var/image/img = image('icons/turf/debug.dmi', found_turf, "circle", PATH_DEBUG_LAYER) + SET_PLANE_EXPLICIT(img, BALLOON_CHAT_PLANE, found_turf) + img.color = color + img.maptext = MAPTEXT(evt["text"]) + img.maptext_width = 200 + img.maptext_height = 64 + img.maptext_x = 0 + img.maptext_y = 32 + images += img + + if(length(images)) + user_overlays[user] = images + user.client.images += images + +///Draw a line of images similar to beams but client-side. I couldn't find anything like this yet so here we are. Maybe making this a global is a good idea. +/datum/event_logger/proc/_make_line_images(turf/turf_A, turf/turf_B, color) + var/list/images = list() + var/beam_icon = 'icons/turf/debug.dmi' + var/beam_icon_state = "beam" + + var/origin_px = turf_A.pixel_x + turf_A.pixel_w + var/origin_py = turf_A.pixel_y + turf_A.pixel_z + var/target_px = turf_B.pixel_x + turf_B.pixel_w + var/target_py = turf_B.pixel_y + turf_B.pixel_z + + var/Angle = get_angle_raw(turf_A.x, turf_A.y, origin_px, origin_py, turf_B.x, turf_B.y, target_px, target_py) + var/matrix/rot_matrix = matrix() + rot_matrix.Turn(Angle) + + var/DX = (ICON_SIZE_X * turf_B.x + target_px) - (ICON_SIZE_X * turf_A.x + origin_px) + var/DY = (ICON_SIZE_Y * turf_B.y + target_py) - (ICON_SIZE_Y * turf_A.y + origin_py) + var/line_length = round(sqrt(DX ** 2 + DY ** 2)) + + for(var/N in 0 to line_length - 1 step 32) + var/Pixel_x + var/Pixel_y + if(DX == 0) + Pixel_x = 0 + else + Pixel_x = round(sin(Angle) + ICON_SIZE_X * sin(Angle) * (N + 16) / 32) + if(DY == 0) + Pixel_y = 0 + else + Pixel_y = round(cos(Angle) + ICON_SIZE_Y * cos(Angle) * (N + 16) / 32) + + var/final_x = turf_A.x + var/final_y = turf_A.y + if(abs(Pixel_x) > ICON_SIZE_X) + final_x += Pixel_x > 0 ? round(Pixel_x / ICON_SIZE_X) : ceil(Pixel_x / ICON_SIZE_X) + Pixel_x %= ICON_SIZE_X + if(abs(Pixel_y) > ICON_SIZE_Y) + final_y += Pixel_y > 0 ? round(Pixel_y / ICON_SIZE_Y) : ceil(Pixel_y / ICON_SIZE_Y) + Pixel_y %= ICON_SIZE_Y + + var/turf/seg_turf = locate(final_x, final_y, turf_A.z) + if(!seg_turf) + continue + + var/image/img = image(beam_icon, seg_turf, beam_icon_state, PATH_DEBUG_LAYER) + SET_PLANE_EXPLICIT(img, BALLOON_CHAT_PLANE, seg_turf) + if(N + 32 > line_length) + // Terminal segment: crop the icon to avoid overshooting the target + var/icon/clipped = new(beam_icon, beam_icon_state) + clipped.DrawBox(null, 1, (line_length - N), 32, 32) + img.icon = clipped + img.color = color + img.transform = rot_matrix + img.pixel_x = origin_px + Pixel_x + img.pixel_y = origin_py + Pixel_y + images += img + + return images + + +/// Creates a single coloured tile overlay image. +/datum/event_logger/proc/_make_tile_image(turf/selected_turf, icon_state, color, alpha_fraction) + var/image/img = image('icons/turf/debug.dmi', selected_turf, icon_state, PATH_DEBUG_LAYER) + SET_PLANE_EXPLICIT(img, BALLOON_CHAT_PLANE, selected_turf) + img.color = color + img.alpha = round(alpha_fraction * 255) + return img + +/// Returns the hex color string for a category, or white if unknown. +/datum/event_logger/proc/get_category_color(category) + return category_colors[category] || "#ffffff" + +/// Clears all tracks, categories and overlays. +/datum/event_logger/proc/clear() + _clear_all_overlays() + QDEL_LIST_ASSOC_VAL(tracks) + tracks = list() + events_by_id = list() + categories = list() + category_colors = list() + next_event_id = 0 + selected_ref = null + time_start = null + running = FALSE + +///Tgui stuff below!! + + +/datum/event_logger/ui_assets(mob/user) + return list( + get_asset_datum(/datum/asset/simple/chat_dark), + ) + +/datum/event_logger/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "EventLogger", "Event Logger") + ui.open() + +/datum/event_logger/ui_close(mob/user) + . = ..() + _clear_user_overlays(user) + if(awaiting_pick_user == user) + _end_pick_target() + +/datum/event_logger/ui_state(mob/user) + return ADMIN_STATE(R_DEBUG) + +/datum/event_logger/ui_data(mob/user) + var/list/data = list() + + data["running"] = running + data["time_start"] = time_start + data["time_current"] = world.time + data["selected_ref"] = selected_ref + data["awaiting_pick"] = !isnull(awaiting_pick_user) + + // Categories + var/list/cats = list() //meow + for(var/cat, enabled in categories) + cats += list(list("name" = cat, "enabled" = enabled)) + data["categories"] = cats + + // Tracks + var/list/track_list = list() + for(var/ref, track_val in tracks) + var/datum/event_logger_track/track = track_val + + // Converts track info to a list for tgui + var/list/info_pairs = list() + for(var/title, entry in track.info) + info_pairs += list(list("title" = title, "entry" = entry)) + + track_list += list(list( + "name" = track.name, + "ref" = track.ref, + "events" = track.events, + "info" = info_pairs, + )) + data["tracks"] = track_list + + return data + +/datum/event_logger/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + var/mob/user = ui.user + + switch(action) + if("toggle_running") //Start or stop the logger + if(running) + stop() + else + start() + return TRUE + + if("toggle_category") //Enable categories + var/cat = params["name"] + if(!isnull(categories[cat])) + categories[cat] = !categories[cat] + return TRUE + + if("select_track") //Select a track on the timeline + selected_ref = params["ref"] + return TRUE + + if("select_events") ///We selected events on the timeline, clear old overlays, add new ones. + var/list/ids = params["ids"] + if(!islist(ids) || !length(ids)) + _clear_user_overlays(user) + return TRUE + + var/list/matched = list() + for(var/id in ids) + var/list/evt = events_by_id["[id]"] + if(evt) + matched += list(evt) + + _apply_event_overlays(user, matched) + return TRUE + + if("clear") + clear() + return TRUE + + if("start_pick_target") + toggle_pick_target(user) + return TRUE + + if("disable_evlogging") + var/track_ref = params["ref"] + var/datum/target = locate(track_ref) + if(!target) + return FALSE + if(tgui_alert(user, "Stop tracking [target]?", "Confirm", list("Stop Tracking", "Cancel")) != "Stop Tracking") + return FALSE + target.disable_evlogging() + return TRUE + + if("remove_track") // Removes a track and all its events from the logger entirely, and stops tracking the datum + var/track_ref = params["ref"] + var/datum/event_logger_track/track = tracks[track_ref] + if(!track) + return FALSE + // Remove all events from events_by_id + for(var/list/evt as anything in track.events) + events_by_id -= "[evt["id"]]" + // Disable evlogging on the datum if it still exists + var/datum/target = locate(track_ref) + if(target) + target.disable_evlogging() + // Clear overlays and deselect if this was the selected track + _clear_user_overlays(user) + if(selected_ref == track_ref) + selected_ref = null + qdel(track) + tracks -= track_ref + return TRUE + + if("teleport_to_event") + var/target_id = params["id"] + var/list/found_evt = events_by_id["[target_id]"] + if(!found_evt) + return FALSE + var/dest_x + var/dest_y + var/dest_z + switch(found_evt["log_type"]) + if(EVLOG_TYPE_LOCATION, EVLOG_TYPE_MAPTEXT) + dest_x = found_evt["x"] + dest_y = found_evt["y"] + dest_z = found_evt["z"] + if(EVLOG_TYPE_TURFS, EVLOG_TYPE_PATH) + var/list/coords = found_evt["coords"] + if(!length(coords)) + return FALSE + var/list/first = coords[1] + dest_x = first["x"] + dest_y = first["y"] + dest_z = first["z"] + if(EVLOG_TYPE_LINES) + dest_x = found_evt["x1"] + dest_y = found_evt["y1"] + dest_z = found_evt["z1"] + else + return FALSE + var/turf/dest = locate(dest_x, dest_y, dest_z) + if(!dest) + return FALSE + user.forceMove(dest) + return TRUE diff --git a/code/modules/admin/verbs/path_debugger.dm b/code/modules/admin/verbs/path_debugger.dm index bd6b6620828..98bed7a0f34 100644 --- a/code/modules/admin/verbs/path_debugger.dm +++ b/code/modules/admin/verbs/path_debugger.dm @@ -103,22 +103,10 @@ GLOBAL_DATUM_INIT(pathfind_dude, /obj/pathfind_guy, new()) return /datum/action/innate/path_debug/proc/render_path(list/turf/draw_list) - if(!length(draw_list)) - return list() - - var/list/image/turf_images = list() - // Render everything but the first and last - for(var/i in 1 to (length(draw_list) - 1)) - var/turf/problem_child = draw_list[i] - var/turf/next = draw_list[i + 1] - turf_images += render_turf(problem_child, get_dir(problem_child, next)) - - return turf_images + return SSpathfinder.render_path_images(draw_list) /datum/action/innate/path_debug/proc/render_turf(turf/draw, direction) - var/image/arrow = image('icons/turf/debug.dmi', draw, "arrow", PATH_ARROW_DEBUG_LAYER, direction) - SET_PLANE_EXPLICIT(arrow, BALLOON_CHAT_PLANE, draw) - return arrow + return SSpathfinder.render_path_arrow(draw, direction) /datum/action/innate/path_debug/jps name = "JPS Test" diff --git a/code/modules/asset_cache/assets/tgui.dm b/code/modules/asset_cache/assets/tgui.dm index 4b31d93e037..89556bc4487 100644 --- a/code/modules/asset_cache/assets/tgui.dm +++ b/code/modules/asset_cache/assets/tgui.dm @@ -33,3 +33,9 @@ ) #endif + +/datum/asset/simple/chat_dark + keep_local_name = FALSE + assets = list( + "tgui-chat-dark.bundle.css" = file("tgui/public/tgui-chat-dark.bundle.css"), + ) diff --git a/code/modules/mob/living/basic/bots/bot_ai.dm b/code/modules/mob/living/basic/bots/bot_ai.dm index 6f726c9586c..509cd4aa355 100644 --- a/code/modules/mob/living/basic/bots/bot_ai.dm +++ b/code/modules/mob/living/basic/bots/bot_ai.dm @@ -107,11 +107,15 @@ ///set the target if we can reach them /datum/ai_controller/basic_controller/bot/proc/set_if_can_reach(key, target, duration, distance = 10, bypass_add_to_blacklist = FALSE) if(can_reach_target(target, distance)) + EVLOG_MAPTEXT(src, EVLOG_CATEGORY_AI_TARGETING, "[pawn] has selected [target] as a target for blackboard key [key]!", get_turf(target), "Target: [target]") + EVLOG_LINES(src, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(pawn), get_turf(target)) set_blackboard_key(key, target) return TRUE if(bypass_add_to_blacklist) return FALSE var/final_duration = duration || blackboard[BB_UNREACHABLE_LIST_COOLDOWN] + EVLOG_MAPTEXT(src, EVLOG_CATEGORY_AI_TARGETING, "[pawn] has added [target] to its targetting blacklist!", get_turf(target), "Target: [target]") + EVLOG_LINES(src, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(pawn), get_turf(target)) add_to_blacklist(target, final_duration) return FALSE diff --git a/icons/turf/debug.dmi b/icons/turf/debug.dmi index 4bc24f6c7878d8c35368b7b8887cba66d61ac837..6864dd09e6c8d7f8885f69ea79d1a30b8270d950 100644 GIT binary patch delta 1363 zcmV-Z1+4nd3WN)g7!3pi0001fO@Al=001qKB`$ve{{Za5^hN*x00DGTPE!Ct=GbNc z007B)R9JLGWpiV4X>fFDZ*Bkpc$|&Wy$*sf5C`y?K1Bn&@oR8#F%ZKbuRuzBKoweY zEuwF4mBB&Hx&80*yWy@)_#r#y1y4^oy8=3G4d3(aV#%UOE8m7z*5@I zB8fvlPbx5qGy>61Z?*fb&I7%Gb8euw)F?apdX>GEHavbO?z9Eb|3V4RiuCo99#W00 zyIGC@`2YX}FiAu~RA_GG5|00z~~B}8$%no%T3^+G=ay$0G`-sHp&t5Fgnmk?0<9W#V-yo;0yQ^ zF#2}xW$$Ha{l~mfM+S~M2(;T2pstrll$fNM0Hy$(k(gz}x4SqJNt^=K^_+ji?2+9& zQu62}HUdVJSQD5E;G{&*_qd}vICjK_+-?B9O#$oLB{9{2*#lH&$jmfP;nXxvmz_~g z*em@A~|s6=_Z zG$@AMhS@|ubP1elKQ6xxiB5qN z?Hzi?qcSIOj5l{rE{U@OM*}bh&OYGl&=kHBbj`(?#Pue= z6F4)qye_$Sc9WAopm={wl&h)o6jt6`T}~9xzyj=&7+B&1E%qW5eVL5DkpJhV{?=bv zCL1ilZA9zt-w`o@o2z#A1F08ymEeSZk z3h@n{ zTutC66vz`!;q6Kwmq2RWYVpfD zAWaOQn2BxD(v`p=9ui2ANijLs04Z-BK>EZ?X?c(02Jor5jJ~z1!q9FQ`FbEvtV`6` zPwuP!OKABrO-MVXgWw22Q}>!BU>3Myk@5f+FQZyRH3(pFW7!3#m0002Hy^<*a001zNB`$x!z`y|i09hQ3J^%m!0d!JMQvg8b z*k%9#0JeHmSad{Xb7OL8aCB*JZU6vyoQ=`H4uUWc2JpFkiUxM$zrn@DKn#Pt0x9$; z6>P~}k=M7%;Gm{%-(7y+aJf{R@^ev&>{9SMAQ@-HQS4W1&Ss;07lz%Ivkr{z{4$W2 z9zuUiG*~}jTWJKa31`Y6h5d%J7Ud0={Gsb%g1@mtX?2{pQ`3X0YDw^{QMAxFw18FP z28U4`0{W-|tD;6Cy7{dR-_>=DA3#LoFO-VB%w8XT0K8F(FtayCLjV8-3rR#lRCt{2 zn2UC+AP_}Onnx8~{Quuxb$ARgyhM@J%V~c~G$QPrVFugb711N2XGA{{4KcmmLA@TI zpdQa}P|u&=p#B8lxJoqd?PUW_t3>nJUN+!%t7yL4%K=<3{f_I~b}-5|2XF&{W+VYO z5NIY6a07v6qyTrA9T0wH`Tu$mJ2GippY$G8~~8{}6m8;R}*OrHWMwcLyh#AUa;Hc1>iSb+t0EmLk$C416GXqj zsha0p0LCsOBwz;OqwxHU6%a1#kMj|xaS?L?89Pq6EGwYRxOfO>-Pnww*%^N;AY3G+ zJb`13IPZ+R6j}}-V;O|bbwRcWCx=C1YEw;?S+o}6Sy}A%(%u<>4w5>VQKYW`bO{v@ z04^cGSvhDCo(Smia1aS2SUrI9{8B)QaFItYpiyQ}KA!@}XwiU+sEKxnQ4-H8i?0Pl zT^E%bCZHy?l34N80$fy5sMvoR7TWQp4ZJqQwp2yQ+1-a{h_166Us-0t zngPiufF^fRbTR<*_%x$BBPWloo^-%`zLZg;t2-I+G$?2KFhc&?I#Bkq zk;``jc6D`iH6d%$PJKeHC>royCQ}sPy+pQL!15u_?0kWPStGvUdqU*s4Qm^|uqyrueQ6gV1Vdf+!@P!Fu_eQ#&!WSe=11u=CMTI^c@Yq&W9;1w> z2^fKC!1!jL!RIGT20VZAxRk+n@IS2f2o7NEmaOp)a-k6%z~UiK)d2^q>cASTDnt;btGv>=r2ZyjRvXEqG`hl?WI3EKE~kz;C|my*xkeCSIIw3nts79!y5bVf11NtT9*8d23)r~!$GWKA z_+XIMl4+c06{^5i1=Jzq_J`hHoLH!rNli`FX8nNS9z??t=`0fQw)SCPLB}&mM;APwhPf6NInIUAVnRaO7$2XM|&Fp0NXVbv2`ZU7Dy- UV)U1U00000Nks-uM6N<$f(@cgBme*a diff --git a/tgstation.dme b/tgstation.dme index d27510c11c0..8df27166a3e 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -98,6 +98,7 @@ #include "code\__DEFINES\dye_keys.dm" #include "code\__DEFINES\economy.dm" #include "code\__DEFINES\electrified_buckle.dm" +#include "code\__DEFINES\event_logger.dm" #include "code\__DEFINES\events.dm" #include "code\__DEFINES\exosuit_fab.dm" #include "code\__DEFINES\experisci.dm" @@ -3163,6 +3164,7 @@ #include "code\modules\admin\chat_commands.dm" #include "code\modules\admin\check_antagonists.dm" #include "code\modules\admin\create_mob.dm" +#include "code\modules\admin\event_logger.dm" #include "code\modules\admin\force_event.dm" #include "code\modules\admin\fun_balloon.dm" #include "code\modules\admin\greyscale_modify_menu.dm" diff --git a/tgui/packages/tgui-chat-dark/index.ts b/tgui/packages/tgui-chat-dark/index.ts new file mode 100644 index 00000000000..dd105da9774 --- /dev/null +++ b/tgui/packages/tgui-chat-dark/index.ts @@ -0,0 +1 @@ +import '../tgui-panel/styles/tgchat/chat-dark.scss'; diff --git a/tgui/packages/tgui/interfaces/EventLogger/CategoryBar.tsx b/tgui/packages/tgui/interfaces/EventLogger/CategoryBar.tsx new file mode 100644 index 00000000000..865825cbd61 --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/CategoryBar.tsx @@ -0,0 +1,35 @@ +import { Box, Button, Stack } from 'tgui-core/components'; + +import type { Category } from './types'; + +export type CategoryBarProps = { + categories: Category[]; + colors: Record; + act: (action: string, params?: object) => void; +}; + +export function CategoryBar(props: CategoryBarProps) { + const { categories, colors, act } = props; + if (!categories.length) { + return ( + + No categories logged yet. + + ); + } + return ( + + {categories.map((cat) => ( + + + + ))} + + ); +} diff --git a/tgui/packages/tgui/interfaces/EventLogger/ControlBar.tsx b/tgui/packages/tgui/interfaces/EventLogger/ControlBar.tsx new file mode 100644 index 00000000000..5207993b308 --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/ControlBar.tsx @@ -0,0 +1,63 @@ +import { Box, Button, Stack } from 'tgui-core/components'; + +export type ControlBarProps = { + running: boolean; + zoom: number; + awaitingPick: boolean; + autoScroll: boolean; + onAutoScrollChange: (val: boolean) => void; + act: (action: string, params?: object) => void; +}; + +export function ControlBar(props: ControlBarProps) { + const { running, zoom, awaitingPick, autoScroll, onAutoScrollChange, act } = + props; + return ( + + + + + + + + + + + + + + + + Zoom: {zoom < 1 ? zoom.toFixed(1) : zoom}x + + + + ); +} diff --git a/tgui/packages/tgui/interfaces/EventLogger/InfoPanel.tsx b/tgui/packages/tgui/interfaces/EventLogger/InfoPanel.tsx new file mode 100644 index 00000000000..d153d0ed218 --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/InfoPanel.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { Box, Button, LabeledList, Section, Stack } from 'tgui-core/components'; +import { sanitizeText } from '../../sanitize'; +import type { Track, TrackInfoEntry } from './types'; +import { CATEGORY_PALETTE } from './types'; + +export type InfoPanelProps = { + tracks: Track[]; + selectedRef: string | null; + selectedEventIds: Set; +}; + +export function InfoPanel(props: InfoPanelProps) { + const { tracks, selectedRef, selectedEventIds } = props; + const track = selectedRef ? tracks.find((t) => t.ref === selectedRef) : null; + + const [disabledInfoCats, setDisabledInfoCats] = useState>( + new Set(), + ); + + // Resolve which track_info snapshot to display and whether it's event-specific + let snapshotEntries: TrackInfoEntry[] | null = null; + let snapshotTrack: Track | null = null; + let isLatest = false; + + if (selectedEventIds.size > 0) { + // Find the highest-id selected event that carries a snapshot + let bestId = -1; + for (const t of tracks) { + for (const evt of t.events) { + if ( + selectedEventIds.has(evt.id) && + evt.track_info?.length && + evt.id > bestId + ) { + bestId = evt.id; + snapshotEntries = evt.track_info; + snapshotTrack = t; + } + } + } + } + + if (!snapshotEntries && track) { + let latestId = -1; + for (const evt of track.events) { + if (evt.track_info?.length && evt.id > latestId) { + latestId = evt.id; + snapshotEntries = evt.track_info; + } + } + if (snapshotEntries) { + snapshotTrack = track; + isLatest = true; + } + } + + const infoCategoryOrder: string[] = []; + const infoCategoryColors: Record = {}; + if (snapshotEntries) { + for (const entry of snapshotEntries) { + const cat = entry.category || 'Info'; + if (!infoCategoryColors[cat]) { + infoCategoryColors[cat] = + CATEGORY_PALETTE[infoCategoryOrder.length % CATEGORY_PALETTE.length]; + infoCategoryOrder.push(cat); + } + } + } + + function toggleInfoCat(cat: string) { + setDisabledInfoCats((prev) => { + const next = new Set(prev); + if (next.has(cat)) next.delete(cat); + else next.add(cat); + return next; + }); + } + + function renderSnapshot(entries: TrackInfoEntry[]) { + const grouped: Record = {}; + for (const entry of entries) { + const cat = entry.category || 'Info'; + if (disabledInfoCats.has(cat)) continue; + if (!grouped[cat]) grouped[cat] = []; + grouped[cat].push(entry); + } + return Object.entries(grouped).map(([cat, items]) => ( +
+ + {items.map((item) => ( + + + + ))} + +
+ )); + } + + const displayName = snapshotTrack?.name ?? track?.name ?? 'Track Info'; + + return ( +
+ {!track && !snapshotEntries ? ( + + No track selected. Click a row in the timeline. + + ) : !snapshotEntries ? ( + + No snapshot available. Events on this track do not carry info + snapshots. + + ) : ( + <> + {infoCategoryOrder.length > 1 && ( + + {infoCategoryOrder.map((cat) => ( + + + + ))} + + )} + {renderSnapshot(snapshotEntries)} + + )} +
+ ); +} diff --git a/tgui/packages/tgui/interfaces/EventLogger/SelectedEventsPanel.tsx b/tgui/packages/tgui/interfaces/EventLogger/SelectedEventsPanel.tsx new file mode 100644 index 00000000000..9867daa7a66 --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/SelectedEventsPanel.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef } from 'react'; +import { Box, Button, Section } from 'tgui-core/components'; +import { sanitizeText } from '../../sanitize'; +import type { EventEntry, Track } from './types'; +import { + getEventPrimaryCoord, + LOG_TYPE_COLORS, + LOG_TYPE_LABELS, +} from './types'; + +type LogTypeBadgeProps = { log_type: string }; + +export function LogTypeBadge(props: LogTypeBadgeProps) { + const { log_type } = props; + return ( + + {LOG_TYPE_LABELS[log_type] || log_type.toUpperCase()} + + ); +} + +export type SelectedEventsPanelProps = { + tracks: Track[]; + selectedEventIds: Set; + act: (action: string, params?: object) => void; +}; + +export function SelectedEventsPanel(props: SelectedEventsPanelProps) { + const { tracks, selectedEventIds, act } = props; + const scrollRef = useRef(null); + + const eventById: Record = {}; + for (const track of tracks) { + for (const evt of track.events) { + eventById[evt.id] = evt; + } + } + + const selected = [...selectedEventIds] + .sort((a, b) => a - b) + .map((id) => eventById[id]) + .filter(Boolean); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [selectedEventIds.size]); + + return ( +
+
+ {!selected.length ? ( + + No events selected. Click marks in the timeline. Hold Ctrl to select + multiple. + + ) : ( + selected.map((evt) => { + const coord = getEventPrimaryCoord(evt); + return ( + + + {coord && ( +
+
+ ); +} diff --git a/tgui/packages/tgui/interfaces/EventLogger/Timeline.tsx b/tgui/packages/tgui/interfaces/EventLogger/Timeline.tsx new file mode 100644 index 00000000000..a6ace61f59d --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/Timeline.tsx @@ -0,0 +1,432 @@ +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +export type TimelineMark = { + rowId: string; + tick: number; + color: string; + isCircle: boolean; + isSelected: boolean; + isMulti: boolean; + count: number; + tooltip: string; + tooltipHtml?: string | null; + data: T; +}; + +export type TimelineRow = { + id: string; + renderLabel: () => ReactNode; +}; + +export type TimelineProps = { + rows: TimelineRow[]; + marks: TimelineMark[]; + timeStart: number | null; + timeCurrent: number; + zoom: number; + running: boolean; + autoScroll: boolean; + /** Ticks between interval ruler lines (default 50 = 5 s at 10 t/s). */ + intervalTicks?: number; + /** How many ticks are in one second (default 10 as byond is in deciseconds). */ + ticksPerSecond?: number; + onMarkClick: (mark: TimelineMark, ctrl: boolean) => void; + onEmptyClick: () => void; + onZoomChange: (next: number) => void; +}; + +// Constants + +const LABEL_WIDTH = 240; +const ROW_HEIGHT = 36; +const RULER_HEIGHT = 20; +const MARK_RADIUS = 7; +const ZOOM_MAX = 40; + +// Component + +export function Timeline(props: TimelineProps) { + const { + rows, + marks, + timeStart, + timeCurrent, + zoom, + running, + autoScroll, + intervalTicks = 50, + ticksPerSecond = 10, + onMarkClick, + onEmptyClick, + onZoomChange, + } = props; + + const scrollRef = useRef(null); + const outerRef = useRef(null); + const anchorRef = useRef<{ tick: number; px: number } | null>(null); + const [containerWidth, setContainerWidth] = useState(0); + const [hoverTooltip, setHoverTooltip] = useState<{ + x: number; + y: number; + html: string; + } | null>(null); + + // Track container width so we can compute fit-zoom + useEffect(() => { + const el = outerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + setContainerWidth(entries[0].contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Derived timing + + const elapsed = timeStart !== null ? Math.max(0, timeCurrent - timeStart) : 0; + + const contentAreaWidth = Math.max(0, containerWidth - LABEL_WIDTH); + + /** + * The virtual duration used for layout. Always at least contentAreaWidth ticks, + * so at 1x zoom (1px/tick) the timeline always fills the full viewport. + * As real elapsed time exceeds this, fitZoom drops below 1 and you can zoom out. + */ + const effectiveElapsed = Math.max(elapsed, contentAreaWidth); + + /** + * The zoom level at which the full timeline exactly fills the content area. + * fitZoom == 1 until elapsed overflows the viewport, then shrinks below 1. + */ + const fitZoom = + effectiveElapsed > 0 && contentAreaWidth > 0 + ? contentAreaWidth / effectiveElapsed + : 1; + + // Whenever the stored zoom is below fitZoom, bring it back up (e.g. on open). + useEffect(() => { + if (zoom < fitZoom) { + onZoomChange(fitZoom); + } + }, [fitZoom]); // eslint-disable-line react-hooks/estive-deps + + /** px per tick at current zoom — never less than fitZoom */ + const pxPerTick = Math.max(fitZoom, zoom); + + // Ruler interval: auto-double when lines get too close + let rulerInterval = intervalTicks; + const minRulerSpacingPx = 60; + while (rulerInterval * pxPerTick < minRulerSpacingPx) { + rulerInterval *= 2; + } + + /** Total content width in px — uses effectiveElapsed so ruler/rows fill the viewport */ + const contentWidth = effectiveElapsed * pxPerTick + rulerInterval * pxPerTick; + + // Auto-scroll: only scroll if the playhead would be off-screen to the right + + useEffect(() => { + if (!autoScroll || !running || !scrollRef.current) return; + const container = scrollRef.current; + const playheadLeft = elapsed * pxPerTick; + const visibleRight = container.scrollLeft + container.clientWidth; + if (playheadLeft > visibleRight) { + container.scrollLeft = playheadLeft - container.clientWidth + 40; + } + }); + + // Zoom on wheel + + function handleWheel(e: React.WheelEvent) { + e.preventDefault(); + const container = scrollRef.current; + if (!container) return; + + // Save anchor: which tick is under the mouse + const rect = container.getBoundingClientRect(); + const localX = Math.max(0, e.clientX - rect.left); + const mouseX = localX + container.scrollLeft; + const tickUnderMouse = mouseX / pxPerTick; + anchorRef.current = { tick: tickUnderMouse, px: localX }; + + const delta = e.deltaY > 0 ? -1 : 1; + let next: number; + if (pxPerTick < 1) { + next = pxPerTick + delta * 0.1; + } else { + next = pxPerTick + delta * 1; + } + // Floor is fitZoom so you can never see empty space past the end + next = Math.max(fitZoom, Math.min(ZOOM_MAX, parseFloat(next.toFixed(1)))); + onZoomChange(next); + } + + // Re-anchor scroll after zoom change + useEffect(() => { + const anchor = anchorRef.current; + const container = scrollRef.current; + if (!anchor || !container) return; + anchorRef.current = null; + container.scrollLeft = anchor.tick * pxPerTick - anchor.px; + }, [pxPerTick]); + + // Render + + const contentHeight = rows.length * ROW_HEIGHT + RULER_HEIGHT; + + // Build a map from rowId -> row index for fast position lookup + const rowIndex: Record = {}; + rows.forEach((row, i) => { + rowIndex[row.id] = i; + }); + + // Ruler tick labels + + const rulerLabels: { tick: number; label: string; leftPx: number }[] = []; + for (let t = 0; t <= effectiveElapsed + rulerInterval; t += rulerInterval) { + const seconds = t / ticksPerSecond; + const leftPx = Math.max(0, t * pxPerTick); + rulerLabels.push({ tick: t, label: `${seconds}s`, leftPx }); + } + + // Render ruler vertical guide lines (behind rows) + + const rulerGuides = rulerLabels.map(({ tick, leftPx }) => ( +
+ )); + + // Render mark dots grouped by row + + const markEls = marks.map((mark, i) => { + const idx = rowIndex[mark.rowId]; + if (idx === undefined) return null; + const tickOffset = timeStart !== null ? mark.tick - timeStart : mark.tick; + const rawLeft = Math.max(0, tickOffset * pxPerTick); + const topPx = RULER_HEIGHT + idx * ROW_HEIGHT + ROW_HEIGHT / 2; + + const size = mark.isMulti ? MARK_RADIUS * 2 + 4 : MARK_RADIUS * 2; + + return ( +
{ + e.stopPropagation(); + onMarkClick(mark, e.ctrlKey || e.metaKey); + }} + onMouseEnter={(e) => { + if (mark.tooltipHtml) { + setHoverTooltip({ + x: e.clientX, + y: e.clientY, + html: mark.tooltipHtml, + }); + } + }} + onMouseMove={(e) => { + if (mark.tooltipHtml) { + setHoverTooltip({ + x: e.clientX, + y: e.clientY, + html: mark.tooltipHtml, + }); + } + }} + onMouseLeave={() => setHoverTooltip(null)} + style={{ + position: 'absolute', + left: `${rawLeft - size / 2}px`, + top: `${topPx - size / 2}px`, + width: `${size}px`, + height: `${size}px`, + borderRadius: mark.isCircle ? '50%' : '2px', + background: mark.color, + opacity: mark.isSelected ? 1 : 0.75, + border: mark.isSelected + ? '2px solid #fff' + : '1px solid rgba(0,0,0,0.4)', + cursor: 'pointer', + zIndex: mark.isSelected ? 2 : 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '9px', + fontWeight: 'bold', + color: '#000', + userSelect: 'none', + }} + > + {mark.isMulti && mark.count > 1 ? mark.count : null} +
+ ); + }); + + return ( +
+ {/* Row labels column */} +
+ {/* Ruler header space */} +
+ {/* Row labels */} + {rows.map((row) => ( +
+ {row.renderLabel()} +
+ ))} +
+ + {/* Scrollable content area */} +
+ {/* Inner content panel */} +
+ {/* Ruler strip */} +
+ {rulerLabels.map(({ tick, label, leftPx }) => ( +
+ {label} +
+ ))} +
+ {/* Row stripes + guides (behind marks) */} + {rulerGuides} + {rows.map((row, i) => ( +
+ ))} + {/* Marks */} + {markEls} + {/* Playhead */} + {!!running && timeStart !== null && ( +
+ )} +
+
+ {hoverTooltip && ( +
+ )} +
+ ); +} diff --git a/tgui/packages/tgui/interfaces/EventLogger/index.tsx b/tgui/packages/tgui/interfaces/EventLogger/index.tsx new file mode 100644 index 00000000000..0b877328ec1 --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/index.tsx @@ -0,0 +1,253 @@ +import { useState } from 'react'; +import { Stack } from 'tgui-core/components'; +import { useBackend } from '../../backend'; +import { Window } from '../../layouts'; +import { sanitizeText } from '../../sanitize'; + +import { CategoryBar } from './CategoryBar'; +import { ControlBar } from './ControlBar'; +import { InfoPanel } from './InfoPanel'; +import { SelectedEventsPanel } from './SelectedEventsPanel'; +import type { TimelineMark, TimelineRow } from './Timeline'; +import { Timeline } from './Timeline'; +import type { Category, EventEntry, EventLoggerData, Track } from './types'; +import { buildCategoryColors } from './types'; + +// Build the marks in the track + +function buildMarks( + tracks: Track[], + categories: Category[], + colors: Record, + timeStart: number | null, + selectedEventIds: Set, +): TimelineMark[] { + const marks: TimelineMark[] = []; + + const enabledCats = new Set( + categories.filter((c) => c.enabled).map((c) => c.name), + ); + + for (const track of tracks) { + // Group events by tick + const byTick: Map = new Map(); + for (const evt of track.events) { + if (!enabledCats.has(evt.category)) continue; + const existing = byTick.get(evt.tick); + if (existing) { + existing.push(evt); + } else { + byTick.set(evt.tick, [evt]); + } + } + + for (const [tick, evts] of byTick) { + const isSelected = evts.some((e) => selectedEventIds.has(e.id)); + const isMulti = evts.length > 1; + // Use first event's category color; if mixed, still use first + const color = colors[evts[0].category] || '#888'; + const isSpatial = evts.some((e) => e.log_type !== 'text'); + + marks.push({ + rowId: track.ref, + tick, + color, + isCircle: !isSpatial, + isSelected, + isMulti, + count: evts.length, + tooltip: isMulti + ? `${evts.length} events at tick ${tick - (timeStart ?? 0)}` + : evts[0].info.replace(/<[^>]*>/g, ''), + tooltipHtml: isMulti + ? null + : sanitizeText(evts[0].info, false, undefined, [ + 'style', + 'background', + ]), + data: evts, + }); + } + } + + return marks; +} + +// Event logger root + +export function EventLogger() { + const { act, data } = useBackend(); + const { + running, + time_start, + time_current, + categories, + tracks, + selected_ref, + awaiting_pick, + } = data; + + const [zoom, setZoom] = useState(1); + const [autoScroll, setAutoScroll] = useState(true); + const [selectedEventIds, setSelectedEventIds] = useState>( + new Set(), + ); + + const colors = buildCategoryColors(categories); + + const rows: TimelineRow[] = tracks.map((track) => ({ + id: track.ref, + renderLabel: () => ( +
+ ]*>/g, '')} + onClick={() => handleSelectTrack(track)} + dangerouslySetInnerHTML={{ __html: sanitizeText(track.name) }} + /> + +
+ ), + })); + + const marks = buildMarks( + tracks, + categories, + colors, + time_start, + selectedEventIds, + ); + + function handleSelectTrack(track: Track) { + act('select_track', { ref: track.ref }); + setSelectedEventIds(new Set()); + } + + function handleMarkClick(mark: TimelineMark, ctrl: boolean) { + const evtIds = mark.data.map((e) => e.id); + + // Find the track this mark belongs to so we can select it + const track = tracks.find((t) => t.ref === mark.rowId); + if (track && selected_ref !== track.ref) { + act('select_track', { ref: track.ref }); + } + + // Select events on server (for overlay rendering) + act('select_events', { ids: evtIds }); + + setSelectedEventIds((prev) => { + if (ctrl) { + // Toggle group: if all already selected, deselect; else add + const allSelected = evtIds.every((id) => prev.has(id)); + const next = new Set(prev); + if (allSelected) { + for (const id of evtIds) next.delete(id); + } else { + for (const id of evtIds) next.add(id); + } + return next; + } + // Replace selection + return new Set(evtIds); + }); + } + + return ( + + + + {/* Control bar */} + + + + + {/* Category toggles */} + + + + + {/* Timeline */} + +
+ + rows={rows} + marks={marks} + timeStart={time_start} + timeCurrent={time_current} + zoom={zoom} + running={running} + autoScroll={autoScroll} + onMarkClick={handleMarkClick} + onEmptyClick={() => { + setSelectedEventIds(new Set()); + act('select_events', { ids: [] }); + }} + onZoomChange={setZoom} + /> +
+
+ + {/* Bottom row: track info + selected events */} + + + + + + + + + + +
+
+
+ ); +} diff --git a/tgui/packages/tgui/interfaces/EventLogger/types.ts b/tgui/packages/tgui/interfaces/EventLogger/types.ts new file mode 100644 index 00000000000..0b1e7bb4780 --- /dev/null +++ b/tgui/packages/tgui/interfaces/EventLogger/types.ts @@ -0,0 +1,140 @@ +// ── Palette / label constants ───────────────────────────────────────────────── + +export const CATEGORY_PALETTE: string[] = [ + '#4fc3f7', + '#81c784', + '#ffb74d', + '#e57373', + '#ba68c8', + '#4dd0e1', + '#fff176', + '#f06292', + '#a1887f', + '#90a4ae', +]; + +export const LOG_TYPE_LABELS: Record = { + text: 'TXT', + location: 'LOC', + turfs: 'TURF', + lines: 'LINE', + path: 'PATH', + maptext: 'MAPTXT', +}; + +export const LOG_TYPE_COLORS: Record = { + text: '#aaaaaa', + location: '#4fc3f7', + turfs: '#81c784', + lines: '#ffb74d', + path: '#ce93d8', + maptext: '#ffe082', +}; + +// ── Data types ──────────────────────────────────────────────────────────────── + +export type Category = { name: string; enabled: boolean }; + +export type TrackInfoEntry = { category: string; title: string; entry: string }; + +export type BaseEvent = { + id: number; + tick: number; + category: string; + log_type: 'text' | 'location' | 'turfs' | 'lines' | 'path' | 'maptext'; + info: string; + track_info?: TrackInfoEntry[]; +}; + +export type TextEvent = BaseEvent & { log_type: 'text' }; + +export type LocationEvent = BaseEvent & { + log_type: 'location'; + x: number; + y: number; + z: number; +}; + +export type TurfsEvent = BaseEvent & { + log_type: 'turfs'; + coords: Array<{ x: number; y: number; z: number }>; +}; + +export type LinesEvent = BaseEvent & { + log_type: 'lines'; + x1: number; + y1: number; + z1: number; + x2: number; + y2: number; + z2: number; +}; + +export type PathEvent = BaseEvent & { + log_type: 'path'; + coords: Array<{ x: number; y: number; z: number }>; +}; + +export type MapTextEvent = BaseEvent & { + log_type: 'maptext'; + x: number; + y: number; + z: number; + text: string; +}; + +export type EventEntry = + | TextEvent + | LocationEvent + | TurfsEvent + | LinesEvent + | PathEvent + | MapTextEvent; + +export type InfoPair = { title: string; entry: string }; + +export type Track = { + name: string; + ref: string; + events: EventEntry[]; + info: InfoPair[]; +}; + +export type EventLoggerData = { + running: boolean; + time_start: number | null; + time_current: number; + categories: Category[]; + tracks: Track[]; + selected_ref: string | null; + awaiting_pick: boolean; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +export function buildCategoryColors( + categories: Category[], +): Record { + const map: Record = {}; + categories.forEach((cat, i) => { + map[cat.name] = CATEGORY_PALETTE[i % CATEGORY_PALETTE.length]; + }); + return map; +} + +export function getEventPrimaryCoord( + evt: EventEntry, +): { x: number; y: number; z: number } | null { + switch (evt.log_type) { + case 'location': + case 'maptext': + return { x: evt.x, y: evt.y, z: evt.z }; + case 'turfs': + case 'path': + return evt.coords.length > 0 ? evt.coords[0] : null; + case 'lines': + return { x: evt.x1, y: evt.y1, z: evt.z1 }; + default: + return null; + } +} diff --git a/tgui/packages/tgui/styles/interfaces/EventLogger.scss b/tgui/packages/tgui/styles/interfaces/EventLogger.scss new file mode 100644 index 00000000000..f8e33cbf22b --- /dev/null +++ b/tgui/packages/tgui/styles/interfaces/EventLogger.scss @@ -0,0 +1,16 @@ +// Custom HTML tooltip shown on timeline mark hover, rendered at fixed position +.evlog-mark-tooltip { + position: fixed; + background: #1e1e2e; + border: 1px solid #555; + border-radius: 4px; + padding: 4px 8px; + white-space: pre-wrap; + font-size: 11px; + color: #ccc; + pointer-events: none; + z-index: 9999; + min-width: 120px; + max-width: 320px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6); +} diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss index d61fc9fc2c5..4f01038d289 100644 --- a/tgui/packages/tgui/styles/main.scss +++ b/tgui/packages/tgui/styles/main.scss @@ -50,6 +50,7 @@ @include meta.load-css('./interfaces/ColorPicker.scss'); @include meta.load-css('./interfaces/AnomalyTower.scss'); @include meta.load-css('./interfaces/CivCargoHoldTerminal.scss'); +@include meta.load-css('./interfaces/EventLogger.scss'); // Layouts @include meta.load-css('./layouts/Layout.scss'); diff --git a/tgui/rspack.config.ts b/tgui/rspack.config.ts index 2639920b031..6c5f65b9690 100644 --- a/tgui/rspack.config.ts +++ b/tgui/rspack.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ tgui: './packages/tgui', 'tgui-panel': './packages/tgui-panel', 'tgui-say': './packages/tgui-say', + 'tgui-chat-dark': './packages/tgui-chat-dark', }, mode: 'production', module: {