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 4bc24f6c787..6864dd09e6c 100644 Binary files a/icons/turf/debug.dmi and b/icons/turf/debug.dmi differ 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: {