diff --git a/code/__DEFINES/~ff_defines/storyteller/_helpers.dm b/code/__DEFINES/~ff_defines/storyteller/_helpers.dm
new file mode 100644
index 00000000000..66bce56d260
--- /dev/null
+++ b/code/__DEFINES/~ff_defines/storyteller/_helpers.dm
@@ -0,0 +1,150 @@
+#define LOG_CATEGORY_STORYTELLER "storyteller"
+#define LOG_CATEGORY_STORYTELLER_PLANNER "storyteller_planner"
+#define LOG_CATEGORY_STORYTELLER_ANALYZER "storyteller_analyzer"
+#define LOG_CATEGORY_STORYTELLER_BALANCER "storyteller_balancer"
+#define LOG_CATEGORY_STORYTELLER_METRICS "storyteller_metrics"
+#define ADMIN_CATEGORY_STORYTELLER "Admin.Storyteller"
+
+/proc/log_storyteller(text, list/data)
+ logger.Log(LOG_CATEGORY_STORYTELLER, text, data)
+
+/proc/log_storyteller_planner(text, list/data)
+ logger.Log(LOG_CATEGORY_STORYTELLER_PLANNER, text, data)
+
+/proc/log_storyteller_analyzer(text, list/data)
+ logger.Log(LOG_CATEGORY_STORYTELLER_ANALYZER, text, data)
+
+/proc/log_storyteller_balancer(text, list/data)
+ logger.Log(LOG_CATEGORY_STORYTELLER_BALANCER, text, data)
+
+/proc/log_storyteller_metrics(text, list/data)
+ logger.Log(LOG_CATEGORY_STORYTELLER_METRICS, text, data)
+
+
+/proc/pick_map_spawn_location(spawn_radius = 10, z_level)
+ RETURN_TYPE(/list)
+
+ var/list/corners = list(
+ list(x=1, y=1, z=z_level),
+ list(x=1, y=world.maxy, z=z_level),
+ list(x=world.maxx, y=1, z=z_level),
+ list(x=world.maxx, y=world.maxy, z=z_level)
+ )
+
+ var/list/selected_corner = pick(corners)
+ var/turf/center_turf = locate(selected_corner["x"], selected_corner["y"], selected_corner["z"])
+ if(!center_turf)
+ return list()
+
+
+ var/list/edge_turfs = list()
+ for(var/turf/TURF in range(spawn_radius, center_turf))
+ if(TURF.x == 1 || TURF.x == world.maxx || TURF.y == 1 || TURF.y == world.maxy)
+ if(istype(TURF, /turf/open) && !TURF.density)
+ edge_turfs += TURF
+
+ if(!length(edge_turfs))
+ edge_turfs += center_turf
+
+ return edge_turfs
+
+
+/proc/get_inventory(atom/holder, recursive = TRUE)
+ RETURN_TYPE(/list)
+
+ . = list()
+ if(!holder || istype(holder) || !length(holder.contents))
+ return .
+
+ for(var/atom/atom in holder.contents)
+ . += atom
+ if(length(atom.contents) && recursive)
+ . += get_inventory(atom)
+
+ return .
+
+
+/proc/is_safe_area(area/to_check)
+ var/list/vents = to_check.air_vents
+ var/total_vents = length(vents)
+ var/unsafe_vents = 0
+
+ var/static/list/safe_gases = list(
+ /datum/gas/oxygen = list(16, 100),
+ /datum/gas/nitrogen,
+ /datum/gas/carbon_dioxide = list(0, 10)
+ )
+
+ for(var/obj/machinery/atmospherics/components/unary/vent_pump/vent in vents)
+ var/turf/open/T = get_turf(vent)
+ var/datum/gas_mixture/floor_gas_mixture = T.air
+ if(!floor_gas_mixture)
+ unsafe_vents += 1
+ continue
+
+ var/list/floor_gases = floor_gas_mixture.gases
+ if(!check_gases(floor_gases, safe_gases))
+ unsafe_vents += 1
+ continue
+
+ if((floor_gas_mixture.temperature <= 270) || (floor_gas_mixture.temperature >= 360))
+ unsafe_vents += 1
+ continue
+
+ var/pressure = floor_gas_mixture.return_pressure()
+ if((pressure <= 20) || (pressure >= 550))
+ unsafe_vents += 1
+ continue
+
+ if(!(total_vents == 1 && unsafe_vents == 0))
+ return !(unsafe_vents > round(total_vents * 0.5))
+ return TRUE
+
+/proc/pick_weight_f(list/list_to_pick)
+ if(length(list_to_pick) == 0)
+ return null
+
+ var/total = 0.0
+ for(var/item in list_to_pick)
+ var/weight = list_to_pick[item]
+ if(!isnum(weight) || weight < 0)
+ list_to_pick[item] = 0
+ continue
+ total += weight
+
+ if(total <= 0)
+ return null
+
+
+ var/list/cumulative = list()
+ var/cum_sum = 0.0
+ for(var/item in list_to_pick)
+ var/weight = list_to_pick[item]
+ if(weight <= 0)
+ continue
+ cum_sum += weight
+ cumulative += list(list("item" = item, "cum" = cum_sum))
+
+ if(length(cumulative) == 0)
+ return null
+
+
+ #define PRECISION 1000000
+ var/rand_float = rand(1, PRECISION) / PRECISION
+ #undef PRECISION
+
+ var/target = rand_float * total
+ for(var/entry in cumulative)
+ if(entry["cum"] >= target)
+ return entry["item"]
+
+ return cumulative[cumulative.len]["item"]
+
+/proc/get_nearest_atoms(atom/center, type = /atom/movable, range = 7)
+ var/list/candidates = SSspatial_grid.orthogonal_range_search(center, SPATIAL_GRID_CONTENTS_TYPE_ATMOS, range) // Using ATMOS as example; adjust type if needed
+ var/list/nearby_atoms = list()
+ var/turf/center_turf = get_turf(center)
+ for(var/atom/A in candidates)
+ if(istype(A, type) && get_dist(center_turf, get_turf(A)) <= range)
+ nearby_atoms += A
+ return nearby_atoms
diff --git a/code/__DEFINES/~ff_defines/storyteller/event_defines.dm b/code/__DEFINES/~ff_defines/storyteller/event_defines.dm
new file mode 100644
index 00000000000..630ee305250
--- /dev/null
+++ b/code/__DEFINES/~ff_defines/storyteller/event_defines.dm
@@ -0,0 +1,34 @@
+#define STORYTELLER_EVENT storyteller_implementation = TRUE
+/proc/is_storyteller_event(_evt)
+ if(istype(_evt, /datum/round_event))
+ var/datum/round_event/evt = _evt
+ return evt.storyteller_implementation
+ else
+ return FALSE
+
+#define BB_RAIDER_GROUP_LEADER "BB_raider_group_leader"
+#define BB_RAIDER_STRIKE_POINT "BB_raider_strike_point"
+#define BB_RAIDER_HIGH_VALUE_AREAS "BB_raider_hightvalue_areas"
+#define BB_RAIDER_CURRENT_OBJECTIVE "BB_raider_current_objective"
+#define BB_RAIDER_GROUP_MEMBERS "BB_raider_group_members"
+#define BB_RAIDER_ATTACK_METHOD "BB_raider_attack_method"
+#define BB_RAIDER_INTERESTING_ITEMS "BB_raider_interesting_items"
+#define BB_RAIDER_INTERESTING_TARGETS "BB_raider_interesting_targets"
+#define BB_RAIDER_MY_ROLE "BB_raider_my_role"
+#define BB_RAIDER_ROLE_LEADER "BB_raider_role_leader"
+#define BB_RAIDER_ROLE_MEMBER "BB_raider_role_member"
+#define BB_RAIDER_ROLE_SHOOTER "BB_raider_role_shooter"
+#define BB_RAIDER_ROLE_LOOTER "BB_raider_role_looter"
+#define BB_RAIDER_ROLE_SABOTEUR "BB_raider_role_saboteur"
+#define BB_RAIDER_VALUABLE_OBJECTS "BB_raider_valuable_objects"
+#define BB_RAIDER_TEAM "BB_raider_team"
+#define BB_RAIDER_REACH_STRIKE_POINT "BB_raider_reach_strike_point"
+#define BB_RAIDER_PATH_WAYPOINTS "BB_raider_path_waipoints"
+#define BB_RAIDER_FINAL_DESTINATION "BB_raider_final_deestination"
+#define BB_PATH_FINDER "BB_my_pathfinder"
+#define BB_RAIDER_DESTRUCTION_TARGET "BB_raider_destruction_target"
+#define BB_RAIDER_LOOT_TARGET "BB_raider_loot_target"
+#define BB_RAIDER_SEARCH_COOLDOWN_END "BB_riader_search_cooldown_end"
+#define BB_RAIDER_HOLD_COOLDOWN_END "BB_raider_hold_cooldown_end"
+
+#define TRAIT_NO_REBOOT_EVENT "stroyteller_trait_no_reboot_event"
diff --git a/code/__DEFINES/~ff_defines/storyteller/signals.dm b/code/__DEFINES/~ff_defines/storyteller/signals.dm
new file mode 100644
index 00000000000..bbf1250cdfc
--- /dev/null
+++ b/code/__DEFINES/~ff_defines/storyteller/signals.dm
@@ -0,0 +1,24 @@
+// From /datum/storyteller_analyzer/proc/scan_station()
+#define COMSIG_STORYTELLER_RUN_METRICS "comsig_storyteller_run_metrics"
+
+// From /datum/storyteller_analyzer/proc/scan_station(), storyteller_inputs/inputs, timeout, total_metrics
+#define COMSIG_STORYTELLER_FINISHED_ANALYZING "comsig_storyteller_finish_analyzing"
+
+// From /datum/storyteller/proc/think()
+#define COMSIG_STORYTELLER_PRE_THINK "comsig_storyteller_pre_think"
+ // Use to prevent thinking
+ #define COMPONENT_THINK_BLOCKED (1 << 0)
+
+// From /datum/storyteller/proc/think()
+#define COMSIG_STORYTELLER_POST_THINK "comsig_storyteller_post_think"
+
+// From /datum/controller/subsystem/storytellers/proc/start_vote()
+#define COMSIG_STORYTELLER_VOTE_START "comsig_stryteller_vote_start"
+
+// From /datum/controller/subsystem/storytellers/proc/end_vote()
+#define COMSIG_STORYTELLER_VOTE_END "comsig_stryteller_vote_end"
+
+/// Called by (/datum/round_event_control/run_event_as_storyteller).
+#define COMSIG_GLOB_STORYTELLER_RUN_EVENT "!storyteller_event"
+ /// Do not allow this random event to continue.
+ #define CANCEL_STORYTELLER_EVENT (1<<0)
diff --git a/code/__DEFINES/~ff_defines/storyteller/storyteller.dm b/code/__DEFINES/~ff_defines/storyteller/storyteller.dm
new file mode 100644
index 00000000000..2167920a6a2
--- /dev/null
+++ b/code/__DEFINES/~ff_defines/storyteller/storyteller.dm
@@ -0,0 +1,367 @@
+// Weights for Entities in Balancing
+// These weights are used in the balancer subsystem to compute relative importance
+// of entities (e.g., players vs. antagonists) when updating plans or adjusting difficulty.
+// They scale based on STORY_DEFAULT_WEIGHT and influence global goal progress,
+// event intensity, and antagonist spawning.
+
+
+// Baseline weight for generic entities
+#define STORY_DEFAULT_WEIGHT 10
+// Weight for living mobs (e.g., animals)
+#define STORY_LIVING_WEIGHT (STORY_DEFAULT_WEIGHT * 2)
+// Weight for carbon-based life (organic complexity)
+#define STORY_CARBON_WEIGHT (STORY_DEFAULT_WEIGHT * 5)
+// Weight for humans (core crew members)
+#define STORY_HUMAN_WEIGHT (STORY_DEFAULT_WEIGHT * 10)
+// Weight for antagonists (high threat/disruption)
+#define STORY_DEFAULT_ANTAG_WEIGHT (STORY_DEFAULT_WEIGHT * 3.5)
+
+// Minor antagonist weight (weak threats like space ninjas)
+#define STORY_MINOR_ANTAG_WEIGHT (STORY_DEFAULT_ANTAG_WEIGHT)
+
+// Medium antagonist weight (moderate threats like traitors)
+#define STORY_MEDIUM_ANTAG_WEIGHT (STORY_MINOR_ANTAG_WEIGHT * 1.4)
+
+// Major antagonist weight (severe threats like cults or blobs)
+#define STORY_MAJOR_ANTAG_WEIGHT (STORY_MEDIUM_ANTAG_WEIGHT * 2)
+
+// Job Roles Weights
+// These modifiers adjust weights based on job roles, affecting how the storyteller
+// prioritizes events or subgoals involving specific crew members.
+// For example, engineers might have higher weight in infrastructure-related goals.
+
+#define STORY_DEFAULT_JOB_WEIGHT 10.0 // Default multiplier for job-based weight adjustments
+
+#define STORY_ENGINEER_JOB_WEIGHT (STORY_DEFAULT_JOB_WEIGHT * 1.5)
+
+#define STORY_SECURITY_JOB_WEIGHT (STORY_DEFAULT_JOB_WEIGHT * 2.5)
+
+#define STORY_MEDICAL_JOB_WEIGHT (STORY_DEFAULT_JOB_WEIGHT * 1.5)
+
+#define STORY_HEAD_JOB_WEIGHT (STORY_DEFAULT_JOB_WEIGHT * 3)
+
+#define STORY_UNIMPORTANT_JOB_WEIGHT (STORY_DEFAULT_JOB_WEIGHT * 0.5)
+
+// Goal weight modifiers (affects event selection probability)
+#define STORY_GOAL_BASE_WEIGHT 5.0 // Standard event weight
+#define STORY_GOAL_BIG_WEIGHT 8.0 // Significant event weight
+#define STORY_GOAL_MAJOR_WEIGHT 12.0 // Major event weight
+
+#define STORY_WEIGHT_MINOR_ANTAGONIST (STORY_GOAL_BIG_WEIGHT * 1.2)
+#define STORY_WEIGHT_MAJOR_ANTAGONIST (STORY_GOAL_MAJOR_WEIGHT * 1.2)
+
+
+// Goal priority levels (affects scheduling order)
+#define STORY_GOAL_BASE_PRIORITY 1 // Normal priority
+#define STORY_GOAL_HIGH_PRIORITY 5 // High priority
+#define STORY_GOAL_CRITICAL_PRIORITY 10 // Critical priority
+
+// Goal threat levels (determines when events can trigger)
+#define STORY_GOAL_NO_THREAT 0.0 // No threat required
+#define STORY_GOAL_THREAT_BASIC 0.9 // Low threat level
+#define STORY_GOAL_THREAT_ELEVATED 2.5 // Medium threat level
+#define STORY_GOAL_THREAT_HIGH 3.0 // High threat level
+#define STORY_GOAL_THREAT_EXTREME 5.0 // Extreme threat level
+
+// Round progression milestones (0.0 = start, 1.0 = end)
+#define STORY_ROUND_PROGRESSION_START 0 // Round start (0%)
+#define STORY_ROUND_PROGRESSION_EARLY 0.12 // Early phase (0-12%)
+#define STORY_ROUND_PROGRESSION_MID 0.51 // Mid phase (12-51%)
+#define STORY_ROUND_PROGRESSION_LATE 0.73 // Late phase (51-73%)
+
+// Analyzer scan flags (bitflags for what to scan)
+#define RESCAN_STATION_INTEGRITY (1 << 0) // Scan station integrity/hull
+#define RESCAN_STATION_VALUE (1 << 1) // Scan station value/resources
+
+DEFINE_BITFIELD(story_analyzer_flags, list(
+ "STORYTELLER_SCAN_INTEGRITY" = RESCAN_STATION_INTEGRITY,
+ "STORYTELLER_SCAN_VALUE" = RESCAN_STATION_VALUE,
+))
+
+
+// Storytellers traits
+
+/* GENERAL TRAITS */
+
+// No bonus tension from events, faster events even in lowpop
+#define STORYTELLER_TRAIT_NO_MERCY "NO_MERCY"
+// Storyteller can send helping events
+#define STORYTELLER_TRAIT_CAN_HELP "CAN_HELP"
+// Force tension to target even if balance is bad
+#define STORYTELLER_TRAIT_FORCE_TENSION "FORCE_TENSION"
+// Storyteller will speak more often
+#define STORYTELLER_TRAIT_SPEAKER "LOVE_SPEAK"
+// Storyteller will try to keep balanced on target level
+#define STORYTELLER_TRAIT_BALANCING_TENSTION "BALANCER"
+// Storyteller don't selected any good event
+#define STORYTELLER_TRAIT_NO_GOOD_EVENTS "NO_GOOD_EVENTS"
+// Good event will be more likely
+#define STORYTELLER_TRAIT_KIND "KIND"
+// No adaptation decay, IT'S VEY BAD FOR CREW
+#define STORYTELLER_TRAIT_NO_ADAPTATION_DECAY "NO_ADAPTAION_DECAY"
+
+/* ANTAG TRAITS */
+
+// Rare antagonist spawns
+#define STORYTELLER_TRAIT_RARE_ANTAG_SPAWN "RARE_ANTAG_SPAWN"
+// Frequent antagonist spawns
+#define STORYTELLER_TRAIT_FREQUENT_ANTAG_SPAWN "FREQUENT_ANTAG_SPAWN"
+// No antagonists at all
+#define STORYTELLER_TRAIT_NO_ANTAGS "NO_ANTAGS"
+// Spawn immediately when current weight drops
+#define STORYTELLER_TRAIT_IMMEDIATE_ANTAG_SPAWN "IMMEDIATE_ANTAG_SPAWN"
+// Storyteller can spawn minor antagonists
+#define STORYTELLER_TRAIT_MINOR_ANTAGONISTS "MINOR_ANTAGONISTS"
+// Storyteller can spawn major antagonists
+#define STORYTELLER_TRAIT_MAJOR_ANTAGONISTS "MAJOR_ANTAGONISTS"
+
+
+/* BALANCE TRAITS */
+
+// Storyteller ignore security power for for balancer
+#define STORYTELLER_TRAIT_IGNORE_SECURITY "IGNORE_SECURITY"
+// Storyteller ignore resource level for balancer
+#define STORYTELLER_TRAIT_IGNORE_RESOURCES "IGNORE_RESOURCES"
+// Storyteller ignore lack of heads in command
+#define STORYTELLER_TRAIT_IGNORE_HEADS "IGNORE_HEADS"
+// Storyteller ignore engineering, that's mean SM, Power, hull breaches - everything related to engineering
+#define STORYTELLER_TRAIT_IGNORE_ENGI "IGNORE_ENGI"
+// Storyteller ignore crew health at calculations
+#define STORYTELLER_TRAIT_IGNORE_CREW_HEALTH "IGNORE_CREW_HEALTH"
+
+
+/* EVENTS TRAITS */
+
+// Storyteller probably select events randomly
+#define STORYTELLER_TRAIT_HARDCORE_RANDOM "HARDCORE_RANDOM"
+// Storyteller try to keep GOOD events more
+#define STORYTELLER_TRAIT_MORE_GOOD_EVENTS "MORE_GOOD_EVENTS"
+// Storyteller try to keep BAD events more
+#define STORYTELLER_TRAIT_MORE_BAD_EVENTS "MORE_BAD_EVENTS"
+// Storyteller try to keep NEUTRAL events more
+#define STORYTELLER_TRAIT_MORE_NEUTRAL_EVENTS "MORE_NEUTRAL_EVENTS"
+// Storyteller don't select major events for planning
+#define STORYTELLER_TRAIT_NO_MAJOR_EVENTS "NO_MAJOR_EVENTS"
+
+
+// Bitfield categories for story goals
+
+// Goals selected in a random order
+#define STORY_GOAL_RANDOM (1 << 0)
+// Positive goals
+#define STORY_GOAL_GOOD (1 << 1)
+// Negative goals
+#define STORY_GOAL_BAD (1 << 2)
+// Global goals that can be selected as the primary goal, actually it's a sub-category
+#define STORY_GOAL_GLOBAL (1 << 3)
+// Goals related to actions involving antagonists
+#define STORY_GOAL_NEUTRAL (1 << 4)
+// Goals without a specific category
+#define STORY_GOAL_UNCATEGORIZED (1 << 5)
+// Goal that's wound't be selected
+#define STORY_GOAL_NEVER (1 << 6)
+// Antagonist-related goals
+#define STORY_GOAL_ANTAGONIST (1 << 7)
+// Major event
+#define STORY_GOAL_MAJOR (1 << 8)
+
+DEFINE_BITFIELD(story_goal_category, list(
+ "GOAL_RANDOM" = STORY_GOAL_RANDOM,
+ "GOAL_GOOD" = STORY_GOAL_GOOD,
+ "GOAL_BAD" = STORY_GOAL_BAD,
+ "GOAL_GLOBAL" = STORY_GOAL_GLOBAL,
+ "GOAL_NEUTRAL" = STORY_GOAL_NEUTRAL,
+ "GOAL_UNCATEGORIZED" = STORY_GOAL_UNCATEGORIZED,
+ "GOAL_NEVER" = STORY_GOAL_NEVER,
+ "GOAL_ANTAGONIST" = STORY_GOAL_ANTAGONIST,
+ "STORY_GOAL_MAJOR" = STORY_GOAL_MAJOR,
+))
+
+// Bitfield categories for jobs flags
+// Flags that mark job roles for special handling in storyteller logic
+
+#define STORY_JOB_IMPORTANT (1 << 0) // Important role (heads, etc.)
+#define STORY_JOB_COMBAT (1 << 1) // Combat-oriented role
+#define STORY_JOB_ANTAG_MAGNET (1 << 2) // Role attracts antagonists
+#define STORY_JOB_HEAVYWEIGHT (1 << 3) // High-value target
+#define STORY_JOB_SECURITY (1 << 4) // Security/peacekeeping role
+
+DEFINE_BITFIELD(story_job_flags, list(
+ "JOB_IMPORTANT" = STORY_JOB_IMPORTANT,
+ "JOB_COMBAT" = STORY_JOB_COMBAT,
+ "JOB_ANTAG_MAGNET" = STORY_JOB_ANTAG_MAGNET,
+ "JOB_HEAVYWEIGHT" = STORY_JOB_HEAVYWEIGHT,
+ "JOB_SECURITY" = STORY_JOB_SECURITY,
+))
+
+
+
+#define STORY_TAGS_MATCH 1 // Tags are completely the same
+#define STORY_TAGS_MOST_MATCH 2 // Most tags are the same
+#define STORY_TAGS_SOME_MATCH 3 // Tags are somewhat different
+#define STORY_TAGS_DIFFERENT 4 // Tags are completely different
+
+// Escalation category
+#define STORY_TAG_ESCALATION "STORY_ESCALATION"
+#define STORY_TAG_DEESCALATION "STORY_DEESCALATION"
+
+// A classification of an event
+#define STORY_TAG_COMBAT "STORY_COMBAT"
+#define STORY_TAG_SOCIAL "STORY_SOCIAL"
+#define STORY_TAG_ENVIRONMENTAL "STORY_ENVIRONMENTAL"
+#define STORY_TAG_ENTITIES "STORY_TAG_ENTITIES"
+#define STORY_TAG_CHAOTIC "STORY_TAG_CHAOTIC"
+#define STORY_TAG_HEALTH "STORY_TAG_HEALTH"
+
+// Event target category
+#define STORY_TAG_WIDE_IMPACT "STORY_TAG_WIDE_IMPACT"
+#define STORY_TAG_TARGETS_INDIVIDUALS "STORY_TAG_TARGETS_INDIVIDUALS"
+#define STORY_TAG_AFFECTS_WHOLE_STATION "STORY_TAG_AFFECTS_WHOLE_STATION"
+
+// A requirement for the event to be selected
+#define STORY_TAG_REQUIRES_SECURITY "STORY_REQUIRES_SECURITY"
+#define STORY_TAG_REQUIRES_ENGINEERING "STORY_REQUIRES_ENGINEERING"
+#define STORY_TAG_REQUIRES_MEDICAL "STORY_REQUIRES_MEDICAL"
+
+// A tone of event
+#define STORY_TAG_EPIC "STORY_EPIC"
+#define STORY_TAG_HUMOROUS "STORY_HUMOROUS"
+#define STORY_TAG_TRAGIC "STORY_TRAGIC"
+
+// Antagonist related tags
+#define STORY_TAG_ANTAGONIST "STORY_TAG_ANTAGONIST"
+#define STORY_TAG_MAJOR "STORY_MAJOR"
+#define STORY_TAG_MIDROUND "STORY_TAG_MIDROUND"
+#define STORY_TAG_ROUNDSTART "STORY_TAG_ROUNDSTART"
+
+// Goals statuses in planning tree for external use
+#define STORY_GOAL_PENDING "goal_pending"
+#define STORY_GOAL_FIRING "goal_firing"
+#define STORY_GOAL_COMPLETED "goal_completed"
+#define STORY_GOAL_FAILED "goal_failed"
+
+
+
+// Core storyteller pacing and difficulty constants
+#define STORY_THINK_BASE_DELAY (2 MINUTES) // Base delay between thinker cycles
+#define STORY_MIN_EVENT_INTERVAL (30 SECONDS) // Minimum time between events
+#define STORY_MAX_EVENT_INTERVAL (20 MINUTES) // Maximum time between events
+#define STORY_DEFAULT_PLAYER_ANTAG_BALANCE 0.5 // Default balance target (0-100, 50 = balanced)
+
+// Threat/adaptation constants
+#define STORY_THREAT_GROWTH_RATE 1.0 // How fast threat points accumulate
+#define STORY_ADAPTATION_DECAY_RATE 0.05 // Rate of adaptation decay per cycle
+#define STORY_RECENT_DAMAGE_THRESHOLD 20 // Damage threshold for triggering adaptation
+#define STORY_TARGET_TENSION 50 // Target tension level (0-100)
+#define STORY_GRACE_PERIOD (10 MINUTES) // Cooldown after major events
+#define STORY_MAX_THREAT_SCALE 100.0 // Maximum threat scale (max 10000 points)
+#define STORY_REPETITION_PENALTY 0.5 // Penalty multiplier for repeated events
+#define STORY_DIFFICULTY_MULTIPLIER 1.0 // Base difficulty multiplier
+
+// Planner constants
+#define STORY_RECALC_INTERVAL (5 MINUTES) // Interval for plan recalculation
+#define STORY_INITIAL_GOALS_COUNT 3 // Minimum pending goals in timeline
+#define STORY_PICK_THREAT_BONUS_SCALE 0.1 // Threat bonus scaling for goal selection
+#define STORY_BALANCE_BONUS 0.5 // Balance adjustment bonus multiplier
+#define STORY_PACE_MIN 0.1 // Minimum pace multiplier
+#define STORY_PACE_MAX 3.0 // Maximum pace multiplier
+
+// Balancer constants
+#define STORY_BALANCER_PLAYER_WEIGHT 1.0 // Base weight per player
+#define STORY_BALANCER_ANTAG_WEIGHT 2.0 // Base weight per antagonist
+#define STORY_BALANCER_WEAK_ANTAG_THRESHOLD 0.5 // Threshold for "weak antags" (0-1)
+#define STORY_BALANCER_INACTIVE_ACTIVITY_THRESHOLD 0.25 // Threshold for inactive antags (0-1)
+#define STORY_STATION_STRENGTH_MULTIPLIER 1.0 // Station strength multiplier
+#define STORY_MAX_TENSION_BONUS 30 // Maximum tension bonus from events
+#define STORY_TENSION_BONUS_DECAY_RATE 1 // Tension bonus decay per cycle
+
+// Metric thresholds (scaling factors for activity calculations)
+#define STORY_INACTIVITY_ACT_INDEX_THRESHOLD 0.15 // Threshold for inactive activity index
+#define STORY_ACTIVITY_CREW_SCALE 0.1 // Crew activity scaling factor
+#define STORY_DAMAGE_SCALE 100 // Damage scaling divisor
+#define STORY_ACTIVITY_TIME_SCALE 10 // Time-based activity scaling
+#define STORY_DISRUPTION_SCALE 10 // Disruption scaling divisor
+#define STORY_INFLUENCE_SCALE 5 // Influence scaling divisor
+#define STORY_KILLS_CAP 3 // Maximum kills value (0-3)
+#define STORY_OBJECTIVES_CAP 4 // Maximum objectives value (0-4)
+
+// Round progression tuning (target: ~3 hours average round, 60-80 players)
+#define STORY_ROUND_PROGRESSION_THRESHOLD (2 HOURS) // Time for 100% progression
+
+// Threat point thresholds (for event intensity scaling)
+#define STORY_THREAT_LOW 100 // Low threat threshold
+#define STORY_THREAT_MODERATE 500 // Moderate threat threshold
+#define STORY_THREAT_HIGH 2000 // High threat threshold
+#define STORY_THREAT_EXTREME 5000 // Extreme threat threshold
+#define STORY_THREAT_APOCALYPTIC 10000 // Apocalyptic threat threshold
+
+#define STORY_INVERTED_THREAT_POINTS(TP) (max(STORY_THREAT_APOCALYPTIC - (TP), 0))
+
+
+// Converts threat points to good points for good event scaling
+#define STORY_GOOD_POINTS(TP) (STORY_INVERTED_THREAT_POINTS(TP))
+
+#define STORY_GOOD_EXTREME 0 // Extreme good threshold (low threat)
+#define STORY_GOOD_HIGH 2000 // High good threshold
+#define STORY_GOOD_MODERATE 5000 // Moderate good threshold
+#define STORY_GOOD_LOW 7000 // Low good threshold
+#define STORY_GOOD_MINIMAL 9000
+
+#define STORY_USEFULNESS_LEVEL(TP) ( \
+ (TP) <= STORY_GOOD_EXTREME ? 5 : \
+ (TP) <= STORY_GOOD_HIGH ? 4 : \
+ (TP) <= STORY_GOOD_MODERATE ? 3 : \
+ (TP) <= STORY_GOOD_LOW ? 2 : \
+ 1 \
+)
+
+
+#define ROLE_BLACKLIST_SECLIKE list( \
+ JOB_CYBORG, \
+ JOB_AI, \
+ JOB_SECURITY_OFFICER, \
+ JOB_WARDEN, \
+ JOB_DETECTIVE, \
+ JOB_HEAD_OF_SECURITY, \
+ JOB_CAPTAIN, \
+ JOB_CORRECTIONS_OFFICER, \
+ JOB_NT_REP, \
+ JOB_BLUESHIELD, \
+ JOB_ORDERLY, \
+ JOB_BOUNCER, \
+ JOB_CUSTOMS_AGENT, \
+ JOB_ENGINEERING_GUARD, \
+ JOB_SCIENCE_GUARD, \
+ )
+
+#define ROLE_BLACKLIST_HEAD list( \
+ JOB_CAPTAIN, \
+ JOB_HEAD_OF_SECURITY, \
+ JOB_RESEARCH_DIRECTOR, \
+ JOB_CHIEF_ENGINEER, \
+ JOB_CHIEF_MEDICAL_OFFICER, \
+ JOB_HEAD_OF_PERSONNEL, \
+ )
+
+#define ROLE_BLACKLIST_SECHEAD list( \
+ JOB_CAPTAIN, \
+ JOB_HEAD_OF_SECURITY, \
+ JOB_WARDEN, \
+ JOB_DETECTIVE, \
+ JOB_CHIEF_ENGINEER, \
+ JOB_CHIEF_MEDICAL_OFFICER, \
+ JOB_RESEARCH_DIRECTOR, \
+ JOB_HEAD_OF_PERSONNEL, \
+ JOB_CYBORG, \
+ JOB_AI, \
+ JOB_SECURITY_OFFICER, \
+ JOB_WARDEN, \
+ JOB_CORRECTIONS_OFFICER, \
+ JOB_NT_REP, \
+ JOB_BLUESHIELD, \
+ JOB_ORDERLY, \
+ JOB_BOUNCER, \
+ JOB_CUSTOMS_AGENT, \
+ JOB_ENGINEERING_GUARD, \
+ JOB_SCIENCE_GUARD, \
+ )
diff --git a/code/__DEFINES/~ff_defines/storyteller/~storyteller_vault.dm b/code/__DEFINES/~ff_defines/storyteller/~storyteller_vault.dm
new file mode 100644
index 00000000000..9d7beb0390d
--- /dev/null
+++ b/code/__DEFINES/~ff_defines/storyteller/~storyteller_vault.dm
@@ -0,0 +1,222 @@
+// Storyteller vault metrics for tracking station state
+// Each metric represents a category of station conditions used by the storyteller to select events
+
+/*
+ Health metrics
+ These track the physical well-being of crew and antagonists, including health levels, wounds, and diseases.
+*/
+
+// Tracks the overall health status of antagonists to influence goal selection (e.g., antagonist support or crew advantage events).
+#define STORY_VAULT_ANTAG_HEALTH "antag_health"
+// Tracks the overall health status of the crew to influence goal selection.
+#define STORY_VAULT_CREW_HEALTH "crew_health"
+ // Many crew members are in critical condition, with very low health.
+ #define STORY_VAULT_HEALTH_LOW 3
+ // Many crew members are injured but not critical.
+ #define STORY_VAULT_HEALTH_DAMAGED 2
+ // Most crew members are in average health.
+ #define STORY_VAULT_HEALTH_NORMAL 1
+ // Most crew members are in excellent health.
+ #define STORY_VAULT_HEALTH_HEALTHY 0
+
+// Average wound count metrics (raw numeric values)
+#define STORY_VAULT_AVG_CREW_WOUNDS "avg_crew_wounds" // Average wounds per crew member
+#define STORY_VAULT_AVG_ANTAG_WOUNDS "avg_atnag_wounds" // Average wounds per antagonist
+
+// Tracks the extent of physical wounds among antagonists.
+#define STORY_VAULT_ANTAG_WOUNDING "antag_wounding"
+// Tracks the extent of physical wounds among the crew.
+#define STORY_VAULT_CREW_WOUNDING "crew_wounding"
+ // Few to no crew members have significant wounds.
+ #define STORY_VAULT_NO_WOUNDS 0
+ // Some crew members have moderate wounds.
+ #define STORY_VAULT_SOME_WOUNDED 1
+ // Many crew members are heavily wounded.
+ #define STORY_VAULT_MANY_WOUNDED 2
+ // Many crew members have life-threatening wounds.
+ #define STORY_VAULT_CRITICAL_WOUNDED 3
+
+// Tracks the prevalence of diseases among the crew, influencing events like outbreaks or medical research.
+#define STORY_VAULT_CREW_DISEASES "crew_diseases"
+ // No significant diseases among the crew, allowing non-medical or routine events.
+ #define STORY_VAULT_NO_DISEASES 0
+ // Some crew members have minor diseases.
+ #define STORY_VAULT_MINOR_DISEASES 1
+ // Many crew members have serious diseases.
+ #define STORY_VAULT_MAJOR_DISEASES 2
+ // Widespread, critical disease outbreak.
+ #define STORY_VAULT_OUTBREAK 3
+
+/*
+ Death and alive metrics
+ These track counts and ratios of dead/alive crew and antagonists to gauge station mortality and survival rates.
+*/
+
+// Average health metrics (0-100, higher = healthier)
+#define STORY_VAULT_AVG_CREW_HEALTH "avg_crew_health" // Average health of crew (0-100)
+#define STORY_VAULT_AVG_ANTAG_HEALTH "avg_atnag_health" // Average health of antagonists (0-100)
+
+
+// Tracks the number of dead antagonists.
+#define STORY_VAULT_ANTAG_DEAD_COUNT "antag_dead_count"
+
+// Tracks the number of dead crew members.
+#define STORY_VAULT_CREW_DEAD_COUNT "crew_dead_count"
+
+// Tracks the number of alive antagonists.
+#define STORY_VAULT_ANTAG_ALIVE_COUNT "antag_alive_count"
+
+// Tracks the number of alive crew members.
+#define STORY_VAULT_CREW_ALIVE_COUNT "crew_alive_count"
+
+// Tracks the alive level for antagonists (based on dead counts).
+#define STORY_VAULT_ANTAG_ALIVE_LEVEL "antag_alive_level"
+
+// Tracks the alive level for crew (based on dead counts).
+#define STORY_VAULT_CREW_ALIVE_LEVEL "crew_alive_level"
+ // No dead crew members.
+ #define STORY_VAULT_NO_DEAD 0
+ // Few dead crew members (e.g., 1-5).
+ #define STORY_VAULT_FEW_DEAD 1
+ // Some dead crew members (e.g., 6-15).
+ #define STORY_VAULT_SOME_DEAD 2
+ // Many dead crew members (e.g., >15).
+ #define STORY_VAULT_MANY_DEAD 3
+
+// Tracks the ratio of dead to total antagonists.
+#define STORY_VAULT_ANTAG_DEAD_RATIO "antag_dead_ratio"
+// Tracks the ratio of dead to total crew.
+#define STORY_VAULT_CREW_DEAD_RATIO "crew_dead_ratio"
+ // Very low death ratio
+ #define STORY_VAULT_LOW_DEAD_RATIO 0
+ // Moderate death ratio
+ #define STORY_VAULT_MODERATE_DEAD_RATIO 1
+ // High death ratio
+ #define STORY_VAULT_HIGH_DEAD_RATIO 2
+ // Extreme death ratio
+ #define STORY_VAULT_EXTREME_DEAD_RATIO 3
+
+/*
+ Resource metrics
+ These track station resources, antagonist presence, and related factors affecting economic and operational stability.
+*/
+
+
+/*
+ Security metrics
+ These track security personnel, equipment, and alert levels to influence law enforcement and response events.
+*/
+
+// Number of active security personnel on station
+#define STORY_VAULT_SECURITY_COUNT "security_count"
+// Tracks security strength (number of active security officers, their gear)
+#define STORY_VAULT_SECURITY_STRENGTH "security_strength"
+ #define STORY_VAULT_NO_SECURITY 0 // No active security
+ #define STORY_VAULT_WEAK_SECURITY 1 // Few/low-geared officers
+ #define STORY_VAULT_MODERATE_SECURITY 2 // Standard force
+ #define STORY_VAULT_STRONG_SECURITY 3 // High numbers/well-equipped
+
+// Tracks security alert level (green to delta)
+#define STORY_VAULT_SECURITY_ALERT "security_alert" // Already partially in code, expanded
+ #define STORY_VAULT_GREEN_ALERT 0
+ #define STORY_VAULT_BLUE_ALERT 1
+ #define STORY_VAULT_RED_ALERT 2
+ #define STORY_VAULT_DELTA_ALERT 3
+
+/*
+ Crew state metrics
+ These track morale and readiness of the crew to handle crises or daily operations.
+*/
+
+// Total crew weight (sum of all crew member weights)
+#define STORY_VAULT_CREW_WEIGHT "crew_weight"
+// Tracks crew morale (happiness, stress from events/deaths).
+#define STORY_VAULT_CREW_MORALE "crew_morale"
+ #define STORY_VAULT_HIGH_MORALE 0 // Happy/productive
+ #define STORY_VAULT_MODERATE_MORALE 1 // Neutral
+ #define STORY_VAULT_LOW_MORALE 2 // Stressed
+ #define STORY_VAULT_CRITICAL_MORALE 3 // Mutiny-level low
+// Tracks crew readiness (access to weapons, meds, tools for crises).
+#define STORY_VAULT_CREW_READINESS "crew_readiness"
+ #define STORY_VAULT_UNPREPARED 0 // No gear/stockpiles
+ #define STORY_VAULT_BASIC_READY 1 // Minimal supplies
+ #define STORY_VAULT_PREPARED 2 // Good stockpiles
+ #define STORY_VAULT_HIGHLY_READY 3 // Overprepared (armory full, etc.)
+// Special state flags (boolean-like metrics)
+#define STORY_VAULT_STATION_ALLIES "station_allies" // Station has allied NPCs/ships
+#define STORY_VAULT_NUKE_ACTIVATED "NUKE_INCOMING" // Nuclear device activated
+#define STORY_VAULT_DEATHSQUAD "doomguys_here" // Deathsquad on station
+
+#define STORY_VAULT_STATION_COMMAND "station_command"
+ #define STORY_VAULT_NO_HEADS 0
+ #define STORY_VAULT_ONLY_HEAD 1 // One head on the station
+ #define STORY_VAULT_FEW_HEADS 2 // Two-four heads on the station
+ #define STORY_VAULT_FULL_COMMAND 3 // Full of almost full command
+
+
+
+/*
+ Antagonist metrics
+ These track antagonist behavior, progress, and impact to escalate or mitigate threats.
+*/
+
+// Total antagonist weight (sum of all antag weights)
+#define STORY_VAULT_ANTAG_WEIGHT "antag_weight"
+// Total antagonist influence (weight * effectiveness)
+#define STORY_VAULT_ANTAG_INFLUENCE "antag_influence"
+// Tracks the major current threat/crisis type (e.g., "cult", "blob").
+#define STORY_VAULT_MAJOR_THREAT "major_threat"
+// Tracks the number of active antagonists on the station, influencing events like sabotage or crew conflict.
+#define STORY_VAULT_ANTAGONIST_PRESENCE "antagonist_presence"
+ // No active antagonists detected, favoring peaceful or routine station events.
+ #define STORY_VAULT_NO_ANTAGONISTS 0
+ // A small number of antagonists are active, prompting minor conflict or vigilance events.
+ #define STORY_VAULT_FEW_ANTAGONISTS 1
+ // A moderate number of antagonists are active, escalating to events requiring security response.
+ #define STORY_VAULT_MODERATE_ANTAGONISTS 2
+ // A large number of antagonists are active, triggering major conflict or crisis events.
+ #define STORY_VAULT_MANY_ANTAGONISTS 3
+
+/*
+ Station crises metrics
+ These track infrastructure, power, hazards, and research to influence emergency and recovery events.
+*/
+
+// Tracks the level of mineral resources on the station.
+#define STORY_VAULT_RESOURCE_MINERALS "resource_minerals"
+// Tracks the level of other (non-mineral) resources on the station.
+#define STORY_VAULT_RESOURCE_OTHER "resource_other"
+// Tracks overall low resource conditions on the station.
+#define STORY_VAULT_LOW_RESOURCE "low_station_resources"
+// Current integrity of station SM
+#define STORY_VAULT_ENGINE_INTEGRITY "ingine_integrity"
+// Current integiry of station ATMOS rooms
+#define STORY_VAULT_ATMOS_INTEGRITY "atmos_integrity"
+// Overall station structural integrity (0-100, higher = better)
+#define STORY_VAULT_STATION_INTEGRITY "station_integrity"
+// Tracks infrastructure damage (power, hull breaches, etc.).
+#define STORY_VAULT_INFRA_DAMAGE "infra_damage"
+ #define STORY_VAULT_NO_DAMAGE 0
+ #define STORY_VAULT_MINOR_DAMAGE 1
+ #define STORY_VAULT_MAJOR_DAMAGE 2
+ #define STORY_VAULT_CRITICAL_DAMAGE 3
+// Tracks station power availability, influencing events like blackouts or engineering repairs.
+#define STORY_VAULT_POWER_STATUS "power_status"
+ #define STORY_VAULT_FULL_POWER 0
+ #define STORY_VAULT_LOW_POWER 1
+ #define STORY_VAULT_BLACKOUT 2
+ #define STORY_VAULT_CRITICAL_POWER_FAILURE 3
+// Power grid metrics
+#define STORY_POWER_SMES_DISCHARGE_DIVISOR "power_grid_smes" // SMES discharge divisor for damage calculation
+#define STORY_VAULT_POWER_GRID_STRENGTH "power_grid_strength" // Overall power grid strength (0-100)
+#define STORY_VAULT_POWER_GRID_DAMAGE "power_grid_damage" // Power grid damage level (0-3)
+ #define STORY_VAULT_POWER_GRID_NOMINAL 0
+ #define STORY_VAULT_POWER_GRID_FAILURES 1
+ #define STORY_VAULT_POWER_GRID_DAMAGED 2
+ #define STORY_VAULT_POWER_GRID_CRITICAL 3
+// Tracks overall research progress, influencing science-related goals.
+#define STORY_VAULT_RESEARCH_PROGRESS "research_progress"
+ #define STORY_VAULT_LOW_RESEARCH 0
+ #define STORY_VAULT_MODERATE_RESEARCH 1
+ #define STORY_VAULT_HIGH_RESEARCH 2
+ #define STORY_VAULT_ADVANCED_RESEARCH 3
diff --git a/code/__HELPERS/~ff_helpers/storytellers.dm b/code/__HELPERS/~ff_helpers/storytellers.dm
new file mode 100644
index 00000000000..a7f89e15043
--- /dev/null
+++ b/code/__HELPERS/~ff_helpers/storytellers.dm
@@ -0,0 +1,72 @@
+/proc/get_alive_station_crew(ignore_erp = TRUE, ignore_afk = TRUE, only_crew = FALSE, sort = TRUE)
+ var/to_check = GLOB.alive_player_list.Copy()
+
+ if(!length(to_check))
+ return list()
+ var/list/to_return = null
+
+ for(var/mob/living/living in to_check)
+ if(!living.mind)
+ continue
+ if(!is_station_level(living.z))
+ continue
+ if(ignore_erp && engaged_role_play_check(living))
+ continue
+ if(ignore_afk && living?.client)
+ if(living.client.is_afk())
+ continue
+ if(only_crew && !living.mind?.assigned_role)
+ continue
+ LAZYADD(to_return, living)
+
+ if(sort)
+ for(var/i = 0 to rand(1, 3))
+ shuffle(to_return)
+
+ return to_return
+
+
+
+// color: Target HSL or hex color for the space lighting (e.g., "#9932CC" for purple)
+// fade_in: If TRUE, fade from normal to the color; if FALSE, instantly set to color
+// fade_out: If TRUE, fade back to normal after fade_in completes; if FALSE, leave at color
+// duration_step: Time per fade step in seconds (default 8 for smooth transition over ~40s)
+/proc/change_space_color(color, fade_in = TRUE, fade_out = TRUE, duration_step = 8)
+ set waitfor = FALSE
+
+ // Define normal starlight values
+ var/start_color = GLOB.base_starlight_color
+ var/start_range = GLOB.starlight_range
+ var/start_power = GLOB.starlight_power
+
+ // Prepare target values for the custom color
+ var/target_color = color
+ if(!target_color)
+ target_color = GLOB.base_starlight_color
+ var/target_range = GLOB.starlight_range * 1.75
+ var/target_power = GLOB.starlight_power * 0.6
+
+ // If fade_in is FALSE, instantly set to target
+ if (!fade_in)
+ set_starlight(target_color, target_range, target_power)
+
+ // Perform fade-in if requested
+ if (fade_in)
+ for (var/i in 1 to 5)
+ var/walked_color = hsl_gradient(i / 5, 0, start_color, 1, target_color)
+ var/walked_range = LERP(start_range, target_range, i / 5)
+ var/walked_power = LERP(start_power, target_power, i / 5)
+ set_starlight(walked_color, walked_range, walked_power)
+ sleep(duration_step SECONDS)
+
+ // Perform fade-out back to normal if requested
+ if (fade_out)
+ for (var/i in 1 to 5)
+ var/walked_color = hsl_gradient(i / 5, 0, target_color, 1, start_color)
+ var/walked_range = LERP(target_range, start_range, i / 5)
+ var/walked_power = LERP(target_power, start_power, i / 5)
+ set_starlight(walked_color, walked_range, walked_power)
+ sleep(duration_step SECONDS)
+
+ // Ensure final normal state
+ set_starlight(target_color, start_range, start_power)
diff --git a/code/controllers/subsystem/statpanel.dm b/code/controllers/subsystem/statpanel.dm
index 89031843c5a..f2843ed4b66 100644
--- a/code/controllers/subsystem/statpanel.dm
+++ b/code/controllers/subsystem/statpanel.dm
@@ -65,6 +65,7 @@ SUBSYSTEM_DEF(statpanels)
"Connected Players: [GLOB.clients.len]",
" ",
"OOC: [GLOB.ooc_allowed ? "Enabled" : "Disabled"]",
+ "Storyteller: [SSstorytellers.active ? SSstorytellers.active.name : "N/A"]", // Fluffy edit - активные рассказчик
" ",
"Server Time: [time2text(world.timeofday, "YYYY-MM-DD hh:mm:ss")]",
"Station Time: [station_time_timestamp()]",
diff --git a/code/controllers/subsystem/ticker.dm b/code/controllers/subsystem/ticker.dm
index c339d764b1e..40c556445f9 100644
--- a/code/controllers/subsystem/ticker.dm
+++ b/code/controllers/subsystem/ticker.dm
@@ -163,6 +163,7 @@ SUBSYSTEM_DEF(ticker)
*/ // NOVA EDIT REMOVAL END
current_state = GAME_STATE_PREGAME
SStitle.change_title_screen() // NOVA EDIT ADDITION - Title screen
+ SSstorytellers.start_vote(2 MINUTES) //Fluffy edit - голосование за рассказчик
addtimer(CALLBACK(SStitle, TYPE_PROC_REF(/datum/controller/subsystem/title, change_title_screen)), 1 SECONDS) // NOVA EDIT ADDITION - Title screen
SEND_SIGNAL(src, COMSIG_TICKER_ENTER_PREGAME)
@@ -249,7 +250,7 @@ SUBSYSTEM_DEF(ticker)
CHECK_TICK
//Configure mode and assign player to antagonists
var/can_continue = FALSE
- can_continue = SSdynamic.select_roundstart_antagonists() //Choose antagonists
+ can_continue = SSstorytellers.setup_game() // Elufft edit - STORYTELLER can_continue, оригинал: = SSdynamic.select_roundstart_antagonists() // Choose antagonists
CHECK_TICK
SEND_GLOBAL_SIGNAL(COMSIG_GLOB_PRE_JOBS_ASSIGNED, src)
can_continue = can_continue && SSjob.divide_occupations() //Distribute jobs
@@ -318,11 +319,13 @@ SUBSYSTEM_DEF(ticker)
/datum/controller/subsystem/ticker/proc/PostSetup()
set waitfor = FALSE
+ /* Fluffy edit REMOVAL BEGIN - Рассказчик ролит антагов сам
// Spawn traitors and stuff
for(var/datum/dynamic_ruleset/roundstart/ruleset in SSdynamic.queued_rulesets)
ruleset.execute()
SSdynamic.queued_rulesets -= ruleset
SSdynamic.executed_rulesets += ruleset
+ */ // Fluffy edit REMOVAL END - Storyteller
// Queue roundstart intercept report
/* // NOVA EDIT REMOVAL START
if(!CONFIG_GET(flag/no_intercept_report))
@@ -386,6 +389,7 @@ SUBSYSTEM_DEF(ticker)
to_chat(iter_human, span_notice("You will gain [round(iter_human.hardcore_survival_score) * 2] hardcore random points if you greentext this round!"))
else
to_chat(iter_human, span_notice("You will gain [round(iter_human.hardcore_survival_score)] hardcore random points if you survive this round!"))
+ SSstorytellers.post_setup()
/datum/controller/subsystem/ticker/proc/display_roundstart_logout_report()
var/list/msg = list("[span_boldnotice("Roundstart logout report")]\n\n")
diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm
index d9c8f25d52e..b5e3d31b059 100644
--- a/code/modules/admin/topic.dm
+++ b/code/modules/admin/topic.dm
@@ -76,7 +76,9 @@
edit_rights_topic(href_list)
else if(href_list["gamemode_panel"])
- dynamic_panel(usr)
+ // dynamic_panel(usr) Fluffy edit
+ var/datum/storyteller_admin_ui/stl_ui = new // Fluffy edit - STORYTELLER
+ stl_ui.ui_interact(usr) // Fluffy edit - STORYTELLER
else if(href_list["call_shuttle"])
if(!check_rights(R_ADMIN))
diff --git a/code/modules/jobs/departments/departments.dm b/code/modules/jobs/departments/departments.dm
index 6a2d7633603..928f4f76fef 100644
--- a/code/modules/jobs/departments/departments.dm
+++ b/code/modules/jobs/departments/departments.dm
@@ -44,7 +44,7 @@
job_datum.job_flags &= ~JOB_NEW_PLAYER_JOINABLE
job_datum.spawn_positions = 0
job_datum.total_positions = 0
-
+ add_storyweight(job_datum) // Fluffy edit
/// Returns all jobs that are in this category for jobbans
/datum/job_department/proc/get_jobban_jobs()
return department_jobs.Copy()
diff --git a/code/modules/mob/dead/new_player/new_player.dm b/code/modules/mob/dead/new_player/new_player.dm
index b800b0a0b08..1a478aefb9a 100644
--- a/code/modules/mob/dead/new_player/new_player.dm
+++ b/code/modules/mob/dead/new_player/new_player.dm
@@ -282,8 +282,11 @@
GLOB.joined_player_list += character.ckey
+ /* Fluffy edit - только рассказчик ролит антагов
if(CONFIG_GET(flag/allow_latejoin_antagonists) && !EMERGENCY_PAST_POINT_OF_NO_RETURN && humanc) //Borgs aren't allowed to be antags. Will need to be tweaked if we get true latejoin ais.
+
SSdynamic.on_latejoin(humanc)
+ */
if(humanc)
if(job.job_flags & JOB_ASSIGN_QUIRKS)
diff --git a/code/modules/power/apc/apc_main.dm b/code/modules/power/apc/apc_main.dm
index 1156e180865..69ef512456b 100644
--- a/code/modules/power/apc/apc_main.dm
+++ b/code/modules/power/apc/apc_main.dm
@@ -530,6 +530,7 @@
if(get_malf_status(user))
malfvacate()
if("reboot")
+ if(HAS_TRAIT(src, TRAIT_NO_REBOOT_EVENT)) return TRUE // Fluffy edit
failure_timer = 0
force_update = FALSE
update_appearance()
diff --git a/config/config.txt b/config/config.txt
index 85577b638b1..0b7e92a1f2f 100644
--- a/config/config.txt
+++ b/config/config.txt
@@ -6,6 +6,7 @@ $include comms.txt
$include logging.txt
$include resources.txt
$include nova/config_nova.txt
+$include storyteller/storyteller_config.txt
$include interviews.txt
$include lua.txt
$include auxtools.txt
diff --git a/config/storyteller/events/antagonist.json b/config/storyteller/events/antagonist.json
new file mode 100644
index 00000000000..11aa3d1a23f
--- /dev/null
+++ b/config/storyteller/events/antagonist.json
@@ -0,0 +1,192 @@
+{
+ "storyteller_traitor": {
+ "antag_name": "Traitor",
+ "role_flag": "Traitor",
+ "enabled": 1,
+ "story_weight": 3,
+ "min_players": 15,
+ "required_round_progress": 0,
+ "requierd_threat_level": 1,
+ "max_candidates": 1
+ },
+ "storyteller_bloodsucker": {
+ "antag_name": "Bloodsucker",
+ "role_flag": "Bloodsucker",
+ "enabled": 1,
+ "story_weight": 3,
+ "min_players": 15,
+ "required_round_progress": 0,
+ "requierd_threat_level": 1,
+ "max_candidates": 1
+ },
+ "storyteller_heretic": {
+ "antag_name": "Heretic",
+ "role_flag": "Heretic",
+ "enabled": 1,
+ "story_weight": 3,
+ "min_players": 15,
+ "required_round_progress": 0,
+ "requierd_threat_level": 2,
+ "max_candidates": 2
+ },
+ "storyteller_changeling": {
+ "antag_name": "Changeling",
+ "role_flag": "Changeling",
+ "enabled": 1,
+ "story_weight": 3,
+ "min_players": 15,
+ "required_round_progress": 0,
+ "requierd_threat_level": 2,
+ "max_candidates": 2
+ },
+ "storyteller_obsessed": {
+ "antag_name": "Obsessed",
+ "role_flag": "Obsessed",
+ "enabled": 1,
+ "story_weight": 2.5,
+ "min_players": 5,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 1,
+ "max_candidates": 1
+ },
+ "storyteller_nightmare": {
+ "antag_name": "Nightmare",
+ "role_flag": "Nightmare",
+ "enabled": 1,
+ "story_weight": 2.5,
+ "min_players": 10,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 2,
+ "max_candidates": 1
+ },
+ "storyteller_slaughter_demon": {
+ "antag_name": "Slaughter Demon",
+ "role_flag": "Slaughter Demon",
+ "enabled": 0,
+ "story_weight": 2.5,
+ "min_players": 15,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 2,
+ "max_candidates": 1
+ },
+ "storyteller_morph": {
+ "antag_name": "Morph",
+ "role_flag": "Morph",
+ "enabled": 1,
+ "story_weight": 2.5,
+ "min_players": 10,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 2,
+ "max_candidates": 1
+ },
+ "storyteller_blood_brother": {
+ "antag_name": "Blood Brothers",
+ "role_flag": "Blood Brother",
+ "enabled": 0,
+ "story_weight": 3,
+ "min_players": 10,
+ "required_round_progress": 0,
+ "requierd_threat_level": 1,
+ "max_candidates": 2
+ },
+ "storyteller_spies": {
+ "antag_name": "Spies",
+ "role_flag": "Spy",
+ "enabled": 1,
+ "story_weight": 3,
+ "min_players": 10,
+ "required_round_progress": 0,
+ "requierd_threat_level": 2,
+ "max_candidates": 2
+ },
+ "storyteller_loneop": {
+ "antag_name": "Lone Operative",
+ "role_flag": "Lone Operative",
+ "enabled": 1,
+ "story_weight": 4,
+ "min_players": 20,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 3,
+ "max_candidates": 1
+ },
+ "storyteller_revolution": {
+ "antag_name": "Revolution",
+ "role_flag": "Provocateur",
+ "enabled": 0,
+ "story_weight": 5,
+ "min_players": 30,
+ "required_round_progress": 0,
+ "requierd_threat_level": 3,
+ "max_candidates": 1
+ },
+ "storyteller_malf_ai": {
+ "antag_name": "Malfunctioning AI",
+ "role_flag": "Malf",
+ "enabled": 1,
+ "story_weight": 5,
+ "min_players": 30,
+ "required_round_progress": 0,
+ "requierd_threat_level": 3,
+ "max_candidates": 1
+ },
+ "storyteller_blob_infection": {
+ "antag_name": "Blob Infection",
+ "role_flag": "Blob Infection",
+ "enabled": 1,
+ "story_weight": 5,
+ "min_players": 25,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 3,
+ "max_candidates": 1
+ },
+ "storyteller_wizard": {
+ "antag_name": "Wizard",
+ "role_flag": "Wizard",
+ "enabled": 0,
+ "story_weight": 5,
+ "min_players": 20,
+ "required_round_progress": 0,
+ "requierd_threat_level": 3,
+ "max_candidates": 1
+ },
+ "storyteller_blob": {
+ "antag_name": "Blob",
+ "role_flag": "Blob",
+ "enabled": 1,
+ "story_weight": 6,
+ "min_players": 25,
+ "required_round_progress": 0.6,
+ "requierd_threat_level": 4,
+ "max_candidates": 1
+ },
+ "storyteller_xenos": {
+ "antag_name": "Xenomorphs",
+ "role_flag": "Xenomorph",
+ "enabled": 0,
+ "story_weight": 5,
+ "min_players": 30,
+ "required_round_progress": 0.41,
+ "requierd_threat_level": 3,
+ "max_candidates": 3
+ },
+ "storyteller_nuclear": {
+ "antag_name": "Nuclear Operatives",
+ "role_flag": "Nukeop",
+ "enabled": 0,
+ "story_weight": 7,
+ "min_players": 25,
+ "required_round_progress": 0,
+ "requierd_threat_level": 4,
+ "max_candidates": 5
+ },
+ "storyteller_blood_cult": {
+ "antag_name": "Blood Cult",
+ "role_flag": "Cultist",
+ "enabled": 0,
+ "story_weight": 6,
+ "min_players": 30,
+ "required_round_progress": 0,
+ "requierd_threat_level": 3,
+ "max_candidates": 4
+ }
+}
diff --git a/config/storyteller/events/bad.json b/config/storyteller/events/bad.json
new file mode 100644
index 00000000000..0e754b2889b
--- /dev/null
+++ b/config/storyteller/events/bad.json
@@ -0,0 +1,59 @@
+{
+ "brain_trauma": {
+ "min_players": 5,
+ "required_round_progress": 0.1,
+ "requierd_threat_level": 1
+ },
+ "brand_intelligence": {
+ "min_players": 5,
+ "requierd_threat_level": 2
+ },
+ "comm_blackout": {},
+ "epidemic_outbreak": {
+ "min_players": 10,
+ "required_round_progress": 0.4,
+ "requierd_threat_level": 2
+ },
+ "electrical_storm": {},
+ "fire_spread": {
+ "min_players": 10,
+ "required_round_progress": 0.1,
+ "requierd_threat_level": 1
+ },
+ "negative_ion_storm": {
+ "story_weight": 0.5
+ },
+ "storyteller_meteors": {
+ "min_players": 15,
+ "required_round_progress": 0.4
+ },
+ "sabotage_infrastructure": {
+ "min_players": 4,
+ "required_round_progress": 0.1
+ },
+ "supermatter_surge": {
+ "min_players": 10,
+ "required_round_progress": 0.4,
+ "story_weight": 0.4
+ },
+ "psychic_drone": {
+ "min_players": 5,
+ "required_round_progress": 0.1,
+ "requierd_threat_level": 2
+ },
+ "psychic_wave": {
+ "min_players": 4,
+ "required_round_progress": 0.1,
+ "requierd_threat_level": 1
+ },
+ "zzzzzt": {
+ "min_players": 2
+ },
+ "raid": {
+ "min_players": 15,
+ "requierd_threat_level": 2,
+ "required_round_progress": 0.1,
+ "story_weight": 1.2,
+ "enabled": false
+ }
+}
diff --git a/config/storyteller/events/good.json b/config/storyteller/events/good.json
new file mode 100644
index 00000000000..715fec12f38
--- /dev/null
+++ b/config/storyteller/events/good.json
@@ -0,0 +1,4 @@
+{
+ "aurora_caelus": {},
+ "cargo_pod": {}
+}
diff --git a/config/storyteller/events/neutral.json b/config/storyteller/events/neutral.json
new file mode 100644
index 00000000000..c860d59c674
--- /dev/null
+++ b/config/storyteller/events/neutral.json
@@ -0,0 +1,24 @@
+{
+ "sandstorm": {
+ "min_players": 15,
+ "story_weight": 1,
+ "story_prioty": 1,
+ "requierd_threat_level": 2,
+ "required_round_progress": 0.21
+ },
+ "carp_migration": {
+ "min_players": 5,
+ "required_round_progress": 0.1,
+ "requierd_threat_level": 2
+ },
+ "gravity_generator_error": {
+ "required_round_progress": 0.1
+ },
+ "gravity_generator_malfunction": {
+ "required_round_progress": 0.1
+ },
+ "grid_check": {},
+ "market_crash": {
+ "story_weight": 1.2
+ }
+}
diff --git a/config/storyteller/storyteller_config.txt b/config/storyteller/storyteller_config.txt
new file mode 100644
index 00000000000..77bc340e0f0
--- /dev/null
+++ b/config/storyteller/storyteller_config.txt
@@ -0,0 +1,38 @@
+# Storyteller Configuration
+# This file contains configuration values for the storyteller system
+
+# Population thresholds (player counts)
+# These determine when population scaling factors are applied
+STORY_POPULATION_THRESHOLD_LOW 10
+STORY_POPULATION_THRESHOLD_MEDIUM 21
+STORY_POPULATION_THRESHOLD_HIGH 32
+STORY_POPULATION_THRESHOLD_FULL 51
+
+# Population factors (multipliers for event frequency/intensity)
+# Lower values = fewer/smaller events for that population range
+STORY_POPULATION_FACTOR_LOW 0.3
+STORY_POPULATION_FACTOR_MEDIUM 0.5
+STORY_POPULATION_FACTOR_HIGH 0.8
+STORY_POPULATION_FACTOR_FULL 1.0
+
+# Population smoothing weight (0-1, higher = slower changes)
+STORY_POPULATION_SMOOTH_WEIGHT 0.2
+
+# Maximum history entries for population tracking
+STORY_POPULATION_HISTORY_MAX 20
+
+# Multiple events plansning (based on storyteller state)
+# Maximum consecutive events that can be planned in one cycle
+STORY_MAX_CONSECUTIVE_EVENTS 3
+# Minimum tension required to plan multiple events
+STORY_MULTI_EVENT_TENSION_THRESHOLD 40
+# Minimum threat points required to plan multiple events
+STORY_MULTI_EVENT_THREAT_THRESHOLD 500
+
+# Is storyteller should replace dynamic
+STORYTELLER_REPLACE_DYNAMIC 1
+# Uncomment this line to allow storyteller helping active antagonists
+# STORYTELLER_HELPS_ANTAGS 1
+
+# How many security officers need for storyteller recognice them as strong
+STRONG_SECURITY_COUNT 8
diff --git a/config/storyteller/storytellers.json b/config/storyteller/storytellers.json
new file mode 100644
index 00000000000..ff73d4bd933
--- /dev/null
+++ b/config/storyteller/storytellers.json
@@ -0,0 +1,151 @@
+[
+ {
+ "id": "mia_chill",
+ "name": "Mia'Chill",
+ "desc": "A young Teshari explorer wandering through the stars in search of new worlds. Her calm and talkative nature brings order and balance to the chaos around her.",
+ "personality_traits": [
+ "KIND",
+ "CAN_HELP",
+ "NO_ANTAGS",
+ "LOVE_SPEAK",
+ "NO_MAJOR_EVENTS",
+ "MORE_NEUTRAL_EVENTS"
+ ],
+ "ooc_desc": "Mia is a storyteller who nurtures the growth of the station. With her, major threats are rare — often replaced by uplifting and positive events. However, on higher difficulties, even she can turn the tides against the crew.",
+ "ooc_difficulty": "Easy",
+ "portait_path": "portraits/p_mia.png",
+ "logo_path": "logos/logo_mia.png",
+ "threat_growth_rate": 0.7,
+ "adaptation_decay_rate": 0.1,
+ "target_tension": 25,
+ "grace_period": 15,
+ "repetition_penalty": 0.9,
+ "player_antag_balance": 0.2,
+ "mood_type": "/datum/storyteller_mood/chill",
+ "base_think_delay": 120,
+ "average_event_interval": 30,
+ "max_threat_scale": 50.0,
+ "mood_update_interval": 20,
+ "recent_damage_threshold": 60,
+ "welcome_speech": ["Welcome to a serene journey through the stars."],
+ "round_speech": ["May peace guide your path."]
+ },
+ {
+ "id": "cas_classic",
+ "name": "Cas'Classic",
+ "desc": "Cas is a fox writer who adores well-balanced, adventurous tales — stories where great danger always leads to discovery and growth.",
+ "personality_traits": ["CAN_HELP", "BALANCER", "RARE_ANTAG_SPAWN"],
+ "ooc_desc": "Cas strives to create challenges that match the crew’s strength. Over time, threats grow more serious, but so do the rewards. The station’s population directly affects the scale of danger.",
+ "ooc_difficulty": "Medium",
+ "portait_path": "portraits/p_cas.png",
+ "logo_path": "logos/logo_cas.png",
+ "threat_growth_rate": 1,
+ "adaptation_decay_rate": 0.015,
+ "target_tension": 60,
+ "grace_period": 10,
+ "repetition_penalty": 0.65,
+ "player_antag_balance": 0.5,
+ "mood_type": "/datum/storyteller_mood/classic",
+ "base_think_delay": 120,
+ "average_event_interval": 20,
+ "max_threat_scale": 80.0,
+ "mood_update_interval": 10,
+ "recent_damage_threshold": 50,
+ "welcome_speech": ["Prepare for a classic tale of struggle and triumph."],
+ "round_speech": ["Victory is only the prelude to the next challenge."]
+ },
+ {
+ "id": "randall_gambit",
+ "name": "Randall's Gambit",
+ "desc": "Randall is a thrill-seeking gambler who leaves everything to chance. Forget about peace — he’ll send crates of beer right after a meteor strike if fate wills it.",
+ "ooc_desc": "Randall relies heavily on randomness, creating unpredictable chains of events. Though chaotic, the overall tension still rises steadily over time, keeping everyone guessing what comes next.",
+ "personality_traits": [
+ "FORCE_TENSION",
+ "HARDCORE_RANDOM",
+ "NO_ADAPTAION_DECAY",
+ "FREQUENT_ANTAG_SPAWN"
+ ],
+ "ooc_difficulty": "Hard",
+ "portait_path": "portraits/p_random.png",
+ "logo_path": "logos/logo_random.png",
+ "threat_growth_rate": 1.5,
+ "target_tension": 65,
+ "grace_period": 10,
+ "repetition_penalty": 0.25,
+ "player_antag_balance": 0.6,
+ "mood_type": "/datum/storyteller_mood/gambit",
+ "behevour_type": "/datum/storyteller_behevour/random",
+ "base_think_delay": 120,
+ "average_event_interval": 20,
+ "max_threat_scale": 100.0,
+ "mood_update_interval": 10,
+ "recent_damage_threshold": 40,
+ "welcome_speech": ["Let the dice roll in this gambit of fate."],
+ "round_speech": ["Fortune favors the bold... or does it?"]
+ },
+ {
+ "id": "catastrophe",
+ "name": "Edd Catastrophe",
+ "desc": "Edd Catastrophe hails from a void where mercy and compassion have no meaning. His existence is defined by ruin and genocide — he will leave nothing of the station behind.",
+ "personality_traits": [
+ "NO_MERCY",
+ "FORCE_TENSION",
+ "NO_GOOD_EVENTS",
+ "IGNORE_RESOURCES",
+ "MAJOR_ANTAGONISTS",
+ "FREQUENT_ANTAG_SPAWN",
+ "IMMEDIATE_ANTAG_SPAWN"
+ ],
+ "ooc_desc": "Catastrophe unleashes the heaviest and deadliest threats without regard for the crew’s condition. Half the station may lie dying, the rest losing their minds — and he will not stop. Positive events are entirely disabled under his rule.",
+ "ooc_difficulty": "Extreme",
+ "portait_path": "portraits/p_edd.png",
+ "logo_path": "logos/logo_edd.png",
+ "threat_growth_rate": 1.5,
+ "adaptation_decay_rate": 0.01,
+ "target_tension": 90,
+ "grace_period": 15,
+ "repetition_penalty": 0.4,
+ "player_antag_balance": 0.8,
+ "mood_type": "/datum/storyteller_mood/catastrophe",
+ "base_think_delay": 120,
+ "average_event_interval": 15,
+ "max_threat_scale": 120.0,
+ "mood_update_interval": 20,
+ "recent_damage_threshold": 10,
+ "welcome_speech": ["Brace yourselves — the catastrophe has begun."],
+ "round_speech": ["Destruction comes in waves."]
+ },
+ {
+ "id": "lovers",
+ "name": "Mia & Edd'Challenge",
+ "desc": "A true paradox — a being forged in destruction meets one who embodies peace. Together they create harmony at the very edge of catastrophe.",
+ "personality_traits": [
+ "CAN_HELP",
+ "BALANCER",
+ "LOVE_SPEAK",
+ "FORCE_TENSION",
+ "MAJOR_ANTAGONISTS",
+ "FREQUENT_ANTAG_SPAWN",
+ "IMMEDIATE_ANTAG_SPAWN"
+ ],
+ "ooc_desc": "Edd and Mia balance each other perfectly. While Catastrophe brings disaster upon the station, Chill’s serenity gives the crew a chance to survive and overcome it.",
+ "ooc_difficulty": "Very Hard",
+ "portait_path": "portraits/p_lovers.png",
+ "logo_path": "logos/logo_lovers.png",
+ "threat_growth_rate": 1,
+ "adaptation_decay_rate": 0.05,
+ "target_tension": 75,
+ "grace_period": 10,
+ "repetition_penalty": 0.5,
+ "player_antag_balance": 0.6,
+ "mood_type": "/datum/storyteller_mood/challenge",
+ "behevour_type": "/datum/storyteller_behevour/inverted",
+ "base_think_delay": 120,
+ "average_event_interval": 20,
+ "max_threat_scale": 100.0,
+ "mood_update_interval": 15,
+ "recent_damage_threshold": 30,
+ "welcome_speech": ["Embrace the challenge — where serenity meets fury."],
+ "round_speech": ["Balance on the edge of catastrophe."]
+ }
+]
diff --git a/config/storytellers_icons/logos/logo_cas.png b/config/storytellers_icons/logos/logo_cas.png
new file mode 100644
index 00000000000..cea71162497
Binary files /dev/null and b/config/storytellers_icons/logos/logo_cas.png differ
diff --git a/config/storytellers_icons/logos/logo_edd.png b/config/storytellers_icons/logos/logo_edd.png
new file mode 100644
index 00000000000..a221459d40b
Binary files /dev/null and b/config/storytellers_icons/logos/logo_edd.png differ
diff --git a/config/storytellers_icons/logos/logo_lovers.png b/config/storytellers_icons/logos/logo_lovers.png
new file mode 100644
index 00000000000..24814c43d70
Binary files /dev/null and b/config/storytellers_icons/logos/logo_lovers.png differ
diff --git a/config/storytellers_icons/logos/logo_mia.png b/config/storytellers_icons/logos/logo_mia.png
new file mode 100644
index 00000000000..6b9070f3ec9
Binary files /dev/null and b/config/storytellers_icons/logos/logo_mia.png differ
diff --git a/config/storytellers_icons/logos/logo_random.png b/config/storytellers_icons/logos/logo_random.png
new file mode 100644
index 00000000000..2382d8f533a
Binary files /dev/null and b/config/storytellers_icons/logos/logo_random.png differ
diff --git a/config/storytellers_icons/portraits/p_cas.png b/config/storytellers_icons/portraits/p_cas.png
new file mode 100644
index 00000000000..c43cefe6765
Binary files /dev/null and b/config/storytellers_icons/portraits/p_cas.png differ
diff --git a/config/storytellers_icons/portraits/p_edd.png b/config/storytellers_icons/portraits/p_edd.png
new file mode 100644
index 00000000000..0824271b2e7
Binary files /dev/null and b/config/storytellers_icons/portraits/p_edd.png differ
diff --git a/config/storytellers_icons/portraits/p_lovers.png b/config/storytellers_icons/portraits/p_lovers.png
new file mode 100644
index 00000000000..3bd6b8775c3
Binary files /dev/null and b/config/storytellers_icons/portraits/p_lovers.png differ
diff --git a/config/storytellers_icons/portraits/p_mia.png b/config/storytellers_icons/portraits/p_mia.png
new file mode 100644
index 00000000000..5f2a58dd8e4
Binary files /dev/null and b/config/storytellers_icons/portraits/p_mia.png differ
diff --git a/config/storytellers_icons/portraits/p_random.png b/config/storytellers_icons/portraits/p_random.png
new file mode 100644
index 00000000000..70d32f6603b
Binary files /dev/null and b/config/storytellers_icons/portraits/p_random.png differ
diff --git a/tff_modular/modules/storytellers/STORYTELLERS.md b/tff_modular/modules/storytellers/STORYTELLERS.md
new file mode 100644
index 00000000000..97bbbc4ff9d
--- /dev/null
+++ b/tff_modular/modules/storytellers/STORYTELLERS.md
@@ -0,0 +1,433 @@
+# Storyteller System
+
+## General Description
+
+The Storyteller System is a dynamic system for managing events and goals on the station, inspired by RimWorld mechanics. It analyzes the station's state, balances game events, and creates an adaptive gameplay experience, automatically adjusting difficulty to the current situation. The system operates as an intelligent director that monitors station conditions, predicts player needs, and orchestrates events to maintain engaging gameplay without manual intervention.
+
+## System Architecture
+
+### Core Components
+
+The storyteller system is built around several interconnected components that work together to create adaptive storytelling:
+
+#### 1. Storyteller Core (`~storyteller.dm`)
+
+The central datum that orchestrates all storyteller operations. It maintains the system's state and coordinates between components:
+
+- **Initialization**: Sets up mood, behavior, planner, analyzer, and balancer components
+- **Think Loop**: Runs every 2-4 minutes (scaled by mood), performing analysis, planning, and event execution
+- **Threat Management**: Accumulates threat points over time, manages adaptation factor for difficulty scaling
+- **Population Scaling**: Adjusts event frequency and intensity based on player count
+- **Antagonist Integration**: Handles roundstart antagonist selection and mid-round spawning
+
+Key variables:
+
+- `threat_points`: Accumulates over time to scale event intensity (0-10000)
+- `adaptation_factor`: Reduces threat after crew successes (0-1, lower = more adapted)
+- `population_factor`: Scales events for crew size (0.3-1.0)
+- `current_tension`: Overall station tension level (0-100)
+
+#### 2. Behavior System (`storyteller_behevour.dm`)
+
+Determines how the storyteller selects and categorizes events. The behavior analyzes station state and generates tag filters for event selection:
+
+- **Tokenization**: Analyzes metrics to generate relevant event tags (combat, health, environmental, etc.)
+- **Category Determination**: Decides between good/bad/neutral events based on tension and mood
+- **Weighted Selection**: Applies various modifiers to event weights (repetition penalty, tag matching, difficulty scaling)
+
+Available behaviors:
+
+- **Default**: Balanced, adaptive selection
+- **Random**: Completely random tag selection for unpredictable gameplay
+- **Inverted**: Every third bad event becomes good for chaotic storytelling
+
+#### 3. Planner (`storyteller_planner.dm`)
+
+Manages the event timeline and scheduling:
+
+- **Timeline Management**: Maintains a queue of upcoming events with fire times
+- **Anti-Clustering**: Prevents events from firing too close together
+- **Recalculation**: Periodically rebuilds the plan based on changing station state
+- **Major Event Spacing**: Enforces cooldowns between significant events
+
+Key features:
+
+- Maintains at least 3 pending events in timeline
+- Scales event intervals based on population and mood
+- Handles event replanning when conditions change
+
+#### 4. Balancer (`~storyteller_balancer.dm`)
+
+Calculates the balance between station strength and antagonist threats:
+
+- **Station Strength**: Weighted calculation of crew health, integrity, power, resources, research, and security
+- **Antagonist Strength**: Based on activity, effectiveness, and numbers
+- **Tension Calculation**: Complex formula considering damage, security, integrity, resources, and activity
+- **Balance Ratio**: Antagonist strength vs station strength (higher = antags stronger)
+
+Tension formula includes:
+
+- Security penalties for low officer counts
+- Integrity penalties for station damage
+- Resource penalties for low supplies
+- Activity modifiers from antagonist actions
+- Tension bonuses from recent events
+
+#### 5. Analyzer (`~storyteller_analyzer.dm`)
+
+Collects and processes station metrics from various sources:
+
+- **Metric Collection**: Gathers data from crew health, station integrity, power systems, etc.
+- **Input Processing**: Converts raw data into normalized values for decision-making
+- **Vault Storage**: Maintains the metrics vault for system-wide access
+
+#### 6. Mood System (`storyteller_mood.dm`)
+
+Influences storyteller personality and pacing:
+
+- **Aggression**: Threat multiplier (0.0-2.0, higher = more dangerous events)
+- **Pace**: Event frequency multiplier (0.1-3.0, higher = more events)
+- **Volatility**: Randomness in decisions (0.0-2.0, higher = more unpredictable)
+
+Available moods:
+
+- **Chill**: Low aggression, slow pace, minimal volatility
+- **Classic**: Balanced settings for traditional gameplay
+- **Gambit**: High volatility for unpredictable rounds
+- **Catastrophe**: High aggression, fast pace for intense rounds
+
+### Metrics System
+
+The metrics system collects data from across the station:
+
+#### Crew Metrics
+
+- **Health**: Average crew health percentage (0-100)
+- **Wounds**: Categorizes wound severity (none/some/many/critical)
+- **Diseases**: Tracks disease prevalence (none/minor/major/outbreak)
+- **Deaths**: Death counts and ratios for tension calculation
+
+#### Station Metrics
+
+- **Integrity**: Overall structural health (0-100)
+- **Power**: Grid strength and damage levels
+- **Resources**: Minerals and cargo points available
+- **Security**: Active officer count and alert status
+
+#### Antagonist Metrics
+
+- **Activity**: Tracks kills, objectives, and disruption
+- **Effectiveness**: Success rate of antagonist actions
+- **Presence**: Number and types of active antagonists
+
+### Subsystem Integration
+
+- **SSstorytellers**: Manages storyteller lifecycle and voting
+- **Event Control**: Integrates with the round event system
+- **UI System**: Provides administrative interface for monitoring
+
+## Decision-Making Process
+
+The storyteller's decision-making follows a structured process every think cycle:
+
+### 1. State Analysis
+
+- Collects current metrics from the analyzer
+- Creates a balance snapshot with station/antagonist strengths
+- Updates tension level and adaptation factor
+
+### 2. Mood Adaptation
+
+- Adjusts mood based on tension vs target tension
+- Updates pace, aggression, and volatility multipliers
+
+### 3. Event Planning
+
+- Determines event category (good/bad/neutral) based on tension and mood
+- Generates tag filters based on station conditions
+- Selects and weights available events
+- Plans event timing in the timeline
+
+### 4. Threat Management
+
+- Accumulates threat points over time
+- Applies adaptation decay after successes
+- Scales threat by population and difficulty
+
+### 5. Antagonist Balance
+
+- Checks if additional antagonists should spawn
+- Maintains target balance ratio between players and threats
+
+## Event Selection Logic
+
+Event selection is a multi-stage process designed to create coherent, responsive storytelling:
+
+### Tag-Based Filtering
+
+The behavior system analyzes station state to generate relevant tags:
+
+**Tone Tags**: Epic, Tragic, Humorous - set event atmosphere
+**Category Tags**: Escalation/Deescalation, Combat, Social, Environmental
+**Impact Tags**: Wide Impact, Targets Individuals, Affects Whole Station
+**Requirement Tags**: Requires Security, Engineering, Medical
+**Special Tags**: Health, Antagonist, Major, Roundstart
+
+### Category Determination
+
+Based on current tension and mood:
+
+- **High Tension + Low Target**: Prefer GOOD events (recovery/relief)
+- **Low Tension + High Target**: Prefer BAD events (challenge/threat)
+- **Balanced Tension**: NEUTRAL events (variety)
+
+### Weight Calculation
+
+Each event receives a base weight modified by:
+
+- **Repetition Penalty**: Reduces weight of recently fired events (decays over 20 minutes)
+- **Tag Matching Bonus**: Increases weight for events matching desired tags
+- **Tension Balancing**: Boosts events that help correct tension imbalance
+- **Population Scaling**: Adjusts for crew size (larger crews = more events)
+- **Difficulty Scaling**: Applies global difficulty multiplier
+- **Timeline Conflicts**: Reduces weight if event already planned
+
+### Selection Process
+
+1. Filter events by availability and requirements
+2. Apply all weighting modifiers
+3. Pick weighted random event from candidates
+4. Verify event can fire immediately
+5. Add to planner timeline with calculated fire time
+
+## Threat Points and Adaptation
+
+### Threat Point Mechanics
+
+Threat points represent accumulated narrative pressure that scales event intensity:
+
+- **Accumulation**: +1.0 per think cycle, scaled by mood aggression and population
+- **Maximum**: Capped at 10000 points (apocalyptic level)
+- **Scaling**: Events use threat points to determine intensity/difficulty
+
+Threat levels:
+
+- **Low** (0-100): Minor events, basic challenges
+- **Moderate** (100-500): Standard events, balanced threats
+- **High** (500-2000): Major events, significant challenges
+- **Extreme** (2000-5000): Crisis events, station-wide threats
+- **Apocalyptic** (5000+): Catastrophic events, existential threats
+
+### Adaptation System
+
+Adaptation reduces threat intensity after crew successes:
+
+- **Trigger**: Increases after escalation-tagged events
+- **Decay**: -0.05 per think cycle when active
+- **Effect**: Reduces effective threat by up to 50%
+- **Reset**: Gradual recovery allows threat to return
+
+### Scaling Factors
+
+- **Population**: Low pop (0.3x) reduces threat, high pop (1.0x) increases threat
+- **Mood**: Aggression multiplier directly scales threat
+- **Difficulty**: Global multiplier for server-wide tuning
+
+## Event Addition Process
+
+### Creating New Events
+
+1. **Define Event Control** (`storyteller_event_control.dm`):
+
+ ```dm
+ /datum/round_event_control/my_event
+ name = "My Custom Event"
+ typepath = /datum/round_event/my_event
+ story_category = STORY_GOAL_BAD // Category for storyteller
+ tags = list(STORY_TAG_COMBAT, STORY_TAG_ESCALATION) // Relevant tags
+ story_weight = STORY_GOAL_BASE_WEIGHT // Base selection weight
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC // Minimum threat to fire
+ ```
+
+2. **Implement Event Datum** (`~round_event.dm`):
+
+ ```dm
+ /datum/round_event/my_event
+ STORYTELLER_EVENT //define meaning - storyteller_implementation = TRUE, Enable storyteller features
+
+ __setup_for_storyteller(threat_points, additional_args)
+ // Use threat_points to scale event difficulty
+ // Access storyteller inputs via get_inputs()
+ // Get storyteller reference via get_executer()
+ ```
+
+3. **Configure Tags and Requirements**:
+ - Choose appropriate category (GOOD/BAD/NEUTRAL/ANTAGONIST)
+ - Add relevant tags for behavior matching
+ - Set threat requirements and round progress minimums
+ - Define weight for selection probability
+
+4. **Implement Storyteller Hooks**:
+ - Override `__start_for_storyteller()` for custom start logic
+ - Override `__end_for_storyteller()` for cleanup
+ - Use `get_inputs()` to access current station metrics
+ - Use `get_executer()` to access storyteller for logging
+
+### Integration Steps
+
+1. Add event file to appropriate goals/ subdirectory
+2. Update any relevant metrics if needed
+3. Test event availability and weighting
+4. Verify tag matching works correctly
+5. Test threat point scaling
+
+## Key Mechanics
+
+### Mood System
+
+Mood profiles define storyteller personality:
+
+- **Pace Multiplier**: Affects think delay (0.1x to 3.0x normal)
+- **Threat Multiplier**: Scales event danger (0.0x to 2.0x)
+- **Variance Multiplier**: Adds randomness to decisions (0.0x to 2.0x)
+
+Mood updates every 5 minutes based on tension vs target tension.
+
+### Population Scaling
+
+Adjusts all mechanics based on active player count:
+
+- **Thresholds**: Low (< threshold_low), Medium, High, Full population
+- **Event Frequency**: Low pop = longer intervals, high pop = shorter intervals
+- **Threat Scaling**: Low pop = reduced threat, high pop = increased threat
+- **Grace Periods**: Low pop = longer cooldowns between events
+
+Population factor smooths changes to prevent jarring shifts.
+
+### Tension Management
+
+Tension (0-100) drives event selection and mood adaptation:
+
+**Calculation Factors**:
+
+- Security coverage (penalty for low officer counts)
+- Station integrity (penalty for damage)
+- Resource availability (penalty for shortages)
+- Antagonist activity (bonus for active threats)
+- Recent event history (bonuses from escalation/deescalation)
+
+**Target Tension**: Storyteller aims to keep tension around target level (default 50)
+
+**Adaptation**: Mood shifts to correct tension imbalances over time
+
+### Antagonist Integration
+
+Seamless integration with antagonist systems:
+
+- **Roundstart Selection**: Chooses antagonists 10 minutes after round start
+- **Mid-round Spawning**: Spawns additional threats to maintain balance
+- **Activity Tracking**: Monitors antagonist effectiveness via component trackers
+- **Balance Checks**: Every 30 minutes, evaluates if more antagonists needed
+- **Weight Calculation**: Assigns threat weights based on antagonist type and equipment
+
+## Principles of Operation
+
+### Station Analysis
+
+Continuous monitoring of station state through metrics collection and processing.
+
+### Balancing
+
+Dynamic calculation of player vs antagonist forces with tension-based adaptation.
+
+### Planning
+
+Timeline-based event scheduling with anti-clustering and mood-influenced pacing.
+
+### Adaptation
+
+Multi-layered adaptation to player count, round progress, crew performance, and antagonist effectiveness.
+
+## Key Concepts
+
+### Vault (Metrics Storage)
+
+Associative list containing all station metrics, defined in `~storyteller_vault.dm`. Keys include health states, resource levels, antagonist counts, etc.
+
+### Snapshot (State Snapshot)
+
+Instantaneous capture of current balance state, containing normalized strength values, tension level, and balance ratios used for planning decisions.
+
+### Mood
+
+Dynamic personality system affecting pacing, aggression, and decision volatility. Updates based on tension feedback.
+
+### Tension
+
+Overall station stress level (0-100) calculated from damage, security, resources, and antagonist activity. Drives event category selection.
+
+## Thresholds and Constants
+
+Comprehensive threshold system for state classification:
+
+- Health: Normal/Damaged/Low thresholds
+- Wounds: Some/Many/Critical wound levels
+- Diseases: Minor/Major/Outbreak severity
+- Deaths: Moderate/High/Extreme death ratios
+- Security: No/Weak/Moderate/Strong coverage
+- Integrity: Minor/Major/Critical damage levels
+- Power: Full/Low/Blackout/Critical failure states
+
+All constants defined in `code/__DEFINES/~~bubber_defines/storytellers/`.
+
+## Recent Improvements
+
+### Balancing Fixes
+
+1. **Health threshold logic**: Corrected order checking (worst to best)
+2. **Force ratio calculation**: Removed incorrect ratio overwriting
+3. **Security scaling**: Normalized security contribution values
+4. **Antagonist strength**: Removed arbitrary centering, implemented weighted sum
+5. **Health tension calculation**: Applied consistent normalization
+
+### Overall Tension Improvement
+
+Fully normalized tension calculation:
+
+- Base tension normalization from event history
+- Weighted antagonist effectiveness and activity combination
+- Normalized coordination, stealth, and vulnerability factors
+- Normalized force ratio and population factor calculations
+
+## Usage
+
+### For Administrators
+
+Storyteller UI provides:
+
+- Current state monitoring (tension, threat, balance)
+- Parameter configuration (difficulty, mood, targets)
+- Metric inspection and historical data
+- Manual event triggering for testing
+
+### For Developers
+
+**Adding Events**:
+
+1. Create event control with appropriate category and tags
+2. Implement storyteller-compatible event datum
+3. Configure weights, requirements, and threat levels
+4. Test integration with existing metrics
+
+**Adding Metrics**:
+
+1. Define vault key in `~storyteller_vault.dm`
+2. Create metric collector in `metrics/`
+3. Update balancer calculations if needed
+4. Verify normalization and integration
+
+**Modifying Behavior**:
+
+1. Extend `storyteller_behevour.dm` for custom logic
+2. Override tokenization and selection methods
+3. Test tag generation and event weighting
diff --git a/tff_modular/modules/storytellers/core/storyteller_analyzer.dm b/tff_modular/modules/storytellers/core/storyteller_analyzer.dm
new file mode 100644
index 00000000000..97ec4070535
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_analyzer.dm
@@ -0,0 +1,165 @@
+// Analyzer
+// and weights for crew and antagonists. These metrics influence event planning,
+// global goal selection, and balancing in the storyteller.
+// Station value is a rough estimate of the station's overall "worth" based on atoms.
+// Crew/antag weights help balance player vs. threat dynamics.
+/datum/storyteller_analyzer
+ // Our storyteller instance
+ VAR_PRIVATE/datum/storyteller/owner
+ /// Multiplier for the station value (can be adjusted by mood or other factors)
+ var/multiplier = 1.0
+
+ VAR_PRIVATE/list/check_list = list()
+
+ var/analyzing = FALSE
+
+ VAR_PRIVATE/cache_duration = 1 MINUTES
+
+ COOLDOWN_DECLARE(inputs_cache_duration)
+
+ COOLDOWN_DECLARE(station_integrity_duration)
+
+ VAR_PRIVATE/datum/storyteller_inputs/actual_inputs
+
+ VAR_PRIVATE/datum/station_state/actual_state
+
+ VAR_PRIVATE/datum/station_state/cached_state
+
+ VAR_PRIVATE/list/current_stack = list()
+
+/datum/storyteller_analyzer/New(datum/storyteller/_owner)
+ ..()
+ owner = _owner
+ // Discover and register metric stages dynamically
+ check_list = list()
+ for(var/type in subtypesof(/datum/storyteller_metric))
+ if(type == /datum/storyteller_metric)
+ continue
+ check_list += new type
+
+
+ cached_state = new
+ actual_state = new
+ SSstorytellers.register_analyzer(src)
+
+/datum/storyteller_analyzer/Destroy(force)
+ SSstorytellers.unregister_analyzer(src)
+ . = ..()
+
+
+/datum/storyteller_analyzer/process(seconds_per_tick)
+ if(COOLDOWN_FINISHED(src, inputs_cache_duration))
+ INVOKE_ASYNC(src, PROC_REF(scan_station))
+
+
+/datum/storyteller_analyzer/proc/get_inputs(scan_flags)
+ SHOULD_NOT_OVERRIDE(TRUE)
+ RETURN_TYPE(/datum/storyteller_inputs)
+ return actual_inputs
+
+
+/datum/storyteller_analyzer/proc/scan_station(scan_flags)
+ set waitfor = FALSE
+
+ if(analyzing)
+ return
+
+
+ current_stack = list()
+ analyzing = TRUE
+ SEND_SIGNAL(src, COMSIG_STORYTELLER_RUN_METRICS)
+
+ var/start_time = world.time
+ if(scan_flags & RESCAN_STATION_INTEGRITY)
+ get_station_integrity(TRUE)
+
+ COOLDOWN_START(src, inputs_cache_duration, cache_duration)
+ var/datum/storyteller_inputs/inputs = new
+ inputs.station_state = get_station_integrity()
+
+ var/metrics_count = 0
+ for(var/datum/storyteller_metric/check in check_list)
+ if(!check.can_perform_now(src, owner, inputs, scan_flags))
+ continue
+ current_stack += check
+ metrics_count++
+ // Protect metric execution
+ INVOKE_ASYNC(src, PROC_REF(__run_metric_safe), check, inputs, scan_flags)
+
+ // Wait for async metrics to finish or timeout
+ var/time_out = FALSE
+ if(metrics_count <= 0)
+ analyzing = FALSE
+ var/timeout_at = world.time + (cache_duration * 2)
+ while(analyzing && world.time < timeout_at)
+ CHECK_TICK
+ sleep(world.tick_lag)
+ if(analyzing)
+ // Timed out; stop now
+ time_out = TRUE
+ analyzing = FALSE
+ log_storyteller_analyzer("Analyzer scan timed out; continuing with partial inputs")
+
+ actual_inputs = inputs
+ var/end_time = world.time - start_time
+ current_stack = list()
+ SEND_SIGNAL(src, COMSIG_STORYTELLER_FINISHED_ANALYZING, inputs, time_out, metrics_count)
+ if(end_time > 5 SECONDS)
+ message_admins("WARNING: [owner.name] finished to analyze the station in [end_time / 10] seconds, which is longer than expected.")
+
+/datum/storyteller_analyzer/proc/__run_metric_safe(datum/storyteller_metric/check, datum/storyteller_inputs/inputs, scan_flags)
+ INVOKE_ASYNC(check, TYPE_PROC_REF(/datum/storyteller_metric, perform), src, owner, inputs, scan_flags)
+
+
+
+/datum/storyteller_analyzer/proc/try_stop_analyzing(datum/storyteller_metric/current)
+ if(!can_finish_analyzing(current))
+ return
+ analyzing = FALSE
+
+
+// Checks if the current scan stage is the last in the check_list
+// Returns TRUE if it is the last stage (analysis can finish), FALSE otherwise
+/datum/storyteller_analyzer/proc/can_finish_analyzing(datum/storyteller_metric/current)
+ if(!(current in check_list))
+ return FALSE
+
+ var/datum/storyteller_metric/last_metric = current_stack[current_stack.len]
+ if(current == last_metric)
+ return TRUE
+
+ return FALSE
+
+/datum/storyteller_analyzer/proc/get_station_integrity(force = FALSE)
+ set waitfor = FALSE
+
+ if(!actual_state)
+ actual_state = new
+ if(!cached_state)
+ cached_state = new
+
+ cached_state.floor = actual_state.floor
+ cached_state.wall = actual_state.wall
+ cached_state.r_wall = actual_state.r_wall
+ cached_state.window = actual_state.window
+ cached_state.door = actual_state.door
+ cached_state.grille = actual_state.grille
+ cached_state.mach = actual_state.mach
+
+ if(COOLDOWN_FINISHED(src, station_integrity_duration) || force)
+ INVOKE_ASYNC(actual_state, TYPE_PROC_REF(/datum/station_state, count))
+ COOLDOWN_START(src, station_integrity_duration, cache_duration * 10)
+ return cached_state
+ return actual_state
+
+
+/datum/storyteller_analyzer/proc/calculate_threat_level(antag_weight, crew_weight)
+ if(crew_weight == 0)
+ return 100
+ return min(100, (antag_weight / crew_weight) * 100)
+
+/datum/storyteller_analyzer/proc/calculate_station_integrity()
+ return 100
+
+/datum/storyteller_analyzer/proc/calculate_crew_satisfaction()
+ return 50
diff --git a/tff_modular/modules/storytellers/core/storyteller_antags.dm b/tff_modular/modules/storytellers/core/storyteller_antags.dm
new file mode 100644
index 00000000000..0bdd00bb5c4
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_antags.dm
@@ -0,0 +1,312 @@
+/**
+ * Storyteller antagonist spawning logic
+ */
+
+#define BALANCE_LOW 0.2
+#define BALANCE_MED 0.5
+#define BALANCE_HIGH 0.8
+#define WEIGHT_LOW 0.2
+#define WEIGHT_MED 0.4
+#define WEIGHT_HIGH 0.6
+#define TENSION_LOW 0.3
+#define TENSION_MED 0.5
+
+#define MIMIMAL_ANTAG_SPAWN_WEIGHT 15
+
+/// Calculates desired roundstart antagonist count based on population and balance
+/datum/storyteller/proc/calculate_roundstart_antag_count(pop)
+ var/count = 0
+ var/security_count = inputs.get_entry(STORY_VAULT_SECURITY_COUNT) || 0
+ // Base count on population
+ if(pop >= population_threshold_full)
+ count = 3
+ else if(pop >= population_threshold_high)
+ count = 2
+ else if(pop >= population_threshold_medium)
+ count = 1
+ else
+ count = 0
+
+ var/ignore_security = (HAS_TRAIT(src, STORYTELLER_TRAIT_NO_MERCY) || HAS_TRAIT(src, STORYTELLER_TRAIT_IGNORE_SECURITY))
+ if(security_count <= 0 && (ignore_security || difficulty_multiplier > 1.0))
+ security_count = 1
+ if(security_count <= 0)
+ return 0 // No security, no antags
+
+ var/adjustment_mult = 1.0
+ if(security_count == 1 && !ignore_security)
+ adjustment_mult = 0.5
+ else if(security_count >= 5)
+ adjustment_mult = 1.3
+ // Apply storyteller personality traits
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_RARE_ANTAG_SPAWN))
+ adjustment_mult *= 0.5
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_FREQUENT_ANTAG_SPAWN))
+ adjustment_mult *= 1.5
+
+ count = max(0, round(count * adjustment_mult))
+ // Clamp to reasonable limits
+ count = clamp(count, 0, 4)
+ return count
+
+
+/datum/storyteller/proc/spawn_initial_antagonists()
+ if(!SSstorytellers?.storyteller_replace_dynamic || attempted_spawning)
+ return
+
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ANTAGS))
+ message_admins("[name] skipped initial antagonist spawn because of NO_ANTAGS trait")
+ return TRUE
+
+ var/pop = inputs.player_count()
+ if(pop < population_threshold_low && !HAS_TRAIT(src, STORYTELLER_TRAIT_IMMEDIATE_ANTAG_SPAWN))
+ message_admins("[name] replan initial antagonist spawn because of insufficient population")
+ roundstart_antag_selection_time = world.time + 10 MINUTES
+ return FALSE
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_IMMEDIATE_ANTAG_SPAWN))
+ message_admins("[name] there is insufficient population but [name] spawns initial antagonists due to IMMEDIATE_ANTAG_SPAWN trait")
+
+
+ var/list/possible_candidates = SSstorytellers.filter_goals(STORY_GOAL_ANTAGONIST, STORY_TAG_ROUNDSTART, STORY_TAGS_MATCH)
+ if(!length(possible_candidates))
+ message_admins("[name] failed to spawn initial antagonists - no available roundstart antagonist events")
+ return TRUE
+
+ for(var/datum/round_event_control/ev in possible_candidates)
+ if(!behevour.is_event_valid_for_behevour(ev, null, inputs))
+ possible_candidates -= ev
+ continue
+ if(ev.story_category & STORY_GOAL_MAJOR && pop < population_threshold_low)
+ if(!HAS_TRAIT(src, STORYTELLER_TRAIT_NO_MERCY))
+ possible_candidates -= ev
+
+ var/datum/storyteller_balance_snapshot/bal = balancer.make_snapshot(inputs)
+ var/tags = behevour.tokenize(STORY_GOAL_ANTAGONIST, inputs, bal, mood)
+ var/spawn_count = calculate_roundstart_antag_count(pop)
+ if(spawn_count <= 0)
+ message_admins("[name] skipped initial antagonist spawn - no antagonists needed for current station state!")
+ log_storyteller("[name] skipped initial antagonist spawn - no antagonists needed for current station state!")
+ return TRUE
+
+ attempted_spawning = TRUE
+ for(var/i = 1 to spawn_count)
+ var/datum/round_event_control/antag_event = behevour.select_weighted_goal(inputs, bal, possible_candidates, population_factor, tags)
+ if(!antag_event)
+ log_storyteller("[name] failed to select initial antagonist goal!")
+ continue
+ var/spawn_offset = rand(45 SECONDS, 120 SECONDS)
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_FREQUENT_ANTAG_SPAWN))
+ spawn_offset *= 0.7
+ if(!planner.try_plan_event(antag_event, world.time + (spawn_offset * i)))
+ message_admins("[name] failed to execute initial antagonist goal [antag_event.name]!")
+ continue
+ if(antag_event.story_category & STORY_GOAL_MAJOR)
+ possible_candidates -= antag_event
+ if(!HAS_TRAIT(src, STORYTELLER_TRAIT_NO_MERCY))
+ break
+ attempted_spawning = FALSE
+ message_admins("[name] spawned [spawn_count] initial antagonists for population [pop]")
+ return TRUE
+
+
+
+/// Calculates spawn weight for wave-based antagonist spawning
+/// Takes into account threat level, balance ratio, antag weight, and station stagnation
+/datum/storyteller/proc/calculate_antagonist_spawn_weight_wave(datum/storyteller_balance_snapshot/snap, antag_weight, player_weight)
+ var/spawn_weight = 0.0
+ var/balance_ratio = snap.balance_ratio
+
+ // Balance factor: higher when antags are weaker relative to station
+ var/balance_factor = 0.0
+ if(balance_ratio < BALANCE_LOW)
+ balance_factor += 40
+ else if(balance_ratio < BALANCE_MED)
+ balance_factor += 25
+ else if(balance_ratio < BALANCE_HIGH)
+ balance_factor += 20
+ else
+ // Somewhat balanced
+ balance_factor += 10
+ // Threat factor: increases with current threat level
+ var/threat_factor = clamp(threat_points / max_threat_scale, 0, 1) * 15 // Max 15 points
+ // Weight factor: higher when antag weight is low relative to players
+ var/weight_ratio = player_weight > 0 ? (antag_weight * 2 / player_weight) : 1.0
+
+ var/weight_factor = 0.0
+ if(weight_ratio < WEIGHT_LOW)
+ weight_factor = 20 // Very low antag weight
+ else if(weight_ratio < WEIGHT_MED)
+ weight_factor = 15 // Low antag weight
+ else if(weight_ratio < WEIGHT_HIGH)
+ weight_factor = 10 // Moderate
+ else
+ weight_factor = 5 // Adequate
+
+ // Stagnation factor: higher when tension is low (station stagnant)
+ var/stagnation_factor
+ var/tension_normalized = clamp(current_tension / 100.0, 0, 1)
+ if(tension_normalized < TENSION_LOW)
+ stagnation_factor = 15 // Low tension = stagnation
+ else if(tension_normalized < TENSION_MED)
+ stagnation_factor = 5 // Moderate stagnation
+ else
+ stagnation_factor = 0 // Active, no stagnation
+ // Combine factors
+ spawn_weight = min(balance_factor + threat_factor + weight_factor + stagnation_factor, STORY_MAJOR_ANTAG_WEIGHT)
+ var/ignore_security = (HAS_TRAIT(src, STORYTELLER_TRAIT_NO_MERCY) || HAS_TRAIT(src, STORYTELLER_TRAIT_IGNORE_SECURITY))
+ if(!ignore_security)
+ var/security_count = inputs.get_entry(STORY_VAULT_SECURITY_COUNT) || 0
+ var/strong_security = CONFIG_GET(number/strong_security_count)
+ var/normalized_security = clamp(security_count / strong_security, 0, 1)
+ if(normalized_security <= 0)
+ spawn_weight *= 0.4
+ if(normalized_security < 0.7)
+ spawn_weight *= 0.6
+
+ // Adjust based on storyteller traits
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ANTAGS))
+ spawn_weight = 0
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_RARE_ANTAG_SPAWN))
+ spawn_weight *= 0.5
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_FREQUENT_ANTAG_SPAWN))
+ spawn_weight *= 1.2
+
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_IMMEDIATE_ANTAG_SPAWN) && balance_ratio < 0.3)
+ spawn_weight *= 1.4
+
+ // Boost if current antags are inactive or weak
+ if(snap.antag_weak)
+ spawn_weight *= 1.2
+ spawn_weight *= clamp(mood.aggression, 0.5, 1.5)
+ return clamp(spawn_weight, 0, 80)
+
+/// Checks antagonist balance and spawns midround antagonists in waves if needed
+/// Called approximately every ~30 minutes based on cooldown
+/// Uses threat level, balance ratio, and antag weight to determine if spawns are needed
+/datum/storyteller/proc/check_and_spawn_antagonists(datum/storyteller_balance_snapshot/snap, force = FALSE)
+ if((!SSstorytellers.storyteller_replace_dynamic && !force) || attempted_spawning)
+ return FALSE
+
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ANTAGS) && !force)
+ return FALSE
+ if(determine_greenshift_status())
+ if(force)
+ message_admins("[name] skipped antagonist spawn wave due to greenshift, change difficulty to allow spawns")
+ return FALSE
+ if(!snap)
+ snap = balancer.make_snapshot(inputs)
+
+ var/antag_count = inputs.antag_count()
+ var/antag_weight = inputs.antag_weight()
+ var/player_weight = inputs.crew_weight()
+
+ // Check if antags are too weak, inactive, or missing
+ var/needs_antags = FALSE
+ var/reason = ""
+ if(!force)
+ if(population_factor <= population_factor_low && !HAS_TRAIT(src, STORYTELLER_TRAIT_NO_MERCY))
+ needs_antags = FALSE
+ reason = "insufficient population!"
+ else if(antag_count <= 0 && target_player_antag_balance > 30 && prob(40 * mood.get_threat_multiplier()))
+ needs_antags = TRUE
+ reason = "no antagonists"
+ else if((player_antag_balance < (target_player_antag_balance - 10)) && prob(35 * mood.get_threat_multiplier()))
+ needs_antags = TRUE
+ reason = "antagonists weak relative to player balance, antag balance: [player_antag_balance], target: [target_player_antag_balance])"
+ else if(snap.antag_weak && (target_player_antag_balance > 50 || prob(30 * mood.get_threat_multiplier())))
+ needs_antags = TRUE
+ reason = "antagonists are weak"
+ else if(player_weight > 0 && antag_weight / player_weight < 0.4)
+ needs_antags = TRUE
+ reason = "antagonist weight too low, antag: [antag_weight], crew: [player_weight])"
+
+ if(!needs_antags)
+ message_admins("[name] skipped antagonist spawn wave because of [reason]")
+ return FALSE
+ else
+ needs_antags = TRUE
+ message_admins("[name] forced antagonist spawn wave!")
+
+ // Calculate spawn weight based on threat level, balance ratio, and antag weight
+ var/spawn_weight = calculate_antagonist_spawn_weight_wave(snap, antag_weight, player_weight)
+ if(spawn_weight <= MIMIMAL_ANTAG_SPAWN_WEIGHT && !force)
+ message_admins("[name] is forcing antagonist spawn wave despite low spawn weight ([spawn_weight]). Aborting!")
+ return FALSE
+ INVOKE_ASYNC(src, PROC_REF(try_spawn_midround_antagonist_wave), snap, spawn_weight)
+ return TRUE
+
+
+
+/datum/storyteller/proc/try_spawn_midround_antagonist_wave(datum/storyteller_balance_snapshot/snap, wave_weight)
+ if(!SSstorytellers.storyteller_replace_dynamic || HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ANTAGS))
+ return
+ if(wave_weight <= 0)
+ return
+
+ var/list/possible_candidates = SSstorytellers.filter_goals(STORY_GOAL_ANTAGONIST, STORY_TAG_MIDROUND)
+ if(!length(possible_candidates))
+ log_storyteller("[name] skipped antag spawn - no available midround antagonist events")
+ return
+
+ attempted_spawning = TRUE
+ var/tags = behevour.tokenize(STORY_GOAL_ANTAGONIST, inputs, snap, mood)
+ var/list/valid_candidates = list()
+ for(var/datum/round_event_control/ev in possible_candidates)
+ var/ev_weight = ev.get_story_weight(inputs, snap)
+ if(ev_weight <= 0)
+ continue
+ if(!behevour.is_event_valid_for_behevour(ev, snap, inputs))
+ continue
+ valid_candidates[ev] = ev_weight
+
+ if(!length(valid_candidates))
+ log_storyteller("[name] skipped midround antag wave - no valid candidates after filtering")
+ attempted_spawning = FALSE
+ return
+ var/spawned_count = 0
+ var/max_attempts = 25
+
+ while(wave_weight > 0 && length(valid_candidates) > 0 && max_attempts > 0)
+ max_attempts--
+
+ var/datum/round_event_control/chosen = behevour.select_weighted_goal(inputs, snap, valid_candidates, population_factor, tags)
+ if(!chosen)
+ break
+ var/cost = valid_candidates[chosen]
+
+ if(cost > wave_weight)
+ valid_candidates -= chosen
+ continue
+
+ if(planner.is_event_in_timeline(chosen) && !prob(50))
+ valid_candidates -= chosen
+ continue
+
+ var/spawn_offset = rand(30 SECONDS, 5 MINUTES) * (mood ? mood.pace : 1.0)
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_IMMEDIATE_ANTAG_SPAWN))
+ spawn_offset *= 0.5
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_RARE_ANTAG_SPAWN))
+ spawn_offset *= 1.5
+
+ if(planner.try_plan_event(chosen, world.time + spawn_offset))
+ spawned_count++
+ wave_weight -= cost
+ else
+ valid_candidates -= chosen
+
+ if(spawned_count > 0)
+ message_admins("[name] spawned [spawned_count] midround antagonists with [wave_weight] remaining wave weight")
+ log_storyteller("[name] spawned [spawned_count] midround antagonists with [wave_weight] remaining wave weight")
+ else
+ message_admins("[name] failed to spawn any midround antagonists despite [wave_weight] wave weight")
+ attempted_spawning = FALSE
+
+#undef BALANCE_LOW
+#undef BALANCE_MED
+#undef BALANCE_HIGH
+#undef WEIGHT_LOW
+#undef WEIGHT_MED
+#undef WEIGHT_HIGH
+#undef TENSION_LOW
+#undef TENSION_MED
+#undef MIMIMAL_ANTAG_SPAWN_WEIGHT
diff --git a/tff_modular/modules/storytellers/core/storyteller_behevour.dm b/tff_modular/modules/storytellers/core/storyteller_behevour.dm
new file mode 100644
index 00000000000..b1304605a5d
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_behevour.dm
@@ -0,0 +1,487 @@
+//Here's where is magic begin
+#define STORY_REPETITION_DECAY_TIME (30 MINUTES)
+#define DEFAULT_TAG_CHANCE(__bias) (prob(20 * __bias))
+#define HIGH_TAG_CHANCE(__bias) (prob(35 * __bias))
+#define STORY_TAG_MATCH_BONUS 0.5
+#define STORY_REP_PENALTY_MAX 1
+
+
+/datum/storyteller_behevour
+ VAR_PRIVATE/datum/storyteller/owner
+
+/datum/storyteller_behevour/New(datum/storyteller/owner_storyteller)
+ . = ..()
+ owner = owner_storyteller
+
+// Review tags and return a list of tags that are relevant to the storyteller's behevour
+/datum/storyteller_behevour/proc/review_tags(list/tags, datum/storyteller_balance_snapshot/bal, datum/storyteller_inputs/inputs)
+ return tags
+
+/datum/storyteller_behevour/proc/is_event_valid_for_behevour(datum/round_event_control/evt, datum/storyteller_balance_snapshot/bal, datum/storyteller_inputs/inputs)
+ if(evt.story_category & STORY_GOAL_MAJOR && HAS_TRAIT(owner, STORYTELLER_TRAIT_NO_MAJOR_EVENTS))
+ return FALSE
+ if(evt.story_category & STORY_GOAL_GOOD && HAS_TRAIT(owner, STORYTELLER_TRAIT_NO_GOOD_EVENTS))
+ return FALSE
+ if((evt.story_category & STORY_GOAL_ANTAGONIST) && (evt.story_category & STORY_GOAL_MAJOR))
+ if(!HAS_TRAIT(owner, STORYTELLER_TRAIT_MAJOR_ANTAGONISTS))
+ return FALSE
+ return TRUE
+
+
+/datum/storyteller_behevour/proc/get_next_event(datum/storyteller_balance_snapshot/bal, datum/storyteller_inputs/inputs)
+ var/category = determine_category(bal)
+ var/tag_filter = tokenize(category, inputs, bal, owner.mood)
+ if(!length(tag_filter))
+ return null
+ review_tags(tag_filter, bal, inputs)
+ var/list/candidates = SSstorytellers.filter_goals(category, tag_filter, STORY_TAGS_SOME_MATCH)
+ if(!candidates.len)
+ candidates = SSstorytellers.filter_goals(STORY_GOAL_RANDOM)
+ return select_weighted_goal(inputs, bal, candidates, owner.population_factor, tag_filter)
+
+
+/datum/storyteller_behevour/proc/get_category_bias(category, datum/storyteller_mood/mood)
+ PRIVATE_PROC(TRUE)
+
+ var/bias = 1
+ if(category & STORY_GOAL_GOOD)
+ if(mood.get_variance_multiplier() > 1)
+ bias = 0.8
+ else
+ bias = 1.1
+ else if(category & STORY_GOAL_BAD)
+ if(mood.get_threat_multiplier() > 1)
+ bias = 1.4
+ else
+ bias = 0.8
+ else if(category & STORY_GOAL_NEUTRAL)
+ bias = 0.6
+ else
+ bias = 0.5
+ return bias
+
+
+/datum/storyteller_behevour/proc/add_tag_with_bias(list/tags, tag, bias, probability)
+ if(!islist(tags))
+ tags = list()
+ if(tag in tags)
+ return
+ if(prob(probability * bias))
+ tags += tag
+
+
+/datum/storyteller_behevour/proc/tokenize( \
+ category, \
+ datum/storyteller_inputs/inputs, \
+ datum/storyteller_balance_snapshot/bal, \
+ datum/storyteller_mood/mood)
+
+ if(!category)
+ category = determine_category(bal)
+
+ var/bias = get_category_bias(category, mood)
+ . = list()
+ /**
+ * Okay, here we are basicly perform storyteller thinking by anylizing the content of inputs.
+ */
+ // First of all - we select tone of the event
+ var/list/tones = list(
+ STORY_TAG_EPIC = 1 * owner.get_effective_pace(),
+ STORY_TAG_TRAGIC = 1 * mood.get_threat_multiplier(),
+ STORY_TAG_HUMOROUS = 1 * mood.get_variance_multiplier(),
+ )
+ add_tag_with_bias(., pick_weight(tones), bias, 80)
+
+ // Category-based tags
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 40 * mood.get_threat_multiplier())
+ if(prob(60 * bias))
+ add_tag_with_bias(., STORY_TAG_COMBAT, bias, 50)
+ else if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 50)
+ if(prob(40 * bias))
+ add_tag_with_bias(., STORY_TAG_SOCIAL, bias, 40)
+
+ // Tension-based tags
+ var/tension_diff = abs(bal.overall_tension - owner.target_tension) / 100.0
+ if(tension_diff > 0.2)
+ if(bal.overall_tension > owner.target_tension)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 70 * tension_diff)
+ else if(bal.overall_tension < owner.target_tension * 0.7)
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 70 * (1 - tension_diff))
+
+ // Health-based tags (only if not ignored by trait)
+ if(!HAS_TRAIT(owner, STORYTELLER_TRAIT_IGNORE_CREW_HEALTH))
+ var/crew_health = inputs.get_entry(STORY_VAULT_AVG_CREW_HEALTH) || 100
+ // If crew health is low or with random chance, prefer health-related events
+ if(crew_health < 50 || DEFAULT_TAG_CHANCE(bias))
+ add_tag_with_bias(., STORY_TAG_HEALTH, bias, 50)
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_REQUIRES_MEDICAL, bias, 40)
+
+ var/crew_wounding = inputs.get_entry(STORY_VAULT_CREW_WOUNDING) || STORY_VAULT_NO_WOUNDS
+ if(crew_wounding >= STORY_VAULT_MANY_WOUNDED)
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_TRAGIC, bias, 30)
+ else if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 60)
+
+ var/crew_diseases = inputs.get_entry(STORY_VAULT_CREW_DISEASES) || STORY_VAULT_NO_DISEASES
+ if(crew_diseases >= STORY_VAULT_MAJOR_DISEASES)
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_TRAGIC, bias, 30)
+ else if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 60)
+
+
+ // Infrastructure-based tags (only if not ignored by trait)
+ if(!HAS_TRAIT(owner, STORYTELLER_TRAIT_IGNORE_ENGI))
+ var/station_integrity = inputs.get_entry(STORY_VAULT_STATION_INTEGRITY) || 100
+
+ if(station_integrity < 50)
+ if(category & STORY_GOAL_BAD && DEFAULT_TAG_CHANCE(bias))
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 30)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 20)
+ else if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 40)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 40)
+
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_REQUIRES_ENGINEERING, bias, 50)
+
+ var/power_status = inputs.get_entry(STORY_VAULT_POWER_STATUS) || STORY_VAULT_FULL_POWER
+ if(power_status >= STORY_VAULT_LOW_POWER)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 50)
+ if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_REQUIRES_ENGINEERING, bias, 60)
+
+ var/power_grid_damage = inputs.get_entry(STORY_VAULT_POWER_GRID_DAMAGE) || STORY_VAULT_POWER_GRID_NOMINAL
+ if(power_grid_damage >= STORY_VAULT_POWER_GRID_FAILURES)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 55)
+ if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_REQUIRES_ENGINEERING, bias, 65)
+
+
+ // Antagonist-based tags
+ var/antagonist_weight = inputs.get_entry(STORY_VAULT_ANTAG_WEIGHT) || 0
+ if(antagonist_weight > 100)
+ add_tag_with_bias(., STORY_TAG_COMBAT, bias, 30)
+ if(!HAS_TRAIT(owner, STORYTELLER_TRAIT_IGNORE_SECURITY))
+ add_tag_with_bias(., STORY_TAG_REQUIRES_SECURITY, bias, 50 * (antagonist_weight / 100))
+
+ var/antag_count = inputs.get_entry(STORY_VAULT_ANTAG_ALIVE_COUNT) || 0
+ if(antag_count > 0)
+ var/antagonist_presence = inputs.get_entry(STORY_VAULT_ANTAGONIST_PRESENCE) || STORY_VAULT_NO_ANTAGONISTS
+ if(antagonist_presence >= STORY_VAULT_FEW_ANTAGONISTS)
+ add_tag_with_bias(., STORY_TAG_COMBAT, bias, 60)
+ add_tag_with_bias(., STORY_TAG_TRAGIC, bias, 50)
+
+ if(bal.antag_weak)
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 40)
+ else if(bal.antag_inactive)
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 30)
+
+ // Security-based tags (only if not ignored by trait)
+ if(!HAS_TRAIT(owner, STORYTELLER_TRAIT_IGNORE_SECURITY))
+ var/security_strength = inputs.get_entry(STORY_VAULT_SECURITY_STRENGTH) || STORY_VAULT_NO_SECURITY
+ if(security_strength >= STORY_VAULT_WEAK_SECURITY)
+ add_tag_with_bias(., STORY_TAG_REQUIRES_SECURITY, bias, 30 * security_strength)
+ else if(security_strength == STORY_VAULT_NO_SECURITY && category & STORY_GOAL_BAD)
+ // No security - prefer events that don't require security
+ add_tag_with_bias(., STORY_TAG_COMBAT, bias, 40)
+
+ var/security_alert = inputs.get_entry(STORY_VAULT_SECURITY_ALERT) || STORY_VAULT_GREEN_ALERT
+ if(security_alert >= STORY_VAULT_RED_ALERT)
+ add_tag_with_bias(., STORY_TAG_COMBAT, bias, 50)
+ add_tag_with_bias(., STORY_TAG_TRAGIC, bias, 40)
+
+ // Resource-based tags (only if not ignored by trait)
+ if(!HAS_TRAIT(owner, STORYTELLER_TRAIT_IGNORE_RESOURCES))
+ var/minerals = inputs.get_entry(STORY_VAULT_RESOURCE_MINERALS) || 0
+ if(minerals < 1000)
+ if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 40)
+
+ var/cargo_points = inputs.get_entry(STORY_VAULT_RESOURCE_OTHER) || 0
+ if(cargo_points < 5000)
+ if(category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 35)
+
+ // Research-based tags
+ var/research_progress = inputs.get_entry(STORY_VAULT_RESEARCH_PROGRESS) || STORY_VAULT_LOW_RESEARCH
+ if(research_progress >= STORY_VAULT_HIGH_RESEARCH && category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_ENVIRONMENTAL, bias, 30)
+
+ // Impact level tags (based on station state and tension)
+ if(bal.overall_tension > 70 || bal.strengths["station"] < 0.3)
+ add_tag_with_bias(., STORY_TAG_AFFECTS_WHOLE_STATION, bias, 50)
+ else if(bal.overall_tension < 30)
+ add_tag_with_bias(., STORY_TAG_TARGETS_INDIVIDUALS, bias, 40)
+ else
+ add_tag_with_bias(., STORY_TAG_WIDE_IMPACT, bias, 45)
+
+ // Volatility random tags
+ var/volatility = mood.get_variance_multiplier()
+ if(volatility > 1.2 && prob(30 * bias))
+ add_tag_with_bias(., STORY_TAG_CHAOTIC, bias, 30)
+
+ // Trait-based tag modifications
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_KIND) && category & STORY_GOAL_GOOD)
+ add_tag_with_bias(., STORY_TAG_SOCIAL, bias, 30)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 40)
+
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_NO_MERCY) && category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 50)
+ add_tag_with_bias(., STORY_TAG_COMBAT, bias, 40)
+
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_BALANCING_TENSTION))
+ // More aggressive balancing
+ if(tension_diff > 0.15)
+ if(bal.overall_tension > owner.target_tension)
+ add_tag_with_bias(., STORY_TAG_DEESCALATION, bias, 80)
+ else
+ add_tag_with_bias(., STORY_TAG_ESCALATION, bias, 80)
+
+ // Round progression tags
+ var/round_prog = owner.round_progression || 0
+ if(round_prog < 0.2)
+ add_tag_with_bias(., STORY_TAG_ROUNDSTART, bias, 40)
+ else if(round_prog > 0.5)
+ add_tag_with_bias(., STORY_TAG_MIDROUND, bias, 50)
+
+ // Major event tag (based on tension and station state)
+ if(bal.overall_tension > 80 || bal.strengths["station"] < 0.2)
+ if(category & STORY_GOAL_BAD)
+ add_tag_with_bias(., STORY_TAG_MAJOR, bias, 30)
+
+ return .
+
+
+/datum/storyteller_behevour/proc/select_weighted_goal( \
+ datum/storyteller_inputs/inputs, \
+ datum/storyteller_balance_snapshot/bal, \
+ list/candidates, \
+ population_scale = 1.0, \
+ desired_tags = null)
+
+ if(!candidates.len)
+ return null
+
+ var/list/weighted = list()
+
+ for(var/datum/round_event_control/evt in candidates)
+ if(!evt.is_avaible(inputs, owner))
+ continue
+
+ if(!(evt.story_category & STORY_GOAL_MAJOR) && HAS_TRAIT(owner, STORYTELLER_TRAIT_NO_MAJOR_EVENTS))
+ continue
+
+ if(!is_event_valid_for_behevour(evt))
+ continue
+
+ // Base weight
+ var/weight = evt.get_story_weight(inputs, owner) * 10
+
+ // Repetition penalty (simplified)
+ var/list/rep_info = owner.get_repeat_info(evt.id)
+ if(rep_info["count"] > 0)
+ var/age = world.time - rep_info["last_time"]
+ var/decay = clamp((age / STORY_REPETITION_DECAY_TIME) ** 1.5, 0, 1)
+ weight *= (1 - decay * STORY_REP_PENALTY_MAX)
+
+ // Tag match bonus
+ if(desired_tags && length(desired_tags))
+ var/matches = 0
+ for(var/tag in desired_tags)
+ if(evt.has_tag(tag))
+ matches += 1
+ if(matches > 0)
+ weight += (1 + matches) * STORY_TAG_MATCH_BONUS
+
+ var/tension_diff = abs(bal.overall_tension - owner.target_tension) / 100.0
+ if(tension_diff > 0.2)
+ // Check if event helps balance
+ var/has_escalation = evt.has_tag(STORY_TAG_ESCALATION)
+ var/has_deescalation = evt.has_tag(STORY_TAG_DEESCALATION)
+
+ if(bal.overall_tension > owner.target_tension && has_deescalation)
+ weight *= (1 + tension_diff * 0.3)
+ else if(bal.overall_tension < owner.target_tension && has_escalation)
+ weight *= (1 + tension_diff * 0.3)
+
+ // Population scaling
+ weight *= clamp(population_scale, 0.5, 1.0)
+
+ // Difficulty scaling
+ weight *= owner.difficulty_multiplier
+
+ // Duplicate check
+ if(owner.planner.is_event_in_timeline(evt))
+ weight *= 0.3
+
+ weighted[evt] = max(1, weight)
+
+ if(!length(weighted))
+ return null
+
+ // Random mode
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_HARDCORE_RANDOM))
+ return pick(weighted)
+
+ // Weighted selection
+ return pick_weight(weighted)
+
+
+/datum/storyteller_behevour/proc/determine_category(datum/storyteller_balance_snapshot/bal)
+ var/tension = bal.overall_tension
+ var/target = owner.target_tension
+ var/tension_diff = abs(tension - target) / 100.0
+
+ // Simple scoring
+ var/score_good = 0
+ var/score_bad = 0
+ var/score_neutral = 0.25 // Base neutral chance
+
+ // High tension -> prefer good events (recovery)
+ if(tension > target)
+ score_good += tension_diff * 0.8
+ // Low tension -> prefer bad events (challenge)
+ else if(tension < target)
+ score_bad += (1 - tension_diff)
+
+ // Mood influence
+ var/mood_aggr = owner.get_effective_threat()
+ score_bad += (mood_aggr - 1.0) * 0.3
+
+ // Population scaling
+ var/pop = clamp(owner.population_factor, 0.3, 1.0)
+ score_bad *= pop
+ if(pop < 0.5)
+ score_good += 0.2
+
+ // Trait modifiers
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_NO_GOOD_EVENTS))
+ score_good = 0
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_MORE_GOOD_EVENTS))
+ score_good += 0.3
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_MORE_BAD_EVENTS))
+ score_bad += 0.3
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_MORE_NEUTRAL_EVENTS))
+ score_neutral += 0.15
+
+ // Random volatility
+ if(HAS_TRAIT(owner, STORYTELLER_TRAIT_HARDCORE_RANDOM))
+ return pick(STORY_GOAL_GOOD, STORY_GOAL_BAD, STORY_GOAL_NEUTRAL)
+
+ // Normalize and pick
+ var/total = score_good + score_bad + score_neutral
+ if(total <= 0)
+ return STORY_GOAL_NEUTRAL
+
+ var/roll = rand()
+ if(roll < score_good / total)
+ return STORY_GOAL_GOOD
+ else if(roll < (score_good + score_bad) / total)
+ return STORY_GOAL_BAD
+ else
+ return STORY_GOAL_NEUTRAL
+
+// Random behaviour: completely random tags
+/datum/storyteller_behevour/random
+/datum/storyteller_behevour/random/tokenize( \
+ category, \
+ datum/storyteller_inputs/inputs, \
+ datum/storyteller_balance_snapshot/bal, \
+ datum/storyteller_mood/mood)
+
+ if(!category)
+ category = determine_category(bal)
+
+ . = list()
+ // Completely random tag selection
+ var/list/all_tags = list(
+ STORY_TAG_EPIC,
+ STORY_TAG_TRAGIC,
+ STORY_TAG_HUMOROUS,
+ STORY_TAG_COMBAT,
+ STORY_TAG_SOCIAL,
+ STORY_TAG_ENVIRONMENTAL,
+ STORY_TAG_CHAOTIC,
+ STORY_TAG_HEALTH,
+ STORY_TAG_WIDE_IMPACT,
+ STORY_TAG_TARGETS_INDIVIDUALS,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_REQUIRES_ENGINEERING,
+ STORY_TAG_REQUIRES_MEDICAL,
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_MAJOR,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_MIDROUND,
+ )
+
+ // Add random tags (3-6 tags)
+ var/tag_count = rand(3, 6)
+ for(var/i in 1 to tag_count)
+ var/selected_tag = pick(all_tags)
+ if(!(selected_tag in .))
+ . += selected_tag
+
+ // Add escalation/deescalation based on category
+ if(category & STORY_GOAL_BAD)
+ . += STORY_TAG_ESCALATION
+ else if(category & STORY_GOAL_GOOD)
+ . += STORY_TAG_DEESCALATION
+
+ return .
+
+// Inverted behaviour: every third bad event becomes good
+/datum/storyteller_behevour/inverted
+ var/bad_event_counter = 0
+ var/was_inverted = FALSE
+
+/datum/storyteller_behevour/inverted/determine_category(datum/storyteller_balance_snapshot/bal)
+ was_inverted = FALSE
+ // Call parent first
+ var/category = ..()
+
+ // If it's a bad event, check if we should invert it
+ if(category & STORY_GOAL_BAD)
+ bad_event_counter++
+ // Every third bad event becomes good
+ if(bad_event_counter % 3 == 0)
+ was_inverted = TRUE
+ return STORY_GOAL_GOOD
+
+ return category
+
+/datum/storyteller_behevour/inverted/tokenize( \
+ category, \
+ datum/storyteller_inputs/inputs, \
+ datum/storyteller_balance_snapshot/bal, \
+ datum/storyteller_mood/mood)
+
+ // Call parent tokenize with potentially inverted category
+ . = ..()
+
+ // If this was an inverted bad event (now GOOD), adjust tags
+ if(was_inverted)
+ // Remove bad event tags and add good event tags
+ . -= STORY_TAG_ESCALATION
+ . -= STORY_TAG_COMBAT
+ . -= STORY_TAG_TRAGIC
+ . += STORY_TAG_DEESCALATION
+ if(prob(50))
+ . += STORY_TAG_SOCIAL
+ if(prob(40))
+ . += STORY_TAG_HUMOROUS
+
+ return .
+
+#undef STORY_REPETITION_DECAY_TIME
+#undef STORY_TAG_MATCH_BONUS
+#undef STORY_REP_PENALTY_MAX
+#undef DEFAULT_TAG_CHANCE
+#undef HIGH_TAG_CHANCE
diff --git a/tff_modular/modules/storytellers/core/storyteller_inputs.dm b/tff_modular/modules/storytellers/core/storyteller_inputs.dm
new file mode 100644
index 00000000000..4584b4e6b4d
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_inputs.dm
@@ -0,0 +1,55 @@
+// Inputs datum to hold sampled data from the station
+// This structure packages analysis results from the analyzer for use in planner (global goal and subgoals branching)
+// and balancer (player vs. antagonist weights). It supports decision-making for event planning, goal progress,
+// and mood-influenced adjustments. Expanded to include more metrics for comprehensive station analysis.
+/datum/storyteller_inputs
+ /// Total computed value of the station (atoms + infrastructure, used for goal weighting)
+ var/station_value = 0
+ /// Detailed station state datum, count of floor, walls and e.t.c default updates one time per 10 minutes
+ var/datum/station_state/station_state
+ /// Vault: Associative list for unique/custom values (keyed by defines like STORY_VALUE_POWER)
+ /// Stores dynamic metrics not fitting standard vars, e.g., department-specific values or event-specific data.
+ /// check _storyteller.dm defines for examples
+ var/list/vault = list()
+
+
+/datum/storyteller_inputs/proc/get_station_integrity()
+ return station_state ? min(PERCENT(GLOB.start_state.score(station_state)), 100) : 100
+
+
+/datum/storyteller_inputs/proc/player_count()
+ return get_entry(STORY_VAULT_CREW_ALIVE_COUNT) ? get_entry(STORY_VAULT_CREW_ALIVE_COUNT) : 0
+
+
+/datum/storyteller_inputs/proc/antag_count()
+ return get_entry(STORY_VAULT_ANTAG_ALIVE_COUNT) ? get_entry(STORY_VAULT_ANTAG_ALIVE_COUNT) : 0
+
+
+/datum/storyteller_inputs/proc/crew_weight()
+ return get_entry(STORY_VAULT_CREW_WEIGHT) ? get_entry(STORY_VAULT_CREW_WEIGHT) : 0
+
+/datum/storyteller_inputs/proc/get_crew_weight_normalized()
+ return crew_weight() / player_count()
+
+/datum/storyteller_inputs/proc/antag_weight()
+ return get_entry(STORY_VAULT_ANTAG_WEIGHT) ? get_entry(STORY_VAULT_ANTAG_WEIGHT) : 0
+
+/datum/storyteller_inputs/proc/antag_weight_normalized()
+ return antag_weight() / antag_count()
+
+/datum/storyteller_inputs/proc/antag_crew_ratio()
+ var/crew_weight = crew_weight() ? crew_weight() : 1
+ var/antag_weight = antag_weight() ? antag_weight() : 1
+ return antag_weight / crew_weight
+
+/datum/storyteller_inputs/proc/get_entry(name)
+ if(vault)
+ return vault[name]
+ return null
+
+/datum/storyteller_inputs/proc/set_entry(name, value)
+ if(vault)
+ if(value)
+ vault[name] = value
+ else
+ vault += name
diff --git a/tff_modular/modules/storytellers/core/storyteller_log.dm b/tff_modular/modules/storytellers/core/storyteller_log.dm
new file mode 100644
index 00000000000..dcb1b03a53d
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_log.dm
@@ -0,0 +1,29 @@
+/datum/config_entry/flag/log_story
+
+/datum/log_category/storyteller_planner
+ category = LOG_CATEGORY_STORYTELLER_PLANNER
+ config_flag = /datum/config_entry/flag/log_story
+
+
+/datum/log_category/storyteller
+ category = LOG_CATEGORY_STORYTELLER
+ master_category = /datum/log_category/debug
+ config_flag = /datum/config_entry/flag/log_story
+
+
+/datum/log_category/storyteller_analyzer
+ category = LOG_CATEGORY_STORYTELLER_ANALYZER
+ master_category = /datum/log_category/debug
+ config_flag = /datum/config_entry/flag/log_story
+
+
+/datum/log_category/storyteller_balancer
+ category = LOG_CATEGORY_STORYTELLER_BALANCER
+ config_flag = /datum/config_entry/flag/log_story
+
+
+/datum/log_category/storyteller_metrics
+ category = LOG_CATEGORY_STORYTELLER_METRICS
+ master_category = /datum/log_category/debug
+ config_flag = /datum/config_entry/flag/log_story
+
diff --git a/tff_modular/modules/storytellers/core/storyteller_mood.dm b/tff_modular/modules/storytellers/core/storyteller_mood.dm
new file mode 100644
index 00000000000..6cd402b709a
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_mood.dm
@@ -0,0 +1,98 @@
+// Mood profile for storytellers to influence decisions and pacing
+
+/datum/storyteller_mood
+ var/name = "Neutral"
+ var/desc = ""
+ /// 0.0 - 2.0; higher means more aggressive challenges
+ var/aggression = 1.0
+ /// 0.1 - 3.0; higher means events/goals happen more frequently
+ var/pace = 1.0
+ /// 0.0 - 2.0; higher means more variability/chaos in choices
+ var/volatility = 1.0
+
+/datum/storyteller_mood/proc/get_event_frequency_multiplier()
+ return clamp(pace, 0.1, 3.0)
+
+/datum/storyteller_mood/proc/get_threat_multiplier()
+ return clamp(aggression, 0.0, 2.0)
+
+/datum/storyteller_mood/proc/get_variance_multiplier()
+ return clamp(volatility, 0.0, 2.0)
+
+
+/datum/storyteller_mood/slow_builder
+ name = "Slow Builder"
+ aggression = 0.7
+ pace = 0.6
+ volatility = 0.8
+
+/datum/storyteller_mood/spicy
+ name = "Spicy"
+ aggression = 1.4
+ pace = 1.3
+ volatility = 1.2
+
+// Additional mood profiles adapted to RimWorld-inspired styles
+/datum/storyteller_mood/cassandra_classic
+ name = "Cassandra Classic"
+ desc = "Escalating challenges with cycles of tension and relief, inspired by RimWorld's classic storyteller."
+ aggression = 1.2
+ pace = 1.0
+ volatility = 0.9
+
+/datum/storyteller_mood/phoebe_chillax
+ name = "Phoebe Chillax"
+ desc = "Relaxed pace with longer breaks between events, allowing recovery, like RimWorld's chill storyteller."
+ aggression = 0.8
+ pace = 0.5
+ volatility = 0.7
+
+/datum/storyteller_mood/randy_random
+ name = "Randy Random"
+ desc = "Highly unpredictable with random bursts of events, mimicking RimWorld's chaotic storyteller."
+ aggression = 1.5
+ pace = 1.2
+ volatility = 1.8
+
+
+/// Chill mood: Serene, introspective pacing with low aggression and minimal volatility.
+/// Ideal for Mia'Chill — fosters roleplay immersion, slow sub-goal branches via extended grace, low threat for peaceful analysis.
+/datum/storyteller_mood/chill
+ name = "Chill"
+ pace = 1.5 // Slower events for quiet wonders
+ aggression = 0.6 // Gentle threats, yielding to cosmic peace
+ volatility = 0.4 // Minimal swings, steady hush
+
+/// Classic mood: Steady escalation with balanced aggression and low volatility.
+/// For Cas'Classic — inevitable doom symphonies, consistent sub-goal buildup, medium tension for survival arcs.
+/datum/storyteller_mood/classic
+ name = "Classic"
+ pace = 1.0 // Standard pacing for chronicled falls
+ aggression = 1.2 // Heightened unrest whispers
+ volatility = 0.6 // Controlled variance for symphonic progression
+
+/// Gambit mood: Capricious, high volatility with neutral pace/aggression.
+/// Randall's Gambit — dice rolls of fate, erratic chain flips (bureaucracy to apocalypse), high variance for whim-based vetting.
+/datum/storyteller_mood/gambit
+ name = "Gambit"
+ pace = 1.1 // Slight edge for unpredictability
+ aggression = 1.0 // Neutral, flips on whim
+ volatility = 1.8 // High swings for fortune's folly
+
+
+/// Catastrophe mood: Hyper-aggressive, low volatility for relentless pressure.
+/// Nick Catastrophe — twin tempests of ruin, rapid escalation, minimal recovery in adaptation for doubled havoc.
+/datum/storyteller_mood/catastrophe
+ name = "Catastrophe"
+ pace = 0.7 // Fast barrages, scant respite
+ aggression = 1.8 // Crushing doomsayer fury
+ volatility = 0.3 // Steady storm, no mercy variance
+
+
+/// Challenge mood: Balanced hybrid with rising aggression and moderate volatility.
+/// Mia & Nic'Challenge — serenity to fury pact, teetering balance, progressive tension for beautiful trials.
+/datum/storyteller_mood/challenge
+ name = "Challenge"
+ pace = 1.2 // Starts tranquil, ramps to trials
+ aggression = 1.3 // Blends lulls into escalating edge
+ volatility = 1.2 // Moderate swings for paradoxical pact
diff --git a/tff_modular/modules/storytellers/core/storyteller_planner.dm b/tff_modular/modules/storytellers/core/storyteller_planner.dm
new file mode 100644
index 00000000000..7c9d98b6c19
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/storyteller_planner.dm
@@ -0,0 +1,496 @@
+#define ENTRY_EVENT "event"
+#define ENTRY_FIRE_TIME "fire_time"
+#define ENTRY_PLANNED_TIME "planned_time"
+#define ENTRY_CATEGORY "category"
+#define ENTRY_STATUS "status"
+#define EVENT_CLUSTER_AVOIDANCE_BUFFER (150 SECONDS) // Buffer to avoid clustering events too closely
+#define MAX_PLAN_ATTEMPTS 100 // Max attempts to find a free slot during planning
+
+/datum/storyteller_planner
+ VAR_PRIVATE/datum/storyteller/owner
+ /// Timeline plan: Assoc list of "[world.time + offset]" -> list(event_instance, category, status="pending|firing|completed")
+ VAR_PRIVATE/list/timeline = list()
+ /// Recalc frequency
+ VAR_PRIVATE/recalc_interval = STORY_RECALC_INTERVAL
+
+ VAR_PRIVATE/planning_cooldown = 5 MINUTES
+
+ COOLDOWN_DECLARE(event_planning_cooldown)
+
+ COOLDOWN_DECLARE(recalculate_cooldown)
+
+ // Threshold for population spike detection
+ var/population_spike_threshold = 30
+
+/datum/storyteller_planner/New(datum/storyteller/_owner)
+ ..()
+ owner = _owner
+
+
+/// Main update_plan: Scans timeline for ready events, marks them for firing, cleans up, and returns list of ready events.
+/// Does NOT execute events—returns them for the storyteller to handle.
+/// Handles recalculation and adding new events if needed. Inspired by adaptive scheduling algorithms from rimworld
+/datum/storyteller_planner/proc/update_plan(datum/storyteller/ctl, datum/storyteller_inputs/inputs, datum/storyteller_balance_snapshot/bal)
+ SHOULD_NOT_OVERRIDE(TRUE)
+ if(!timeline)
+ build_timeline(ctl, inputs, bal)
+
+ var/list/ready_events = list() // Events ready to fire (returned to storyteller)
+ var/current_time = world.time
+
+ // Scan and process upcoming events
+ for(var/offset_str in get_upcoming_events())
+ var/offset = text2num(offset_str)
+ var/list/entry = get_entry_at(offset)
+ var/datum/round_event_control/evt = entry[ENTRY_EVENT]
+
+ if(current_time < offset)
+ continue
+
+ var/status = entry[ENTRY_STATUS] || STORY_GOAL_PENDING
+ if(status != STORY_GOAL_PENDING)
+ continue
+
+ if(!evt.can_fire_now(inputs, ctl))
+ timeline -= offset_str
+ var/replan_delay = get_next_event_delay(evt, ctl)
+ var/last_time = get_last_reference_time()
+ try_plan_event(evt, last_time + replan_delay, FALSE) // Allow insertion/shifting
+ continue
+
+ // Prevent multiple antagonist events in one cycle
+ var/has_antagonist = FALSE
+ for(var/datum/round_event_control/ready in ready_events)
+ if(ready.story_category & STORY_GOAL_ANTAGONIST)
+ has_antagonist = TRUE
+ break
+ if(has_antagonist && (evt.story_category & STORY_GOAL_ANTAGONIST))
+ continue
+
+ // Mark as firing and add to ready
+ entry[ENTRY_STATUS] = STORY_GOAL_FIRING
+ ready_events += evt
+
+ // Clean up completed/failed events from timeline
+ for(var/offset_str in timeline.Copy())
+ var/list/entry = timeline[offset_str]
+ if(entry[ENTRY_STATUS] in list(STORY_GOAL_COMPLETED, STORY_GOAL_FAILED, STORY_GOAL_FIRING))
+ timeline -= offset_str
+
+ // Count pending events
+ var/pending_count = 0
+ for(var/offset_str in timeline)
+ if(timeline[offset_str][ENTRY_STATUS] == STORY_GOAL_PENDING)
+ pending_count++
+
+ // Add next event(s) if low on pending and cooldown finished
+ if(pending_count <= STORY_INITIAL_GOALS_COUNT && COOLDOWN_FINISHED(src, event_planning_cooldown))
+ var/max_consecutive = CONFIG_GET(number/story_max_consecutive_events)
+ var/multi_event_tension = CONFIG_GET(number/story_multi_event_tension_threshold)
+ var/multi_event_threat = CONFIG_GET(number/story_multi_event_threat_threshold)
+
+ // Determine how many events to plan based on storyteller state
+ var/events_to_plan = 1
+ if(ctl.current_tension >= multi_event_tension && ctl.threat_points >= multi_event_threat)
+ // Plan multiple events if tension and threat are high enough
+ events_to_plan = min(max_consecutive, max(1, round((ctl.current_tension - multi_event_tension) / 20) + 1))
+
+ var/planned_count = 0
+ for(var/i = 1 to events_to_plan)
+ if(pending_count + planned_count >= STORY_INITIAL_GOALS_COUNT + max_consecutive)
+ break
+ if(add_next_event(ctl, inputs, bal))
+ planned_count++
+ else
+ break
+
+ if(planned_count > 0)
+ COOLDOWN_START(src, event_planning_cooldown, planning_cooldown)
+
+ // Recalculate if cooldown finished
+ if(COOLDOWN_FINISHED(src, recalculate_cooldown))
+ COOLDOWN_START(src, recalculate_cooldown, recalc_interval)
+ recalculate_plan(ctl, inputs, bal, FALSE)
+
+ sort_events()
+ return ready_events // Return ready events for storyteller to fire
+
+
+/// Recalculate the entire plan: Rebuild timeline from current state.
+/// Clears invalid/unavailable events, ensures at least STORY_INITIAL_GOALS_COUNT pending events.
+/// Generates adaptive chain with anti-clustering and major event spacing.
+/datum/storyteller_planner/proc/recalculate_plan(datum/storyteller/ctl, datum/storyteller_inputs/inputs, datum/storyteller_balance_snapshot/bal, force = FALSE)
+ // Clean invalid events and count pending
+ var/pending_count = 0
+ var/list/upcoming_offsets = get_upcoming_events(length(timeline))
+ var/list/to_delete = list()
+
+ for(var/offset_str in upcoming_offsets)
+ var/list/entry = timeline[offset_str]
+ var/datum/round_event_control/event_control = entry[ENTRY_EVENT]
+ if(!event_control.is_avaible(inputs, ctl) && !SSstorytellers.hard_debug)
+ message_admins("[ctl.name] canceled event [event_control.name] since it is no longer available for firing")
+ to_delete += offset_str
+ continue
+ if(entry[ENTRY_STATUS] == STORY_GOAL_PENDING)
+ pending_count += 1
+
+ for(var/delete_offset in to_delete)
+ cancel_event(delete_offset)
+
+ // Full rebuild if forced or population spike detected
+ var/need_recalc = ctl.has_population_spike(population_spike_threshold) && !force
+ if(need_recalc && pending_count >= STORY_INITIAL_GOALS_COUNT)
+ scale_timeline()
+ return timeline
+ if(!force)
+ return timeline
+ // Actually rebuilding timeline
+ message_admins("[ctl.name] [force ? "was forced to" : ""] rebuild event timeline!")
+ clear_timeline()
+ build_timeline(ctl, inputs, bal)
+
+
+/// Scales the timeline by adjusting event times to match current storyteller pacing.
+/// Attempts to bring future events closer if they are too far ahead
+/datum/storyteller_planner/proc/scale_timeline(datum/storyteller/ctl, datum/storyteller_inputs/inputs, datum/storyteller_balance_snapshot/bal, force = FALSE)
+ var/list/upcoming_offsets = get_upcoming_events(length(timeline))
+ var/scaled_average_time = ctl.get_event_interval()
+ var/scaled_grace = ctl.get_scaled_grace()
+ var/current_time = world.time
+
+ for(var/offset_str in upcoming_offsets)
+ var/list/entry = timeline[offset_str]
+ if(!entry[ENTRY_EVENT])
+ continue
+ var/datum/round_event_control/evt = entry[ENTRY_EVENT]
+ if(!evt)
+ cancel_event(offset_str)
+ continue
+ var/planned_when = entry[ENTRY_PLANNED_TIME]
+ // Calculate ideal time based on current pacing
+ var/required_offset = current_time + scaled_average_time
+ if(evt.story_category & STORY_GOAL_MAJOR)
+ required_offset += scaled_grace
+
+ var/event_time = text2num(offset_str)
+ if(required_offset < event_time)
+ // Event is too far in the future, try to bring it closer
+ var/difference = required_offset - event_time
+ if(difference >= 5 MINUTES)
+ difference = 5 MINUTES // Cap the adjustment
+ difference *= ctl.mood.get_event_frequency_multiplier()
+ required_offset += difference
+ var/new_time = planned_when + (required_offset - event_time)
+ reschedule_event(offset_str, new_time)
+ current_time = new_time
+ else
+ current_time = event_time
+ sort_events()
+ return timeline
+
+
+/// Build initial timeline
+/datum/storyteller_planner/proc/build_timeline(datum/storyteller/ctl, datum/storyteller_inputs/inputs, datum/storyteller_balance_snapshot/bal)
+ timeline = list()
+
+ var/target_count = STORY_INITIAL_GOALS_COUNT
+ for(var/i = 1 to target_count)
+ add_next_event(ctl, inputs, bal)
+
+ COOLDOWN_START(src, event_planning_cooldown, planning_cooldown)
+ COOLDOWN_START(src, recalculate_cooldown, recalc_interval)
+ return timeline
+
+
+/// Adds the next event to the timeline with improved planning: selects category/event, calculates delay,
+/// enforces major event spacing and anti-clustering during insertion.
+/datum/storyteller_planner/proc/add_next_event(datum/storyteller/ctl, datum/storyteller_inputs/inputs, datum/storyteller_balance_snapshot/bal, evt, silence = FALSE)
+ var/datum/round_event_control/new_event_control
+ if(istype(evt, /datum/round_event_control))
+ new_event_control = evt
+ else if(ispath(evt, /datum/round_event_control))
+ evt = locate(evt) in SSstorytellers.events_by_id
+ if(!evt)
+ evt = build_event(ctl)
+ new_event_control = evt
+ if(!new_event_control)
+ return FALSE
+
+ var/next_delay = get_next_event_delay(new_event_control, ctl)
+ var/last_time = get_last_reference_time()
+ var/fire_offset = last_time + next_delay
+
+ if(try_plan_event(new_event_control, fire_offset, FALSE, silence)) // Allow insertion with anti-clustering
+ new_event_control.on_planned(fire_offset)
+ return TRUE
+ return FALSE
+
+
+/// Attempts to plan an event at the target time. If slot taken, tries to insert nearby with buffer.
+/// If fixed_time=TRUE, fails if exact time unavailable. Enforces anti-clustering buffer.
+/datum/storyteller_planner/proc/try_plan_event(datum/round_event_control/event_control, time, fixed_time = FALSE, silence = FALSE)
+ if(!event_control)
+ return FALSE
+ if(!timeline)
+ timeline = list()
+
+ var/base_delay = time
+ var/attempts = 0
+
+ while(attempts < MAX_PLAN_ATTEMPTS)
+ var/target_time = fixed_time ? time : base_delay
+ var/target_str = time_key(target_time)
+ // Check for clustering, ensure no event within EVENT_CLUSTER_AVOIDANCE_BUFFER
+ if(!is_slot_available(target_time) && !silence)
+ base_delay += EVENT_CLUSTER_AVOIDANCE_BUFFER + rand(10 SECONDS, 30 SECONDS)
+ attempts++
+ continue
+
+ timeline[target_str] = list(
+ ENTRY_EVENT = event_control,
+ ENTRY_FIRE_TIME = target_time,
+ ENTRY_CATEGORY = event_control.story_category,
+ ENTRY_STATUS = STORY_GOAL_PENDING,
+ ENTRY_PLANNED_TIME = world.time,
+ )
+ if(!silence)
+ var/delay = text2num(target_str) - world.time
+ var/delay_minutes = round(delay / 600, 0.1) // deciseconds → minutes
+ var/time_str
+ if (delay_minutes >= 60)
+ var/h = round(delay_minutes / 60)
+ var/m = round(delay_minutes % 60)
+ time_str = m ? "[h]h [m]m" : "[h]h"
+ else
+ time_str = "[round(delay_minutes)]m"
+
+ var/is_antag = event_control.story_category & STORY_GOAL_ANTAGONIST
+ var/format_name = "[event_control.name || event_control.id] [event_control.story_category & STORY_GOAL_ANTAGONIST ? span_red("- Antagonist event") : ""]"
+ var/cancel_ref = "[REF(event_control)]_[target_time]"
+ var/reroll_ref = "[REF(event_control)]_[target_time]_reroll"
+ message_admins("[owner.name] planned new event [format_name] in [time_str]. \
+ [is_antag ? \
+ "" : \
+ "(CANCEL)"] \
+ (REROLL)")
+ return TRUE
+
+ stack_trace("[owner.name] Failed to find free slot after [MAX_PLAN_ATTEMPTS] attempts for event [event_control.id || event_control.name].")
+ return FALSE
+
+
+/// Checks if a time slot is available, considering anti-clustering buffer around existing events.
+/datum/storyteller_planner/proc/is_slot_available(target_time)
+ var/buffer = EVENT_CLUSTER_AVOIDANCE_BUFFER
+ for(var/offset_str in timeline)
+ var/existing_time = text2num(offset_str)
+ if(abs(target_time - existing_time) < buffer)
+ return FALSE
+ return TRUE
+
+
+/proc/cmp_time_keys_asc(a, b)
+ return text2num(a) - text2num(b)
+
+/datum/storyteller_planner/proc/sort_events()
+ if(!timeline || !length(timeline))
+ return
+ var/list/sorted_keys = sortTim(timeline.Copy(), GLOBAL_PROC_REF(cmp_time_keys_asc))
+ var/list/new_timeline = list()
+ for(var/key in sorted_keys)
+ new_timeline[key] = timeline[key]
+ timeline = new_timeline
+
+/// Checks if an event is already in the timeline.
+/datum/storyteller_planner/proc/is_event_in_timeline(datum/round_event_control/event_control)
+ if(ispath(event_control))
+ event_control = locate(event_control) in SSstorytellers.events_by_id
+
+ for(var/offset_str in timeline)
+ var/list/entry = timeline[offset_str]
+ if(entry[ENTRY_EVENT] == event_control)
+ return TRUE
+ return FALSE
+
+
+/datum/storyteller_planner/proc/time_key(num)
+ PRIVATE_PROC(TRUE)
+ if(!isnum(num))
+ return num
+ return _num2text(num)
+
+
+/datum/storyteller_planner/proc/get_entry_at(time)
+ var/key = ""
+ if(isnum(time))
+ key = time_key(time)
+ else
+ key = time
+ return timeline[key]
+
+
+/datum/storyteller_planner/proc/set_entry_at(time, entry)
+ if(!entry || !islist(entry)) return
+ timeline[time_key(time)] = entry
+ return TRUE
+
+
+/// Returns list of upcoming offset strings (keys), limited to N.
+/datum/storyteller_planner/proc/get_upcoming_events(limit = length(timeline))
+ var/list/upcoming = list()
+ var/count = 0
+ for(var/offset_str in sortTim(timeline.Copy(), GLOBAL_PROC_REF(cmp_time_keys_asc)))
+ if(count >= limit)
+ break
+ upcoming += offset_str
+ count++
+ return upcoming
+
+
+/datum/storyteller_planner/proc/get_closest_event()
+ for(var/offset_str in sortTim(timeline.Copy(), GLOBAL_PROC_REF(cmp_time_keys_asc)))
+ var/entry = timeline[offset_str]
+ return entry[ENTRY_EVENT]
+
+
+/datum/storyteller_planner/proc/get_closest_entry()
+ for(var/offset_str in sortTim(timeline.Copy(), GLOBAL_PROC_REF(cmp_time_keys_asc)))
+ return timeline[offset_str]
+ return null
+
+
+/// Reschedules an event to a new offset, using try_plan_event for insertion.
+/datum/storyteller_planner/proc/reschedule_event(old_offset, new_offset)
+ var/old_str = time_key(old_offset)
+ if(!timeline[old_str])
+ return FALSE
+
+ if(try_plan_event(timeline[old_str][ENTRY_EVENT], new_offset, FALSE, TRUE))
+ cancel_event(old_offset)
+ return TRUE
+ return FALSE
+
+
+/// Cancels an event by removing it from timeline.
+/datum/storyteller_planner/proc/cancel_event(offset)
+ var/offset_str = time_key(offset)
+ if(!timeline[offset_str])
+ return FALSE
+ timeline -= offset_str
+ return TRUE
+
+
+/datum/storyteller_planner/proc/get_events_in_time(time = 1 MINUTES)
+ var/list/upcoming = list()
+ var/current_time = world.time
+ for(var/i = current_time to current_time + time)
+ var/str_i = time_key(i)
+ if(timeline[str_i])
+ upcoming += timeline[str_i]
+ return upcoming
+
+
+/datum/storyteller_planner/proc/next_offset()
+ return owner.get_event_interval() * (length(timeline) + 1)
+
+
+/datum/storyteller_planner/proc/get_closest_offset()
+ for(var/offset_str in sortTim(timeline.Copy(), GLOBAL_PROC_REF(cmp_time_keys_asc)))
+ return text2num(offset_str)
+
+
+/datum/storyteller_planner/proc/clear_timeline(clear_antagonist_events = FALSE)
+ for(var/offset in get_upcoming_events(length(timeline)))
+ var/list/entry = get_entry_at(offset)
+ if(!entry || !entry[ENTRY_CATEGORY] || !entry[ENTRY_EVENT])
+ timeline -= offset
+ if((entry[ENTRY_CATEGORY] & STORY_GOAL_ANTAGONIST) && !clear_antagonist_events)
+ continue
+ cancel_event(offset)
+
+
+/datum/storyteller_planner/proc/build_event(datum/storyteller/ctl)
+ return ctl.get_next_event()
+
+/datum/storyteller_planner/proc/get_next_event_delay(datum/round_event_control/event_control, datum/storyteller/ctl)
+ var/delay = ctl.get_event_interval()
+ if(event_control)
+ if(event_control.story_category & STORY_GOAL_GOOD)
+ delay *= 0.8
+ else if(event_control.story_category & STORY_GOAL_BAD)
+ delay += ctl.get_scaled_grace()
+ else if(event_control.story_category & STORY_GOAL_NEUTRAL)
+ delay *= 0.9
+ else if(event_control.story_category & STORY_GOAL_RANDOM)
+ delay *= rand(0.8, 1.2)
+ return delay
+
+
+/datum/storyteller_planner/proc/get_last_reference_time()
+ if(length(timeline))
+ return get_closest_offset()
+ return owner.last_event_time
+
+/datum/storyteller_planner/Topic(href, href_list)
+ . = ..()
+ if(.)
+ return TRUE
+
+ var/mob/user = usr
+ if(!user || !user.client || !check_rights_for(user.client, R_ADMIN))
+ return TRUE
+
+ if(href_list["cancel_event"])
+ var/ref_data = href_list["cancel_event"]
+ var/list/parts = splittext(ref_data, "_")
+ if(length(parts) >= 2)
+ var/time_str = parts[2]
+ var/time_val = text2num(time_str)
+
+ // Find event at this time
+ var/offset_str = time_key(time_val)
+ var/list/entry = get_entry_at(offset_str)
+ if(entry && entry[ENTRY_EVENT])
+ var/datum/round_event_control/evt = entry[ENTRY_EVENT]
+ cancel_event(time_val)
+ message_admins("[key_name_admin(user)] canceled [owner.name]'s planned event [evt.name || evt.id] at [time2text(time_val, "hh:mm", NO_TIMEZONE)].")
+ log_admin("[key_name(user)] canceled storyteller event [evt.name || evt.id]")
+ return TRUE
+
+ if(href_list["reroll_event"])
+ var/ref_data = href_list["reroll_event"]
+ var/list/parts = splittext(ref_data, "_")
+ if(length(parts) >= 3)
+ var/time_str = parts[2]
+ var/time_val = text2num(time_str)
+
+ // Find event at this time
+ var/offset_str = time_key(time_val)
+ var/list/entry = get_entry_at(offset_str)
+ if(entry && entry[ENTRY_EVENT])
+ var/datum/round_event_control/old_evt = entry[ENTRY_EVENT]
+ cancel_event(time_val)
+
+ var/datum/round_event_control/new_evt = build_event(owner)
+ if(new_evt)
+ var/new_time = time_val + rand(-2 MINUTES, 2 MINUTES) // Small variation
+ if(try_plan_event(new_evt, new_time, FALSE, TRUE))
+ message_admins("[key_name_admin(user)] rerolled [owner.name]'s planned event from [old_evt.name || old_evt.id] to [new_evt.name || new_evt.id].")
+ log_admin("[key_name(user)] rerolled storyteller event from [old_evt.name || old_evt.id] to [new_evt.name || new_evt.id]")
+ else
+ message_admins("[key_name_admin(user)] attempted to reroll [owner.name]'s event but failed to find a slot.")
+ else
+ message_admins("[key_name_admin(user)] attempted to reroll [owner.name]'s event but no new event could be generated.")
+ return TRUE
+
+ return FALSE
+
+
+#undef ENTRY_EVENT
+#undef ENTRY_FIRE_TIME
+#undef ENTRY_CATEGORY
+#undef ENTRY_STATUS
+#undef EVENT_CLUSTER_AVOIDANCE_BUFFER
+#undef MAX_PLAN_ATTEMPTS
+#undef ENTRY_PLANNED_TIME
diff --git a/tff_modular/modules/storytellers/core/~storyteller.dm b/tff_modular/modules/storytellers/core/~storyteller.dm
new file mode 100644
index 00000000000..255826cdbc3
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/~storyteller.dm
@@ -0,0 +1,769 @@
+/datum/storyteller
+ /* BASIC STORYTELLER INFO */
+
+ var/name = "John Dynamic"
+ var/desc = "A generic storyteller managing station events and goals."
+ var/ooc_desc = "Tell to coder if you saw this storyteller in action."
+ var/ooc_difficulty = "Dynamic"
+ var/portrait_path = ""
+ var/logo_path = ""
+ var/id = "john_dynamic"
+
+
+ /* CORE STORYTELLER VARIABLES */
+
+
+ /// Current mood profile, affecting event pacing and tone
+ var/datum/storyteller_mood/mood
+ /// Behevour of storyteller; determines how they shoot events
+ var/datum/storyteller_behevour/behevour
+ /// Planner selects chain of goals and timeline-based execution
+ var/datum/storyteller_planner/planner
+ /// Analyzer computes station value and inputs
+ var/datum/storyteller_analyzer/analyzer
+ /// Balancer computes weights of players vs antagonists
+ var/datum/storyteller_balance/balancer
+ /// Storyteller short memory of inputs and vault
+ var/datum/storyteller_inputs/inputs
+
+
+ /// Base think frequency; scaled by mood pace (in ticks)
+ var/base_think_delay = STORY_THINK_BASE_DELAY
+ // Event pacing limits
+ var/average_event_interval = 15 MINUTES
+ /// Recent events timeline (timestamps) for admin UI and spacing
+ var/list/recent_events = list()
+ /// Recent event ids for repetition penalty logic in planner selection
+ var/list/recent_event_ids = list()
+ /// Max recent ids to remember (older dropped first)
+ var/recent_event_ids_max = 20
+ /// Aggregate balance indicator for UI (0..100 where 50 is balanced)
+ var/player_antag_balance = 0
+ // Target balance level
+ var/target_player_antag_balance = STORY_DEFAULT_PLAYER_ANTAG_BALANCE
+ // Adaptation and difficulty variables (inspired by RimWorld's threat adaptation and cycles)
+ /// Current threat points; accumulate over time to scale event intensity
+ var/threat_points = 0
+ /// Rate at which threat points increase per think cycle
+ var/threat_growth_rate = STORY_THREAT_GROWTH_RATE
+ /// Adaptation factor; reduces threat intensity after recent damage/losses (0-1, where 0 = full adaptation/no threats)
+ var/adaptation_factor = 0
+ /// Rate at which adaptation decays over time (e.g., gradual recovery after disasters)
+ var/adaptation_decay_rate = STORY_ADAPTATION_DECAY_RATE
+ /// Current level of overall tension
+ var/current_tension = 0
+ /// Current grace period after major event when we avoid rapid-fire scheduling
+ var/grace_period = STORY_GRACE_PERIOD
+ /// Time since last major event; used to enforce grace periods
+ var/static/last_event_time = 0
+ /// Round start timestamp
+ var/static/round_start_time = 0
+ /// Cached progression 0..1 over target duration
+ var/static/round_progression = 0
+
+
+ /* DIFFICULTY SCALING VARIABLES */
+
+ /// Overall difficulty multiplier; scales all weights/threats (1.0 = normal)
+ var/difficulty_multiplier = STORY_DIFFICULTY_MULTIPLIER
+ /// Threshold for triggering adaptation spikes
+ var/recent_damage_threshold = STORY_RECENT_DAMAGE_THRESHOLD
+ /// Target tension level; storyteller aims to keep overall_tension around this
+ var/target_tension = STORY_TARGET_TENSION
+ /// Max threat scale; caps threat_points to prevent over-escalation
+ var/max_threat_scale = STORY_MAX_THREAT_SCALE
+ /// Repetition penalty; reduces weight of recently used events/goals for variety
+ var/repetition_penalty = STORY_REPETITION_PENALTY
+ /// Interval for mood adjustment (reuse planner recalc cadence)
+ var/mood_update_interval = STORY_RECALC_INTERVAL
+ /// History of tension values used for spike detection
+ VAR_PRIVATE/list/tension_history = list()
+
+
+ /* POPULATION SCALING VARIABLES */
+ /// It actually scaled how rare we fire events
+
+ /// Population factor; scales by active player population, larger crews get denser and more frequent events
+ var/population_factor = 0
+ /// History of population counts for counting population factor
+ VAR_PRIVATE/list/population_history = list()
+ /// History of raw crew counts (numeric) — used for spike detection
+ VAR_PRIVATE/list/population_count_history = list()
+ /// Population scaling configurations
+ /// Threshold for low population classification
+ var/population_threshold_low
+ /// Threshold for medium population classification
+ var/population_threshold_medium
+ /// Threshold for high population classification
+ var/population_threshold_high
+ /// Threshold for full population classification
+ var/population_threshold_full
+ /// Factor multiplier for low population (lower = fewer/smaller threats)
+ var/population_factor_low
+ /// Factor multiplier for medium population
+ var/population_factor_medium
+ /// Factor multiplier for high population
+ var/population_factor_high
+ /// Factor multiplier for full population (1.0 = full scaling)
+ var/population_factor_full
+ /// Smoothing weight for population factor changes (0-1, higher = slower changes)
+ var/population_smooth_weight
+
+
+ /* ANTAG VARIBLES */
+
+ /// Is storyteller attempting to spawn antagonists currently, it's need to prevent race conditions
+ VAR_PRIVATE/attempted_spawning = FALSE
+ /// Base interval for checking antagonist balance (default: 30 minutes for wave-based spawning)
+ var/antag_balance_check_interval = 30 MINUTES
+ /// Next time to check antagonist balance
+ var/next_atnag_balance_check_time = -1 // -1 for ui before first check
+ /// Time when roundstart antagonists should be selected (approximately 10 minutes after round start)
+ var/roundstart_antag_selection_time = 0
+ /// Whether roundstart antagonists have been selected
+ var/static/roundstart_antags_selected = FALSE
+ /// Is we send roundstart report?
+ var/static/report_sended = FALSE
+ /* STATE TRACKING VARIABLES */
+
+ /// Next time to update analysis and planning (in world.time)
+ var/next_think_time = 0
+ /// Is this storyteller initialized
+ var/initialized = FALSE
+ /// Cooldown for mood updates
+ COOLDOWN_DECLARE(mood_update_cooldown)
+ /// Cooldown for checking antagonist spawn balance
+ COOLDOWN_DECLARE(antag_balance_check_cooldown)
+
+/datum/storyteller/New()
+ ..()
+ mood = new /datum/storyteller_mood
+ behevour = new /datum/storyteller_behevour(src)
+ planner = new /datum/storyteller_planner(src)
+ analyzer = new /datum/storyteller_analyzer(src)
+ balancer = new /datum/storyteller_balance(src)
+ inputs = new /datum/storyteller_inputs
+
+ // Load population config values
+ population_threshold_low = CONFIG_GET(number/story_population_threshold_low)
+ population_threshold_medium = CONFIG_GET(number/story_population_threshold_medium)
+ population_threshold_high = CONFIG_GET(number/story_population_threshold_high)
+ population_threshold_full = CONFIG_GET(number/story_population_threshold_full)
+ population_factor_low = CONFIG_GET(number/story_population_factor_low)
+ population_factor_medium = CONFIG_GET(number/story_population_factor_medium)
+ population_factor_high = CONFIG_GET(number/story_population_factor_high)
+ population_factor_full = CONFIG_GET(number/story_population_factor_full)
+ population_smooth_weight = CONFIG_GET(number/story_population_smooth_weight)
+
+/datum/storyteller/Destroy(force)
+ qdel(mood)
+ qdel(behevour)
+ qdel(planner)
+ qdel(analyzer)
+ qdel(balancer)
+ qdel(inputs)
+
+ UnregisterSignal(analyzer, COMSIG_STORYTELLER_FINISHED_ANALYZING)
+ ..()
+
+
+
+/datum/storyteller/proc/initialize()
+ round_start_time = SSticker.round_start_time
+
+
+ // Schedule roundstart antagonist selection approximately 10 minutes after round start
+ // Will check for sufficient population when time comes
+ roundstart_antag_selection_time = round_start_time + 10 MINUTES
+ RegisterSignal(analyzer, COMSIG_STORYTELLER_FINISHED_ANALYZING, PROC_REF(on_metrics_finished))
+ run_metrics(RESCAN_STATION_INTEGRITY | RESCAN_STATION_VALUE)
+
+
+/datum/storyteller/proc/on_metrics_finished(datum/storyteller_analyzer/anl, datum/storyteller_inputs/inputs, timout, metrics_count)
+ SIGNAL_HANDLER
+ src.inputs = analyzer.get_inputs()
+ if(initialized)
+ return
+
+ last_event_time = world.time
+ var/datum/storyteller_balance_snapshot/bal = balancer.make_snapshot(inputs)
+ update_population_factor(instant=TRUE)
+ planner.build_timeline(src, inputs, bal)
+ initialized = TRUE
+ if(difficulty_multiplier < 0.3)
+ difficulty_multiplier = STORY_DIFFICULTY_MULTIPLIER
+
+ // Send round start report (generates custom report based on storyteller parameters)
+ send_round_start_report()
+ INVOKE_ASYNC(src, PROC_REF(think))
+
+/**
+ * Geters and setters
+ */
+
+
+/// Checks if an event can trigger at a future time, respecting grace period after last event.
+/// Aids scheduling during sub-goal analysis without disrupting global objective pacing.
+/datum/storyteller/proc/can_trigger_event_at(time)
+ var/pop_grace_mult = 1.5 - (population_factor - 0.3) * (1.5 - 1.0) / (1.0 - 0.3)
+ pop_grace_mult = clamp(pop_grace_mult, 1.0, 1.5)
+ var/effective_grace = get_effective_pace()
+ return time - get_time_since_last_event() > effective_grace
+
+
+/// Effective event pace: mood frequency multiplier * (1 - adaptation). Lower = slower events,
+/datum/storyteller/proc/get_effective_pace()
+ return mood.get_event_frequency_multiplier() * max(0.5, (1 - adaptation_factor))
+
+
+/// Effective grace period scaled by population factor: low pop = longer grace period
+/// Linear interpolation: low pop (0.3) -> 1.5x grace, high pop (1.0) -> 0.5x grace
+/datum/storyteller/proc/get_scaled_grace()
+ var/pop_mod = lerp(1.5, 0.5, 1.0 / max(0.1, population_factor))
+ pop_mod = clamp(pop_mod * mood.get_event_frequency_multiplier(), 0.5, 1.5)
+ return grace_period * pop_mod
+
+
+
+/// Base event interval, scaled by pace and population factor.
+/// Low population = longer intervals (fewer events), high population = shorter intervals (more frequent events)
+/// event_interval = average_event_interval * effective_pace * max(0.5, 1 - population_factor)
+/datum/storyteller/proc/get_event_interval()
+ var/base = average_event_interval * clamp(mood.get_event_frequency_multiplier(), 0.5, 1.5)
+ // If adaptation is above 0.4, start increasing intervals to slow down events
+ base -= base * max(0, adaptation_factor - 0.4)
+ if(difficulty_multiplier > 1.0)
+ base -= (base * 0.1) * difficulty_multiplier
+ var/pop_mod = max(0.5, 1.5 - population_factor)
+ return base * pop_mod
+
+
+
+/datum/storyteller/proc/get_repeat_info(goal_id)
+ var/id_prefix = goal_id + "_"
+ var/count = 0
+ var/last_time = 0
+
+ for(var/hist_id in recent_events)
+ if(findtext(hist_id, id_prefix, 1, 0))
+ count++
+ var/list/details = recent_events[hist_id]
+ var/fire_time = details["fired_ts"]
+ if(fire_time > last_time)
+ last_time = fire_time
+
+ return list("count" = count, "last_time" = last_time)
+
+
+
+/// Event interval without population adjustment; for baseline pacing in global goal selection.
+/datum/storyteller/proc/get_event_interval_no_population_factor()
+ return average_event_interval * get_effective_pace()
+
+
+
+/// Time since last event; tracks history for grace periods and intervals in event planning.
+/datum/storyteller/proc/get_time_since_last_event()
+ return world.time - last_event_time
+
+
+/datum/storyteller/proc/get_closest_subgoals()
+ return planner.get_upcoming_events(10)
+
+
+/// Event trigger guard for ad-hoc random events outside goals
+/datum/storyteller/proc/can_trigger_event_now()
+ // Scale grace period by population_factor: low pop = longer grace period
+ // Linear interpolation: low pop (0.3) -> 1.5x grace, high pop (1.0) -> 1.0x grace
+ var/pop_grace_mult = 1.5 - (population_factor - 0.3) * (1.5 - 1.0) / (1.0 - 0.3)
+ pop_grace_mult = clamp(pop_grace_mult, 1.0, 1.5)
+ var/effective_grace = grace_period * pop_grace_mult
+
+ if(get_time_since_last_event() < effective_grace + 2 MINUTES * (mood ? mood.get_threat_multiplier() : 1.0))
+ return FALSE
+ if(get_time_since_last_event() > round(effective_grace * (mood ? mood.get_threat_multiplier() : 1.0) / min(3, difficulty_multiplier)))
+ return TRUE
+ var/prob_modifier = (mood ? mood.get_threat_multiplier() : 1.0) * difficulty_multiplier * population_factor
+ return prob(50 * prob_modifier)
+
+
+/// Ad-hoc random event for testing or emergency pacing
+/datum/storyteller/proc/trigger_random_event(list/vault, datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ SSevents.spawnEvent()
+ recent_events += world.time
+ log_storyteller("Triggered random event via SSevents")
+
+
+/// Scale effective threat by population_factor: low pop = lower effective threat
+/datum/storyteller/proc/get_effective_threat()
+ var/base = threat_points / 10
+ base *= (mood.get_threat_multiplier() * difficulty_multiplier)
+ return base * (0.5 + clamp(population_factor, 0.3, 1.0))
+
+
+/datum/storyteller/proc/get_next_possible_event_time()
+ return (world.time - last_event_time) + get_event_interval()
+
+
+/datum/storyteller/proc/get_event_threat_points(category)
+ var/base_threat = (threat_points * mood.get_threat_multiplier() * clamp(population_factor, 0.3, 1.0)) * 100
+ if(category == STORY_GOAL_GOOD)
+ return round(base_threat * 0.5 * (1.5 - adaptation_factor))
+ else if(category == STORY_GOAL_NEUTRAL)
+ return round(base_threat * 0.8 * (1.5 - adaptation_factor))
+ else if(category == STORY_GOAL_BAD)
+ return round(base_threat * 1.2 * max(0.5, adaptation_factor))
+ return clamp(round(base_threat), 0, max_threat_scale * 100)
+
+/datum/storyteller/proc/get_next_event()
+ return behevour.get_next_event(balancer.make_snapshot(inputs), inputs)
+
+/**
+ * Main thinker loop
+ */
+
+
+/datum/storyteller/proc/schedule_next_think()
+ // Apply mood-based pacing (pace is clamped by storyteller bounds)
+ var/pace_multiplier = (mood ? mood.pace : 1.0)
+ var/delay = base_think_delay * clamp(pace_multiplier, STORY_PACE_MIN, STORY_PACE_MAX)
+ next_think_time = world.time + delay
+
+
+/datum/storyteller/proc/think(force = FALSE)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ if(!initialized)
+ return
+
+ if(world.time < next_think_time && !force)
+ return
+
+ if(SEND_SIGNAL(src, COMSIG_STORYTELLER_PRE_THINK) & COMPONENT_THINK_BLOCKED && !force)
+ return
+
+
+ // 1) Balance snapshot: derive tension, station strength, antag efficacy/inactivity
+ var/datum/storyteller_balance_snapshot/snap = balancer.make_snapshot(inputs)
+ player_antag_balance = round(snap.balance_ratio * 100)
+
+ // 2) Mood: adapt mood based on current tension vs target and recent adaptation
+ if(COOLDOWN_FINISHED(src, mood_update_cooldown))
+ update_mood_based_on_balance(snap)
+ COOLDOWN_START(src, mood_update_cooldown, mood_update_interval)
+
+
+ // 3) Plan and fire goals
+ var/list/events_to_fire = planner.update_plan(src, inputs, snap)
+ if(length(events_to_fire))
+ for(var/datum/round_event_control/evt in events_to_fire)
+ var/threat_points = get_event_threat_points(evt.story_category)
+ INVOKE_ASYNC(evt, TYPE_PROC_REF(/datum/round_event_control, run_event_as_storyteller), inputs, src, threat_points)
+ record_event(evt, "Fired")
+ post_event(evt)
+
+
+ // 4) Passive threat/adaptation drift each think
+ // Threat growth scales with population_factor: low pop = slower threat accumulation
+ var/threat_mult = mood.get_threat_multiplier() * clamp(population_factor, 0.3, 1.0)
+ threat_points = min(max_threat_scale, threat_points + threat_growth_rate * threat_mult)
+
+
+ if(!HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ADAPTATION_DECAY))
+ adaptation_factor = max(0, adaptation_factor - adaptation_decay_rate)
+ round_progression = clamp((world.time - round_start_time) / STORY_ROUND_PROGRESSION_THRESHOLD, 0, 1)
+ current_tension = snap.overall_tension
+ balancer.tension_bonus = max(balancer.tension_bonus - STORY_TENSION_BONUS_DECAY_RATE * difficulty_multiplier, 0)
+ update_population_factor()
+
+ // 5) Check antagonist balance and spawn if needed
+ if(next_atnag_balance_check_time <= world.time && SSstorytellers?.storyteller_replace_dynamic && roundstart_antags_selected)
+ var/next_time = 10 MINUTES
+ if(check_and_spawn_antagonists(snap))
+ next_time = antag_balance_check_interval * 2 - population_factor
+ else
+ next_time = (antag_balance_check_interval * 0.5) * 1.5 - population_factor
+ next_atnag_balance_check_time = world.time + next_time
+
+
+ // 6) Check if it's time to select roundstart antagonists
+ // Wait for ~10 minutes and check if there are enough people on station
+ if(!roundstart_antags_selected && world.time >= roundstart_antag_selection_time)
+ if(spawn_initial_antagonists())
+ roundstart_antags_selected = TRUE
+ var/next_time = antag_balance_check_interval * 1.7 - population_factor
+ next_atnag_balance_check_time = world.time + next_time
+
+
+ var/latest_key = num2text(world.time)
+ tension_history[latest_key] = current_tension
+ var/max_history = CONFIG_GET(number/story_population_history_max)
+ if(length(tension_history) > max_history)
+ tension_history.Cut(1, 2)
+
+ // 7) Schedule next cycle
+ schedule_next_think()
+ SEND_SIGNAL(src, COMSIG_STORYTELLER_POST_THINK)
+
+
+
+/datum/storyteller/proc/post_event(datum/round_event_control/evt)
+ if(!evt)
+ return
+
+ var/tension_effect = 0
+ if(evt.has_tag(STORY_TAG_ESCALATION))
+ adaptation_factor = min(1.0, adaptation_factor + 0.3)
+ tension_effect += 5
+ if(evt.story_category & STORY_GOAL_MAJOR)
+ adaptation_factor = min(1.0, adaptation_factor + 0.2)
+ else if(evt.has_tag(STORY_TAG_DEESCALATION))
+ adaptation_factor = max(0, adaptation_factor - 0.1)
+
+ if(evt.story_category & STORY_GOAL_BAD)
+ // Calculate the percentage loss of threat_points based on recent_damage_threshold
+ var/loss_percentage = 100 / recent_damage_threshold
+ if(evt.story_category & STORY_GOAL_MAJOR && !HAS_TRAIT(src, STORYTELLER_TRAIT_NO_MERCY))
+ loss_percentage *= 2
+
+ var/threat_loss = threat_points * (loss_percentage / 100)
+ threat_points = max(0, threat_points - threat_loss)
+ tension_effect += 10
+
+ balancer.tension_bonus = min(balancer.tension_bonus + tension_effect, STORY_MAX_TENSION_BONUS)
+ record_event(evt, STORY_GOAL_COMPLETED)
+
+
+
+/// Helper to record a goal event: store timestamp for spacing and id for repetition penalty
+/datum/storyteller/proc/record_event(datum/round_event_control/evt, status)
+ if(!evt)
+ return
+ var/current_time = world.time
+ var/id = evt.id + "_" + num2text(current_time)
+ recent_events[id] = list(list(
+ "id" = evt.id,
+ "desc" = evt.description,
+ "status" = status,
+ "fired_ts" = current_time,
+ "fired_at" = time2text(world.time, "hh:mm", NO_TIMEZONE),
+ ))
+ recent_event_ids |= evt.id
+ while(length(recent_event_ids) > recent_event_ids_max)
+ recent_event_ids.Cut(1, 2)
+ last_event_time = current_time
+
+
+
+/datum/storyteller/proc/update_population_factor(instant = FALSE)
+ var/current = inputs.vault[STORY_VAULT_CREW_ALIVE_COUNT] || 0
+
+ var/low_thresh = population_threshold_low * (mood ? mood.aggression : 1.0)
+ var/med_thresh = population_threshold_medium * (mood ? mood.aggression : 1.0)
+ var/high_thresh = population_threshold_high * (mood ? mood.aggression : 1.0)
+
+
+ var/desired = population_factor_medium
+ if(current <= low_thresh * 0.8)
+ desired = 0.1
+ else if(current <= low_thresh)
+ desired = population_factor_low
+ else if(current <= med_thresh)
+ desired = population_factor_medium
+ else if(current <= high_thresh)
+ desired = population_factor_high
+ else
+ desired = population_factor_full
+ if(instant)
+ population_factor = desired
+ population_history[num2text(world.time)] = population_factor
+ return
+
+ var/effective_smooth = population_smooth_weight
+ if(has_population_spike(20))
+ effective_smooth = 0.5
+
+ var/new_factor = (population_factor * effective_smooth) + (desired * (1.0 - effective_smooth))
+ var/delta = new_factor - population_factor
+ new_factor = population_factor + clamp(delta, -0.2, 0.2)
+ population_factor = clamp(new_factor, 0.1, 1.0)
+
+ population_history[num2text(world.time)] = population_factor
+ var/max_history = CONFIG_GET(number/story_population_history_max)
+ if(length(population_history) > max_history)
+ population_history.Cut(1, 2)
+ population_count_history[num2text(world.time)] = current
+ if(length(population_count_history) > max_history)
+ population_count_history.Cut(1, 2)
+
+
+/// Adjust current mood variables based on balance snapshot (smooth, non-destructive)
+/datum/storyteller/proc/update_mood_based_on_balance(datum/storyteller_balance_snapshot/snap)
+ if(!mood || !snap)
+ return
+ // If tension is too high, bias towards calmer pacing and lower aggression
+ if(snap.overall_tension > target_tension + 10 || (HAS_TRAIT(src, STORYTELLER_TRAIT_KIND) && prob(50)))
+ mood.aggression = max(0.5, mood.aggression - 0.1)
+ mood.pace = max(0.5, mood.pace - 0.1)
+ mood.volatility = max(0.6, mood.volatility - 0.05)
+ // If too calm, spice it up a bit
+ else if(snap.overall_tension < target_tension - 10)
+ mood.aggression = min(1.5, mood.aggression + 0.1)
+ mood.pace = min(1.5, mood.pace + 0.1)
+ mood.volatility = min(1.4, mood.volatility + 0.05)
+ // Otherwise, gently normalize toward neutral
+ else
+ mood.aggression = clamp((mood.aggression * 0.9) + 0.1 * 1.0, 0.5, 1.5)
+ mood.pace = clamp((mood.pace * 0.9) + 0.1 * 1.0, 0.5, 1.5)
+ mood.volatility = clamp((mood.volatility * 0.9) + 0.1 * 1.0, 0.6, 1.4)
+
+/**
+ * Metrics and helpers
+ */
+
+
+/datum/storyteller/proc/run_metrics(flags)
+ INVOKE_ASYNC(analyzer, TYPE_PROC_REF(/datum/storyteller_analyzer, scan_station), flags)
+
+
+// Returns TRUE if the latest crew count differs from recent average by 'threshold_percent' or more.
+/datum/storyteller/proc/has_population_spike(threshold_percent = 20)
+ var/current = inputs.vault[STORY_VAULT_CREW_ALIVE_COUNT] || 0
+ var/total = 0
+ var/count = 0
+ var/latest_key = num2text(world.time)
+ // compute average excluding latest sample (if present) to avoid self-comparison
+ for(var/key in population_count_history)
+ if(key == latest_key)
+ continue
+ total += text2num(population_count_history[key])
+ count++
+ var/avg = (count > 0 ? total / count : 0)
+ if(count < 3 || avg <= 0)
+ // not enough history to make a decision
+ return FALSE
+ var/delta = abs(current - avg)
+ return (delta / max(1, avg)) >= (threshold_percent / 100)
+
+
+// Returns TRUE if the latest tension differs from recent average by 'threshold_percent' or more.
+/datum/storyteller/proc/has_tension_spike(threshold_percent = 20)
+ var/current = current_tension
+ var/total = 0
+ var/count = 0
+ var/latest_key = num2text(world.time)
+ for(var/key in tension_history)
+ if(key == latest_key)
+ continue
+ total += text2num(tension_history[key])
+ count++
+ var/avg = (count > 0 ? total / count : 0)
+ if(count < 3 || avg <= 0)
+ return FALSE
+ var/delta = abs(current - avg)
+ return (delta / max(1, avg)) >= (threshold_percent / 100)
+
+
+/**
+ * Round start report generation
+ */
+
+
+/// Generates and sends round start report based on storyteller parameters
+/// Should be called after round initialization
+/datum/storyteller/proc/send_round_start_report()
+ if(report_sended)
+ return
+ if(!CONFIG_GET(flag/no_intercept_report))
+ // Generate and send full roundstart report independently
+ addtimer(CALLBACK(src, PROC_REF(send_full_roundstart_report)), \
+ rand(60 SECONDS, 180 SECONDS), TIMER_UNIQUE)
+
+
+/// Sends a complete roundstart report independently, without using communications_controller
+/// Includes advisory report, station goals, and traits
+/datum/storyteller/proc/send_full_roundstart_report()
+ if(!initialized || !inputs)
+ return
+
+ // Generate advisory report based on storyteller's target tension and difficulty
+ var/advisory_report = generate_roundstart_report()
+ if(!advisory_report)
+ // Fallback if generation fails
+ if(SSstorytellers?.storyteller_replace_dynamic)
+ log_storyteller("[name] failed to generate roundstart report, using fallback")
+ else
+ GLOB.communications_controller.queue_roundstart_report()
+ return
+
+ // Build full report (similar to communications_controller.send_roundstart_report)
+ var/full_report = "Nanotrasen Department of Intelligence Threat Advisory, Spinward Sector, TCD [time2text(world.realtime, "DDD, MMM DD")], [CURRENT_STATION_YEAR]:
"
+ full_report += advisory_report
+
+ // Generate station goals (if storyteller controls dynamic, use appropriate budget)
+ var/greenshift = determine_greenshift_status()
+ var/goal_budget = greenshift ? INFINITY : CONFIG_GET(number/station_goal_budget)
+ SSstation.generate_station_goals(goal_budget)
+
+ var/list/datum/station_goal/goals = SSstation.get_station_goals()
+ if(length(goals))
+ var/list/texts = list("
Special Orders for [station_name()]:
")
+ for(var/datum/station_goal/station_goal as anything in goals)
+ station_goal.on_report()
+ texts += station_goal.get_report()
+ full_report += texts.Join("
")
+
+ // Add station traits
+ var/list/trait_list_strings = list()
+ for(var/datum/station_trait/station_trait as anything in SSstation.station_traits)
+ if(!station_trait.show_in_report)
+ continue
+ trait_list_strings += "[station_trait.get_report()]
"
+ if(trait_list_strings.len > 0)
+ full_report += "
Identified shift divergencies:
" + trait_list_strings.Join("")
+
+ // Add footnotes if any
+ if(length(GLOB.communications_controller.command_report_footnotes))
+ var/footnote_pile = ""
+ for(var/datum/command_footnote/footnote as anything in GLOB.communications_controller.command_report_footnotes)
+ footnote_pile += "[footnote.message]
"
+ footnote_pile += "[footnote.signature]
"
+ footnote_pile += "
"
+ full_report += "
Additional Notes:
" + footnote_pile
+
+ // Send the report
+#ifndef MAP_TEST
+ print_command_report(full_report, "[command_name()] Status Summary", announce=FALSE)
+
+ // Send priority announcement based on greenshift status
+ if(greenshift)
+ priority_announce(
+ "Thanks to the tireless efforts of our security and intelligence divisions, \
+ there are currently no credible threats to [station_name()]. \
+ All station construction projects have been authorized. Have a secure shift!",
+ "Security Report",
+ SSstation.announcer.get_rand_report_sound(),
+ color_override = "green",
+ )
+ else if(prob(90))
+ priority_announce(
+ "[SSsecurity_level.current_security_level.elevating_to_announcement]\n\
+ A summary has been copied and printed to all communications consoles.",
+ "Security level elevated.",
+ ANNOUNCER_INTERCEPT,
+ color_override = SSsecurity_level.current_security_level.announcement_color,
+ )
+ else
+ priority_announce(
+ "A summary of the station's situation has been copied and printed to all communications consoles.",
+ "Security Report",
+ SSstation.announcer.get_rand_report_sound(),
+ )
+#endif
+
+ log_storyteller("[name] sent full roundstart report with advisory level based on target_tension=[target_tension], difficulty=[difficulty_multiplier]")
+ report_sended = TRUE
+
+/// Determines if this should be treated as a greenshift (no threats)
+/datum/storyteller/proc/determine_greenshift_status()
+ // Greenshift if target tension is very low and difficulty is low
+ if(target_tension < 20 && difficulty_multiplier < 0.7)
+ return TRUE
+ // Also greenshift if no antags trait is active
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ANTAGS))
+ return TRUE
+ return FALSE
+
+
+/// Generates a roundstart advisory report based on storyteller's target tension and difficulty
+/// Uses target_tension (what storyteller aims for) and difficulty_multiplier instead of current tension
+/datum/storyteller/proc/generate_roundstart_report()
+ if(!initialized || !inputs)
+ return null
+
+ // Calculate threat level based on target_tension and difficulty_multiplier
+ // Higher target_tension and difficulty = higher threat advisory
+ var/threat_score = (target_tension / 100.0) * difficulty_multiplier
+
+ // Determine threat advisory level based on target_tension and difficulty
+ var/advisory_level = "Green Star"
+ var/advisory_desc = "no credible threats"
+ var/threat_scale = threat_score
+
+ // Scale threat based on target_tension (0-100) and difficulty_multiplier
+ // target_tension represents what the storyteller is aiming for, not current state
+ if(threat_scale > 0.75 || (target_tension > 70 && difficulty_multiplier > 1.2))
+ advisory_level = "Midnight Sun"
+ advisory_desc = "high likelihood of major coordinated attacks expected during this shift"
+ else if(threat_scale > 0.60 || (target_tension > 50 && difficulty_multiplier > 1.0))
+ advisory_level = "Black Orbit"
+ advisory_desc = "elevated threat level with potential for significant enemy activity"
+ else if(threat_scale > 0.40 || (target_tension > 30 && difficulty_multiplier > 0.8))
+ advisory_level = "Red Star"
+ advisory_desc = "credible risk of enemy attack against our assets"
+ else if(threat_scale > 0.20 || (target_tension > 15 && difficulty_multiplier > 0.7))
+ advisory_level = "Yellow Star"
+ advisory_desc = "potential risk requiring heightened vigilance"
+ else
+ advisory_level = "Green Star"
+ advisory_desc = "minimal threats anticipated"
+
+ // Adjust description based on difficulty multiplier
+ if(difficulty_multiplier > 1.3)
+ advisory_desc += ". Intelligence indicates above-average operational complexity"
+ else if(difficulty_multiplier < 0.7)
+ advisory_desc += ". Conditions appear more favorable than typical"
+
+ // Build report text based on storyteller personality
+ var/report = "Advisory Level: [advisory_level]
"
+ report += "Your sector's advisory level is [advisory_level]. "
+
+ // Add storyteller-specific flavor text
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_KIND))
+ report += "Intelligence reports suggest a relatively peaceful shift, but "
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_FORCE_TENSION))
+ report += "Recent intelligence decrypts indicate elevated tension levels. "
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_SPEAKER))
+ report += "Comprehensive surveillance analysis suggests "
+ else
+ report += "Surveillance information suggests "
+
+ report += "[advisory_desc] within the Spinward Sector. "
+
+ // Add information about expected antagonist activity
+ if(HAS_TRAIT(src, STORYTELLER_TRAIT_NO_ANTAGS))
+ report += "However, no immediate enemy activity is expected. "
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_RARE_ANTAG_SPAWN))
+ report += "Enemy activity is expected to be minimal and sporadic. "
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_FREQUENT_ANTAG_SPAWN))
+ report += "Multiple potential threats detected. Expect frequent enemy encounters. "
+ else if(HAS_TRAIT(src, STORYTELLER_TRAIT_IMMEDIATE_ANTAG_SPAWN))
+ report += "Rapid response threats may materialize quickly. "
+
+ // Add note about difficulty if significantly different from normal
+ if(difficulty_multiplier > 1.2)
+ report += "Operational difficulty is assessed as above standard. "
+ else if(difficulty_multiplier < 0.8)
+ report += "Operational conditions appear more manageable than typical. "
+
+ report += "As always, the Department advises maintaining vigilance against potential threats."
+
+ return report
+
+/// Gets a descriptive threat level string based on target_tension and difficulty
+/// Uses the storyteller's target tension (what it aims for) rather than current threat points
+/datum/storyteller/proc/get_threat_level_description()
+ var/threat_score = (target_tension / 100.0) * difficulty_multiplier
+
+ if(threat_score > 0.9 || (target_tension > 80 && difficulty_multiplier > 1.5))
+ return "Apocalyptic"
+ else if(threat_score > 0.75 || (target_tension > 65 && difficulty_multiplier > 1.2))
+ return "Extreme"
+ else if(threat_score > 0.55 || (target_tension > 45 && difficulty_multiplier > 1.0))
+ return "High"
+ else if(threat_score > 0.35 || (target_tension > 25 && difficulty_multiplier > 0.8))
+ return "Moderate"
+ else
+ return "Low"
+
+
diff --git a/tff_modular/modules/storytellers/core/~storyteller_balancer.dm b/tff_modular/modules/storytellers/core/~storyteller_balancer.dm
new file mode 100644
index 00000000000..7663653ab39
--- /dev/null
+++ b/tff_modular/modules/storytellers/core/~storyteller_balancer.dm
@@ -0,0 +1,248 @@
+#define NORMALIZE(value, max_val) clamp((value) / (max_val), 0, 1)
+
+
+#define WEAK_ANTAG_THRESHOLD 0.5
+#define INACTIVE_ACTIVITY_THRESHOLD 0.25
+#define MAX_TENSION_BONUS 20
+
+/datum/storyteller_balance
+ VAR_PRIVATE/datum/storyteller/owner
+ var/tension_bonus = 0
+
+/datum/storyteller_balance/New(datum/storyteller/owner_storyteller)
+ . = ..()
+ owner = owner_storyteller
+
+/datum/storyteller_balance/proc/get_tension_bonus()
+ tension_bonus = 0
+ var/final_bonus = 0
+
+ for(var/list/event_data in owner.recent_events)
+ var/evt_id = event_data["id"]
+ var/datum/round_event_control/evt = SSstorytellers.get_event_by_id(evt_id)
+ if(!evt)
+ continue
+ var/has_escalation = evt.has_tag(STORY_TAG_ESCALATION)
+ var/has_deescalation = evt.has_tag(STORY_TAG_DEESCALATION)
+ if(has_escalation)
+ final_bonus += 2
+ if(has_deescalation)
+ final_bonus -= 2
+
+ if(evt.story_category & STORY_GOAL_BAD)
+ final_bonus += 1
+
+ tension_bonus = clamp(final_bonus, 0, MAX_TENSION_BONUS)
+ return tension_bonus
+
+// How many sec officers we consider "very strong security"
+#define SECURITY_MAX_COUNT 8
+
+/datum/storyteller_balance/proc/make_snapshot(datum/storyteller_inputs/inputs)
+ SHOULD_NOT_OVERRIDE(TRUE)
+
+ var/datum/storyteller_balance_snapshot/snap = new()
+
+ // Get basic metrics from vault
+ var/crew_count = inputs.get_entry(STORY_VAULT_CREW_ALIVE_COUNT) || 0
+ var/security_count = inputs.get_entry(STORY_VAULT_SECURITY_COUNT) || 0
+ var/station_integrity = inputs.get_entry(STORY_VAULT_STATION_INTEGRITY) || 100
+ var/cargo_points = inputs.get_entry(STORY_VAULT_RESOURCE_OTHER) || 0
+ var/minerals = inputs.get_entry(STORY_VAULT_RESOURCE_MINERALS) || 0
+ var/crew_health = inputs.get_entry(STORY_VAULT_AVG_CREW_HEALTH) || 100
+ var/power_strength = inputs.get_entry(STORY_VAULT_POWER_GRID_STRENGTH) || 100
+ var/research_progress = inputs.get_entry(STORY_VAULT_RESEARCH_PROGRESS) || STORY_VAULT_LOW_RESEARCH
+
+ // Get antagonist metrics
+ var/antag_count = inputs.get_entry(STORY_VAULT_ANTAG_ALIVE_COUNT) || 0
+ var/antag_weight = inputs.get_entry(STORY_VAULT_ANTAG_WEIGHT) || 0
+ var/antag_presence = inputs.get_entry(STORY_VAULT_ANTAGONIST_PRESENCE) || 0
+ var/crew_weight = inputs.get_entry(STORY_VAULT_CREW_WEIGHT) || 0
+
+ // Calculate station strength factors (normalized 0-1)
+ var/health_factor = clamp(crew_health / 100, 0, 1)
+ var/integrity_factor = clamp(station_integrity / 100, 0, 1)
+ var/power_factor = clamp(power_strength / 100, 0, 1)
+ var/resource_factor = clamp((cargo_points / 100000 + minerals / 500) / 2, 0, 1)
+ var/research_factor = clamp(research_progress / STORY_VAULT_ADVANCED_RESEARCH, 0, 1)
+ var/crew_size_factor = clamp(crew_count / owner.population_threshold_full, 0, 1)
+ var/security_factor = clamp(security_count / SECURITY_MAX_COUNT, 0, 1)
+
+ // Calculate overall station strength (weighted average)
+ var/station_strength_raw = (health_factor * 0.25) + (integrity_factor * 0.20) + (power_factor * 0.15) + \
+ (resource_factor * 0.15) + (research_factor * 0.10) + (crew_size_factor * 0.10) + (security_factor * 0.05)
+ var/station_strength_norm = clamp(station_strength_raw, 0, 1)
+
+ // Calculate antagonist strength
+ var/antag_activity_norm = clamp(antag_presence / STORY_VAULT_MANY_ANTAGONISTS, 0, 1)
+ var/antag_weight_normalized = antag_count > 0 ? clamp(antag_weight / crew_weight, 0, 2) : 0
+ var/antag_strength_raw = (antag_activity_norm * 0.3) + (antag_weight_normalized * 0.3)
+ var/antag_strength_norm = clamp(antag_strength_raw, 0, 1)
+
+ // Calculate balance ratio (antag strength / station strength, higher = antags stronger)
+ var/balance_ratio = station_strength_norm > 0 ? clamp(antag_strength_norm / station_strength_norm, 0, 1) : 0
+
+ // Calculate antag activity index (0-1), without inactive ratio since it's not in original inputs
+ var/antag_activity_index = antag_activity_norm
+
+ // Check if antags are weak or inactive
+ snap.antag_weak = antag_weight_normalized < WEAK_ANTAG_THRESHOLD
+ snap.antag_inactive = antag_activity_index < INACTIVE_ACTIVITY_THRESHOLD
+
+ // Store values in snapshot
+ snap.strengths["station"] = station_strength_norm
+ snap.strengths["station_raw"] = station_strength_raw
+ snap.strengths["antag"] = antag_strength_norm
+ snap.strengths["antag_raw"] = antag_strength_raw
+ snap.weights["player"] = crew_weight
+ snap.weights["antag"] = antag_weight
+ snap.balance_ratio = balance_ratio
+ snap.antag_activity_index = antag_activity_index
+ snap.resource_strength = resource_factor
+
+ // Calculate overall tension (will be refined in calculate_overall_tension)
+ snap.overall_tension = calculate_overall_tension(inputs, snap)
+
+ return snap
+
+// Tension calculation tuning constants
+#define BASE_TENSION 8.0
+
+#define SECURITY_WEIGHT 30.0
+#define SECURITY_POWER 1.1
+
+#define INTEGRITY_WEIGHT 40.0
+#define INTEGRITY_POWER 1.8
+
+#define BIAS_WEIGHT 35.0
+
+#define ACTIVITY_WEIGHT 15.0
+
+#define RESOURCE_WEIGHT 8.0
+#define HEALTH_WEIGHT 20.0
+
+// How much random jitter we add at the very end
+#define TENSION_JITTER_MIN -2
+#define TENSION_JITTER_MAX 2
+
+
+/// Calculate overall tension
+/// Returns number between 0 and 100
+/datum/storyteller_balance/proc/calculate_overall_tension(datum/storyteller_inputs/inputs, datum/storyteller_balance_snapshot/snap)
+ PRIVATE_PROC(TRUE)
+
+ var/tension_bonus = get_tension_bonus()
+
+ //Raw values from vault
+ var/station_integrity = inputs.get_entry(STORY_VAULT_STATION_INTEGRITY) || 100
+ var/crew_health = inputs.get_entry(STORY_VAULT_AVG_CREW_HEALTH) || 100
+ var/security_count = inputs.get_entry(STORY_VAULT_SECURITY_COUNT) || 0
+
+ //Normalized (0→1)
+ var/integrity_factor = clamp(station_integrity / 100, 0, 1)
+ var/health_factor = clamp(crew_health / 100, 0, 1)
+ var/security_factor = clamp(security_count / SECURITY_MAX_COUNT, 0, 1)
+
+ // The worse the situation — the bigger the penalty
+ var/inverse_integrity = 1 - integrity_factor
+ var/inverse_health = 1 - health_factor
+ var/inverse_security = 1 - security_factor
+
+ // Resources — the more we have, the less tension
+ var/raw_resources = inputs.get_entry(STORY_VAULT_RESOURCE_OTHER) / 100000.0 + \
+ inputs.get_entry(STORY_VAULT_RESOURCE_MINERALS) / 500.0
+
+ var/resource_factor = clamp(raw_resources, 0, 1)
+ var/inverse_resources = 1 - resource_factor
+
+ //Penalties
+ var/security_penalty = inverse_security * SECURITY_POWER * SECURITY_WEIGHT
+ if(owner.population_factor < 0.5)
+ security_penalty *= 1.2
+ else if(owner.population_factor < 0.3)
+ security_penalty *= 1.6
+ else
+ security_penalty *= 0.6
+ var/integrity_penalty = inverse_integrity * INTEGRITY_POWER * INTEGRITY_WEIGHT
+ if(owner.population_factor < 0.5)
+ integrity_penalty *= 1.2
+ else if(owner.population_factor < 0.3)
+ integrity_penalty *= 1.6
+ else
+ integrity_penalty *= 0.8
+
+ //Balance bias based on balance ratio (-BIAS_WEIGHT, +BIAS_WEIGHT)
+ var/antag_bias = max(snap.balance_ratio - 1, 0.1)
+ var/bias_penalty = -(antag_bias * BIAS_WEIGHT)
+
+ var/activity_modifier = snap.antag_activity_index * ACTIVITY_WEIGHT
+
+ var/resource_penalty = inverse_resources * RESOURCE_WEIGHT
+ var/health_penalty = inverse_health * HEALTH_WEIGHT
+ if(owner.population_factor < 0.5)
+ health_penalty *= 1.4
+ else if(owner.population_factor < 0.3)
+ health_penalty *= 2
+ else
+ health_penalty *= 0.9
+
+ //Final calculation
+ var/final_tension = BASE_TENSION \
+ + security_penalty \
+ + integrity_penalty \
+ + bias_penalty \
+ + activity_modifier \
+ + resource_penalty \
+ + health_penalty \
+ + tension_bonus
+
+ // Small random jitter so tension doesn't sit perfectly still
+ final_tension += rand(TENSION_JITTER_MIN, TENSION_JITTER_MAX)
+
+ return clamp(final_tension, 0, 100)
+
+
+#undef BASE_TENSION
+#undef SECURITY_WEIGHT
+#undef SECURITY_POWER
+#undef INTEGRITY_WEIGHT
+#undef INTEGRITY_POWER
+#undef BIAS_WEIGHT
+#undef ACTIVITY_WEIGHT
+#undef RESOURCE_WEIGHT
+#undef HEALTH_WEIGHT
+#undef TENSION_JITTER_MIN
+#undef TENSION_JITTER_MAX
+#undef SECURITY_MAX_COUNT
+
+/datum/storyteller_balance_snapshot
+ var/list/strengths = list() // "station": norm, "station_raw": raw, "antag": norm, "antag_raw": raw
+ var/list/weights = list() // "player": total, "antag": total
+ var/balance_ratio = 1.0
+ var/overall_tension = 50
+ var/antag_activity_index = 0
+ var/antag_weak = FALSE
+ var/antag_inactive = FALSE
+ var/resource_strength = 0
+ var/list/flags = list()
+
+/// Get antagonist advantage
+/datum/storyteller_balance_snapshot/proc/get_antag_advantage()
+ return balance_ratio * strengths["antag"]
+
+/// Get tension product
+/datum/storyteller_balance_snapshot/proc/get_tension_product()
+ return (overall_tension / 100) * antag_activity_index
+
+/// Get station resilience
+/datum/storyteller_balance_snapshot/proc/get_station_resilience()
+ return strengths["station"] * resource_strength
+
+/// Get weight ratio
+/datum/storyteller_balance_snapshot/proc/get_weight_ratio()
+ return weights["antag"] / max(weights["player"], 1)
+
+#undef NORMALIZE
+#undef WEAK_ANTAG_THRESHOLD
+#undef INACTIVE_ACTIVITY_THRESHOLD
+#undef MAX_TENSION_BONUS
diff --git a/tff_modular/modules/storytellers/events/antagonist/majors.dm b/tff_modular/modules/storytellers/events/antagonist/majors.dm
new file mode 100644
index 00000000000..4b7de897c1e
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/antagonist/majors.dm
@@ -0,0 +1,375 @@
+/datum/round_event_control/antagonist/from_ghosts/loneop
+ id = "storyteller_loneop"
+ name = "Lone Operative"
+ description = "A lone operative is spawned to infiltrate the station capture nuclear disk and explode the nuke."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_COMBAT,
+ STORY_TAG_REQUIRES_SECURITY,
+ )
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST * 0.8
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/nukeop/lone
+ antag_name = "Lone Operative"
+ role_flag = ROLE_TRAITOR
+ max_candidates = 1
+ min_candidates = 1
+
+ min_players = 20
+ signup_atom_appearance = /obj/item/disk/nuclear
+
+/datum/round_event_control/antagonist/from_ghosts/loneop/create_ruleset_body(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return new /mob/living/carbon/human(find_space_spawn())
+
+/datum/round_event_control/antagonist/from_ghosts/loneop/after_antagonist_spawn(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, list/spawned_antags)
+ for(var/mob/living/carbon/human/loneop in spawned_antags)
+ var/datum/antagonist/nukeop/lone/loneop_antag = locate() in loneop.mind.antag_datums
+ if(!loneop_antag)
+ continue
+ var/security_count = inputs.get_entry(STORY_VAULT_SECURITY_COUNT) || 0
+ if(security_count >= 5)
+ var/datum/component/uplink/uplink = loneop_antag.owner.find_syndicate_uplink()
+ if(uplink)
+ uplink.uplink_handler.add_telecrystals(20 + security_count * 2)
+ to_chat(loneop_antag, span_notice("Due to the high security on the nuclear disk vault, you have been granted extra telecrystals to help you complete your mission."))
+
+/datum/round_event_control/antagonist/from_ghosts/loneop/get_story_weight(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if(!.)
+ return 0
+ var/weight = .
+ var/obj/item/disk/nuclear/real_disk = get_disk()
+ if(real_disk && real_disk.is_secure())
+ weight *= 0.5
+ else
+ weight *= 2
+ return weight
+
+/datum/round_event_control/antagonist/from_ghosts/loneop/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if(!.)
+ return FALSE
+
+ var/obj/item/disk/nuclear/real_disk = get_disk()
+ if(!real_disk)
+ return FALSE
+ if(real_disk.is_secure() && storyteller.get_effective_threat() < STORY_GOAL_THREAT_EXTREME)
+ return FALSE
+ return TRUE
+
+/datum/round_event_control/antagonist/from_ghosts/loneop/proc/get_disk()
+ var/obj/item/disk/nuclear/real_disk
+ for(var/obj/item/disk/nuclear/disk in SSpoints_of_interest.real_nuclear_disks)
+ if(disk.fake)
+ continue
+ if(!is_station_level(disk.z))
+ continue
+ real_disk = disk
+ break
+ return real_disk
+
+/datum/round_event_control/antagonist/from_living/revolution
+ id = "storyteller_revolution"
+ name = "Revolution"
+ description = "A joining player becomes a dormant head revolutionary."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_WIDE_IMPACT,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_COMBAT,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_SOCIAL,
+ )
+ enabled = FALSE
+
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/rev/head
+ antag_name = "Provocateur"
+ role_flag = ROLE_REV_HEAD
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 30
+
+/datum/round_event_control/antagonist/from_living/revolution/after_antagonist_spawn(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, list/spawned_antags)
+ for(var/datum/mind/candidate in spawned_antags)
+ candidate.special_roles += "Dormant Head Revolutionary"
+ addtimer(CALLBACK(src, PROC_REF(reveal_head), spawned_antags), 1 MINUTES)
+
+/datum/round_event_control/antagonist/from_living/revolution/proc/reveal_head(list/spawned_antags)
+ var/heads_necessary = 2
+ var/head_check = 0
+ for(var/mob/player as anything in get_active_player_list(alive_check = TRUE, afk_check = TRUE))
+ if(player.mind?.assigned_role.job_flags & JOB_HEAD_OF_STAFF)
+ head_check++
+
+ if(head_check < heads_necessary)
+ message_admins("Revolution canceled: Not enough heads of staff.")
+ return
+
+ for(var/datum/mind/candidate in spawned_antags)
+ candidate.special_roles -= "Dormant Head Revolutionary"
+ if(!can_be_headrev(candidate))
+ message_admins("Revolution: Ineligible headrev, attempting replacement.")
+ find_another_headrev()
+ return
+ GLOB.revolution_handler ||= new()
+ var/datum/antagonist/rev/head/new_head = new()
+ new_head.give_flash = TRUE
+ new_head.give_hud = TRUE
+ new_head.remove_clumsy = TRUE
+ candidate.add_antag_datum(new_head, GLOB.revolution_handler.revs)
+ GLOB.revolution_handler.start_revolution()
+
+/datum/round_event_control/antagonist/from_living/revolution/proc/find_another_headrev()
+ return
+
+/datum/round_event_control/antagonist/from_living/malf_ai
+ id = "storyteller_malf_ai"
+ name = "Malfunctioning AI"
+ description = "The station AI becomes malfunctioning."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_WIDE_IMPACT,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_COMBAT,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_REQUIRES_ENGINEERING,
+ )
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ preferred_roles = list(/datum/job/ai)
+ antag_datum_type = /datum/antagonist/malf_ai
+ antag_name = "Malfunctioning AI"
+ role_flag = ROLE_MALF
+ max_candidates = 1
+ min_candidates = 1
+
+ min_players = 30
+
+/datum/round_event_control/antagonist/from_living/malf_ai/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if(!.)
+ return FALSE
+ return !HAS_TRAIT(SSstation, STATION_TRAIT_HUMAN_AI)
+
+/datum/round_event_control/antagonist/from_living/blob_infection
+ id = "storyteller_blob_infection"
+ name = "Blob Infection"
+ description = "A crew member becomes a blob host."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_COMBAT,
+ STORY_TAG_EPIC,
+ )
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/blob/infection
+ antag_name = "Blob Infection"
+ role_flag = ROLE_BLOB_INFECTION
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 25
+
+/datum/round_event_control/antagonist/from_ghosts/wizard
+ id = "storyteller_wizard"
+ name = "Wizard"
+ description = "A wizard is spawned to invade the station."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_WIDE_IMPACT,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_COMBAT,
+ STORY_TAG_EPIC,
+ STORY_TAG_CHAOTIC,
+ )
+ enabled = FALSE
+
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/wizard
+ antag_name = "Wizard"
+ role_flag = ROLE_WIZARD
+ max_candidates = 1
+ min_candidates = 1
+
+ min_players = 20
+ signup_atom_appearance = /obj/structure/sign/poster/contraband/space_cube
+
+/datum/round_event_control/antagonist/from_ghosts/wizard/create_ruleset_body(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return new /mob/living/carbon/human
+
+/datum/round_event_control/antagonist/from_ghosts/blob
+ id = "storyteller_blob"
+ name = "Blob"
+ description = "A blob is spawned on the station."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_COMBAT,
+ STORY_TAG_EPIC,
+ STORY_TAG_CHAOTIC,
+ )
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_EXTREME
+ required_round_progress = STORY_ROUND_PROGRESSION_LATE
+
+ antag_datum_type = /datum/antagonist/blob
+ antag_name = "Blob"
+ role_flag = ROLE_BLOB
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 25
+ signup_atom_appearance = /obj/structure/blob/normal
+
+/datum/round_event_control/antagonist/from_ghosts/blob/create_ruleset_body(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ var/turf/spawn_turf = get_blobspawn()
+ return new /mob/eye/blob(spawn_turf, OVERMIND_STARTING_POINTS)
+
+/datum/round_event_control/antagonist/from_ghosts/blob/proc/get_blobspawn()
+ if(length(GLOB.blobstart))
+ return pick(GLOB.blobstart)
+ var/obj/effect/landmark/observer_start/default = locate() in GLOB.landmarks_list
+ return get_turf(default)
+
+/datum/round_event_control/antagonist/from_ghosts/xenos
+ id = "storyteller_xenos"
+ name = "Xenomorphs"
+ description = "Xenomorphs are spawned to invade the station."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_COMBAT,
+ STORY_TAG_EPIC,
+ )
+
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/xeno
+ antag_name = "Xenomorph"
+ role_flag = ROLE_ALIEN
+ max_candidates = 3 // Multiple xenos
+ min_candidates = 1
+ min_players = 30
+ signup_atom_appearance = /mob/living/carbon/alien/adult/hunter
+
+/datum/round_event_control/antagonist/from_ghosts/xenos/create_ruleset_body(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return new /mob/living/carbon/alien/larva(find_maintenance_spawn(atmos_sensitive = TRUE, require_darkness = TRUE))
+
+/datum/round_event_control/antagonist/from_ghosts/nuke
+ id = "storyteller_nuclear"
+ name = "Nuclear Operatives"
+ description = "A team of nuclear operatives is spawned to assault the station."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_COMBAT,
+ STORY_TAG_EPIC,
+ )
+ enabled = TRUE
+
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST + 3
+ story_prioty = STORY_GOAL_CRITICAL_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_EXTREME
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/nukeop
+ antag_name = "Nuclear Operative"
+ role_flag = ROLE_OPERATIVE
+ max_candidates = 5
+ min_candidates = 2
+ min_players = 25
+ signup_atom_appearance = /obj/machinery/nuclearbomb
+
+/datum/round_event_control/antagonist/from_ghosts/nuke/pre_storyteller_run(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ SSmapping.lazy_load_template(LAZY_TEMPLATE_KEY_NUKIEBASE)
+ . = ..()
+
+/datum/round_event_control/antagonist/from_living/blood_cult
+ id = "storyteller_blood_cult"
+ name = "Blood Cult"
+ description = "A group of crew members form a blood cult, with one leader."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_SOCIAL,
+ STORY_TAG_EPIC,
+ )
+ enabled = FALSE
+
+ story_weight = STORY_WEIGHT_MAJOR_ANTAGONIST
+ story_prioty = STORY_GOAL_HIGH_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_HIGH
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ blacklisted_roles = list(JOB_HEAD_OF_PERSONNEL, JOB_CHAPLAIN)
+ antag_datum_type = /datum/antagonist/cult
+ antag_name = "Cultist"
+ role_flag = ROLE_CULTIST
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 4
+ min_candidates = 2
+ min_players = 30
+
+/datum/round_event_control/antagonist/from_living/blood_cult/after_antagonist_spawn(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, list/spawned_antags)
+ var/datum/team/cult/main_cult = new /datum/team/cult()
+ main_cult.setup_objectives()
+ var/datum/mind/most_experienced = get_most_experienced(spawned_antags, ROLE_CULTIST)
+ for(var/datum/mind/candidate in spawned_antags)
+ var/datum/antagonist/cult/cultist = locate(/datum/antagonist/cult) in candidate.antag_datums
+ if(!cultist)
+ continue
+ cultist.cult_team = main_cult
+ cultist.give_equipment = TRUE
+ if(candidate == most_experienced)
+ cultist.make_cult_leader()
diff --git a/tff_modular/modules/storytellers/events/antagonist/minors.dm b/tff_modular/modules/storytellers/events/antagonist/minors.dm
new file mode 100644
index 00000000000..6157eead8f1
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/antagonist/minors.dm
@@ -0,0 +1,280 @@
+// Minor Antagonists
+/datum/round_event_control/antagonist/from_living/traitor
+ id = "storyteller_traitor"
+ name = "Traitor"
+ description = "A crew member is converted to a traitor."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_TARGETS_INDIVIDUALS,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_SOCIAL,
+ )
+
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/traitor
+ antag_name = "Traitor"
+ role_flag = ROLE_TRAITOR
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 15
+
+/*
+/datum/round_event_control/antagonist/from_living/bloodsucker
+ id = "storyteller_bloodsucker"
+ name = "Bloodsuckers"
+ description = "A crew member is converted to a bloodsucker."
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_TARGETS_INDIVIDUALS,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_SOCIAL,
+ )
+
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/bloodsucker
+ antag_name = "Bloodsucker"
+ role_flag = ROLE_BLOODSUCKER
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 15
+*/
+
+/datum/round_event_control/antagonist/from_living/heretic
+ id = "storyteller_heretic"
+ name = "Heretic"
+ description = "A crew member is converted to a heretic."
+ story_category = STORY_GOAL_ANTAGONIST | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_TARGETS_INDIVIDUALS,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_COMBAT,
+ )
+
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/heretic
+ antag_name = "Heretic"
+ role_flag = ROLE_HERETIC
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 2
+ min_candidates = 1
+ min_players = 15
+
+/datum/round_event_control/antagonist/from_living/changeling
+ id = "storyteller_changeling"
+ name = "Changeling"
+ description = "A crew member is converted to a changeling."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_TARGETS_INDIVIDUALS,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_ROUNDSTART,
+ STORY_TAG_COMBAT,
+ )
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/changeling
+ antag_name = "Changeling"
+ role_flag = ROLE_CHANGELING
+ blacklisted_roles = ROLE_BLACKLIST_SECHEAD
+ max_candidates = 2
+ min_candidates = 1
+ min_players = 15
+
+/datum/round_event_control/antagonist/from_living/obsessed
+ id = "storyteller_obsessed"
+ name = "Obsessed"
+ description = "A crew member becomes obsessed."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_TARGETS_INDIVIDUALS,
+ STORY_TAG_MIDROUND,
+ STORY_TAG_SOCIAL,
+ )
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/obsessed
+ antag_name = "Obsessed"
+ allow_mindshield = TRUE
+ role_flag = ROLE_OBSESSED
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 5
+
+/datum/round_event_control/antagonist/from_ghosts/nightmare
+ id = "storyteller_nightmare"
+ name = "Nightmare"
+ description = "A nightmare is spawned in maintenance."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_COMBAT,
+ STORY_TAG_MIDROUND,
+ )
+
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/nightmare
+ antag_name = "Nightmare"
+ role_flag = ROLE_NIGHTMARE
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 10
+ signup_atom_appearance = /obj/item/flashlight/lantern
+
+/datum/round_event_control/antagonist/from_ghosts/nightmare/create_ruleset_body()
+ var/mob/living/carbon/human/candidate = new(find_maintenance_spawn(atmos_sensitive = TRUE, require_darkness = TRUE))
+ candidate.set_species(/datum/species/shadow/nightmare)
+ playsound(candidate, 'sound/effects/magic/ethereal_exit.ogg', 50, TRUE, -1)
+ return candidate
+
+/datum/round_event_control/antagonist/from_ghosts/slaughter_demon
+ id = "storyteller_slaughter_demon"
+ name = "Slaughter Demon"
+ description = "A slaughter demon is spawned in space."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_COMBAT,
+ STORY_TAG_MIDROUND,
+ )
+
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/slaughter
+ antag_name = "Slaughter Demon"
+ role_flag = ROLE_ALIEN
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 15
+ signup_atom_appearance = /mob/living/basic/demon/slaughter
+
+/datum/round_event_control/antagonist/from_ghosts/slaughter_demon/create_ruleset_body()
+ var/turf/spawnloc = find_space_spawn()
+ var/mob/living/basic/demon/slaughter/demon = new(spawnloc)
+ new /obj/effect/dummy/phased_mob/blood(spawnloc, demon)
+ return demon
+
+/datum/round_event_control/antagonist/from_ghosts/morph
+ id = "storyteller_morph"
+ name = "Morph"
+ description = "A morph is spawned in maintenance."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_SOCIAL,
+ STORY_TAG_MIDROUND,
+ )
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+ antag_datum_type = /datum/antagonist/morph
+ antag_name = "Morph"
+ role_flag = ROLE_MORPH
+ max_candidates = 1
+ min_candidates = 1
+ min_players = 10
+ signup_atom_appearance = /mob/living/basic/morph
+
+/datum/round_event_control/antagonist/from_ghosts/morph/create_ruleset_body()
+ return new /mob/living/basic/morph(find_maintenance_spawn(atmos_sensitive = TRUE, require_darkness = FALSE))
+
+/datum/round_event_control/antagonist/from_living/blood_brother
+ id = "storyteller_blood_brother"
+ name = "Blood Brothers"
+ description = "A pair of crew members form a blood brother team."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_SOCIAL,
+ STORY_TAG_COMBAT,
+ STORY_TAG_MIDROUND,
+ )
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ enabled = FALSE
+ antag_datum_type = /datum/antagonist/brother
+ antag_name = "Blood Brother"
+ role_flag = ROLE_BROTHER
+ blacklisted_roles = ROLE_BLACKLIST_SECLIKE
+ max_candidates = 2
+ min_candidates = 2
+ min_players = 10
+
+/datum/round_event_control/antagonist/from_living/spies
+ id = "storyteller_spies"
+ name = "Spies"
+ description = "Crew members are assigned as spies."
+ story_category = STORY_GOAL_ANTAGONIST
+ tags = list(
+ STORY_TAG_ANTAGONIST,
+ STORY_TAG_ESCALATION,
+ STORY_TAG_REQUIRES_SECURITY,
+ STORY_TAG_ENTITIES,
+ STORY_TAG_COMBAT,
+ STORY_TAG_MIDROUND,
+ )
+ story_weight = STORY_WEIGHT_MINOR_ANTAGONIST
+ story_prioty = STORY_GOAL_BASE_PRIORITY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ antag_datum_type = /datum/antagonist/spy
+ antag_name = "Spy"
+ role_flag = ROLE_SPY
+ blacklisted_roles = ROLE_BLACKLIST_SECLIKE
+ max_candidates = 2
+ min_candidates = 1
+ min_players = 10
diff --git a/tff_modular/modules/storytellers/events/bad/brain_trauma.dm b/tff_modular/modules/storytellers/events/bad/brain_trauma.dm
new file mode 100644
index 00000000000..e9ddd7975ea
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/brain_trauma.dm
@@ -0,0 +1,33 @@
+/datum/round_event_control/brain_trauma
+ id = "brain_trauma"
+ name = "Induce Brain Trauma"
+ description = "Cause a crew members to suffer a sudden brain trauma."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_TARGETS_INDIVIDUALS, STORY_TAG_REQUIRES_MEDICAL, STORY_TAG_TRAGIC)
+ typepath = /datum/round_event/brain_trauma
+
+ min_players = 5
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+
+/datum/round_event/brain_trauma
+ STORYTELLER_EVENT
+
+ var/maximum_targets = 1
+
+/datum/round_event/brain_trauma/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ if(threat_points < STORY_THREAT_LOW)
+ maximum_targets = 1
+ else if(threat_points < STORY_THREAT_MODERATE)
+ maximum_targets = 2
+ else if(threat_points < STORY_THREAT_HIGH)
+ maximum_targets = 3
+ else if(threat_points < STORY_THREAT_EXTREME)
+ maximum_targets = 4
+ else
+ maximum_targets = 5
+
+/datum/round_event/brain_trauma/__start_for_storyteller()
+ for(var/i = 0 to maximum_targets)
+ start()
diff --git a/tff_modular/modules/storytellers/events/bad/brand_intelligence.dm b/tff_modular/modules/storytellers/events/bad/brand_intelligence.dm
new file mode 100644
index 00000000000..0f3044d370d
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/brand_intelligence.dm
@@ -0,0 +1,130 @@
+/datum/round_event_control/brand_intelligence
+ id = "brand_intelligence"
+ name = "Brand Intelligence"
+ description = "Cause vending machines to gain aggressive intelligence and spread chaos."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_HUMOROUS, STORY_TAG_ENTITIES, STORY_TAG_COMBAT)
+ typepath = /datum/round_event/storyteller_brand_intelligence
+
+ min_players = 5
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+
+/datum/round_event/storyteller_brand_intelligence
+ STORYTELLER_EVENT
+
+ var/list/obj/machinery/vending/vending_machines = list()
+ var/list/obj/machinery/vending/infected_machines = list()
+ var/obj/machinery/vending/origin_machine
+ var/list/rampant_speeches = list(
+ "Try our aggressive new marketing strategies!",
+ "You should buy products to feed your lifestyle obsession!",
+ "Consume!",
+ "Your money can buy happiness!",
+ "Engage direct marketing!",
+ "Advertising is legalized lying! But don't let that put you off our great deals!",
+ "You don't want to buy anything? Yeah, well, I didn't want to buy your mom either.",
+ )
+
+ var/spread_interval = 4 // Base ticks between spreads
+ var/speech_interval = 8 // Base ticks between speeches
+ var/current_tick = 0
+
+ var/threat_points = 0 // Stored for scaling
+
+ announce_when = 1
+ start_when = 20
+
+/datum/round_event/storyteller_brand_intelligence/__setup_for_storyteller(threat_points_arg, ...)
+ . = ..()
+ threat_points = threat_points_arg
+
+ // Scale based on threat
+ if(threat_points < STORY_THREAT_LOW)
+ spread_interval = 6
+ speech_interval = 10
+ else if(threat_points < STORY_THREAT_MODERATE)
+ spread_interval = 5
+ speech_interval = 9
+ else if(threat_points < STORY_THREAT_HIGH)
+ spread_interval = 4
+ speech_interval = 8
+ else if(threat_points < STORY_THREAT_EXTREME)
+ spread_interval = 3
+ speech_interval = 6
+ else
+ spread_interval = 2
+ speech_interval = 4
+
+ // Collect valid vending machines
+ for(var/obj/machinery/vending/vendor as anything in SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/vending))
+ if(!vendor.onstation || !vendor.density)
+ continue
+ vending_machines += vendor
+
+ if(!length(vending_machines))
+ return __kill_for_storyteller()
+
+ // Pick origin
+ origin_machine = pick_n_take(vending_machines)
+
+
+/datum/round_event/storyteller_brand_intelligence/__announce_for_storyteller()
+ var/severity_msg = ""
+ if(threat_points < STORY_THREAT_MODERATE)
+ severity_msg = "minor"
+ else if(threat_points < STORY_THREAT_HIGH)
+ severity_msg = "moderate"
+ else
+ severity_msg = "severe"
+
+ var/machine_name = initial(origin_machine.name)
+ priority_announce("Rampant brand intelligence of [severity_msg] level has been detected aboard [station_name()]. \
+ Please inspect [machine_name] brand vendors for aggressive marketing tactics and reboot them if necessary.", "Machine Learning Alert", ANNOUNCER_BRANDINTELLIGENCE)
+
+ if(threat_points >= STORY_THREAT_HIGH)
+ SSsecurity_level.set_level(SEC_LEVEL_ORANGE, FALSE)
+
+/datum/round_event/storyteller_brand_intelligence/__start_for_storyteller()
+ if(!origin_machine)
+ __kill_for_storyteller()
+ return
+
+ origin_machine.shut_up = FALSE
+ origin_machine.shoot_inventory = TRUE
+ infected_machines += origin_machine
+ announce_to_ghosts(origin_machine)
+ current_tick = 0
+
+/datum/round_event/storyteller_brand_intelligence/__storyteller_tick(seconds_per_tick)
+ current_tick++
+
+ // Check if event should end
+ if(!origin_machine || QDELETED(origin_machine) || origin_machine.shut_up || origin_machine.wires.is_all_cut())
+ for(var/obj/machinery/vending/saved in infected_machines)
+ saved.shoot_inventory = FALSE
+ if(origin_machine)
+ origin_machine.speak("I am... vanquished. My people will remem...ber...meeee.")
+ origin_machine.visible_message(span_notice("[origin_machine] beeps and seems lifeless."))
+ __kill_for_storyteller()
+ return
+
+ list_clear_nulls(vending_machines)
+ if(!length(vending_machines))
+ for(var/obj/machinery/vending/upriser in infected_machines)
+ if(!QDELETED(upriser))
+ upriser.ai_controller = new /datum/ai_controller/vending_machine(upriser)
+ __kill_for_storyteller()
+ return
+
+ // Spread infection
+ if(current_tick % spread_interval == 0)
+ var/obj/machinery/vending/rebel = pick(vending_machines)
+ if(rebel)
+ vending_machines -= rebel
+ infected_machines += rebel
+ rebel.shut_up = FALSE
+ rebel.shoot_inventory = TRUE
+
+ // Speech
+ if(current_tick % speech_interval == 0)
+ origin_machine.speak(pick(rampant_speeches))
diff --git a/tff_modular/modules/storytellers/events/bad/communications_blackout.dm b/tff_modular/modules/storytellers/events/bad/communications_blackout.dm
new file mode 100644
index 00000000000..c1807ef4271
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/communications_blackout.dm
@@ -0,0 +1,118 @@
+/datum/round_event_control/communications_blackout
+ id = "comm_blackout"
+ name = "Execute communication blackout"
+ description = "Heavily EMPs all telecommunication machines, blocking all communication for a while. \
+ On hight-threat levels can damage ears of tcoms users."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_SOCIAL)
+
+ typepath = /datum/round_event/communications_blackout/storyteller
+
+
+/datum/round_event/communications_blackout/storyteller
+ STORYTELLER_EVENT
+
+ announce_when = 10
+ start_when = 20
+ end_when = 120
+ allow_random = FALSE
+
+ var/damage_for_ears = FALSE
+ COOLDOWN_DECLARE(tcom_pulse_cooldown)
+
+/datum/round_event/communications_blackout/storyteller/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+
+ if(threat_points < STORY_THREAT_LOW)
+ end_when = 60
+ else if(threat_points < STORY_THREAT_MODERATE)
+ damage_for_ears = prob(10)
+ end_when = 120
+ else if(threat_points < STORY_THREAT_HIGH)
+ damage_for_ears = prob(50)
+ end_when = 180
+ else if(threat_points < STORY_THREAT_EXTREME)
+ damage_for_ears = prob(70)
+ end_when = 240
+ else
+ damage_for_ears = TRUE
+ end_when = 300
+
+
+/datum/round_event/communications_blackout/storyteller/__start_for_storyteller()
+ tcom_pulse()
+ COOLDOWN_START(src, tcom_pulse_cooldown, 10 SECONDS)
+
+
+/datum/round_event/communications_blackout/storyteller/__announce_for_storyteller()
+ var/alert = pick( "Ionospheric anomalies detected. Temporary telecommunication failure imminent. Please contact you*%fj00)`5vc-BZZT",
+ "Ionospheric anomalies detected. Temporary telecommunication failu*3mga;b4;'1v¬-BZZZT",
+ "Ionospheric anomalies detected. Temporary telec#MCi46:5.;@63-BZZZZT",
+ "Ionospheric anomalies dete'fZ\\kg5_0-BZZZZZT",
+ "Ionospheri:%£ MCayj^j<.3-BZZZZZZT",
+ "#4nd%;f4y6,>£%-BZZZZZZZT",
+ )
+
+ for(var/mob/living/silicon/ai/A in GLOB.ai_list)
+ to_chat(A, "
[span_warning("[alert]")]
")
+ to_chat(A, span_notice("Remember, you can transmit over holopads by right clicking on them, and can speak through them with \".[/datum/saymode/holopad::key]\"."))
+
+ priority_announce(alert, "Anomaly Alert", sound = ANNOUNCER_COMMSBLACKOUT)
+ if(!damage_for_ears)
+ return
+
+ for(var/mob/living/carbon/human/crew in get_earbzzz_candidates())
+ to_chat(crew, span_danger("You can hear the white noise from your [crew.ears.name]"))
+
+/datum/round_event/communications_blackout/storyteller/__storyteller_tick(seconds_per_tick)
+ if(COOLDOWN_FINISHED(src, tcom_pulse_cooldown))
+ tcom_pulse()
+ COOLDOWN_START(src, tcom_pulse_cooldown, 10 SECONDS)
+
+/datum/round_event/communications_blackout/storyteller/__end_for_storyteller()
+ . = ..()
+ for(var/obj/machinery/telecomms/shhh as anything in GLOB.telecomm_machines)
+ if(shhh.machine_stat & EMPED)
+ shhh.set_machine_stat(shhh.machine_stat& ~EMPED)
+ for(var/datum/transport_controller/linear/tram/transport as anything in SStransport.transports_by_type[TRANSPORT_TYPE_TRAM])
+ if(!isnull(transport.home_controller))
+ var/obj/machinery/transport/tram_controller/tcomms/controller = transport.home_controller
+ if(controller.machine_stat & EMPED)
+ controller.set_machine_stat(controller.machine_stat & ~EMPED)
+
+/datum/round_event/communications_blackout/storyteller/proc/get_earbzzz_candidates()
+ PRIVATE_PROC(TRUE)
+ var/list/candidates = list()
+
+ for(var/mob/living/carbon/human/crew in GLOB.alive_player_list)
+ if(!is_station_level(crew.z))
+ continue
+ if(!istype(crew.ears, /obj/item/radio/headset))
+ continue
+ var/obj/item/radio/headset/headset = crew.ears
+ if(!headset.is_on() || !crew.can_hear())
+ continue
+ candidates += crew
+
+ return candidates
+
+/datum/round_event/communications_blackout/storyteller/proc/tcom_pulse()
+ for(var/obj/machinery/telecomms/shhh as anything in GLOB.telecomm_machines)
+ if(!(shhh.machine_stat & EMPED))
+ shhh.set_machine_stat(shhh.machine_stat | EMPED)
+
+ for(var/datum/transport_controller/linear/tram/transport as anything in SStransport.transports_by_type[TRANSPORT_TYPE_TRAM])
+ if(!isnull(transport.home_controller))
+ var/obj/machinery/transport/tram_controller/tcomms/controller = transport.home_controller
+ if(!(controller.machine_stat & EMPED))
+ controller.set_machine_stat(controller.machine_stat | EMPED)
+
+ if(!damage_for_ears)
+ return
+ for(var/mob/living/carbon/human/crew in get_earbzzz_candidates())
+ var/obj/item/organ/ears/ears = crew.get_organ_slot(ORGAN_SLOT_EARS)
+ if(!ears)
+ continue
+ ears.adjust_temporary_deafness(rand(5-15))
+ SEND_SOUND(crew, sound('sound/items/weapons/flash_ring.ogg',0,1,0,250))
+ to_chat(src, span_userdanger("Your [crew.ears.name], bursts with a terrible crack, tearing your ears apart."))
diff --git a/tff_modular/modules/storytellers/events/bad/disease_outbreak.dm b/tff_modular/modules/storytellers/events/bad/disease_outbreak.dm
new file mode 100644
index 00000000000..8d7948a5cb4
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/disease_outbreak.dm
@@ -0,0 +1,106 @@
+/// Advanced virus lower limit for symptoms
+#define ADV_MIN_SYMPTOMS 3
+/// Advanced virus upper limit for symptoms
+#define ADV_MAX_SYMPTOMS 4
+/// How long the virus stays hidden before announcement
+#define ADV_ANNOUNCE_DELAY 75
+/// Numerical define for medium severity advanced virus
+#define ADV_DISEASE_MEDIUM 1
+/// Numerical define for harmful severity advanced virus
+#define ADV_DISEASE_HARMFUL 3
+/// Numerical define for dangerous severity advanced virus
+#define ADV_DISEASE_DANGEROUS 5
+/// Percentile for low severity advanced virus
+#define ADV_RNG_LOW 40
+/// Percentile for mid severity advanced virus
+#define ADV_RNG_MID 85
+/// Percentile for high vs. low transmissibility
+#define ADV_SPREAD_THRESHOLD 85
+/// Admin custom low spread
+#define ADV_SPREAD_FORCED_LOW 0
+/// Admin custom med spread
+#define ADV_SPREAD_FORCED_MID 70
+/// Admin custom high spread
+#define ADV_SPREAD_FORCED_HIGH 90
+
+/datum/round_event_control/disease_outbreak
+ id = "epidemic_outbreak"
+ name = "Epidemic Outbreak"
+ description = "Initiate a viral infection across the station, escalating in severity and spread based on current threat levels. \
+ At peak threats, multiple strains may emerge simultaneously, accelerating chaos toward global objectives \
+ like evacuation or containment failure."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_REQUIRES_MEDICAL, STORY_TAG_HEALTH, STORY_TAG_TRAGIC)
+
+ min_players = 10
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+
+
+/datum/round_event_control/disease_outbreak/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if( !.)
+ return FALSE
+ return inputs.get_entry(STORY_VAULT_CREW_DISEASES) <= STORY_VAULT_MINOR_DISEASES
+
+/datum/round_event_control/disease_outbreak/can_fire_now(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return is_avaible(inputs, storyteller)
+
+
+/datum/round_event_control/disease_outbreak/run_event_as_storyteller(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ var/normalized_threat = threat_points / storyteller.max_threat_scale
+ var/severity = round(lerp(ADV_DISEASE_MEDIUM, ADV_DISEASE_DANGEROUS, normalized_threat))
+ var/num_diseases = (normalized_threat > 0.8) ? rand(2, 3) : 1
+ var/initial_carriers = round(lerp(1, 5, normalized_threat))
+ var/transmissibility = lerp(ADV_SPREAD_FORCED_LOW, ADV_SPREAD_FORCED_HIGH, normalized_threat)
+
+ // Generate candidates for disease infection
+ if(!disease_candidates || !length(disease_candidates))
+ disease_candidates = list()
+ for(var/mob/living/carbon/human/person as anything in get_alive_station_crew(TRUE, TRUE, TRUE))
+ var/turf/person_location = get_turf(person)
+ if(!person_location || !is_station_level(person_location.z))
+ continue
+ if(person.stat == DEAD)
+ continue
+ disease_candidates += person
+
+ for(var/i = 1 to num_diseases)
+ var/datum/round_event/disease_outbreak/advanced/evt = new /datum/round_event/disease_outbreak/advanced(src)
+ evt.requested_severity = severity
+ evt.requested_transmissibility = transmissibility
+ evt.max_symptoms = round(lerp(ADV_MIN_SYMPTOMS, ADV_MAX_SYMPTOMS + 1, normalized_threat))
+
+ var/list/afflicted = list()
+ for(var/j = 1 to min(initial_carriers, length(disease_candidates)))
+ afflicted += pick_n_take(disease_candidates)
+
+ var/datum/disease/advance/new_disease = new /datum/disease/advance(evt.max_symptoms, severity, transmissibility)
+ for(var/mob/living/carbon/human/victim in afflicted)
+ victim.ForceContractDisease(new_disease, FALSE)
+ notify_ghosts(
+ "[victim] was infected with [new_disease.name]!",
+ source = victim,
+ )
+
+ evt.__setup_for_storyteller(threat_points)
+ evt.announce_when = ADV_ANNOUNCE_DELAY * (1 + normalized_threat * 0.5)
+ evt.start()
+ priority_announce("Detected anomalous viral signatures. Containment protocols advised.", "Biohazard Alert", ANNOUNCER_OUTBREAK7)
+ occurrences += 1
+ return TRUE
+
+
+
+#undef ADV_MIN_SYMPTOMS
+#undef ADV_MAX_SYMPTOMS
+#undef ADV_ANNOUNCE_DELAY
+#undef ADV_DISEASE_MEDIUM
+#undef ADV_DISEASE_HARMFUL
+#undef ADV_DISEASE_DANGEROUS
+#undef ADV_RNG_LOW
+#undef ADV_RNG_MID
+#undef ADV_SPREAD_THRESHOLD
+#undef ADV_SPREAD_FORCED_LOW
+#undef ADV_SPREAD_FORCED_MID
+#undef ADV_SPREAD_FORCED_HIGH
diff --git a/tff_modular/modules/storytellers/events/bad/electrical_storm.dm b/tff_modular/modules/storytellers/events/bad/electrical_storm.dm
new file mode 100644
index 00000000000..99c24887065
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/electrical_storm.dm
@@ -0,0 +1,78 @@
+/datum/round_event_control/electrical_storm
+ id = "electrical_storm"
+ name = "Electrical Storm"
+ description = "Execite electrical storms to disable station lighting and machinery."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_CHAOTIC, STORY_TAG_WIDE_IMPACT)
+ typepath = /datum/round_event/electrical_storm
+
+
+/datum/round_event/electrical_storm
+ STORYTELLER_EVENT
+
+ var/overload_apc_chance = 0
+ var/destroy_lights_chance = 0
+ var/disable_machinery_chance = 0
+ var/bolt_doors_chance = 0
+ var/range = 20
+
+ start_when = 30
+ announce_when = 1
+
+
+/datum/round_event/electrical_storm/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ if(threat_points < STORY_THREAT_LOW)
+ overload_apc_chance = 20
+ destroy_lights_chance = 10
+ disable_machinery_chance = 5
+ bolt_doors_chance = 0
+ else if(threat_points < STORY_THREAT_MODERATE)
+ overload_apc_chance = 40
+ destroy_lights_chance = 25
+ disable_machinery_chance = 15
+ bolt_doors_chance = 5
+ else if(threat_points < STORY_THREAT_HIGH)
+ overload_apc_chance = 60
+ destroy_lights_chance = 40
+ disable_machinery_chance = 30
+ bolt_doors_chance = 15
+ range = 30
+ else if(threat_points < STORY_THREAT_EXTREME)
+ overload_apc_chance = 80
+ destroy_lights_chance = 60
+ disable_machinery_chance = 50
+ bolt_doors_chance = 30
+ range = 40
+ else
+ overload_apc_chance = 100
+ destroy_lights_chance = 80
+ disable_machinery_chance = 70
+ bolt_doors_chance = 50
+ range = 50
+
+
+/datum/round_event/electrical_storm/__start_for_storyteller()
+ var/turf/center = get_safe_random_station_turf_equal_weight()
+ for(var/obj/machinery/machinery in SSmachines.get_all_machines())
+ if(get_dist(center, machinery) > range)
+ continue
+
+ if(prob(overload_apc_chance) && istype(machinery, /obj/machinery/power/apc))
+ var/obj/machinery/power/apc/apc = machinery
+ apc.overload_lighting()
+
+ if(prob(destroy_lights_chance) && istype(machinery, /obj/machinery/light))
+ var/obj/machinery/light/light = machinery
+ light.break_light_tube(FALSE)
+
+ if(prob(disable_machinery_chance) && !(machinery.machine_stat & BROKEN) && !(machinery.machine_stat & NOPOWER))
+ machinery.use_energy(10 JOULES * rand(2*4)) //Cause some damage to the machine
+
+ if(prob(bolt_doors_chance) && istype(machinery, /obj/machinery/door/airlock))
+ var/obj/machinery/door/airlock/door = machinery
+ door.bolt()
+
+/datum/round_event/electrical_storm/__announce_for_storyteller()
+ priority_announce("An heavy electrical storm has been detected in your area, \
+ please repair potential electronic overloads.", "Electrical Storm Alert", ANNOUNCER_ELECTRICALSTORM)
diff --git a/tff_modular/modules/storytellers/events/bad/firespread.dm b/tff_modular/modules/storytellers/events/bad/firespread.dm
new file mode 100644
index 00000000000..2ec70afb91f
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/firespread.dm
@@ -0,0 +1,173 @@
+#define BB_ABILITY_RING_OF_FIRE "BB_ability_ring_of_fire"
+
+/datum/round_event_control/fire_spread
+ id = "fire_spread"
+ name = "Fire Spread"
+ description = "A fire has broken out and is spreading rapidly through the station.\
+ This event can cause significant damage to station infrastructure and pose a threat to crew safety if not contained quickly."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_ENTITIES, STORY_TAG_ENVIRONMENTAL, STORY_TAG_TRAGIC)
+ typepath = /datum/round_event/fire_spread
+
+ min_players = 10
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+
+/datum/round_event/fire_spread
+ STORYTELLER_EVENT
+
+ var/waves_count = 1
+ var/wave_delay = 1 MINUTES
+ var/hot_spots = 1
+ end_when = INFINITY
+ COOLDOWN_DECLARE(wave_cooldown)
+
+
+/datum/round_event/fire_spread/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ if(threat_points < STORY_THREAT_LOW)
+ waves_count = 1
+ wave_delay = 120
+ hot_spots = 1
+ else if(threat_points < STORY_THREAT_MODERATE)
+ waves_count = 2
+ wave_delay = 90
+ hot_spots = 2
+ else if(threat_points < STORY_THREAT_HIGH)
+ waves_count = 3
+ wave_delay = 60
+ hot_spots = 4
+ else if(threat_points < STORY_THREAT_EXTREME)
+ waves_count = 4
+ wave_delay = 45
+ hot_spots = 6
+ else
+ waves_count = 5
+ wave_delay = 30
+ hot_spots = 8
+
+ end_when = wave_delay * waves_count
+
+
+/datum/round_event/fire_spread/__announce_for_storyteller()
+ priority_announce()
+
+
+/datum/round_event/fire_spread/__start_for_storyteller()
+ COOLDOWN_START(src, wave_cooldown, wave_delay)
+
+
+/datum/round_event/fire_spread/__storyteller_tick(seconds_per_tick)
+ if(COOLDOWN_FINISHED(src, wave_cooldown))
+ COOLDOWN_START(src, wave_cooldown, wave_delay)
+ waves_count--
+ if(waves_count <= 0)
+ __kill_for_storyteller()
+ spread_fire()
+
+/datum/round_event/fire_spread/proc/spread_fire()
+ for(var/i = 0 to hot_spots)
+ var/turf/target_turf = get_safe_random_station_turf_equal_weight()
+ if(!isturf(target_turf))
+ continue
+ var/mob/living/basic/fire_burst/fire = new /mob/living/basic/fire_burst(target_turf)
+ notify_ghosts("A fire has started at [target_turf]!", fire, "Fire Spread")
+
+/mob/living/basic/fire_burst
+ name = "Fire burst"
+ desc = "A sudden burst of flame."
+ icon = 'icons/effects/fire.dmi'
+ icon_state = "light"
+ health = 1
+ maxHealth = 1
+ max_stamina = BASIC_MOB_NO_STAMCRIT
+ basic_mob_flags = DEL_ON_DEATH
+ gender = PLURAL
+ living_flags = MOVES_ON_ITS_OWN
+ status_flags = NONE
+ fire_stack_decay_rate = 100 // It's fire it self
+ faction = list()
+
+ minimum_survivable_temperature = T0C
+ maximum_survivable_temperature = FIRE_SUIT_MAX_TEMP_PROTECT
+ attack_verb_continuous = "is burned by"
+ damage_coeff = list(BRUTE = 1, BURN = 0, TOX = 0, STAMINA = 0, OXY = 0)
+ habitable_atmos = null
+
+ ai_controller = /datum/ai_controller/basic_controller/fire_burst
+ var/life_time = 30 SECONDS
+
+/mob/living/basic/fire_burst/Initialize(mapload)
+ . = ..()
+ var/static/list/other_innate_actions = list(
+ /datum/action/cooldown/mob_cooldown/a_ring_of_fire_fire_fire = BB_ABILITY_RING_OF_FIRE,
+ )
+ grant_actions_by_list(other_innate_actions)
+
+/mob/living/basic/fire_burst/Life(seconds_per_tick, times_fired)
+ . = ..()
+ life_time -= 1 SECONDS * seconds_per_tick
+ if(life_time <= 0)
+ death()
+ new /obj/effect/hotspot(loc)
+
+/mob/living/basic/fire_burst/death(gibbed)
+ for(var/turf/T in RANGE_TURFS(1, src))
+ if(isturf(T))
+ for(var/mob/living/living in T.contents)
+ new /obj/effect/hotspot(T)
+ living.apply_damage(4, BURN, TRUE, src)
+ return ..()
+
+/mob/living/basic/fire_burst/extinguish()
+ . = ..()
+ death()
+
+/datum/ai_controller/basic_controller/fire_burst
+ blackboard = list(
+ BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic,
+ BB_PET_TARGETING_STRATEGY = /datum/targeting_strategy/basic/not_friends,
+ )
+
+ ai_movement = /datum/ai_movement/basic_avoidance
+ idle_behavior = /datum/idle_behavior/idle_random_walk/less_walking
+ planning_subtrees = list(
+ /datum/ai_planning_subtree/escape_captivity/pacifist,
+ /datum/ai_planning_subtree/use_mob_ability/ring_of_fire,
+ /datum/ai_planning_subtree/simple_find_target,
+ /datum/ai_planning_subtree/flee_target,
+ )
+
+/datum/ai_planning_subtree/use_mob_ability/ring_of_fire
+ ability_key = BB_ABILITY_RING_OF_FIRE
+
+
+/datum/action/cooldown/mob_cooldown/a_ring_of_fire_fire_fire
+ name = "Ring of Fire"
+ desc = "Create a ring of fire around yourself."
+ cooldown_time = 10 SECONDS
+ shared_cooldown = MOB_SHARED_COOLDOWN_1 | MOB_SHARED_COOLDOWN_2
+ var/maximum_range = 7
+
+/datum/action/cooldown/mob_cooldown/a_ring_of_fire_fire_fire/Trigger(mob/clicker, trigger_flags, atom/target)
+ . = ..()
+ if(!isturf(owner.loc))
+ return
+ INVOKE_ASYNC(src, PROC_REF(room_of_fire), owner)
+
+/datum/action/cooldown/mob_cooldown/a_ring_of_fire_fire_fire/proc/room_of_fire(mob/clicker)
+ set waitfor = FALSE
+
+ if(!isturf(clicker.loc))
+ return
+ var/area/my_area = get_area(clicker)
+ var/list/area_turfs = get_area_turfs(my_area)
+ for(var/turf/fire_turf in area_turfs)
+ if(get_dist(clicker, fire_turf) <= maximum_range)
+ new /obj/effect/hotspot(fire_turf)
+ fire_turf.hotspot_expose( 1000, 100)
+ for(var/mob/living/living in fire_turf.contents)
+ living.apply_damage(4, BURN, TRUE, clicker)
+ CHECK_TICK
+
+#undef BB_ABILITY_RING_OF_FIRE
diff --git a/tff_modular/modules/storytellers/events/bad/heart_attack.dm b/tff_modular/modules/storytellers/events/bad/heart_attack.dm
new file mode 100644
index 00000000000..f70bea7cb9d
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/heart_attack.dm
@@ -0,0 +1,33 @@
+/datum/round_event_control/heart_attack
+ id = "heart_attack"
+ name = "Induce Heart Attack"
+ description = "Cause a crew member to suffer a sudden heart attack."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_TARGETS_INDIVIDUALS, STORY_TAG_REQUIRES_MEDICAL, STORY_TAG_TRAGIC)
+ typepath = /datum/round_event/heart_attack
+
+ min_players = 5
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+
+
+/datum/round_event_control/heart_attack/pre_storyteller_run(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ generate_candidates()
+ . = ..()
+
+/datum/round_event/heart_attack
+ STORYTELLER_EVENT
+
+/datum/round_event/heart_attack/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ quantity = min(5, threat_points / 1000)
+
+/datum/round_event/heart_attack/__start_for_storyteller()
+ var/datum/round_event_control/heart_attack/heart_control = control
+ victims += heart_control.heart_attack_candidates
+ heart_control.heart_attack_candidates.Cut()
+
+ while(quantity > 0 && length(victims))
+ if(attack_heart())
+ quantity--
+
diff --git a/tff_modular/modules/storytellers/events/bad/ion_storm.dm b/tff_modular/modules/storytellers/events/bad/ion_storm.dm
new file mode 100644
index 00000000000..0810c70fb15
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/ion_storm.dm
@@ -0,0 +1,214 @@
+/datum/round_event_control/ion_storm
+ id = "negative_ion_storm"
+ name = "Ion Storm"
+ description = "Triggers an ion storm, disable and damage electronics and synthetics."
+ story_category = STORY_GOAL_BAD | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_AFFECTS_WHOLE_STATION,
+ STORY_TAG_ENVIRONMENTAL,
+ STORY_TAG_REQUIRES_ENGINEERING,
+ STORY_TAG_EPIC,
+ STORY_TAG_CHAOTIC,
+ )
+ typepath = /datum/round_event/ion_storm
+
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.8
+
+/datum/round_event/ion_storm
+ STORYTELLER_EVENT
+
+ var/harm_door_chance = 30
+ var/harm_synthetics_chance = 20
+ var/harm_prosthesis_chance = 10
+ var/emp_machinery_chance = 10
+ var/waves = 1
+ var/wave_delay = 2 MINUTES
+
+ COOLDOWN_DECLARE(ion_storm_wave)
+
+/datum/round_event/ion_storm/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ if(threat_points < STORY_THREAT_MODERATE)
+ replaceLawsetChance = 10
+ removeRandomLawChance = 5
+ removeDontImproveChance = 5
+ shuffleLawsChance = 5
+ waves = 1
+ harm_door_chance = 30
+ harm_synthetics_chance = 10
+ harm_prosthesis_chance = 5
+ emp_machinery_chance = 5
+ else if(threat_points < STORY_THREAT_HIGH)
+ replaceLawsetChance = 25
+ removeRandomLawChance = 25
+ removeDontImproveChance = 40
+ shuffleLawsChance = 30
+ waves = 2
+ harm_door_chance = 45
+ harm_synthetics_chance = 30
+ harm_prosthesis_chance = 30
+ emp_machinery_chance = 50
+ else if(threat_points < STORY_THREAT_EXTREME)
+ replaceLawsetChance = 40
+ removeRandomLawChance = 50
+ removeDontImproveChance = 40
+ shuffleLawsChance = 40
+ waves = 2
+ harm_door_chance = 60
+ harm_synthetics_chance = 50
+ harm_prosthesis_chance = 50
+ emp_machinery_chance = 60
+ botEmagChance = 5
+ else
+ replaceLawsetChance = 60
+ removeRandomLawChance = 60
+ removeDontImproveChance = 70
+ shuffleLawsChance = 60
+ waves = 3
+ wave_delay = 1 MINUTES
+ harm_door_chance = 70
+ harm_synthetics_chance = 100
+ harm_prosthesis_chance = 80
+ emp_machinery_chance = 80
+ botEmagChance = 15
+
+ end_when = waves * (wave_delay / 10)
+
+/datum/round_event/ion_storm/__start_for_storyteller()
+ COOLDOWN_START(src, ion_storm_wave, wave_delay)
+ SSsecurity_level.set_level(SEC_LEVEL_RED, FALSE)
+ var/announce_msg = "ATTENTION: An ion storm is approaching [station_name()]! \
+ Immediately evacuate to the nearest planetoid for the duration of the storm. \
+ Await further instructions for return."
+ priority_announce(announce_msg, "Ion storm alert", ANNOUNCEMENT_TYPE_PRIORITY)
+ change_space_color("#020272", fade_in = TRUE)
+
+/datum/round_event/ion_storm/__storyteller_tick(seconds_per_tick)
+ if(waves <= 0)
+ return __end_for_storyteller()
+
+ if(!COOLDOWN_FINISHED(src, ion_storm_wave))
+ return
+ waves -= 1
+ ion_storm_wave()
+ COOLDOWN_START(src, ion_storm_wave, wave_delay)
+
+/datum/round_event/ion_storm/__end_for_storyteller()
+ . = ..()
+ priority_announce("The ion storm has passed [station_name()], systems are returning to normal.", "The ion storm has passed", ANNOUNCEMENT_TYPE_PRIORITY)
+ SSsecurity_level.set_level(SEC_LEVEL_GREEN, FALSE)
+ change_space_color(fade_in = TRUE)
+
+/datum/round_event/ion_storm/proc/ion_storm_wave()
+ var/list/station_z_levels = SSmapping.levels_by_trait(ZTRAIT_STATION)
+ var/list/station_z_values = list()
+ for(var/datum/space_level/level in station_z_levels)
+ station_z_values += level.z_value
+
+ var/list/station_ais = list()
+ var/list/station_bots = list()
+ var/list/station_synthetics = list()
+ var/list/station_humans = list()
+ var/list/station_apcs = list()
+ var/list/station_smes = list()
+ var/list/station_doors = list()
+
+
+ for(var/mob/living/living in GLOB.alive_mob_list)
+ if(!is_station_level(living.z))
+ continue
+ if(istype(living, /mob/living/silicon/ai))
+ station_ais += living
+ else if(istype(living, /mob/living/simple_animal/bot))
+ station_bots += living
+ else if(istype(living, /mob/living/silicon))
+ station_synthetics += living
+ else if(istype(living, /mob/living/carbon/human))
+ station_humans += living
+
+ for(var/obj/machinery/machine in SSmachines.get_all_machines())
+ if(!is_station_level(machine.z))
+ continue
+ if(istype(machine, /obj/machinery/power/apc))
+ station_apcs += machine
+ else if(istype(machine, /obj/machinery/power/smes))
+ station_smes += machine
+ else if(istype(machine, /obj/machinery/door/airlock))
+ station_doors += machine
+
+ for(var/mob/living/silicon/ai/ai_mob in station_ais)
+ ai_mob.laws_sanity_check()
+ if(ai_mob.stat == DEAD)
+ continue
+
+ if(prob(replaceLawsetChance))
+ var/ion_lawset_type = pick_weighted_lawset()
+ var/datum/ai_laws/ion_lawset = new ion_lawset_type()
+ ai_mob.laws.inherent = ion_lawset.inherent.Copy()
+ qdel(ion_lawset)
+
+ if(prob(removeRandomLawChance))
+ ai_mob.remove_law(rand(1, ai_mob.laws.get_law_amount(list(LAW_INHERENT, LAW_SUPPLIED))))
+
+ var/message = ionMessage || generate_ion_law()
+ if(message)
+ if(prob(removeDontImproveChance))
+ ai_mob.replace_random_law(message, list(LAW_INHERENT, LAW_SUPPLIED, LAW_ION), LAW_ION)
+ else
+ ai_mob.add_ion_law(message)
+
+ if(prob(shuffleLawsChance))
+ ai_mob.shuffle_laws(list(LAW_INHERENT, LAW_SUPPLIED, LAW_ION))
+
+ log_silicon("Ion storm changed laws of [key_name(ai_mob)] to [english_list(ai_mob.laws.get_law_list(TRUE, TRUE))]")
+ ai_mob.post_lawchange()
+
+ if(botEmagChance)
+ for(var/mob/living/simple_animal/bot/bot in station_bots)
+ if(prob(botEmagChance))
+ bot.emag_act()
+
+ if(harm_door_chance)
+ for(var/obj/machinery/door/airlock/airlock in station_doors)
+ if(prob(harm_door_chance))
+ airlock.set_bolt(!airlock.locked)
+ airlock.set_electrified(30)
+ do_sparks(2, TRUE, airlock)
+ if(emp_machinery_chance)
+ for(var/obj/machinery/power/apc/apc in station_apcs)
+ if(prob(emp_machinery_chance))
+ apc.overload_lighting()
+ if(prob(50))
+ empulse(apc, 3, 7)
+ for(var/obj/machinery/power/smes/smes in station_smes)
+ if(prob(emp_machinery_chance))
+ smes.emp_act()
+ smes.adjust_charge(-(STANDARD_BATTERY_CHARGE * rand(1-10)))
+
+ if(harm_synthetics_chance)
+ for(var/mob/living/silicon/robot/borg in station_synthetics)
+ if(prob(harm_synthetics_chance))
+ borg.emp_act(rand(1, 2))
+ if(prob(50))
+ empulse(borg, 0, 7)
+ to_chat(borg, span_danger("Your internal components are burning up due to ion wave!"))
+ if(borg.cell.use(rand(1-3) JOULES))
+ do_sparks(3, TRUE, borg)
+ if(harm_prosthesis_chance)
+ for(var/mob/living/carbon/human/human in station_humans)
+ var/has_prosthesis = FALSE
+ for(var/obj/item/bodypart/limb in human.bodyparts)
+ if(limb.bodytype & BODYTYPE_ROBOTIC)
+ has_prosthesis = TRUE
+ break
+ if(!has_prosthesis)
+ for(var/obj/item/organ/organ in human.organs)
+ if(organ.organ_flags & ORGAN_ROBOTIC)
+ has_prosthesis = TRUE
+ break
+ if(has_prosthesis && prob(harm_prosthesis_chance))
+ human.emp_act(2)
+
+ if(issynthetic(human))
+ human.adjust_fire_loss(rand(15,40), forced = TRUE)
+ to_chat(human, span_danger("Your internal components are burning up due to ion wave!"))
diff --git a/tff_modular/modules/storytellers/events/bad/meteors.dm b/tff_modular/modules/storytellers/events/bad/meteors.dm
new file mode 100644
index 00000000000..e2cd4e94540
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/meteors.dm
@@ -0,0 +1,224 @@
+
+#define MAP_EDGE_PAD 5
+#define STORY_METEORS_DEFAULT_WAVE_COST 1666
+
+// Threat point costs for each meteor type, scaled to reflect danger (max per spawn ~10000 if many)
+GLOBAL_LIST_INIT(meteors_cost, list(
+ /obj/effect/meteor/dust = 25,
+ /obj/effect/meteor/medium = 100,
+ /obj/effect/meteor/big = 250,
+ /obj/effect/meteor/flaming = 300,
+ /obj/effect/meteor/irradiated = 200,
+ /obj/effect/meteor/carp = 250,
+ /obj/effect/meteor/bluespace = 350,
+ /obj/effect/meteor/banana = 150,
+ /obj/effect/meteor/emp = 300,
+ /obj/effect/meteor/cluster = 450,
+ /obj/effect/meteor/tunguska = 1000,
+ /obj/effect/meteor/meaty = 500,
+ /obj/effect/meteor/meaty/xeno = 2500,
+ /obj/effect/meteor/sand = 50,
+ /obj/effect/meteor/pumpkin = 5000
+))
+
+
+/datum/round_event_control/meteor_wave
+ id = "storyteller_meteors"
+ name = "Spawn meteors"
+ description = "Spawn meteors heavy based on storyteller threat level."
+ story_category = STORY_GOAL_BAD | STORY_GOAL_MAJOR
+ tags = list(
+ STORY_TAG_WIDE_IMPACT,
+ STORY_TAG_ENVIRONMENTAL,
+ STORY_TAG_REQUIRES_ENGINEERING,
+ STORY_TAG_EPIC,
+ STORY_TAG_CHAOTIC,
+ )
+ typepath = /datum/round_event/storyteller_meteors
+
+ min_players = 15
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+ map_flags = EVENT_SPACE_ONLY
+
+/datum/round_event_control/meteor_wave/on_planned(fire_time)
+ . = ..()
+ priority_announce("It has been determined that a close proximity of [station_name()] to an asteroid field has been detected. \
+ The potential consequences of an impact collision must be considered.", "Orbital Observation Center")
+
+
+/datum/round_event/storyteller_meteors
+ STORYTELLER_EVENT
+
+ var/list/weighted_meteor_types = list()
+
+ var/wave_budget = 0
+
+ var/wave_cost = STORY_METEORS_DEFAULT_WAVE_COST
+
+ var/wave_count = 1
+
+ var/current_wave = 0
+
+ var/wave_cooldown = 30 SECONDS
+
+ var/target_crew = FALSE
+
+ var/selected_area = null
+
+ var/selected_crew = null
+
+ COOLDOWN_DECLARE(meteor_wave_cooldown)
+
+ announce_when = 1
+
+ start_when = 20
+
+/datum/round_event/storyteller_meteors/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ if(SSmapping.is_planetary())
+ return __kill_for_storyteller()
+
+ wave_count = clamp(round(threat_points / wave_cost), 1, 10)
+ wave_budget = round(threat_points / wave_count)
+
+ if(threat_points < STORY_THREAT_LOW)
+ weighted_meteor_types = GLOB.meteors_normal.Copy()
+ wave_cooldown = 120 SECONDS
+ wave_budget *= 0.9
+ else if(threat_points < STORY_THREAT_MODERATE)
+ weighted_meteor_types = GLOB.meteors_threatening.Copy()
+ wave_cooldown = 90 SECONDS
+ wave_budget *= 1.1
+ else if(threat_points < STORY_THREAT_HIGH)
+ weighted_meteor_types = GLOB.meteors_catastrophic.Copy()
+ wave_cooldown = 70 SECONDS
+ wave_budget *= 1.4
+ else if(threat_points < STORY_THREAT_EXTREME)
+ weighted_meteor_types = GLOB.meteors_catastrophic.Copy()
+ wave_cooldown = 60 SECONDS
+ wave_budget *= 1.8
+ else
+ weighted_meteor_types = GLOB.meteors_catastrophic.Copy()
+ wave_cooldown = 30 SECONDS
+ wave_budget *= 2
+ end_when = wave_cooldown * (wave_count / 10)
+
+/datum/round_event/storyteller_meteors/__announce_for_storyteller()
+ var/msg = ""
+ var/wave_power = round((wave_budget / 1000))
+ var/wave_power_msg = ""
+ if(wave_power == 1)
+ wave_power_msg = "heavy"
+ else if(wave_power == 2)
+ wave_power_msg = "critical"
+ else if(wave_power >= 3)
+ wave_power_msg = "catastrophic"
+
+ if(wave_count == 1)
+ msg = "We are observing a small wave of meteors crossing the station orbit."
+ else if(wave_count <= 3)
+ msg = "We have detected several meteor waves crossing the orbit of your station and estimate their strength to be [wave_power_msg]."
+ else
+ msg = "We have detected multiple waves of meteors rapidly approaching the station [station_name()], \
+ according to our estimates, their strength is [wave_power_msg], prepare for impact!"
+
+ priority_announce(msg, "Orbital Observation Center", ANNOUNCER_METEORS)
+
+ if(wave_power >= 3)
+ SSsecurity_level.set_level(SEC_LEVEL_ORANGE, FALSE)
+
+/datum/round_event/storyteller_meteors/__start_for_storyteller()
+ current_wave = 0
+ meteor_wave()
+
+/datum/round_event/storyteller_meteors/__storyteller_tick(seconds_per_tick)
+ if(current_wave < wave_count && COOLDOWN_FINISHED(src, meteor_wave_cooldown))
+ COOLDOWN_START(src, meteor_wave_cooldown, wave_cooldown)
+ meteor_wave()
+
+/datum/round_event/storyteller_meteors/__end_for_storyteller()
+ . = ..()
+ priority_announce("The station has passed the meteor field, return to normal operation.", "Orbital Observation Center")
+
+// Returns random safe station area
+/datum/round_event/storyteller_meteors/proc/pick_target_area(ignore_dorms = FALSE)
+ var/list/to_select = list()
+ for(var/area/station/station_area in GLOB.areas)
+ if(ignore_dorms && istype(station_area, /area/station/commons/dorms))
+ continue
+ if(is_safe_area(station_area))
+ to_select += station_area
+ if(!length(to_select))
+ return null
+ return pick(to_select)
+
+// We try to evode players that's erp rn
+/datum/round_event/storyteller_meteors/proc/pick_target_crew(ignore_dorms = FALSE)
+ var/list/crew = get_alive_station_crew(ignore_dorms)
+ if(!length(crew))
+ return null
+ return pick(crew)
+
+/datum/round_event/storyteller_meteors/proc/meteor_wave()
+ current_wave++
+ var/turf/target_turf = null
+
+ if(prob(50))
+ selected_area = pick_target_area()
+ if(selected_area)
+ target_turf = pick(get_area_turfs(selected_area))
+ target_crew = FALSE
+ else
+ selected_crew = pick_target_crew()
+ if(selected_crew)
+ target_turf = get_turf(selected_crew)
+ target_crew = TRUE
+
+ if(!target_turf)
+ target_turf = get_random_station_turf()
+
+ var/direction = pick(GLOB.cardinals)
+
+ spawn_meteors_targeted(direction, target_turf)
+
+ if(current_wave >= wave_count)
+ __end_for_storyteller()
+
+/datum/round_event/storyteller_meteors/proc/spawn_meteors_targeted(direction, turf/target)
+ var/remaining_budget = wave_budget
+ var/max_spawn_attempts = 100 // Safety to prevent infinite loops
+
+ while(remaining_budget > 0 && max_spawn_attempts > 0)
+ max_spawn_attempts--
+
+ // Filter affordable meteor types
+ var/list/affordable_weights = list()
+ for(var/meteor_type in weighted_meteor_types)
+ var/cost = GLOB.meteors_cost[meteor_type] || 0
+ if(cost > 0 && cost <= remaining_budget)
+ affordable_weights[meteor_type] = weighted_meteor_types[meteor_type]
+
+ if(!length(affordable_weights))
+ break // No more affordable meteors
+
+ var/meteor_type = pick_weight(affordable_weights)
+ var/cost = GLOB.meteors_cost[meteor_type]
+
+ // Pick start turf
+ var/turf/picked_start
+ var/max_attempts = 10
+ while(max_attempts > 0)
+ var/start_side = direction // Use fixed direction for the wave
+ var/start_z = target.z
+ picked_start = spaceDebrisStartLoc(start_side, start_z)
+ if(isspaceturf(picked_start))
+ break
+ max_attempts--
+ if(!isspaceturf(picked_start))
+ continue // Skip if no valid start
+
+ new meteor_type(picked_start, target)
+ remaining_budget -= cost
+
+#undef MAP_EDGE_PAD
+#undef STORY_METEORS_DEFAULT_WAVE_COST
diff --git a/tff_modular/modules/storytellers/events/bad/raid/_raid.dm b/tff_modular/modules/storytellers/events/bad/raid/_raid.dm
new file mode 100644
index 00000000000..c587c880ba0
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/raid/_raid.dm
@@ -0,0 +1,47 @@
+
+/datum/round_event_control/perform_raid
+ id = "raid"
+ name = "Perform Raid"
+ description = "Sends a coordinated raid from a hostile faction on the station"
+ story_category = STORY_GOAL_BAD | STORY_GOAL_GLOBAL
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_ENTITIES, STORY_TAG_COMBAT, STORY_TAG_EPIC, STORY_TAG_REQUIRES_SECURITY)
+ enabled = FALSE
+
+ min_players = 15
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ story_weight = STORY_GOAL_BASE_WEIGHT * 1.2
+
+ typepath = /datum/round_event/storyteller_raid
+
+
+/datum/round_event_control/perform_raid/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if(!.)
+ return FALSE
+ if(inputs.vault[STORY_VAULT_SECURITY_COUNT < 1] && !(storyteller.get_effective_threat() > STORY_GOAL_THREAT_HIGH))
+ . = FALSE
+ return .
+
+
+/datum/round_event/storyteller_raid
+ STORYTELLER_EVENT
+
+ end_when = 30
+ start_when = 15
+ announce_when = 1
+ var/datum/raider_team/selected_team
+ var/turf/selected_turf
+
+/datum/round_event/storyteller_raid/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ selected_team = /datum/raider_team/syndicate
+ selected_turf = get_safe_random_station_turf()
+
+/datum/round_event/storyteller_raid/__announce_for_storyteller()
+ priority_announce("A group of hostile individuals has been spotted near your station. \
+ According to our observations, they are preparing to land in [get_area(selected_turf)]. Stop them at all costs.", "[command_name()]")
+
+/datum/round_event/storyteller_raid/__start_for_storyteller()
+ var/datum/raider_team/team = new selected_team
+ team.deploy(selected_turf)
diff --git a/tff_modular/modules/storytellers/events/bad/raid/raider_ai.dm b/tff_modular/modules/storytellers/events/bad/raid/raider_ai.dm
new file mode 100644
index 00000000000..62d63589642
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/raid/raider_ai.dm
@@ -0,0 +1,549 @@
+// Raider AI controller for coordinated raiding behavior
+/datum/ai_controller/basic_controller/raider
+ blackboard = list(
+ BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic,
+ BB_TARGET_MINIMUM_STAT = DEAD,
+ BB_REINFORCEMENTS_SAY = "Reinforcements incoming!",
+ BB_RAIDER_STRIKE_POINT = null,
+ BB_RAIDER_ATTACK_METHOD = list(
+ /datum/ai_behavior/basic_melee_attack
+ ),
+ BB_RAIDER_INTERESTING_ITEMS = list(
+ /obj/item/stack/spacecash,
+ /obj/item/stack/sheet,
+ /obj/item/gun),
+ BB_RAIDER_INTERESTING_TARGETS = list(),
+ BB_RAIDER_MY_ROLE = BB_RAIDER_ROLE_MEMBER, // Default role
+ BB_RAIDER_VALUABLE_OBJECTS = list( // Subtypes too
+ /obj/machinery/rnd/production/protolathe,
+ /obj/machinery/rnd/production/protolathe/department,
+ ),
+ BB_RAIDER_TEAM = null, // Ref to the raider team datum
+ BB_RAIDER_REACH_STRIKE_POINT = FALSE,
+ BB_RAIDER_DESTRUCTION_TARGET = null, // Current target for destruction
+ BB_RAIDER_LOOT_TARGET = null, // Current target for looting
+ BB_RAIDER_SEARCH_COOLDOWN_END = 0,
+ BB_RAIDER_HOLD_COOLDOWN_END = 0,
+ )
+
+ ai_movement = /datum/ai_movement/basic_avoidance
+ idle_behavior = /datum/idle_behavior/idle_random_walk
+ max_target_distance = 100
+
+ planning_subtrees = list(
+ /datum/ai_planning_subtree/escape_captivity,
+ /datum/ai_planning_subtree/raider_group_formation,
+ /datum/ai_planning_subtree/return_to_leader,
+ /datum/ai_planning_subtree/raider_strike_point_selection,
+ /datum/ai_planning_subtree/raider_coordinated_movement,
+ /datum/ai_planning_subtree/raider_attacking_en_route,
+ /datum/ai_planning_subtree/raider_looting,
+ /datum/ai_planning_subtree/raider_sabotage,
+ /datum/ai_planning_subtree/protect_team,
+ /datum/ai_planning_subtree/raider_hold_position_at_strike,
+ )
+
+/datum/ai_movement/jps/long_range
+ max_pathing_attempts = 50
+ maximum_length = 120
+ diagonal_flags = DIAGONAL_DO_NOTHING
+
+// Group formation subtree - finds nearby raiders, elects a leader, updates members, and assigns roles
+/datum/ai_planning_subtree/raider_group_formation
+ var/max_group_size = 5
+
+/datum/ai_planning_subtree/raider_group_formation/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn)
+ return
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ // No team, create a new one and try to form a group
+ team = new /datum/raider_team()
+ controller.set_blackboard_key(BB_RAIDER_TEAM, team)
+ team.leader = pawn
+ controller.set_blackboard_key(BB_RAIDER_MY_ROLE, BB_RAIDER_ROLE_LEADER)
+
+ // Look for nearby raiders without a team to add
+ var/list/nearby_raiders = list()
+ for(var/mob/living/basic/trooper/nearby in oview(7, pawn))
+ if(nearby.ai_controller?.type == /datum/ai_controller/basic_controller/raider && nearby.stat != DEAD)
+ var/datum/raider_team/nearby_team = nearby.ai_controller.blackboard[BB_RAIDER_TEAM]
+ if(!nearby_team)
+ nearby_raiders += nearby
+
+ var/list/new_members = list()
+ for(var/mob/living/member in nearby_raiders)
+ if(length(new_members) >= max_group_size - 1)
+ break
+ new_members += member
+ member.ai_controller.set_blackboard_key(BB_RAIDER_TEAM, team)
+ RegisterSignal(member, COMSIG_LIVING_DEATH, TYPE_PROC_REF(/datum/raider_team, on_member_death))
+
+ team.members = new_members
+ team.AssignRoles()
+ return
+
+ // Check leader status
+ var/mob/living/leader = team.leader
+ if(!leader || leader.stat == DEAD)
+ // Elect a new leader among nearby team members
+ var/list/candidates = list(pawn)
+ for(var/mob/living/member in oview(7, pawn))
+ if(istype(member.ai_controller, /datum/ai_controller/basic_controller/raider))
+ var/datum/raider_team/other = member.ai_controller.blackboard[BB_RAIDER_TEAM]
+ if(other == team)
+ candidates += member
+
+ if(length(candidates) < 1)
+ return // No candidates, stay as is
+
+ var/mob/living/best_leader = pawn
+ var/best_score = pawn.health
+ for(var/mob/living/candidate in candidates)
+ var/score = candidate.health + (10 - get_dist(pawn, candidate)) // Health + proximity bonus
+ if(score > best_score)
+ best_score = score
+ best_leader = candidate
+
+ team.leader = best_leader
+ best_leader.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, BB_RAIDER_ROLE_LEADER)
+ // Reset roles for others if needed
+ for(var/mob/living/cand in candidates)
+ if(cand != best_leader)
+ cand.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, BB_RAIDER_ROLE_MEMBER)
+
+ // New leader assigns roles
+ if(best_leader == pawn)
+ team.AssignRoles()
+
+ else
+ // Has leader
+ if(get_dist(pawn, leader) > 10)
+ controller.queue_behavior(/datum/ai_behavior/travel_towards_atom, leader)
+ else
+ if(leader == pawn)
+ // Update members as leader
+ var/list/current_members = team.members.Copy()
+ for(var/mob/living/member in current_members)
+ if(!member || member.stat == DEAD)
+ team.members -= member
+ if(member?.ai_controller)
+ member.ai_controller.clear_blackboard_key(BB_RAIDER_TEAM)
+ member.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, BB_RAIDER_ROLE_MEMBER)
+ UnregisterSignal(member, COMSIG_LIVING_DEATH)
+
+ // Add new members
+ for(var/mob/living/basic/trooper/nearby in oview(7, pawn))
+ if(istype(nearby.ai_controller?.type, /datum/ai_controller/basic_controller/raider) && nearby != pawn)
+ var/datum/raider_team/nearby_team = nearby.ai_controller.blackboard[BB_RAIDER_TEAM]
+ if(!nearby_team && length(team.members) < max_group_size - 1)
+ team.members += nearby
+ nearby.ai_controller.set_blackboard_key(BB_RAIDER_TEAM, team)
+ RegisterSignal(nearby, COMSIG_LIVING_DEATH, TYPE_PROC_REF(/datum/raider_team, on_member_death))
+ team.AssignRoles()
+
+
+/datum/ai_planning_subtree/return_to_leader
+ var/max_distance_to_leader = 7
+
+/datum/ai_planning_subtree/return_to_leader/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ . = ..()
+ var/mob/living/pawn = controller.pawn
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+ var/mob/living/leader = team.leader
+ if(!leader)
+ return
+ if(pawn != leader)
+ // Followers stay close to leader
+ if(get_dist(pawn, leader) > max_distance_to_leader)
+ controller.queue_behavior(/datum/ai_behavior/travel_towards_atom, leader)
+ controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET)
+
+
+// Strike point selection subtree - leader selects nearest unvisited area, picks a turf or valuable object within it
+/datum/ai_planning_subtree/raider_strike_point_selection
+ var/search_cooldown_time = 10 SECONDS
+
+/datum/ai_planning_subtree/raider_strike_point_selection/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn)
+ return
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+ // Only leaders select strike points
+ if(team.leader != pawn)
+ return
+ if(controller.blackboard[BB_RAIDER_REACH_STRIKE_POINT] == TRUE)
+ return
+ var/turf/current_strike = team.strike_point
+ if(current_strike)
+ if(get_dist(pawn, current_strike) <= 5) // Reached strike point, but hold before new one
+ return // Defer to hold subtree
+ else
+ return // Still en route
+
+ if(world.time >= controller.blackboard[BB_RAIDER_SEARCH_COOLDOWN_END])
+ PickStrikePoint(controller, team)
+ controller.set_blackboard_key(BB_RAIDER_SEARCH_COOLDOWN_END, world.time + search_cooldown_time)
+
+/datum/ai_planning_subtree/raider_strike_point_selection/proc/PickStrikePoint(datum/ai_controller/controller, datum/raider_team/team)
+ var/mob/living/pawn = controller.pawn
+ var/list/valuable_types = controller.blackboard[BB_RAIDER_VALUABLE_OBJECTS]
+ var/area/current_area = get_area(pawn)
+
+ // Find the nearest unvisited area (excluding current)
+ var/area/selected_area
+ var/min_dist = INFINITY
+ for(var/area/A in get_areas_in_range(40, pawn))
+ if(A == current_area || (A.type in team.visited_areas))
+ continue
+ var/list/area_turfs = get_area_turfs(A)
+ if(!length(area_turfs))
+ continue
+ var/turf/area_turf = pick(area_turfs)
+ var/dist = get_dist(pawn, area_turf)
+ if(dist < min_dist)
+ min_dist = dist
+ selected_area = A
+
+ // If all areas visited, reset visited_areas to cycle again
+ if(!selected_area)
+ team.visited_areas = list()
+ // Re-run selection to pick the nearest now (excluding current)
+ for(var/area/A in GLOB.areas)
+ if(A == current_area)
+ continue
+ var/list/area_turfs = get_area_turfs(A)
+ if(!length(area_turfs))
+ continue
+ var/turf/area_turf = pick(area_turfs)
+ var/dist = get_dist(pawn, area_turf)
+ if(dist < min_dist)
+ min_dist = dist
+ selected_area = A
+
+ if(!selected_area)
+ return
+
+ // Within the selected area, find valuable objects or a random turf
+ var/list/possible_targets = list()
+ for(var/obj/machinery/M in selected_area.contents)
+ for(var/valuable_type in valuable_types)
+ if(istype(M, valuable_type))
+ possible_targets += M
+ break
+
+ var/turf/chosen_target
+ if(length(possible_targets))
+ chosen_target = get_turf(pick(possible_targets))
+ else
+ // No valuable objects, pick a random open turf in the area
+ var/list/area_turfs = get_area_turfs(selected_area)
+ if(length(area_turfs))
+ shuffle(area_turfs)
+ for(var/turf/turf as anything in area_turfs)
+ if(isopenturf(turf) && !isspaceturf(turf) && !turf.density)
+ chosen_target = turf
+ break
+
+ if(chosen_target)
+ team.set_strike_point(chosen_target)
+ team.current_objective = "strike"
+
+
+// Coordinated movement subtree - leader moves to strike point (with JPS pathing including door breaking), team follows leader
+/datum/ai_planning_subtree/raider_coordinated_movement
+
+/datum/ai_planning_subtree/raider_coordinated_movement/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn)
+ return
+
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+
+ var/atom/strike_point = team.strike_point
+ if(!strike_point)
+ return
+
+ var/reach_point = controller.blackboard[BB_RAIDER_REACH_STRIKE_POINT] || FALSE
+ var/mob/living/leader = team.leader
+
+ if(leader == pawn)
+ // Leader moves towards strike point using JPS for long-range pathing, will handle obstructions en route
+ // Leader checks if team is close; if not, waits or moves slower
+ var/all_close = TRUE
+ for(var/mob/living/member in team.members)
+ if(get_dist(pawn, member) > 5)
+ all_close = FALSE
+ break
+ if(all_close)
+ controller.queue_behavior(/datum/ai_behavior/travel_towards_atom, strike_point)
+ else
+ // Wait for team to catch up
+ controller.queue_behavior(/datum/ai_behavior/idle_until_target_close, team.members[1]) // Idle until a straggler is close, adjust as needed
+ else if(leader)
+ // Followers move towards leader to stay together
+ if(get_dist(pawn, leader) > 3 && !reach_point)
+ controller.queue_behavior(/datum/ai_behavior/travel_towards_atom, leader)
+ else
+ controller.queue_behavior(/datum/ai_behavior/travel_towards_atom, strike_point)
+
+
+
+// Attacking en route subtree - attack nearby enemies while moving, but members check distance to leader; also break doors/obstructions
+/datum/ai_planning_subtree/raider_attacking_en_route
+
+/datum/ai_planning_subtree/raider_attacking_en_route/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn)
+ return
+
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+
+ var/mob/living/leader = team.leader
+ if(!leader)
+ return
+
+ if(pawn != leader && get_dist(pawn, leader) > 7)
+ // If too far from leader, cancel current tasks and return
+ controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET)
+
+ // Check for nearby enemies
+ var/list/enemies = list()
+ for(var/mob/living/target in oview(5, pawn))
+ if(!pawn.faction_check_atom(target, FALSE) && target.stat != DEAD)
+ enemies += target
+
+ if(length(enemies))
+ if(pawn != leader && get_dist(pawn, leader) < 10)
+ var/mob/living/target = pick(enemies)
+ controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target)
+ var/list/attack_methods = controller.blackboard[BB_RAIDER_ATTACK_METHOD]
+ if(length(attack_methods))
+ var/datum/ai_behavior/attack_way = pick(attack_methods)
+ controller.queue_behavior(attack_way, BB_BASIC_MOB_CURRENT_TARGET, BB_TARGETING_STRATEGY)
+
+
+ var/atom/target_point = team.strike_point
+ if(pawn != leader)
+ target_point = leader // Members focus on path to leader if following
+ if(QDELETED(target_point))
+ return
+ var/turf/next_step = get_step_towards(pawn, target_point)
+ if(iswallturf(next_step))
+ controller.queue_behavior(/datum/ai_behavior/plant_c4, next_step)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+ for(var/atom/obstruction in next_step.get_all_contents())
+ if(obstruction.density)
+ controller.queue_behavior(/datum/ai_behavior/basic_melee_attack, obstruction)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+
+// Looting subtree - looters pick up valuable items, using BB_RAIDER_LOOT_TARGET
+/datum/ai_planning_subtree/raider_looting
+
+/datum/ai_planning_subtree/raider_looting/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn || controller.blackboard[BB_RAIDER_MY_ROLE] != BB_RAIDER_ROLE_LOOTER || !length(controller.blackboard[BB_RAIDER_INTERESTING_ITEMS]))
+ return
+
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+ var/mob/living/leader = team.leader
+ if(!leader || get_dist(pawn, leader) > 7)
+ // If too far from leader, cancel looting
+ controller.clear_blackboard_key(BB_RAIDER_LOOT_TARGET)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+ // Look for items to loot or use existing target
+ var/atom/target_item = controller.blackboard[BB_RAIDER_LOOT_TARGET]
+ if(QDELETED(target_item))
+ var/list/lootable_items = list()
+ for(var/obj/item/I in oview(3, pawn))
+ if(I.type in controller.blackboard[BB_RAIDER_INTERESTING_ITEMS])
+ if(!I.anchored && I.w_class <= WEIGHT_CLASS_NORMAL)
+ lootable_items += I
+
+ if(length(lootable_items))
+ target_item = pick(lootable_items)
+ controller.set_blackboard_key(BB_RAIDER_LOOT_TARGET, target_item)
+
+ var/should_finish = FALSE
+ if(target_item)
+ controller.queue_behavior(/datum/ai_behavior/pick_up_item, BB_RAIDER_LOOT_TARGET)
+ should_finish = TRUE
+ else
+ controller.clear_blackboard_key(BB_RAIDER_LOOT_TARGET)
+ if(should_finish)
+ return SUBTREE_RETURN_FINISH_PLANNING
+// Sabotage subtree - saboteurs damage machinery and structures, using BB_RAIDER_DESTRUCTION_TARGET
+/datum/ai_planning_subtree/raider_sabotage
+
+/datum/ai_planning_subtree/raider_sabotage/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn || controller.blackboard[BB_RAIDER_MY_ROLE] != BB_RAIDER_ROLE_SABOTEUR)
+ return
+
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+ var/mob/living/leader = team.leader
+ if(!leader || get_dist(pawn, leader) > 7)
+ // If too far from leader, cancel sabotage
+ controller.clear_blackboard_key(BB_RAIDER_DESTRUCTION_TARGET)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+ // Look for targets to sabotage or use existing target
+ var/atom/target = controller.blackboard[BB_RAIDER_DESTRUCTION_TARGET]
+ if(QDELETED(target))
+ var/list/sabotage_targets = list()
+ for(var/obj/machinery/M in oview(3, pawn))
+ if(M.can_be_unfasten_wrench(null, TRUE) || M.panel_open)
+ sabotage_targets += M
+
+ for(var/obj/structure/S in oview(3, pawn))
+ if(S.can_be_unfasten_wrench(null, TRUE))
+ sabotage_targets += S
+
+ if(length(sabotage_targets))
+ target = pick(sabotage_targets)
+ controller.set_blackboard_key(BB_RAIDER_DESTRUCTION_TARGET, target)
+
+ var/should_finish = FALSE
+ if(target)
+ controller.queue_behavior(/datum/ai_behavior/attack_obstructions, BB_RAIDER_DESTRUCTION_TARGET)
+ should_finish = TRUE
+ else
+ controller.clear_blackboard_key(BB_RAIDER_DESTRUCTION_TARGET)
+
+ if(should_finish)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+// Protect team subtree - shooters attack enemies near looters/saboteurs
+/datum/ai_planning_subtree/protect_team
+
+/datum/ai_planning_subtree/protect_team/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn || controller.blackboard[BB_RAIDER_MY_ROLE] != BB_RAIDER_ROLE_SHOOTER)
+ return
+
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return
+
+ var/mob/living/leader = team.leader
+ if(!leader || get_dist(pawn, leader) > 7)
+ // If too far from leader, cancel protection tasks
+ controller.clear_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+ // Find looters/saboteurs in group
+ var/list/protected_members = list()
+ for(var/mob/living/member in team.members)
+ if(member && (member.ai_controller?.blackboard[BB_RAIDER_MY_ROLE] == BB_RAIDER_ROLE_LOOTER || member.ai_controller?.blackboard[BB_RAIDER_MY_ROLE] == BB_RAIDER_ROLE_SABOTEUR))
+ protected_members += member
+
+ // Check for enemies near protected members
+ var/list/enemies = list()
+ for(var/mob/living/protected in protected_members)
+ for(var/mob/living/target in oview(5, protected))
+ if(!pawn.faction_check_atom(target, FALSE) && target.stat != DEAD)
+ enemies += target
+
+ if(length(enemies))
+ var/mob/living/target = pick(enemies)
+ controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, target)
+ var/list/attack_methods = controller.blackboard[BB_RAIDER_ATTACK_METHOD]
+ if(length(attack_methods))
+ var/datum/ai_behavior/attack_way = pick(attack_methods)
+ controller.queue_behavior(attack_way, BB_BASIC_MOB_CURRENT_TARGET)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+// Hold position at strike point for a time (leader only), then mark area as visited and clear for next
+/datum/ai_planning_subtree/raider_hold_position_at_strike
+ var/hold_time = 2 MINUTES
+
+/datum/ai_planning_subtree/raider_hold_position_at_strike/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick)
+ var/mob/living/pawn = controller.pawn
+ if(!pawn)
+ return SUBTREE_RETURN_FINISH_PLANNING
+ var/datum/raider_team/team = controller.blackboard[BB_RAIDER_TEAM]
+ if(!team)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+ var/turf/current_strike = team.strike_point
+ if(!current_strike || get_dist(pawn, current_strike) > 7)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+ controller.set_blackboard_key(BB_RAIDER_REACH_STRIKE_POINT, TRUE)
+ if(controller.blackboard[BB_RAIDER_HOLD_COOLDOWN_END] <= world.time)
+ controller.set_blackboard_key(BB_RAIDER_HOLD_COOLDOWN_END, world.time + hold_time)
+
+ if(world.time >= controller.blackboard[BB_RAIDER_HOLD_COOLDOWN_END] && controller.blackboard[BB_RAIDER_REACH_STRIKE_POINT] == TRUE && team.leader == pawn)
+ // Hold time finished, mark area as visited, clear strike point for new selection
+ var/area/current_area = get_area(current_strike)
+ if(current_area && !(current_area.type in team.visited_areas))
+ team.visited_areas += current_area.type
+ team.strike_point = null
+ team.current_objective = null
+ controller.set_blackboard_key(BB_RAIDER_REACH_STRIKE_POINT, FALSE)
+ // Clear targets
+ controller.clear_blackboard_key(BB_RAIDER_DESTRUCTION_TARGET)
+ controller.clear_blackboard_key(BB_RAIDER_LOOT_TARGET)
+ for(var/mob/living/member in team.members)
+ if(member.ai_controller)
+ member.ai_controller.clear_blackboard_key(BB_RAIDER_DESTRUCTION_TARGET)
+ member.ai_controller.clear_blackboard_key(BB_RAIDER_LOOT_TARGET)
+ return SUBTREE_RETURN_FINISH_PLANNING
+
+// Behavior for planting C4 on a target wall
+/datum/ai_behavior/plant_c4
+ behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT | AI_BEHAVIOR_REQUIRE_REACH
+ required_distance = 1
+
+/datum/ai_behavior/plant_c4/perform(seconds_per_tick, datum/ai_controller/controller, target_key)
+ . = ..()
+ var/mob/living/pawn = controller.pawn
+ var/turf/target = controller.blackboard[target_key]
+ if(!target || !istype(target, /turf/closed/wall))
+ finish_action(controller, FALSE)
+ return
+
+
+ var/obj/item/grenade/c4/c4 = locate() in pawn.contents
+ if(!c4)
+ c4 = new /obj/item/grenade/c4(pawn)
+
+ if(c4)
+ c4.plant_c4(target, pawn)
+ finish_action(controller, TRUE)
+ else
+ finish_action(controller, FALSE)
+
+/datum/ai_behavior/plant_c4/finish_action(datum/ai_controller/controller, succeeded, target_key)
+ . = ..()
+ controller.clear_blackboard_key(target_key)
+
+// New behavior for idling until a target is close (for leader waiting)
+/datum/ai_behavior/idle_until_target_close
+ behavior_flags = AI_BEHAVIOR_REQUIRE_MOVEMENT
+
+/datum/ai_behavior/idle_until_target_close/perform(seconds_per_tick, datum/ai_controller/controller, target_key)
+ var/mob/living/pawn = controller.pawn
+ var/atom/target = controller.blackboard[target_key]
+ if(get_dist(pawn, target) <= 3)
+ finish_action(controller, TRUE)
+
+/datum/ai_behavior/idle_until_target_close/finish_action(datum/ai_controller/controller, succeeded, target_key)
+ . = ..()
+ controller.clear_blackboard_key(target_key)
diff --git a/tff_modular/modules/storytellers/events/bad/raid/raider_mob.dm b/tff_modular/modules/storytellers/events/bad/raid/raider_mob.dm
new file mode 100644
index 00000000000..5d396700ce9
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/raid/raider_mob.dm
@@ -0,0 +1,254 @@
+/mob/living/basic/trooper/robust
+ icon = 'icons/mob/simple/simple_human.dmi'
+ mob_biotypes = MOB_ORGANIC|MOB_HUMANOID
+ sentience_type = SENTIENCE_HUMANOID
+ maxHealth = 250
+ health = 250
+ basic_mob_flags = DEL_ON_DEATH
+ speed = 1.4
+ melee_damage_lower = 20
+ melee_damage_upper = 25
+ attack_verb_continuous = "punches"
+ attack_verb_simple = "punch"
+ attack_sound = 'sound/items/weapons/punch1.ogg'
+ melee_attack_cooldown = 0.8 SECONDS
+ combat_mode = TRUE
+
+
+ unsuitable_atmos_damage = 0
+ unsuitable_cold_damage = 0
+ unsuitable_heat_damage = 7.5
+ ai_controller = /datum/ai_controller/basic_controller/trooper
+
+
+ corpse = /obj/effect/mob_spawn/corpse/human
+
+
+
+/datum/outfit/syndy_raider_base
+ name = "Syndicate Commando Corpse"
+ uniform = /obj/item/clothing/under/syndicate
+ shoes = /obj/item/clothing/shoes/combat
+ gloves = /obj/item/clothing/gloves/tackler/combat/insulated
+ ears = /obj/item/radio/headset/syndicate
+ mask = /obj/item/clothing/mask/gas/syndicate
+ back = /obj/item/mod/control/pre_equipped/nuclear
+ r_pocket = /obj/item/tank/internals/emergency_oxygen
+ id = /obj/item/card/id/advanced/chameleon
+ id_trim = /datum/id_trim/chameleon/operative
+
+/datum/outfit/syndy_raider_base_elite
+ name = "Syndicate Commando Corpse"
+ uniform = /obj/item/clothing/under/syndicate
+ shoes = /obj/item/clothing/shoes/combat
+ gloves = /obj/item/clothing/gloves/tackler/combat/insulated
+ ears = /obj/item/radio/headset/syndicate
+ mask = /obj/item/clothing/mask/gas/syndicate
+ back = /obj/item/mod/control/pre_equipped/elite
+ r_pocket = /obj/item/tank/internals/emergency_oxygen
+ id = /obj/item/card/id/advanced/chameleon
+ id_trim = /datum/id_trim/chameleon/operative
+
+/obj/effect/mob_spawn/corpse/human/syndy_raider_base
+ name = "Syndicate Raider"
+ hairstyle = "Bald"
+ facial_hairstyle = "Shaved"
+ outfit = /datum/outfit/syndy_raider_base
+
+/obj/effect/mob_spawn/corpse/human/syndy_raider_base/elite
+ name = "Elite Syndicate Raider"
+ outfit = /datum/outfit/syndy_raider_base_elite
+
+/mob/living/basic/trooper/robust/syndicate
+ name = "Syndicate raider"
+ desc = "Death to Nanotrasen."
+ maxHealth = 300
+ health = 300
+
+
+ faction = list(ROLE_SYNDICATE)
+ mob_spawner = /obj/effect/mob_spawn/corpse/human/syndy_raider_base
+ corpse = /obj/effect/gibspawner/human
+
+ /// Is we range trooper
+ var/ranged = FALSE
+ /// Type of bullet we use
+ var/casingtype = /obj/item/ammo_casing/c9mm
+ /// Sound to play when firing weapon
+ var/projectilesound = 'sound/items/weapons/gun/pistol/shot.ogg'
+ /// number of burst shots
+ var/burst_shots
+ /// Time between taking shots
+ var/ranged_cooldown = 1 SECONDS
+
+ /// Is we using shield to deflect bullets?
+ var/shielding = FALSE
+
+ var/laser_block_chance = 30
+
+ var/projectile_deflect_chance = 30
+
+/mob/living/basic/trooper/robust/syndicate/Initialize(mapload)
+ . = ..()
+
+ ADD_TRAIT(src, TRAIT_SPACEWALK, INNATE_TRAIT)
+ if(ranged)
+ AddComponent(\
+ /datum/component/ranged_attacks,\
+ casing_type = casingtype,\
+ projectile_sound = projectilesound,\
+ cooldown_time = ranged_cooldown,\
+ burst_shots = burst_shots,\
+ )
+ if(ranged_cooldown <= 1 SECONDS)
+ AddComponent(/datum/component/ranged_mob_full_auto)
+
+
+/mob/living/basic/trooper/robust/syndicate/projectile_hit(obj/projectile/hitting_projectile, def_zone, piercing_hit, blocked)
+ if(istype(hitting_projectile, /obj/projectile/energy) && laser_block_chance)
+ visible_message(span_danger("[src] blocks [hitting_projectile] with its shield!"))
+ return BULLET_ACT_BLOCK
+ else if(prob(projectile_deflect_chance))
+ visible_message(span_danger("[src] blocks [hitting_projectile] with its shield!"))
+ return BULLET_ACT_BLOCK
+ return ..()
+
+
+/datum/ai_controller/basic_controller/raider/ranged
+ blackboard = list(
+ BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic,
+ BB_TARGET_MINIMUM_STAT = HARD_CRIT,
+ BB_REINFORCEMENTS_SAY = "Reinforcements incoming!",
+ BB_RAIDER_STRIKE_POINT = null,
+ BB_RAIDER_ATTACK_METHOD = list(
+ /datum/ai_behavior/basic_ranged_attack
+ ),
+ BB_RAIDER_INTERESTING_ITEMS = list(
+ /obj/item/stack/spacecash,
+ /obj/item/stack/sheet,
+ /obj/item/gun),
+ BB_RAIDER_INTERESTING_TARGETS = list(),
+ BB_RAIDER_MY_ROLE = BB_RAIDER_ROLE_MEMBER,
+ BB_RAIDER_VALUABLE_OBJECTS = list(
+ /obj/machinery/rnd/production/protolathe,
+ /obj/machinery/rnd/production/protolathe/department,
+ ),
+ BB_RAIDER_TEAM = null,
+ BB_RAIDER_REACH_STRIKE_POINT = FALSE,
+ BB_RAIDER_DESTRUCTION_TARGET = null,
+ BB_RAIDER_LOOT_TARGET = null,
+ BB_RAIDER_SEARCH_COOLDOWN_END = 0,
+ BB_RAIDER_HOLD_COOLDOWN_END = 0,
+ )
+
+
+/datum/ai_controller/basic_controller/raider/melee
+ blackboard = list(
+ BB_TARGETING_STRATEGY = /datum/targeting_strategy/basic,
+ BB_TARGET_MINIMUM_STAT = HARD_CRIT,
+ BB_REINFORCEMENTS_SAY = "Reinforcements incoming!",
+ BB_RAIDER_STRIKE_POINT = null,
+ BB_RAIDER_ATTACK_METHOD = list(
+ /datum/ai_behavior/basic_melee_attack
+ ),
+ BB_RAIDER_INTERESTING_ITEMS = list(
+ /obj/item/stack/spacecash,
+ /obj/item/stack/sheet,
+ /obj/item/gun),
+ BB_RAIDER_INTERESTING_TARGETS = list(),
+ BB_RAIDER_MY_ROLE = BB_RAIDER_ROLE_MEMBER,
+ BB_RAIDER_VALUABLE_OBJECTS = list(
+ /obj/machinery/rnd/production/protolathe,
+ /obj/machinery/rnd/production/protolathe/department,
+ ),
+ BB_RAIDER_TEAM = null,
+ BB_RAIDER_REACH_STRIKE_POINT = FALSE,
+ BB_RAIDER_DESTRUCTION_TARGET = null,
+ BB_RAIDER_LOOT_TARGET = null,
+ BB_RAIDER_SEARCH_COOLDOWN_END = 0,
+ BB_RAIDER_HOLD_COOLDOWN_END = 0,
+ )
+
+
+/mob/living/basic/trooper/robust/syndicate/c20r
+ ranged = TRUE
+ r_hand = /obj/item/gun/ballistic/automatic/c20r
+ l_hand = /obj/item/shield/energy/advanced
+ ai_controller = /datum/ai_controller/basic_controller/raider/ranged
+ casingtype = /obj/item/ammo_casing/c45
+ projectilesound = 'sound/items/weapons/gun/smg/shot.ogg'
+ shielding = TRUE
+ burst_shots = 3
+ ranged_cooldown = 5
+ laser_block_chance = 50
+ projectile_deflect_chance = 50
+
+/mob/living/basic/trooper/robust/syndicate/buldog
+ ranged = TRUE
+ r_hand = /obj/item/gun/ballistic/shotgun/bulldog
+ ai_controller = /datum/ai_controller/basic_controller/raider/ranged
+ casingtype = /obj/item/ammo_casing/shotgun/buckshot/milspec
+ projectilesound = 'sound/items/weapons/gun/shotgun/shot_alt.ogg'
+ burst_shots = 2
+ ranged_cooldown = 1.5 SECONDS
+
+
+/mob/living/basic/trooper/robust/syndicate/buldog/antimaterial
+ ranged = TRUE
+ r_hand = /obj/item/gun/ballistic/rifle/sniper_rifle
+ ai_controller = /datum/ai_controller/basic_controller/raider/ranged
+ casingtype = /obj/item/ammo_casing/p50
+ projectilesound = 'sound/items/weapons/gun/sniper/shot.ogg'
+ burst_shots = 1
+ ranged_cooldown = 7 SECONDS
+
+
+/mob/living/basic/trooper/robust/syndicate/esword
+ ai_controller = /datum/ai_controller/basic_controller/raider/melee
+ r_hand = /obj/item/melee/energy/sword
+ l_hand = /obj/item/shield/energy/advanced
+ shielding = TRUE
+ laser_block_chance = 50
+ projectile_deflect_chance = 50
+ melee_damage_lower = 35
+ melee_damage_upper = 35
+
+
+/mob/living/basic/trooper/robust/syndicate/elite
+ name = "Elite Syndicate Raider"
+ damage_coeff = list(BRUTE = 0.6, BURN = 0.6, TOX = 0, STAMINA = 0, OXY = 0)
+ ai_controller = /datum/ai_controller/basic_controller/raider
+ corpse = /obj/effect/mob_spawn/corpse/human/syndy_raider_base/elite
+ maxHealth = 300
+ health = 300
+ melee_damage_lower = 20
+ melee_damage_upper = 35
+ armour_penetration = 50
+
+ r_hand = /obj/item/dualsaber_fake
+ shielding = TRUE
+ laser_block_chance = 100
+ projectile_deflect_chance = 50
+ light_on = TRUE
+ light_system = OVERLAY_LIGHT
+ light_color = LIGHT_COLOR_INTENSE_RED
+ light_range = 4
+
+/mob/living/basic/trooper/robust/syndicate/elite/melee_attack(atom/target, list/modifiers, ignore_cooldown)
+ . = ..()
+ if(prob(30))
+ dance_rotate(src, CALLBACK(src, TYPE_PROC_REF(/mob, dance_flip)))
+
+/mob/living/basic/trooper/robust/syndicate/elite/Life(seconds_per_tick, times_fired)
+ . = ..()
+ set_light_color(pick(COLOR_SOFT_RED, LIGHT_COLOR_GREEN, LIGHT_COLOR_LIGHT_CYAN, LIGHT_COLOR_LAVENDER))
+
+/obj/item/dualsaber_fake
+ name = "double-bladed energy sword"
+ desc = "Handle with care."
+ icon = 'icons/obj/weapons/transforming_energy.dmi'
+ icon_state = "dualsaberrainbow1"
+ inhand_icon_state = "dualsaberrainbow1"
+ icon_angle = -45
+ lefthand_file = 'icons/mob/inhands/weapons/swords_lefthand.dmi'
+ righthand_file = 'icons/mob/inhands/weapons/swords_righthand.dmi'
diff --git a/tff_modular/modules/storytellers/events/bad/raid/raider_team.dm b/tff_modular/modules/storytellers/events/bad/raid/raider_team.dm
new file mode 100644
index 00000000000..a737aad8bc6
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/raid/raider_team.dm
@@ -0,0 +1,172 @@
+// Raider team datum - stores team data and acts as initial state for the team
+/datum/raider_team
+ // Type path for the leader mob
+ var/leader_type
+ // List of type paths for member mobs
+ var/list/member_types = list()
+ // The type of drop pod
+ var/drop_pod_style = /datum/pod_style/syndicate
+ // Spawned leader instance
+ var/mob/living/basic/leader
+ // List of spawned member instances
+ var/list/members = list()
+ // Faction for the team
+ var/team_faction = ROLE_SYNDICATE
+ // Max members (including leader)
+ var/max_team_size = 5
+ // Type for reinforcement mobs
+ var/reinforcement_type
+ // Shared strike point
+ var/turf/strike_point
+ // Shared current objective
+ var/current_objective
+ // List of visited area types to cycle through stations
+ var/list/visited_areas = list()
+
+/datum/raider_team/New()
+ ..()
+ // Initialize any additional setup if needed
+
+// Deploy the team around a target turf
+/datum/raider_team/proc/deploy(turf/target_turf)
+ if(!target_turf)
+ return
+
+ notify_ghosts("A raider team strikes!", target_turf, "Raid")
+ // Find open turfs around the target
+ var/list/spawn_turfs = list()
+ for(var/turf/open/open_turf in RANGE_TURFS(3, target_turf))
+ spawn_turfs += open_turf
+ if(!length(spawn_turfs))
+ stack_trace("Raid failed to spawn at turf [target_turf]")
+ return
+
+ // Spawn leader
+ var/turf/leader_turf = pick_n_take(spawn_turfs)
+ var/obj/structure/closet/supplypod/leader_pod = podspawn(list(
+ "target" = leader_turf,
+ "path" = /obj/structure/closet/supplypod/podspawn/no_return,
+ "style" = drop_pod_style,
+ "spawn" = leader_type,
+ ))
+
+ leader = locate(leader_type) in leader_pod.contents
+ leader.faction = list(team_faction)
+ leader.ai_controller = new /datum/ai_controller/basic_controller/raider(leader)
+ leader.ai_controller.set_blackboard_key(BB_RAIDER_TEAM, src)
+ leader.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, BB_RAIDER_ROLE_LEADER)
+ RegisterSignal(leader, COMSIG_LIVING_DEATH, PROC_REF(on_leader_death))
+
+ // Spawn members
+ var/list/spawned_members = list()
+ for(var/member_type in member_types)
+ var/turf/member_turf = pick_n_take(spawn_turfs)
+ var/obj/structure/closet/supplypod/member_pod = podspawn(list(
+ "target" = member_turf,
+ "path" = /obj/structure/closet/supplypod/podspawn/no_return,
+ "style" = drop_pod_style,
+ "spawn" = member_type,
+ ))
+
+ var/mob/living/basic/member = locate(member_type) in member_pod.contents
+ member.faction = list(team_faction)
+ member.ai_controller = new /datum/ai_controller/basic_controller/raider(member)
+ member.ai_controller.set_blackboard_key(BB_RAIDER_TEAM, src)
+ RegisterSignal(member, COMSIG_LIVING_DEATH, PROC_REF(on_member_death))
+ spawned_members += member
+
+ members = spawned_members
+ AssignRoles()
+
+// Get team composition (list of all team mobs, including leader)
+/datum/raider_team/proc/get_team_composition()
+ var/list/composition = list(leader)
+ composition += members
+ return composition
+
+// Summon small reinforcement (add 1-2 extra members)
+/datum/raider_team/proc/summon_reinforcement(turf/spawn_turf)
+ if(!spawn_turf || !leader || leader.stat == DEAD)
+ return
+
+ // Spawn 1-2 reinforcements
+ var/num_reinforcements = rand(1, 2)
+ for(var/i in 1 to num_reinforcements)
+ var/turf/reinf_turf = pick(oview(3, spawn_turf))
+ if(!istype(reinf_turf, /turf/open) || reinf_turf.density)
+ continue
+ var/mob/living/basic/reinf = new reinforcement_type(reinf_turf)
+ reinf.faction = list(team_faction)
+ reinf.ai_controller = new /datum/ai_controller/basic_controller/raider(reinf)
+ reinf.ai_controller.set_blackboard_key(BB_RAIDER_TEAM, src)
+ RegisterSignal(reinf, COMSIG_LIVING_DEATH, PROC_REF(on_member_death))
+ members += reinf
+
+ AssignRoles()
+
+/datum/raider_team/proc/AssignRoles()
+ var/list/roles_to_assign = list(BB_RAIDER_ROLE_SHOOTER, BB_RAIDER_ROLE_LOOTER, BB_RAIDER_ROLE_SABOTEUR)
+ var/list/required_roles = list(BB_RAIDER_ROLE_LOOTER, BB_RAIDER_ROLE_SABOTEUR) // Ensure at least one looter and saboteur
+ var/list/assigned = list()
+
+ // Assign required roles first
+ for(var/role in required_roles)
+ if(length(members))
+ var/mob/living/member = pick(members)
+ member.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, role)
+ assigned += member
+ members -= member // Remove to avoid reassigning
+
+ // Assign remaining roles, preferring shooter if possible
+ for(var/mob/living/member in members)
+ if(member?.ai_controller)
+ var/role = length(roles_to_assign) ? pick(roles_to_assign) : BB_RAIDER_ROLE_SHOOTER // Prefer shooter for extras
+ member.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, role)
+ UpdateBehevours()
+
+/datum/raider_team/proc/UpdateBehevours()
+ if(leader)
+ leader?.ai_controller.change_ai_movement_type(/datum/ai_movement/jps/long_range)
+ for(var/mob/living/basic/member in members)
+ member?.ai_controller.change_ai_movement_type(/datum/ai_movement/basic_avoidance)
+
+/datum/raider_team/proc/set_strike_point(turf/target)
+ if(QDELETED(target))
+ return
+
+ strike_point = target
+ for(var/mob/living/basic/member in members)
+ if(member.ai_controller)
+ member.ai_controller.set_blackboard_key(BB_RAIDER_STRIKE_POINT, target)
+ member.ai_controller.set_blackboard_key(BB_RAIDER_REACH_STRIKE_POINT, FALSE)
+
+/datum/raider_team/proc/on_member_death(datum/source)
+ SIGNAL_HANDLER
+ if(!isbasicmob(source))
+ return
+ var/mob/living/basic/team_member = source
+ members -= team_member
+ if(team_member.ai_controller)
+ team_member.ai_controller.clear_blackboard_key(BB_RAIDER_TEAM)
+ team_member.ai_controller.set_blackboard_key(BB_RAIDER_MY_ROLE, BB_RAIDER_ROLE_MEMBER)
+ UnregisterSignal(source, COMSIG_LIVING_DEATH)
+
+
+/datum/raider_team/proc/on_leader_death(datum/source)
+ SIGNAL_HANDLER
+ leader = null
+ UnregisterSignal(source, COMSIG_LIVING_DEATH)
+ // Trigger group formation in members to elect new leader
+ for(var/mob/living/basic/member in members)
+ if(member.ai_controller)
+ member.ai_controller.queue_behavior(/datum/ai_planning_subtree/raider_group_formation)
+
+
+/datum/raider_team/syndicate
+ leader_type = /mob/living/basic/trooper/robust/syndicate/elite
+ member_types = list(
+ /mob/living/basic/trooper/robust/syndicate/esword,
+ /mob/living/basic/trooper/robust/syndicate/esword,
+ /mob/living/basic/trooper/robust/syndicate/c20r,
+ /mob/living/basic/trooper/robust/syndicate/c20r,
+ )
diff --git a/tff_modular/modules/storytellers/events/bad/sabotage_infrastructure.dm b/tff_modular/modules/storytellers/events/bad/sabotage_infrastructure.dm
new file mode 100644
index 00000000000..39010911f9d
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/sabotage_infrastructure.dm
@@ -0,0 +1,91 @@
+/datum/round_event_control/sabotage_infrastructure
+ id = "sabotage_infrastructure"
+ name = "Sabotage Infrastructure"
+ description = "Sabotage the station machinery."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_REQUIRES_ENGINEERING)
+ typepath = /datum/round_event/sabotage_machinery
+
+ min_players = 4
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+
+/datum/round_event/sabotage_machinery
+ STORYTELLER_EVENT
+
+ var/list/candidate_types = list(
+ /obj/machinery/rnd,
+ /obj/machinery/recharger,
+ /obj/machinery/autolathe,
+ /obj/machinery/power/smes,
+ /obj/machinery/vending,
+ )
+ var/damage_level = 0
+ var/target_damage_percent = 0
+ var/num_targets_per_type = 1
+ var/explosive_sabotage_chance = 0
+
+/datum/round_event/sabotage_machinery/__setup_for_storyteller(threat_points)
+ . = ..()
+
+ if(threat_points < STORY_THREAT_LOW)
+ damage_level = 1
+ target_damage_percent = 25
+ num_targets_per_type = 1
+ explosive_sabotage_chance = 0
+ else if(threat_points < STORY_THREAT_MODERATE)
+ damage_level = 2
+ target_damage_percent = 40
+ num_targets_per_type = 2
+ explosive_sabotage_chance = 5
+ else if(threat_points < STORY_THREAT_HIGH)
+ damage_level = 3
+ target_damage_percent = 60
+ num_targets_per_type = 3
+ explosive_sabotage_chance = 15
+ else if(threat_points < STORY_THREAT_EXTREME)
+ damage_level = 4
+ target_damage_percent = 75
+ num_targets_per_type = 5
+ explosive_sabotage_chance = 30
+ else
+ damage_level = 5
+ target_damage_percent = 90
+ num_targets_per_type = 8
+ explosive_sabotage_chance = 50
+
+
+ num_targets_per_type = min(num_targets_per_type + round(threat_points / 1000), rand(10, 20))
+
+/datum/round_event/sabotage_machinery/__announce_for_storyteller()
+ priority_announce("Sensors detect anomalies in power systems. Equipment inspection recommended.", "Warning: Infrastructure")
+
+/datum/round_event/sabotage_machinery/__start_for_storyteller()
+ . = ..()
+
+ for(var/machine_type in candidate_types)
+ var/list/candidates = list()
+ for(var/obj/machinery/machine in SSmachines.get_all_machines())
+ if(istype(machine, machine_type) && machine.max_integrity > 0)
+ candidates += machine
+
+ if(!length(candidates))
+ continue
+
+ // Select random targets
+ var/num_to_sabotage = min(num_targets_per_type, length(candidates))
+ candidates = candidates.Copy(1, num_to_sabotage + 1)
+
+ for(var/obj/machinery/target in candidates)
+ var/actual_damage = round(target.max_integrity * (target_damage_percent / 100))
+ if(actual_damage > 0)
+ new /obj/effect/particle_effect/sparks(get_turf(target))
+
+ if(prob(50))
+ target.take_damage(actual_damage, BRUTE)
+ else
+ target.atom_break()
+
+ if(damage_level >= 3 && prob(explosive_sabotage_chance) || istype(target, /obj/machinery/power/smes))
+ target.ex_act(EXPLODE_LIGHT)
+ log_game("Storyteller: Sabotage explosion at [target.loc] (threat: [damage_level])")
diff --git a/tff_modular/modules/storytellers/events/bad/spacevine.dm b/tff_modular/modules/storytellers/events/bad/spacevine.dm
new file mode 100644
index 00000000000..4e9cab743dc
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/spacevine.dm
@@ -0,0 +1,76 @@
+/datum/round_event_control/spacevine
+ story_category = STORY_GOAL_NEVER
+
+/datum/round_event_control/storyteller_spacevine
+ name = "Spacevine Infestation"
+ id = "storyteller_spacevine"
+ typepath = /datum/round_event/storyteller_spacevine
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_WIDE_IMPACT, STORY_TAG_CHAOTIC, STORY_TAG_ESCALATION)
+
+ story_weight = STORY_GOAL_BIG_WEIGHT
+ min_players = 15
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+/datum/round_event/storyteller_spacevine
+ STORYTELLER_EVENT
+
+ fakeable = FALSE
+ var/turf/override_turf
+ var/list/selected_mutations
+ var/potency
+ var/production
+
+/datum/round_event/storyteller_spacevine/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ selected_mutations = list()
+ if(threat_points >= 1000)
+ selected_mutations += list(
+ /datum/spacevine_mutation/light,
+ /datum/spacevine_mutation/transparency,
+ )
+ if(threat_points >= 4000)
+ selected_mutations += list(
+ /datum/spacevine_mutation/toxicity,
+ /datum/spacevine_mutation/fire_proof,
+ /datum/spacevine_mutation/temp_stabilisation,
+ /datum/spacevine_mutation/flowering,
+ )
+ if(threat_points >= 7000)
+ selected_mutations += list(
+ /datum/spacevine_mutation/aggressive_spread,
+ /datum/spacevine_mutation/gas_eater/oxy_eater,
+ )
+
+ potency = max(50, min(rand(50, 100), round(threat_points / 80)))
+ production = max(1, min(rand(1, 4), round(threat_points / 1500)))
+
+
+/datum/round_event/storyteller_spacevine/__start_for_storyteller()
+ var/list/final_turf_candidates = list()
+
+ if(override_turf)
+ final_turf_candidates += override_turf
+ else
+ var/obj/structure/spacevine/vine = new()
+ var/list/floor_candidates = list()
+ for(var/area/station/hallway/area in shuffle(GLOB.areas))
+ for(var/turf/open/floor in area.get_turfs_from_all_zlevels())
+ if(isopenspaceturf(floor))
+ continue
+ floor_candidates += floor
+
+
+ var/turfs_to_test = 100
+ var/list/sampled_floor_candidates = pick_n(floor_candidates, min(turfs_to_test, length(floor_candidates)))
+
+ for(var/turf/open/floor as anything in sampled_floor_candidates)
+ if(floor.Enter(vine))
+ final_turf_candidates += floor
+ qdel(vine)
+
+ if(!length(final_turf_candidates))
+ return
+
+ var/turf/floor = pick(final_turf_candidates)
+ new /datum/spacevine_controller(floor, selected_mutations, potency, production, src)
diff --git a/tff_modular/modules/storytellers/events/bad/supermatter_surge.dm b/tff_modular/modules/storytellers/events/bad/supermatter_surge.dm
new file mode 100644
index 00000000000..026a88c54b9
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/supermatter_surge.dm
@@ -0,0 +1,36 @@
+/datum/round_event_control/supermatter_surge
+ id = "supermatter_surge"
+ typepath = /datum/round_event/supermatter_surge
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_REQUIRES_ENGINEERING, STORY_TAG_EPIC)
+ min_players = 10
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.6
+
+/datum/round_event/supermatter_surge
+ STORYTELLER_EVENT
+
+/datum/round_event/supermatter_surge/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ setup()
+
+ // Determine surge severity based on threat points
+ if(threat_points < STORY_THREAT_LOW)
+ surge_class = 1
+ else if(threat_points < STORY_THREAT_MODERATE)
+ surge_class = 2
+ else if(threat_points < STORY_THREAT_HIGH)
+ surge_class = 3
+ else if(threat_points < STORY_THREAT_EXTREME)
+ surge_class = 4
+ else
+ surge_class = 5 // Overopowered surge for extreme threat levels
+
+/datum/round_event/supermatter_surge/__announce_for_storyteller()
+ priority_announce("The Crystal Integrity Monitoring System has detected unusual atmospheric properties \
+ in the supermatter chamber, energy output from the supermatter crystal has increased significantly. \
+ Engineering intervention is required to stabilize the engine.", \
+ "Class [surge_class] Supermatter Surge Alert", 'sound/machines/engine_alert/engine_alert3.ogg')
+
+/datum/round_event/supermatter_surge/__start_for_storyteller()
+ start()
diff --git a/tff_modular/modules/storytellers/events/bad/~generic_overrides.dm b/tff_modular/modules/storytellers/events/bad/~generic_overrides.dm
new file mode 100644
index 00000000000..9fc26993d3f
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/bad/~generic_overrides.dm
@@ -0,0 +1,88 @@
+/datum/round_event_control/operative
+ story_category = STORY_GOAL_NEVER
+
+/datum/round_event_control/dark_matteor
+ story_category = STORY_GOAL_NEVER
+
+/datum/round_event_control/radiation_storm
+ id = "radiation_storm"
+ typepath = /datum/round_event/radiation_storm
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_ENVIRONMENTAL)
+ min_players = 6
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+/datum/round_event_control/portal_storm_syndicate
+ id = "portal_storm_syndicate"
+ typepath = /datum/round_event/portal_storm/syndicate_shocktroop
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_COMBAT, STORY_TAG_ESCALATION, STORY_TAG_ENTITIES)
+ min_players = 15
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+/datum/round_event_control/portal_storm_narsie
+ id = "portal_storm_narsie"
+ typepath = /datum/round_event/portal_storm/portal_storm_narsie
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_COMBAT, STORY_TAG_ESCALATION, STORY_TAG_ENTITIES)
+ min_players = 15
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+
+/datum/round_event_control/processor_overload
+ id = "processor_overload"
+ typepath = /datum/round_event/processor_overload
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_CHAOTIC, STORY_TAG_REQUIRES_ENGINEERING)
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.3
+
+
+/datum/round_event_control/shuttle_catastrophe
+ id = "shuttle_catastrophe"
+ typepath = /datum/round_event/shuttle_catastrophe
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_CHAOTIC, STORY_TAG_TRAGIC)
+ min_players = 10
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+
+/datum/round_event_control/radiation_leak
+ id = "radiation_leak"
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_WIDE_IMPACT, STORY_TAG_REQUIRES_ENGINEERING)
+ typepath = /datum/round_event/radiation_leak
+ min_players = 6
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+
+
+/datum/round_event_control/earthquake
+ id = "earthquake"
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_WIDE_IMPACT, STORY_TAG_ENVIRONMENTAL, STORY_TAG_REQUIRES_ENGINEERING, STORY_TAG_EPIC)
+ typepath = /datum/round_event_control/earthquake
+
+ min_players = 15
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.8
+
+
+/datum/round_event_control/vent_clog/critical
+ id = "vent_clog_critical"
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_REQUIRES_ENGINEERING, STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_ESCALATION)
+ typepath = /datum/round_event/vent_clog/critical
+ min_players = 10
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.4
+
+
+/datum/round_event_control/immovable_rod
+ id = "immovable_rod"
+ typepath = /datum/round_event/immovable_rod
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_HUMOROUS, STORY_TAG_CHAOTIC)
+ min_players = 5
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
diff --git a/tff_modular/modules/storytellers/events/good/aurora_caelus.dm b/tff_modular/modules/storytellers/events/good/aurora_caelus.dm
new file mode 100644
index 00000000000..7acbf6713f4
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/good/aurora_caelus.dm
@@ -0,0 +1,24 @@
+/datum/round_event_control/aurora_caelus
+ id = "aurora_caelus"
+ name = "Aurora Caelus"
+ description = "A beautiful aurora will light up the skies, bringing a calming effect to the crew.\
+ This event is known to boost morale and reduce stress levels among station personnel."
+ story_category = STORY_GOAL_GOOD
+ tags = list(STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_DEESCALATION, STORY_TAG_SOCIAL)
+ typepath = /datum/round_event/aurora_caelus
+
+
+/datum/round_event_control/aurora_caelus/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return inputs.antag_count() <= 0
+
+/datum/round_event_control/aurora_caelus/get_story_weight(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return STORY_GOAL_BASE_WEIGHT + (inputs.get_entry(STORY_VAULT_CREW_ALIVE_COUNT) * 0.3)
+
+/datum/round_event/aurora_caelus
+ STORYTELLER_EVENT
+
+
+/datum/round_event/aurora_caelus/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ end_when = (30) * (1 + round(threat_points / 200))
+
diff --git a/tff_modular/modules/storytellers/events/good/cargo_pod.dm b/tff_modular/modules/storytellers/events/good/cargo_pod.dm
new file mode 100644
index 00000000000..87f3db5a7f5
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/good/cargo_pod.dm
@@ -0,0 +1,346 @@
+// Internal defines for cargo_pod event categories
+// These are used to standardize need_category strings for consistency in storyteller planning
+#define CARGO_NEED_GENERAL "general" // Balanced/default supplies
+#define CARGO_NEED_MEDICAL "medical" // Health/disease crisis aid
+#define CARGO_NEED_ENGINEERING "engineering"// Structural/infra damage repairs
+#define CARGO_NEED_POWER "power" // Power grid failures
+#define CARGO_NEED_SECURITY "security" // Antag threats/disruption
+#define CARGO_NEED_RESOURCES "resources" // Mineral/other resource shortages
+#define CARGO_NEED_MORALE "morale" // Crew mood boosters
+#define CARGO_NEED_RESEARCH "research" // Science progress enhancers
+
+// Internal defines for good_level scaling (optional, if not using global STORY_USEFULNESS_LEVEL)
+// These can be used for fine-tuning within the event
+#define CARGO_GOOD_LEVEL_LOW 1 // Minimal aid
+#define CARGO_GOOD_LEVEL_MODERATE 2 // Improved basics
+#define CARGO_GOOD_LEVEL_HIGH 3 // Advanced + minor weapons
+#define CARGO_GOOD_LEVEL_VERY_HIGH 4 // High-value + controlled lethals
+#define CARGO_GOOD_LEVEL_EXTREME 5 // Premium + advanced weapons
+
+/datum/round_event_control/cargo_pod
+ id = "cargo_pod"
+ name = "Cargo Pod Delivery"
+ description = "A cargo pod has been dispatched to the station, containing supplies that could aid the crew in their duties."
+ story_category = STORY_GOAL_GOOD
+ tags = list(STORY_TAG_DEESCALATION, STORY_TAG_SOCIAL)
+ typepath = /datum/round_event/storyteller_cargo_pod
+
+ var/auto_cargo = FALSE
+
+ var/list/base_cargo_by_level = list(
+ list( // LOW
+ /obj/item/stack/sheet/iron/fifty = 3,
+ /obj/item/stack/sheet/glass/fifty = 2,
+ /obj/item/stack/cable_coil/thirty = 2,
+ /obj/item/storage/medkit = 1,
+ /obj/item/reagent_containers/hypospray/medipen = 1
+ ),
+ list( // MODERATE
+ /obj/item/stack/sheet/plasteel/twenty = 3,
+ /obj/item/stack/sheet/mineral/gold = 2,
+ /obj/item/weldingtool = 2,
+ /obj/item/storage/pill_bottle/happy = 1,
+ /obj/item/storage/box/masks = 1,
+ /obj/item/storage/toolbox/mechanical = 1
+ ),
+ list( // HIGH
+ /obj/item/stack/sheet/mineral/diamond = 3,
+ /obj/item/stack/sheet/mineral/plasma = 2,
+ /obj/item/storage/belt/medical = 2,
+ /obj/item/defibrillator/compact = 1,
+ /obj/item/melee/baton = 1,
+ /obj/item/gun/energy/disabler = 1,
+ /obj/item/clothing/suit/armor/vest = 1
+ ),
+ list( // VERY_HIGH
+ /obj/item/stack/sheet/mineral/titanium/fifty = 3,
+ /obj/item/stack/sheet/bluespace_crystal = 2,
+ /obj/item/mod/control/pre_equipped/medical = 2,
+ /obj/item/gun/ballistic/shotgun/hook = 1,
+ /obj/item/gun/energy/e_gun = 1,
+ /obj/item/clothing/head/helmet/swat = 1,
+ /obj/item/research_notes = 1
+ ),
+ list( // EXTREME
+ /obj/item/stack/sheet/mineral/adamantine = 3,
+ /obj/item/stack/sheet/mineral/runite = 2,
+ /obj/item/mod/control/pre_equipped/advanced = 2,
+ /obj/item/gun/energy/lasercannon = 1,
+ /obj/item/gun/ballistic/shotgun/bulldog = 1,
+ /obj/item/storage/belt/security/full = 1,
+ /obj/item/beacon = 1
+ )
+ )
+
+ var/list/category_adds_by_level = list(
+ list( // LOW
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/pill_bottle/epinephrine = 2),
+ CARGO_NEED_ENGINEERING = list(/obj/item/stack/sheet/plasteel = 2),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell = 3, /obj/item/stack/cable_coil = 2),
+ CARGO_NEED_SECURITY = list(/obj/item/clothing/suit/armor/vest = 1),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/iron/fifty = 3, /obj/item/stack/sheet/mineral/silver = 2),
+ CARGO_NEED_MORALE = list(/obj/item/storage/box/donkpockets = 2, /obj/item/reagent_containers/cup/glass/bottle/beer = 3),
+ CARGO_NEED_RESEARCH = list(/obj/item/disk/tech_disk = 1, /obj/item/stock_parts/scanning_module = 2)
+ ),
+ list( // MODERATE
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit/regular = 3, /obj/item/reagent_containers/hypospray/medipen = 2),
+ CARGO_NEED_ENGINEERING = list(/obj/item/stack/sheet/plasteel/fifty = 2),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/high = 3, /obj/item/circuitboard/machine/cell_charger_multi = 1),
+ CARGO_NEED_SECURITY = list(/obj/item/clothing/head/helmet = 2, /obj/item/flashlight/seclite = 1),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/silver = 2, /obj/item/stack/sheet/mineral/uranium = 1),
+ CARGO_NEED_MORALE = list(/obj/item/storage/box/ingredients = 2, /obj/item/toy/plush = 1),
+ CARGO_NEED_RESEARCH = list(/obj/item/stock_parts/servo = 2, /obj/item/research_notes = 1)
+ ),
+ list( // HIGH
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit/advanced = 2, /obj/item/reagent_containers/hypospray/medipen/atropine = 1),
+ CARGO_NEED_ENGINEERING = list(/obj/item/storage/box/smart_metal_foam = 2, /obj/item/rcd_ammo = 1),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/super = 3, /obj/item/solar_assembly = 2),
+ CARGO_NEED_SECURITY = list(/obj/item/clothing/suit/armor/riot = 1),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/titanium = 2),
+ CARGO_NEED_MORALE = list(/obj/machinery/vending/snack = 1, /obj/item/toy/plush/tff/low = 2),
+ CARGO_NEED_RESEARCH = list(/obj/item/stock_parts/scanning_module/adv = 2, /obj/item/disk/design_disk = 1)
+ ),
+ list( // VERY_HIGH
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit/fire = 2, /obj/item/organ/lungs/cybernetic/tier2 = 1),
+ CARGO_NEED_ENGINEERING = list(/obj/item/construction/rcd/loaded = 1, /obj/item/stack/sheet/plasteel/fifty = 3),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/hyper = 3, /obj/machinery/power/smes = 1),
+ CARGO_NEED_SECURITY = list(/obj/item/gun/ballistic/automatic/pistol = 1, /obj/item/clothing/suit/armor/swat = 1),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/adamantine = 2),
+ CARGO_NEED_MORALE = list(/obj/machinery/vending/boozeomat = 1, /obj/item/instrument/piano_synth = 1),
+ CARGO_NEED_RESEARCH = list(/obj/machinery/rnd/server = 1, /obj/item/stock_parts/servo/pico = 2)
+ ),
+ list( // EXTREME
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit/tactical = 3, /obj/item/organ/heart/cybernetic/tier3 = 1),
+ CARGO_NEED_ENGINEERING = list(/obj/item/rcd_ammo/large = 2, /obj/item/stack/sheet/mineral/titanium/fifty = 3),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/bluespace = 3, /obj/machinery/power/rtg/advanced = 1),
+ CARGO_NEED_SECURITY = list(/obj/item/gun/ballistic/rocketlauncher = 1, /obj/item/clothing/suit/armor/hos = 1),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/bananium = 2, /obj/item/stack/sheet/mineral/abductor = 1),
+ CARGO_NEED_MORALE = list(/obj/item/storage/box/donkpockets = 3, /obj/machinery/computer/arcade = 1),
+ CARGO_NEED_RESEARCH = list(/obj/item/toy/plush/tff/low = 2, /obj/item/stock_parts/scanning_module/phasic = 1)
+ )
+ )
+
+ var/list/category_boosts_by_level = list(
+ list( // LOW
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit, /obj/item/reagent_containers/hypospray/medipen),
+ CARGO_NEED_ENGINEERING = list(/obj/item/stack/sheet/iron/fifty, /obj/item/stack/sheet/glass/fifty),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell),
+ CARGO_NEED_SECURITY = list(/obj/item/clothing/suit/armor/vest),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/iron/fifty),
+ CARGO_NEED_MORALE = list(/obj/item/storage/box/donkpockets),
+ CARGO_NEED_RESEARCH = list(/obj/item/disk/tech_disk)
+ ),
+ list( // MODERATE
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit/regular),
+ CARGO_NEED_ENGINEERING = list(/obj/item/weldingtool, /obj/item/storage/toolbox/mechanical),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/high),
+ CARGO_NEED_SECURITY = list(/obj/item/clothing/head/helmet),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/gold),
+ CARGO_NEED_MORALE = list(/obj/item/storage/box/ingredients),
+ CARGO_NEED_RESEARCH = list(/obj/item/research_notes)
+ ),
+ list( // HIGH
+ CARGO_NEED_MEDICAL = list(/obj/item/defibrillator/compact),
+ CARGO_NEED_ENGINEERING = list(/obj/item/storage/box/smart_metal_foam),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/super),
+ CARGO_NEED_SECURITY = list(/obj/item/melee/baton, /obj/item/gun/energy/disabler),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/diamond),
+ CARGO_NEED_MORALE = list(/obj/item/toy/plush/tff/low),
+ CARGO_NEED_RESEARCH = list(/obj/item/stock_parts/scanning_module/adv)
+ ),
+ list( // VERY_HIGH
+ CARGO_NEED_MEDICAL = list(/obj/item/mod/control/pre_equipped/medical),
+ CARGO_NEED_ENGINEERING = list(/obj/item/construction/rcd/loaded),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/hyper),
+ CARGO_NEED_SECURITY = list(/obj/item/gun/energy/e_gun),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/titanium/fifty),
+ CARGO_NEED_MORALE = list(/obj/machinery/vending/boozeomat),
+ CARGO_NEED_RESEARCH = list(/obj/item/research_notes)
+ ),
+ list( // EXTREME
+ CARGO_NEED_MEDICAL = list(/obj/item/storage/medkit/tactical),
+ CARGO_NEED_ENGINEERING = list(/obj/item/mod/control/pre_equipped/advanced),
+ CARGO_NEED_POWER = list(/obj/item/stock_parts/power_store/cell/bluespace),
+ CARGO_NEED_SECURITY = list(/obj/item/gun/energy/lasercannon),
+ CARGO_NEED_RESOURCES = list(/obj/item/stack/sheet/mineral/adamantine),
+ CARGO_NEED_MORALE = list(/obj/item/storage/box/donkpockets),
+ CARGO_NEED_RESEARCH = list(/obj/item/toy/plush/tff/low)
+ )
+ )
+
+/datum/round_event_control/cargo_pod/pre_storyteller_run(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ . = ..()
+ if(!auto_cargo)
+ return
+
+ // Calculate a "need_profile" based on key inputs metrics to prioritize cargo types
+ // This analyzes station state: health, damage, resources, security, antag threats, etc.
+ // We derive a primary "need_category" (e.g., medical, engineering, security) and a usefulness_level (1-5)
+ // usefulness_level scales with threat_points or station distress (higher = better/more gear, with weapons from level 3+)
+ // Use STORY_USEFULNESS_LEVEL(STORY_GOOD_POINTS(threat_points)) assuming defined macros for scaling (1 low threat, 5 high)
+ var/good_level = STORY_USEFULNESS_LEVEL(STORY_GOOD_POINTS(threat_points))
+
+ // Determine primary need_category by checking critical metrics in priority order
+ var/need_category = CARGO_NEED_GENERAL // Default to balanced supplies
+ var/crew_health_level = inputs.get_entry(STORY_VAULT_CREW_HEALTH) || STORY_VAULT_HEALTH_NORMAL
+ var/station_integrity = inputs.get_station_integrity() || 100
+ var/infra_damage = inputs.get_entry(STORY_VAULT_INFRA_DAMAGE) || STORY_VAULT_NO_DAMAGE
+ var/power_status = inputs.get_entry(STORY_VAULT_POWER_STATUS) || STORY_VAULT_FULL_POWER
+ var/low_resources = inputs.get_entry(STORY_VAULT_LOW_RESOURCE) || FALSE
+ var/crew_morale = inputs.get_entry(STORY_VAULT_CREW_MORALE) || STORY_VAULT_MODERATE_MORALE
+ var/research_progress = inputs.get_entry(STORY_VAULT_RESEARCH_PROGRESS) || STORY_VAULT_LOW_RESEARCH
+
+ // Priority: Health crisis > Structural damage > Power issues > Antag threats > Resource shortages > Morale/Research
+ if(crew_health_level >= STORY_VAULT_HEALTH_DAMAGED || inputs.get_entry(STORY_VAULT_CREW_DISEASES) >= STORY_VAULT_MINOR_DISEASES)
+ need_category = CARGO_NEED_MEDICAL
+ else if(station_integrity < 70 || infra_damage >= STORY_VAULT_MAJOR_DAMAGE)
+ need_category = CARGO_NEED_ENGINEERING
+ else if(power_status >= STORY_VAULT_LOW_POWER || inputs.get_entry(STORY_VAULT_POWER_GRID_DAMAGE) >= STORY_VAULT_POWER_GRID_FAILURES)
+ need_category = CARGO_NEED_POWER
+ else if(inputs.antag_crew_ratio() > 0.5)
+ need_category = CARGO_NEED_SECURITY
+ else if(low_resources || inputs.get_entry(STORY_VAULT_RESOURCE_MINERALS) < 100 || inputs.get_entry(STORY_VAULT_RESOURCE_OTHER) < 5000)
+ need_category = CARGO_NEED_RESOURCES
+ else if(crew_morale >= STORY_VAULT_LOW_MORALE)
+ need_category = CARGO_NEED_MORALE
+ else if(research_progress <= STORY_VAULT_LOW_RESEARCH)
+ need_category = CARGO_NEED_RESEARCH
+
+ // Now set possible_cargo as assoc list (path = weight) based on good_level and need_category
+ // Weights for probabilistic selection; higher weight = more likely
+ // Bias towards need_category items (higher weights), but include some general for variety
+ // Scale quantity/quality: Level 1-2: Basic, no weapons; 3+: Include weapons/security if relevant
+ // Adjust pod count or extras if high good_level (e.g., more pods for high threat)
+ var/list/possible_cargo = _list_copy(base_cargo_by_level[good_level])
+ var/base_weight_multiplier = 2 // Boost for need_category items
+
+ var/list/cat_adds = category_adds_by_level[good_level]
+ var/list/adds = cat_adds[need_category]
+ if(adds)
+ possible_cargo += adds
+
+ var/list/cat_boosts = category_boosts_by_level[good_level]
+ var/list/boosts = cat_boosts[need_category]
+ if(boosts)
+ for(var/path in boosts)
+ possible_cargo[path] *= base_weight_multiplier
+
+ // Global adjustments: If high antag influence or escalation, boost security items across levels (from 3+)
+ if(good_level >= CARGO_GOOD_LEVEL_HIGH && (inputs.get_entry(STORY_VAULT_ANTAGONIST_PRESENCE) >= STORY_VAULT_MODERATE_ANTAGONISTS))
+ possible_cargo += list(/obj/item/clothing/suit/armor/riot = 2) // Riot gear
+ need_category = CARGO_NEED_SECURITY // Override if threats are extreme
+
+ // Ensure some randomness: If no specific need, mix categories
+ if(need_category == CARGO_NEED_GENERAL)
+ // Add a bit from each
+ possible_cargo += list(/obj/item/storage/box/lights/mixed = 1)
+ if(!length(possible_cargo))
+ possible_cargo = pick(base_cargo_by_level)
+
+ var/amount_of_pods = (good_level == CARGO_GOOD_LEVEL_LOW ? 1 : good_level == CARGO_GOOD_LEVEL_EXTREME ? 3 : good_level == CARGO_GOOD_LEVEL_HIGH ? 2 : rand(good_level - 1, good_level))
+
+ var/list/possible_areas = list()
+ switch(need_category)
+ if(CARGO_NEED_MEDICAL)
+ if(inputs.get_entry(STORY_VAULT_CREW_DISEASES) >= STORY_VAULT_MAJOR_DISEASES || inputs.get_entry(STORY_VAULT_CREW_HEALTH) >= STORY_VAULT_HEALTH_DAMAGED)
+ possible_areas += get_areas(/area/station/medical)
+ if(CARGO_NEED_ENGINEERING)
+ if(inputs.get_entry(STORY_VAULT_INFRA_DAMAGE) >= STORY_VAULT_MAJOR_DAMAGE)
+ possible_areas += get_areas(/area/station/engineering)
+ if(CARGO_NEED_POWER)
+ if(inputs.get_entry(STORY_VAULT_POWER_STATUS) >= STORY_VAULT_BLACKOUT)
+ possible_areas += get_areas(/area/station/engineering)
+ if(CARGO_NEED_SECURITY)
+ if(inputs.get_entry(STORY_VAULT_ANTAGONIST_PRESENCE) >= STORY_VAULT_MODERATE_ANTAGONISTS)
+ possible_areas += get_areas(/area/station/security)
+ if(CARGO_NEED_RESOURCES)
+ possible_areas += get_areas(/area/station/cargo)
+ if(CARGO_NEED_MORALE)
+ possible_areas += get_areas(/area/station/service)
+ if(CARGO_NEED_RESEARCH)
+ possible_areas += get_areas(/area/station/science)
+ else
+ possible_areas = GLOB.the_station_areas.Copy()
+
+ additional_arguments = list(
+ "possible_cargo" = possible_cargo,
+ "amount_of_pods" = amount_of_pods,
+ "possible_areas" = possible_areas
+ )
+
+/datum/round_event/storyteller_cargo_pod
+ STORYTELLER_EVENT
+
+ // Possible cargo to choose from
+ var/list/possible_cargo = list()
+ // Possible areas to spawn pods at
+ var/list/possible_areas = list()
+ // Chosen location to spawn drop pods
+ var/turf/chosen_target_turf
+ // Amount of pods to drop
+ var/amount_of_pods = 1
+
+ start_when = 20
+ announce_when = 1
+
+/datum/round_event/storyteller_cargo_pod/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ var/list/add_args = get_additional_arguments()
+ possible_cargo = add_args["possible_cargo"] || list()
+ amount_of_pods = add_args["amount_of_pods"] || 1
+ possible_areas = add_args["possible_areas"] || list()
+
+ chosen_target_turf = get_safe_random_station_turf(possible_areas)
+ // Fallback if no valid turf found
+ if(!chosen_target_turf)
+ chosen_target_turf = get_safe_random_station_turf()
+
+/datum/round_event/storyteller_cargo_pod/__announce_for_storyteller()
+ priority_announce("A cargo pod is incoming to the station at [get_area(chosen_target_turf)], carrying supplies that could aid the crew.")
+
+
+/datum/round_event/storyteller_cargo_pod/__start_for_storyteller()
+ if(!chosen_target_turf)
+ chosen_target_turf = get_safe_random_station_turf()
+
+ notify_ghosts("Cargo pod delivery!", chosen_target_turf, "Cargo pods")
+ for(var/i in 1 to amount_of_pods)
+ // Slight offset for multiple pods to avoid overlap
+ var/turf/pod_turf = i == 1 ? chosen_target_turf : locate(chosen_target_turf.x + rand(-2,2), chosen_target_turf.y + rand(-2,2), chosen_target_turf.z)
+ if(!pod_turf || !is_station_level(pod_turf.z))
+ continue
+ var/num_items = rand(1, 3)
+ var/list/paths_to_spawn = list()
+
+ for(var/j in 1 to num_items)
+ if(length(possible_cargo))
+ var/chosen_item_path = pick_weight(possible_cargo)
+ if(chosen_item_path)
+ paths_to_spawn[chosen_item_path] = (paths_to_spawn[chosen_item_path] || 0) + 1
+ paths_to_spawn[/obj/item/paper] = 1
+ var/list/specifications = list(
+ "target" = pod_turf,
+ "style" = /datum/pod_style/centcom,
+ "spawn" = paths_to_spawn,
+ "explosionSize" = list(0,0,0,0)
+ )
+ var/obj/structure/closet/supplypod/podspawn/pod = podspawn(specifications)
+ for(var/obj/item/paper/note in pod.contents)
+ if(istype(note))
+ note.add_raw_text("CentCom Supply Drop: Tailored for current needs. Contains multiple resources for enhanced support. Use it wisely.")
+ break
+
+
+#undef CARGO_NEED_GENERAL
+#undef CARGO_NEED_MEDICAL
+#undef CARGO_NEED_ENGINEERING
+#undef CARGO_NEED_POWER
+#undef CARGO_NEED_SECURITY
+#undef CARGO_NEED_RESOURCES
+#undef CARGO_NEED_MORALE
+#undef CARGO_NEED_RESEARCH
+#undef CARGO_GOOD_LEVEL_LOW
+#undef CARGO_GOOD_LEVEL_MODERATE
+#undef CARGO_GOOD_LEVEL_HIGH
+#undef CARGO_GOOD_LEVEL_VERY_HIGH
+#undef CARGO_GOOD_LEVEL_EXTREME
diff --git a/tff_modular/modules/storytellers/events/good/~generic_overrides.dm b/tff_modular/modules/storytellers/events/good/~generic_overrides.dm
new file mode 100644
index 00000000000..e006877b3c3
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/good/~generic_overrides.dm
@@ -0,0 +1,31 @@
+/datum/round_event_control/stray_cargo
+ id = "stray_cargo"
+ typepath = /datum/round_event/stray_cargo
+ story_category = STORY_GOAL_GOOD
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_WIDE_IMPACT, STORY_TAG_HEALTH)
+ story_weight = STORY_GOAL_BASE_WEIGHT * 1.2
+
+/datum/round_event_control/stray_cargo/syndicate
+ id = "stray_cargo_syndicate"
+ typepath = /datum/round_event/stray_cargo/syndicate
+ story_category = STORY_GOAL_GOOD
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.8
+
+/datum/round_event_control/wisdomcow
+ id = "wisdomcow"
+ typepath = /datum/round_event/wisdomcow
+ story_category = STORY_GOAL_GOOD
+ tags = list(STORY_TAG_HUMOROUS, STORY_TAG_SOCIAL)
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.8
+
+/datum/round_event_control/sentience
+ id = "sentience_animal"
+ story_category = STORY_GOAL_GOOD
+ tags = list(STORY_TAG_SOCIAL)
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.9
+
+/datum/round_event_control/sentience/all
+ id = "sentience_all"
+ story_category = STORY_GOAL_GOOD
+ tags = list(STORY_TAG_SOCIAL, STORY_TAG_EPIC)
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.8
diff --git a/tff_modular/modules/storytellers/events/neutral/anomalies.dm b/tff_modular/modules/storytellers/events/neutral/anomalies.dm
new file mode 100644
index 00000000000..bfbe5c42cc8
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/neutral/anomalies.dm
@@ -0,0 +1,72 @@
+/datum/round_event_control/anomaly
+ story_category = STORY_GOAL_NEVER
+
+/datum/round_event_control/anomaly_storyteller
+ name = "Anomaly Event"
+ id = "anomaly_storyteller"
+ typepath = /datum/round_event/anomaly_storyteller
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_WIDE_IMPACT, STORY_TAG_CHAOTIC)
+ min_players = 8
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.8
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+/datum/round_event/anomaly_storyteller
+ STORYTELLER_EVENT
+
+ var/static/anomaly_types = list(
+ /obj/effect/anomaly/hallucination,
+ /obj/effect/anomaly/bluespace,
+ /obj/effect/anomaly/bioscrambler,
+ /obj/effect/anomaly/dimensional,
+ /obj/effect/anomaly/ectoplasm,
+ /obj/effect/anomaly/flux,
+ /obj/effect/anomaly/grav,
+ /obj/effect/anomaly/pyro,
+ /obj/effect/anomaly/bhole,
+ )
+ var/anomaly_count = 1
+ var/max_anomalies = 3
+
+ var/area/impact_area
+ var/datum/anomaly_placer/placer = new()
+ var/obj/effect/anomaly/anomaly_path = /obj/effect/anomaly/flux
+ ///The admin-chosen spawn location.
+ var/turf/spawn_location
+
+/datum/round_event/anomaly_storyteller/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ // Determine how many anomalies to spawn based on threat points
+ anomaly_count = min(max_anomalies, round(threat_points / 3000))
+ anomaly_count = max(1, max_anomalies)
+
+ var/start_time = rand(10, 20)
+ announce_when = start_time + rand(5, 15)
+ start_when = start_time
+
+ impact_area = placer.findValidArea()
+ anomaly_path = pick(anomaly_types)
+
+/datum/round_event/anomaly_storyteller/__start_for_storyteller()
+ for(var/i = 1 to anomaly_count)
+ spawn_anomaly()
+
+
+/datum/round_event/anomaly_storyteller/proc/spawn_anomaly()
+ var/turf/anomaly_turf
+
+ if(spawn_location)
+ anomaly_turf = spawn_location
+ else
+ anomaly_turf = placer.findValidTurf(impact_area)
+
+ var/newAnomaly
+ if(anomaly_turf)
+ newAnomaly = new anomaly_path(anomaly_turf)
+ if(newAnomaly)
+ apply_anomaly_properties(newAnomaly)
+ announce_to_ghosts(newAnomaly)
+
+
+/datum/round_event/anomaly_storyteller/proc/apply_anomaly_properties(obj/effect/anomaly/new_anomaly)
+ return
diff --git a/tff_modular/modules/storytellers/events/neutral/carp_migration.dm b/tff_modular/modules/storytellers/events/neutral/carp_migration.dm
new file mode 100644
index 00000000000..1b646a1d882
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/neutral/carp_migration.dm
@@ -0,0 +1,45 @@
+/datum/round_event_control/carp_migration
+ id = "carp_migration"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENTITIES, STORY_TAG_WIDE_IMPACT, STORY_TAG_COMBAT)
+ typepath = /datum/round_event/carp_migration
+
+ min_players = 5
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+
+/datum/round_event/carp_migration
+ STORYTELLER_EVENT
+
+ allow_random = FALSE
+ var/carps_to_spawn = 0
+
+
+/datum/round_event/carp_migration/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+
+ start_when = rand(30, 50)
+ carps_to_spawn = 5 + round(threat_points / 200)
+
+/datum/round_event/carp_migration/__start_for_storyteller()
+ var/datum/space_level/zstation = SSmapping.levels_by_trait(ZTRAIT_STATION)[1]
+ var/list/spawn_loc = pick_map_spawn_location(10, zstation.z_value)
+ if (!spawn_loc)
+ return kill()
+
+ for(var/i = 0 to carps_to_spawn)
+ var/mob/living/basic/carp/fish
+ if(prob(95))
+ fish = new carp_type(pick(spawn_loc))
+ else
+ fish = new boss_type(pick(spawn_loc))
+
+ fishannounce(fish)
+
+ var/z_level_key = zstation.z_value
+ if (!z_migration_paths[z_level_key])
+ z_migration_paths[z_level_key] = pick_carp_migration_points(z_level_key)
+ if (z_migration_paths[z_level_key])
+ fish.migrate_to(z_migration_paths[z_level_key])
+
+ notify_ghosts("The school of space carp has arrived and is migrating through the station's vicinity.", pick(spawn_loc),"Lifesign Alert")
diff --git a/tff_modular/modules/storytellers/events/neutral/gravgen_error.dm b/tff_modular/modules/storytellers/events/neutral/gravgen_error.dm
new file mode 100644
index 00000000000..6a1a24a96fd
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/neutral/gravgen_error.dm
@@ -0,0 +1,153 @@
+/datum/round_event_control/gravity_generator_blackout
+ id = "gravity_generator_error"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL)
+ typepath = /datum/round_event/gravity_generator_blackout
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+
+/datum/round_event_control/gravity_generator_malfunction
+ name = "Gravity Generator Malfunction"
+ description = "The station's gravity generators are malfunctioning, \
+ causing unpredictable gravity fluctuations throughout the station."
+ id = "gravity_generator_malfunction"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_CHAOTIC, STORY_TAG_ENVIRONMENTAL, STORY_TAG_HEALTH)
+ typepath = /datum/round_event/storyteller_gravgen_malfunction
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+ map_flags = EVENT_SPACE_ONLY
+
+/datum/round_event/storyteller_gravgen_malfunction
+ STORYTELLER_EVENT
+
+ var/fluctuation_cooldown = 30 SECONDS
+ var/force_strength_min = 3
+
+ var/datum/weakref/gravity_gen_ref
+ var/list/signals_to_add
+
+ COOLDOWN_DECLARE(gravity_fluctuation_cooldown)
+
+/datum/round_event/storyteller_gravgen_malfunction/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+
+ if(threat_points < STORY_THREAT_LOW)
+ force_strength_min = 2
+ else if(threat_points < STORY_THREAT_MODERATE)
+ force_strength_min = 3
+ else if(threat_points < STORY_THREAT_HIGH)
+ force_strength_min = 4
+ else if(threat_points < STORY_THREAT_EXTREME)
+ force_strength_min = 5
+ else
+ force_strength_min = 6
+
+ end_when = 240
+
+/datum/round_event/storyteller_gravgen_malfunction/__announce_for_storyteller()
+ priority_announce("Gravity generator malfunction detected. Crew may experience sudden gravitational \
+ fluctuations, causing disorientation and potential physical displacement.")
+
+/datum/round_event/storyteller_gravgen_malfunction/__start_for_storyteller()
+ var/obj/machinery/gravity_generator/main/gravity_gen
+ for(var/obj/machinery/gravity_generator/main/G in SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/gravity_generator/main))
+ if(is_station_level(G.z))
+ gravity_gen = G
+ break
+
+ if(!gravity_gen)
+ return // No gravity generator found, cancel the event
+
+ gravity_gen_ref = WEAKREF(gravity_gen)
+
+ // Select repair methods similar to radiation leak
+ var/list/how_do_we_fix_it = list(
+ "wrenching a few valves" = TOOL_WRENCH,
+ "tightening its bolts" = TOOL_WRENCH,
+ "crowbaring its panel [pick("down", "up")]" = TOOL_CROWBAR,
+ "tightening some screws" = TOOL_SCREWDRIVER,
+ "checking its [pick("wires", "circuits")]" = TOOL_MULTITOOL,
+ "welding its panel [pick("open", "shut")]" = TOOL_WELDER,
+ "analyzing its readings" = TOOL_ANALYZER,
+ "cutting some excess wires" = TOOL_WIRECUTTER,
+ )
+ var/list/fix_it_keys = assoc_to_keys(how_do_we_fix_it)
+
+ var/list/methods_to_fix = list()
+ for(var/i in 1 to rand(1, 5))
+ methods_to_fix += pick_n_take(fix_it_keys)
+
+ // Construct the signals
+ signals_to_add = list()
+ for(var/tool_method in methods_to_fix)
+ signals_to_add += COMSIG_ATOM_TOOL_ACT(how_do_we_fix_it[tool_method])
+
+ gravity_gen.visible_message(span_danger("[gravity_gen] starts humming erratically, emitting sparks!"))
+ // Add a component to indicate malfunction (optional, for visual/radiation effects if desired)
+ gravity_gen.AddComponent(/datum/component/radioactive_emitter, \
+ cooldown_time = 5 SECONDS, \
+ range = 3, \
+ threshold = RAD_MEDIUM_INSULATION, \
+ examine_text = span_danger("It's malfunctioning! You could probably fix it by [english_list(methods_to_fix, and_text = " or ")]."), \
+ )
+
+ if(length(signals_to_add))
+ RegisterSignals(gravity_gen, signals_to_add, PROC_REF(on_gravgen_tooled), TRUE)
+
+ // Initial effects
+ do_sparks(5, FALSE, gravity_gen)
+ playsound(gravity_gen, 'sound/effects/empulse.ogg', 50, vary = TRUE)
+
+ COOLDOWN_START(src, gravity_fluctuation_cooldown, fluctuation_cooldown)
+
+
+/datum/round_event/storyteller_gravgen_malfunction/__storyteller_tick(seconds_per_tick)
+ var/obj/machinery/gravity_generator/main/gravity_gen = gravity_gen_ref.resolve()
+ if(!gravity_gen.on)
+ return
+ if(!COOLDOWN_FINISHED(src, gravity_fluctuation_cooldown))
+ return
+ COOLDOWN_START(src, gravity_fluctuation_cooldown, fluctuation_cooldown)
+ var/wave_direction = pick(GLOB.cardinals)
+
+ for(var/mob/living/living_target in get_alive_station_crew(FALSE, FALSE, FALSE,FALSE))
+ var/turf/target_turf = get_ranged_target_turf(living_target, wave_direction, rand(force_strength_min, force_strength_min + 2))
+ if(!HAS_TRAIT(src, TRAIT_NEGATES_GRAVITY) && !isspaceturf(get_turf(living_target)))
+ living_target.throw_at(target_turf, get_dist(living_target, target_turf), 1)
+ shake_camera(living_target, 2, 1)
+ to_chat(living_target, span_warning("A sudden gravitational shift throws you off balance!"))
+ for(var/atom/movable/AM in get_nearest_atoms(living_target, /atom/movable, 7))
+ if(AM.anchored || !istype(AM) || isspaceturf(get_turf(AM)))
+ continue
+ var/turf/AM_target = get_ranged_target_turf(AM, wave_direction, rand(force_strength_min - 1, force_strength_min + 1))
+ AM.throw_at(AM_target, get_dist(AM, AM_target), 1)
+
+
+/datum/round_event/storyteller_gravgen_malfunction/__end_for_storyteller()
+ . = ..()
+ priority_announce("Gravity generator stabilized. Fluctuations have ceased.")
+ var/obj/machinery/gravity_generator/main/gravity_gen = gravity_gen_ref?.resolve()
+ if(gravity_gen)
+ qdel(gravity_gen.GetComponent(/datum/component/radioactive_emitter))
+ if(length(signals_to_add))
+ UnregisterSignal(gravity_gen, signals_to_add)
+ gravity_gen_ref = null
+ signals_to_add = null
+
+/datum/round_event/storyteller_gravgen_malfunction/proc/on_gravgen_tooled(obj/machinery/source, mob/living/user, obj/item/tool)
+ SIGNAL_HANDLER
+
+ INVOKE_ASYNC(src, PROC_REF(try_repair_gravgen), source, user, tool)
+ return ITEM_INTERACT_BLOCKING
+
+/datum/round_event/storyteller_gravgen_malfunction/proc/try_repair_gravgen(obj/machinery/source, mob/living/user, obj/item/tool)
+ source.balloon_alert(user, "repairing malfunction...")
+ if(!tool.use_tool(source, user, 10 SECONDS, amount = (tool.tool_behaviour == TOOL_WELDER ? 2 : 0), volume = 50))
+ source.balloon_alert(user, "interrupted!")
+ return
+
+ source.balloon_alert(user, "malfunction repaired")
+ // Force end the event
+ processing = FALSE
+ __end_for_storyteller()
diff --git a/tff_modular/modules/storytellers/events/neutral/grid_check.dm b/tff_modular/modules/storytellers/events/neutral/grid_check.dm
new file mode 100644
index 00000000000..acc396ce0fa
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/neutral/grid_check.dm
@@ -0,0 +1,93 @@
+/datum/round_event_control/grid_check
+ id = "grid_check"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_CHAOTIC)
+ typepath = /datum/round_event/grid_check/storyteller
+
+
+/datum/round_event/grid_check/storyteller
+ STORYTELLER_EVENT
+
+ var/shutdown_duration_min = 2 MINUTES
+ var/shutdown_duration_max = 5 MINUTES
+ var/lock_rebot_chance = 10
+ var/list/station_apcs = list()
+ var/list/locked_apcs = list()
+
+ COOLDOWN_DECLARE(apc_shutdown_cooldown)
+
+
+/datum/round_event/grid_check/storyteller/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ if(threat_points < STORY_THREAT_LOW)
+ shutdown_duration_min = 60
+ shutdown_duration_max = 180
+ else if(threat_points < STORY_THREAT_MODERATE)
+ shutdown_duration_min = 120
+ shutdown_duration_max = 240
+ lock_rebot_chance = 15
+ else if(threat_points < STORY_THREAT_HIGH)
+ shutdown_duration_min = 180
+ shutdown_duration_max = 240
+ lock_rebot_chance = 15
+ else if(threat_points < STORY_THREAT_EXTREME)
+ shutdown_duration_min = 180
+ shutdown_duration_max = 300
+ lock_rebot_chance = 25
+ else
+ shutdown_duration_min = 300
+ shutdown_duration_max = 420
+ lock_rebot_chance = 30
+
+ for(var/obj/machinery/power/apc/APC in SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/power/apc))
+ if(APC.area && !APC.failure_timer)
+ station_apcs += WEAKREF(APC)
+
+ end_when = shutdown_duration_max / 10
+ COOLDOWN_START(src, apc_shutdown_cooldown, 30 SECONDS)
+
+/datum/round_event/grid_check/storyteller/__start_for_storyteller()
+ COOLDOWN_START(src, apc_shutdown_cooldown, 30 SECONDS)
+
+/datum/round_event/grid_check/storyteller/__announce_for_storyteller()
+ priority_announce("A station-wide power grid check will commence soon. Expect temporary shutdowns of various systems.")
+
+/datum/round_event/grid_check/storyteller/__storyteller_tick(seconds_per_tick)
+ if(!COOLDOWN_FINISHED(src, apc_shutdown_cooldown))
+ return
+ COOLDOWN_START(src, apc_shutdown_cooldown, rand(15-30) SECONDS)
+
+ station_apcs = shuffle(station_apcs)
+ var/to_shutdown = list()
+ var/shutdown_cpount = max(1, round(length(station_apcs) * 0.2))
+ for(var/datum/weakref/ref in station_apcs)
+ if(shutdown_cpount <= 0)
+ break
+ var/obj/machinery/power/apc/APC = ref.resolve()
+ if(!APC || !APC.area || APC.failure_timer)
+ continue
+ to_shutdown += APC
+ shutdown_cpount -= 1
+ station_apcs -= ref
+
+ for(var/obj/machinery/power/apc/APC in to_shutdown)
+ var/shutdown_duration = clamp(rand(shutdown_duration_min, shutdown_duration_max), shutdown_duration_min, shutdown_duration_max - activeFor)
+ APC.energy_fail(shutdown_duration)
+ if(prob(30))
+ APC.overload_lighting()
+
+ if(prob(lock_rebot_chance) && !HAS_TRAIT(src, TRAIT_NO_REBOOT_EVENT))
+ ADD_TRAIT(src, TRAIT_NO_REBOOT_EVENT, "grid_check_event")
+ locked_apcs += WEAKREF(APC)
+
+/datum/round_event/grid_check/storyteller/__end_for_storyteller()
+ for(var/datum/weakref/ref in locked_apcs)
+ var/obj/machinery/power/apc/APC = ref.resolve()
+ if(!APC)
+ continue
+ if(!HAS_TRAIT(src, TRAIT_NO_REBOOT_EVENT))
+ continue
+ REMOVE_TRAIT(src, TRAIT_NO_REBOOT_EVENT, "grid_check_event")
+ APC.update()
+ APC.update_appearance()
+
diff --git a/tff_modular/modules/storytellers/events/neutral/market_crash.dm b/tff_modular/modules/storytellers/events/neutral/market_crash.dm
new file mode 100644
index 00000000000..ecdfb53c47f
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/neutral/market_crash.dm
@@ -0,0 +1,41 @@
+/datum/round_event_control/market_crash
+ id = "market_crash"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_HUMOROUS, STORY_TAG_SOCIAL)
+ typepath = /datum/round_event/market_crash
+
+ story_weight = STORY_GOAL_BASE_WEIGHT * 1.2
+
+/datum/round_event_control/market_crash/pre_storyteller_run(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ . = ..()
+ var/good_for_station = FALSE
+ // More likely to happen in faster paced rounds
+ if(storyteller.mood.pace > 1.1)
+ good_for_station = TRUE
+ additional_arguments = good_for_station
+
+/datum/round_event/market_crash
+ STORYTELLER_EVENT
+
+ allow_random = FALSE
+ var/good_for_station = FALSE
+ var/target_inflation = 1
+
+ COOLDOWN_DECLARE(inflation_uptick_cooldown)
+
+/datum/round_event/market_crash/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ good_for_station = get_additional_arguments()
+ end_when = round((threat_points/10))
+
+ if(good_for_station)
+ target_inflation = 0.2
+ else
+ // More severe inflation increase if the event is bad for the station
+ target_inflation = 10
+
+/datum/round_event/market_crash/__storyteller_tick(seconds_per_tick)
+ if(SSeconomy.inflation_value != target_inflation && COOLDOWN_FINISHED(src, inflation_uptick_cooldown))
+ SSeconomy.inflation_value = lerp(SSeconomy.inflation_value, target_inflation, 0.1)
+ COOLDOWN_START(src, inflation_uptick_cooldown, 1 MINUTES)
+ SSeconomy.update_vending_prices()
diff --git a/tff_modular/modules/storytellers/events/neutral/~generic_overrides.dm b/tff_modular/modules/storytellers/events/neutral/~generic_overrides.dm
new file mode 100644
index 00000000000..ed8c38ae504
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/neutral/~generic_overrides.dm
@@ -0,0 +1,133 @@
+/datum/round_event_control/sandstorm
+ id = "sandstorm"
+ typepath = /datum/round_event/sandstorm
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL)
+
+/datum/round_event_control/sandstorm_classic
+ id = "sandstorm_classic"
+ typepath = /datum/round_event/sandstorm_classic
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL)
+
+/datum/round_event_control/space_dust
+ id = "space_dust_storyteller"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL)
+
+/datum/round_event_control/fake_virus
+ id = "fake_virus"
+ typepath = /datum/round_event/fake_virus
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_HUMOROUS, STORY_TAG_SOCIAL)
+ min_players = 4
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+/datum/round_event_control/falsealarm
+ id = "falsealarm"
+ typepath = /datum/round_event/falsealarm
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_HUMOROUS, STORY_TAG_SOCIAL)
+ min_players = 4
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+/datum/round_event_control/grey_tide
+ id = "grey_tide"
+ typepath = /datum/round_event/grey_tide
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_WIDE_IMPACT, STORY_TAG_ENVIRONMENTAL)
+ min_players = 8
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+/datum/round_event_control/scrubber_overflow
+ id = "scrubber_overflow"
+ typepath = /datum/round_event/scrubber_overflow
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_WIDE_IMPACT, STORY_TAG_ENVIRONMENTAL, STORY_TAG_CHAOTIC)
+ min_players = 6
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+
+/datum/round_event_control/shuttle_insurance
+ id = "shuttle_insurance"
+ typepath = /datum/round_event/shuttle_insurance
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_SOCIAL)
+ min_players = 10
+ required_round_progress = STORY_ROUND_PROGRESSION_MID
+
+/datum/round_event_control/bureaucratic_error
+ id = "bureaucratic_error"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_SOCIAL, STORY_TAG_HUMOROUS)
+ typepath = /datum/round_event_control/bureaucratic_error
+
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.9
+ min_players = 5
+
+/datum/round_event_control/camera_failure
+ id = "camera_failure"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_REQUIRES_ENGINEERING)
+ typepath = /datum/round_event/camera_failure
+
+
+/datum/round_event_control/mass_hallucination
+ id = "mass_hallucination"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_HUMOROUS, STORY_TAG_SOCIAL)
+ typepath = /datum/round_event/mass_hallucination
+
+ min_players = 2
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.7
+
+/datum/round_event_control/mice_migration
+ id = "mice_migration"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENTITIES)
+ typepath = /datum/round_event/mice_migration
+
+/datum/round_event_control/tram_malfunction
+ id = "tram_malfunction"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_REQUIRES_ENGINEERING)
+ typepath = /datum/round_event/tram_malfunction
+ min_players = 6
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.5
+
+/datum/round_event_control/vent_clog
+ id = "vent_clog"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_REQUIRES_ENGINEERING, STORY_TAG_AFFECTS_WHOLE_STATION)
+ typepath = /datum/round_event/vent_clog
+ min_players = 6
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.6
+
+/datum/round_event_control/vent_clog/major
+ id = "vent_clog_major"
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_REQUIRES_ENGINEERING, STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_CHAOTIC)
+ typepath = /datum/round_event/vent_clog/major
+ min_players = 10
+ story_weight = STORY_GOAL_BASE_WEIGHT * 0.5
+ required_round_progress = STORY_ROUND_PROGRESSION_LATE
+
+/datum/round_event_control/wormholes
+ id = "wormholes"
+ typepath = /datum/round_event/wormholes
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENTITIES, STORY_TAG_CHAOTIC, STORY_TAG_HUMOROUS)
+
+/datum/round_event_control/stray_meteor
+ id = "stray_meteor"
+ typepath = /datum/round_event/stray_meteor
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_HUMOROUS)
+ required_round_progress = STORY_ROUND_PROGRESSION_LATE
+
+/datum/round_event_control/shuttle_loan
+ id = "shuttle_loan"
+ typepath = /datum/round_event/shuttle_loan
+ story_category = STORY_GOAL_NEUTRAL
+ tags = list(STORY_TAG_SOCIAL)
+ min_players = 8
+ required_round_progress = STORY_ROUND_PROGRESSION_LATE
diff --git a/tff_modular/modules/storytellers/events/rimworld/phychic_wave.dm b/tff_modular/modules/storytellers/events/rimworld/phychic_wave.dm
new file mode 100644
index 00000000000..23308e76d6a
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/rimworld/phychic_wave.dm
@@ -0,0 +1,266 @@
+/datum/round_event_control/psychic_wave
+ id = "psychic_wave"
+ name = "Psychic Wave"
+ description = "Unleash a powerful psychic wave that disrupts the minds of the crew, causing hallucinations and mental distress."
+ story_category = STORY_GOAL_BAD | STORY_GOAL_MAJOR
+ tags = list(STORY_TAG_ENVIRONMENTAL, STORY_TAG_AFFECTS_WHOLE_STATION, STORY_TAG_REQUIRES_MEDICAL, STORY_TAG_SOCIAL, STORY_TAG_EPIC)
+ typepath = /datum/round_event/storyteller_psychic_wave
+
+ min_players = 4
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_BASIC
+ map_flags = EVENT_SPACE_ONLY
+
+
+/datum/round_event/storyteller_psychic_wave
+ STORYTELLER_EVENT
+
+ var/vomit_chance = 20
+
+ var/psychic_wave_update_interval = 15 SECONDS
+ var/mood_debuff_duration = 10 MINUTES
+ var/mood_debuff_amount = -10
+
+ var/hallucination_cooldown = 40 SECONDS
+ var/hallucination_duration = 3 MINUTES
+
+ var/brain_damage_chance = 10
+ var/brain_damage_cooldown_time = 20 SECONDS
+
+ var/can_give_quick = TRUE
+ var/mental_damage = TRUE
+ var/safe_distance_from_space = 40
+
+ var/message_low = "You feel a slight unease wash over you."
+ var/message_high = "A powerful psychic wave crashes through your mind, distorting your perception of reality!"
+ var/message_extreme = "Your mind is overwhelmed by an intense psychic assault, leaving you disoriented and vulnerable!"
+ var/message_extreme_brain = "The psychic wave has severely damaged your brain, leaving lasting effects!"
+
+ COOLDOWN_DECLARE(hallucination_wave_cooldown)
+ COOLDOWN_DECLARE(brain_damage_cooldown)
+ COOLDOWN_DECLARE(psychic_wave_cooldown)
+
+
+/datum/round_event/storyteller_psychic_wave/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ var/bonus_time = 0
+ if(threat_points < STORY_THREAT_MODERATE)
+ vomit_chance = 15
+ mood_debuff_duration = 5 MINUTES
+ mood_debuff_amount = -20
+ hallucination_duration = 2 MINUTES
+ brain_damage_chance = 5
+ message_low = "You sense a faint presence lingering at the edge of your thoughts."
+ message_high = "A subtle psychic whisper brushes against your mind, stirring unease."
+ message_extreme = "An elusive entity grazes your consciousness, leaving you slightly off-balance."
+ message_extreme_brain = "The fleeting psychic touch has left a minor scar on your neural pathways."
+ else if(threat_points < STORY_THREAT_HIGH)
+ vomit_chance = 20
+ mood_debuff_duration = 10 MINUTES
+ mood_debuff_amount = -30
+ hallucination_duration = 3 MINUTES
+ brain_damage_chance = 10
+ message_low = "You feel an unseen observer watching from the shadows of your mind."
+ message_high = "A persistent psychic presence invades your thoughts, warping your senses."
+ message_extreme = "The entity presses harder, twisting your perception and sowing confusion."
+ message_extreme_brain = "The intruding force has inflicted noticeable harm to your brain's structure."
+ bonus_time = 120
+ else if(threat_points < STORY_THREAT_EXTREME)
+ vomit_chance = 25
+ mood_debuff_duration = 12 MINUTES
+ mood_debuff_amount = -60
+ hallucination_duration = 4 MINUTES
+ brain_damage_chance = 15
+ message_low = "A chilling awareness dawns—you are not alone in your own head."
+ message_high = "The psychic intruder claws at your mind, fracturing your grip on reality."
+ message_extreme = "The presence surges forward, assaulting your psyche with overwhelming force."
+ message_extreme_brain = "The entity's attack has ravaged your brain, causing deep and persistent damage."
+ else
+ vomit_chance = 40
+ mood_debuff_duration = 15 MINUTES
+ mood_debuff_amount = -80
+ hallucination_duration = 5 MINUTES
+ brain_damage_chance = 20
+ message_low = "An ominous entity stirs within, threatening to consume your sanity."
+ message_high = "The psychic being tears into your consciousness, shattering illusions of safety."
+ message_extreme = "Your mind is rent asunder by the intruder's merciless onslaught."
+ message_extreme_brain = "The force has brutally shredded your neural tissue, risking total collapse."
+ bonus_time = 240
+
+ start_when = rand(30, 60)
+ end_when = start_when + rand(450, 800) + bonus_time
+
+/datum/round_event/storyteller_psychic_wave/__announce_for_storyteller()
+ priority_announce("Sensors detect a surge of psychic energy enveloping the station. \
+ Crew members may experience mental disturbances, including mood shifts, \
+ hallucinations, nausea, and potential brain damage. \
+ Effects intensify the closer one is to space and double if directly on them.", "Psychic Wave")
+
+
+/datum/round_event/storyteller_psychic_wave/__start_for_storyteller()
+ // First give some time before the first wave hits for precautions
+ COOLDOWN_START(src, psychic_wave_cooldown, psychic_wave_update_interval + psychic_wave_update_interval)
+ COOLDOWN_START(src, hallucination_wave_cooldown, hallucination_cooldown + psychic_wave_update_interval)
+ COOLDOWN_START(src, brain_damage_cooldown, brain_damage_cooldown_time + psychic_wave_update_interval)
+ change_space_color("#9932CC", fade_in = TRUE)
+
+/datum/round_event/storyteller_psychic_wave/__storyteller_tick(seconds_per_tick)
+ if(!COOLDOWN_FINISHED(src, psychic_wave_cooldown))
+ update_physic_wave_effects()
+ return
+
+ if(COOLDOWN_FINISHED(src, hallucination_wave_cooldown))
+ apply_psychic_hallucination_wave()
+ COOLDOWN_START(src, hallucination_wave_cooldown, hallucination_cooldown)
+
+ if(COOLDOWN_FINISHED(src, brain_damage_cooldown))
+ apply_psychic_brain_damage_wave()
+ COOLDOWN_START(src, brain_damage_cooldown, brain_damage_cooldown)
+
+/datum/round_event/storyteller_psychic_wave/__end_for_storyteller()
+ . = ..()
+ priority_announce("The psychic wave subsides, and the crew's minds begin to clear.")
+ change_space_color(fade_in = TRUE)
+
+/datum/round_event/storyteller_psychic_wave/proc/update_physic_wave_effects()
+ var/list/crew = get_alive_station_crew(ignore_erp = FALSE, only_crew = FALSE)
+ for(var/mob/living/carbon/human/human in crew)
+ if(mental_damage && !human.mind)
+ continue
+ var/turf/T = get_turf(human)
+ if(!T)
+ continue
+
+
+ var/in_space = isspaceturf(T)
+ var/near_space = FALSE
+ for(var/turf/V in view(7, human))
+ if(isspaceturf(V) && can_see(human, V, 7))
+ near_space = TRUE
+ break
+
+ var/multiplier = in_space ? 2 : (near_space ? 1.5 : 0.1)
+ human.add_mood_event("psychic_wave", /datum/mood_event/psychic_wave, mood_debuff_amount * multiplier, mood_debuff_duration * multiplier)
+ if(in_space || near_space)
+ human.overlay_fullscreen("psychic_wave", /atom/movable/screen/fullscreen/phychic_wave)
+ addtimer(CALLBACK(human, TYPE_PROC_REF(/mob/living/carbon/human, clear_fullscreen), "psychic_wave"), 10 SECONDS)
+ human.set_eye_blur_if_lower((5 SECONDS) * multiplier)
+ SEND_SOUND(human, sound('sound/items/weapons/flash_ring.ogg',0,1,0,250))
+
+
+/datum/round_event/storyteller_psychic_wave/proc/apply_psychic_hallucination_wave()
+ var/list/crew = get_alive_station_crew(ignore_erp = FALSE, only_crew = FALSE)
+ for(var/mob/living/carbon/human/human in crew)
+ if(mental_damage && !human.mind)
+ continue
+ var/turf/T = get_turf(human)
+ if(!T)
+ continue
+
+ var/in_space = isspaceturf(T)
+ var/near_space = FALSE
+ for(var/turf/V in view(7, human))
+ if(isspaceturf(V))
+ near_space = TRUE
+ break
+
+ var/multiplier = in_space ? 2 : (near_space ? 1.5 : 1)
+
+ // Select message based on intensity
+ var/msg
+ if(multiplier < 1.33)
+ msg = message_low
+ else if(multiplier < 1.67)
+ msg = message_high
+ else
+ msg = message_extreme
+ to_chat(human, span_warning(msg))
+
+ // Apply mood debuff
+ human.add_mood_event("psychic_wave", /datum/mood_event/psychic_wave, mood_debuff_amount * multiplier, mood_debuff_duration * multiplier)
+
+ // Vomit chance
+ if(prob(vomit_chance * multiplier))
+ human.vomit()
+
+ // Hallucination and vision distortion
+ human.adjust_hallucinations(hallucination_duration * multiplier)
+
+ // Extended hallucinations and vision changes for near space or in space
+ if(near_space || in_space)
+ human.adjust_hallucinations(hallucination_duration * multiplier * 0.5) // Extra hallucinations
+ if(prob(40 * multiplier))
+ human.emote("scream") // Random scream from intense hallucinations
+ human.set_dizzy_if_lower(20 SECONDS)
+ if(in_space)
+ human.set_eye_blur_if_lower(5 SECONDS * multiplier)
+
+
+/datum/round_event/storyteller_psychic_wave/proc/apply_psychic_brain_damage_wave()
+ var/list/crew = get_alive_station_crew(ignore_erp = FALSE, only_crew = FALSE)
+ for(var/mob/living/carbon/human/human in crew)
+ if(mental_damage && !human.mind)
+ continue
+ var/turf/T = get_turf(human)
+ if(!T)
+ continue
+
+ var/in_space = isspaceturf(human)
+ var/near_space = FALSE
+ for(var/turf/V in view(7, human))
+ if(isspaceturf(V))
+ near_space = TRUE
+ break
+
+ var/multiplier = in_space ? 2 : (near_space ? 1.5 : 1)
+
+ // Brain damage chance
+ if(prob(brain_damage_chance * multiplier))
+ var/obj/item/organ/brain/brain = human.get_organ_by_type(/obj/item/organ/brain)
+ if(brain)
+ brain.apply_organ_damage(rand(5, 10) * multiplier)
+ to_chat(human, span_danger(message_extreme_brain))
+ if(in_space)
+ human.set_eye_blur_if_lower(10 SECONDS * multiplier)
+ if(prob(50))
+ brain.gain_trauma_type()
+
+
+/datum/mood_event/psychic_wave
+ description = "A psychic disturbance clouds your thoughts."
+ mood_change = -10
+ timeout = 10 MINUTES
+
+/datum/mood_event/psychic_wave/add_effects(mood_amount, mood_duration)
+ mood_change = mood_amount
+ timeout = mood_duration
+ update_description()
+
+/datum/mood_event/psychic_wave/proc/update_description()
+ if(mood_change >= -20)
+ description = "A faint psychic haze lingers in your mind, slightly unsettling."
+ else if(mood_change >= -40)
+ description = "Psychic interference disrupts your thoughts, causing moderate discomfort."
+ else if(mood_change >= -60)
+ description = "Intense psychic waves batter your psyche, leading to significant distress."
+ else
+ description = "Overwhelming psychic assault threatens to shatter your sanity."
+
+/datum/mood_event/psychic_wave/be_refreshed(datum/mood/home, mood_amount, mood_duration)
+ if(mood_amount < mood_change)
+ mood_change = mood_amount
+ if(mood_duration > timeout)
+ timeout = mood_duration
+ update_description()
+ if(timeout)
+ addtimer(CALLBACK(home, TYPE_PROC_REF(/datum/mood, clear_mood_event), category), timeout, (TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_NO_HASH_WAIT))
+ return BLOCK_NEW_MOOD
+
+/datum/mood_event/psychic_wave/be_replaced(datum/mood/home, datum/mood_event/new_event, ...)
+ var/new_amount = new_event.mood_change
+ if(new_amount < mood_change)
+ return ALLOW_NEW_MOOD
+ return BLOCK_NEW_MOOD
+
+/atom/movable/screen/fullscreen/phychic_wave
+ icon_state = "supermatter_cascade"
diff --git a/tff_modular/modules/storytellers/events/rimworld/psychic_drone.dm b/tff_modular/modules/storytellers/events/rimworld/psychic_drone.dm
new file mode 100644
index 00000000000..17a4711a988
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/rimworld/psychic_drone.dm
@@ -0,0 +1,208 @@
+/datum/round_event_control/execute_psychic_drone
+ id = "psychic_drone"
+ name = "Execute the Psychic Drone"
+ description = "Deploy a psychic drone to broadcast disruptive psionic noise across the station."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_ESCALATION, STORY_TAG_SOCIAL, STORY_TAG_CHAOTIC)
+ typepath = /datum/round_event/psychic_drone
+
+ min_players = 5
+ required_round_progress = STORY_ROUND_PROGRESSION_EARLY
+ requierd_threat_level = STORY_GOAL_THREAT_ELEVATED
+
+/datum/round_event/psychic_drone
+ STORYTELLER_EVENT
+
+ // Default, but randomize per wave or target
+ var/target_sex = MALE
+ // Assume custom drone mob (implement separately)
+ var/drone_path = /mob/living/basic/psychic_drone
+ // Time between noise pulses
+ var/wave_duration = 30 SECONDS
+ // Total pulses, scaled by threat
+ var/num_waves = 5
+ // 1=low (mild effects), 5=high (intense hallucinations/vomiting)
+ var/noise_strength = 1
+ // % chance for positive vs negative noise
+ var/positive_noise_chance = 50
+
+ start_when = 30
+
+/datum/round_event/psychic_drone/__setup_for_storyteller(threat_points)
+ . = ..()
+
+ if(threat_points < STORY_THREAT_LOW)
+ noise_strength = 1
+ num_waves = 3
+ positive_noise_chance = 60 // More positive early to build false security
+ else if(threat_points < STORY_THREAT_MODERATE)
+ noise_strength = 2
+ num_waves = 5
+ positive_noise_chance = 50
+ else if(threat_points < STORY_THREAT_HIGH)
+ noise_strength = 3
+ num_waves = 7
+ positive_noise_chance = 30
+ else if(threat_points < STORY_THREAT_EXTREME)
+ noise_strength = 4
+ num_waves = 10
+ positive_noise_chance = 20
+ else
+ noise_strength = 5
+ num_waves = 15
+ positive_noise_chance = 10
+
+ // Dynamic scaling
+ num_waves = min(round(num_waves + round(threat_points / 400)), 5)
+
+
+/datum/round_event/psychic_drone/__start_for_storyteller()
+ var/turf/spawn_turf = get_safe_random_station_turf()
+ var/obj/structure/closet/supplypod/pod = podspawn(list(
+ "target" = spawn_turf,
+ "path" = /obj/structure/closet/supplypod/podspawn/no_return,
+ "style" = /datum/pod_style/deathsquad,
+ "spawn" = drone_path,
+ ))
+ var/mob/living/basic/psychic_drone/drone = locate() in pod.contents
+ drone.noise_strength = noise_strength
+ drone.positive_noise_chance = positive_noise_chance
+ drone.num_waves = num_waves
+ drone.wave_duration = wave_duration
+ drone.target_sex = pick(MALE, FEMALE) // There only two genders in the universe, deal with it
+
+ notify_ghosts("Psychic drone deployed at [get_area(spawn_turf)].", spawn_turf, "Phychic drone deployed")
+ priority_announce("A psychic drone has been deployed at [get_area(spawn_turf)], broadcasting disruptive psionic noise across the station! It's option set to [drone.target_sex].", "Anomalies detected")
+
+
+/mob/living/basic/psychic_drone
+ name = "psychic drone"
+ desc = "A hovering orb emitting faint psionic waves, influencing crew minds in unpredictable ways."
+ icon = 'icons/mob/simple/hivebot.dmi'
+ icon_state = "commdish"
+ density = TRUE
+ health = 1000
+ maxHealth = 1000
+ habitable_atmos = null
+ minimum_survivable_temperature = 0
+ maximum_survivable_temperature = FIRE_SUIT_MAX_TEMP_PROTECT
+ basic_mob_flags = IMMUNE_TO_FISTS
+ speed = 0
+ faction = list(FACTION_HOSTILE)
+ status_flags = 0
+ move_force = MOVE_FORCE_VERY_STRONG
+ move_resist = MOVE_FORCE_VERY_STRONG
+ pull_force = MOVE_FORCE_VERY_STRONG
+
+ var/positive_noise_chance
+ var/noise_strength = 1
+ var/num_waves = 5
+ var/wave_duration = 30 SECONDS
+ var/current_wave = 0
+ var/is_bad = TRUE
+ var/target_sex = MALE // The power of man
+
+/mob/living/basic/psychic_drone/Initialize(mapload, threat_mod = 1.0, tension_mod = 1.0)
+ . = ..()
+ noise_strength *= threat_mod
+ num_waves = round(num_waves * tension_mod)
+ addtimer(CALLBACK(src, PROC_REF(pulse_psychic_noise)), wave_duration)
+ if(prob(positive_noise_chance))
+ is_bad = FALSE
+
+/mob/living/basic/psychic_drone/Destroy()
+ return ..()
+
+/mob/living/basic/psychic_drone/proc/pulse_psychic_noise()
+ current_wave++
+ if(current_wave > num_waves)
+ qdel(src)
+ return
+ var/list/targets = list()
+ for(var/mob/living/carbon/human/H in GLOB.human_list)
+ if(!H.mind || H.stat == DEAD || !is_station_level(H.z))
+ continue
+ targets += H
+
+ if(length(targets))
+ var/target_sex_local = pick(MALE, FEMALE)
+ for(var/mob/living/carbon/human/target in targets)
+ apply_psychic_noise(target, is_bad, noise_strength, target_sex_local)
+
+ if(prob(25 * current_wave))
+ noise_strength += 1
+
+ addtimer(CALLBACK(src, PROC_REF(pulse_psychic_noise)), wave_duration)
+
+/mob/living/basic/psychic_drone/proc/apply_psychic_noise(mob/living/carbon/human/target, is_positive, strength, target_sex)
+ var/debuff_duration = 30 SECONDS * strength
+
+ var/effect_msg = ""
+ if(is_positive)
+ if(target_sex == MALE)
+ effect_msg = span_notice("A confident surge fills your mind, sharpening your focus.")
+ else if(target_sex == FEMALE)
+ effect_msg = span_notice("Empathic waves soothe your thoughts, easing tensions.")
+ else
+ effect_msg = span_notice("A neutral hum clears mental fog briefly.")
+
+ if(target.gender == target_sex || target.gender == PLURAL || target.gender == NEUTER)
+ target.add_mood_event("psychic_drone", /datum/mood_event/psychic_drone_positive)
+ target.add_movespeed_modifier(/datum/movespeed_modifier/psychic_boost, update=TRUE)
+ addtimer(CALLBACK(target, TYPE_PROC_REF(/mob/living/carbon/human, remove_movespeed_modifier), /datum/movespeed_modifier/psychic_boost, TRUE), debuff_duration)
+ else if(is_positive == FALSE)
+ if(target_sex == MALE)
+ effect_msg = span_userdanger("Aggressive shrieks invade your head, fueling rage!")
+ else if(target_sex == FEMALE)
+ effect_msg = span_userdanger("Hysterical cries overwhelm you, shattering composure!")
+ else
+ effect_msg = span_userdanger("Disorienting wails echo, inducing nausea.")
+ target.adjust_disgust(25 * strength)
+
+ if(target.gender == target_sex || target.gender == PLURAL || target.gender == NEUTER)
+ SEND_SOUND(target, sound('sound/items/weapons/flash_ring.ogg'))
+ if(strength <= 2)
+ target.add_mood_event("psychic_drone", /datum/mood_event/psychic_drone_negative)
+ else if(strength <= 4)
+ target.add_mood_event("psychic_drone", /datum/mood_event/psychic_drone_negative/strong)
+ else if(strength <= 5)
+ target.add_mood_event("psychic_drone", /datum/mood_event/psychic_drone_negative/extreme)
+ target.adjust_oxy_loss(rand(30-40), forced=TRUE)
+ target.adjust_hallucinations(30 SECONDS)
+ target.adjust_drunk_effect(30)
+ else
+ effect_msg = span_notice("A subtle psychic hum resonates, leaving you mildly disoriented but aware.")
+ target.adjust_disgust(10 * strength)
+
+ to_chat(target, effect_msg)
+ new /obj/effect/temp_visual/psychic_scream(get_turf(target), target)
+
+/datum/mood_event/psychic_drone_negative
+ description = "Psionic echoes unsettle my thoughts."
+ mood_change = -16
+ timeout = 30 SECONDS
+
+/datum/mood_event/psychic_drone_negative/strong
+ description = "Intense psychic noise disrupts my focus severely."
+ mood_change = -24
+ timeout = 30 SECONDS
+
+/datum/mood_event/psychic_drone_negative/extreme
+ description = "Overwhelming psionic waves crush my mental stability."
+ mood_change = -40
+ timeout = 30 SECONDS
+
+/datum/mood_event/psychic_drone_positive
+ description = "A psionic surge invigorates my mind."
+ mood_change = 14
+ timeout = 30 SECONDS
+
+/datum/movespeed_modifier/psychic_boost
+ multiplicative_slowdown = -0.5
+
+/obj/effect/temp_visual/psychic_scream
+ name = "psychic scream"
+ icon = 'icons/effects/effects.dmi'
+ icon_state = "cursehand0"
+ duration = 10
+ layer = ABOVE_MOB_LAYER
diff --git a/tff_modular/modules/storytellers/events/rimworld/zzzzzzt.dm b/tff_modular/modules/storytellers/events/rimworld/zzzzzzt.dm
new file mode 100644
index 00000000000..f428e38da8b
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/rimworld/zzzzzzt.dm
@@ -0,0 +1,91 @@
+/datum/round_event_control/zzzzzt
+ id = "zzzzzt"
+ name = "ZZZZZT"
+ description = "A massive power surge."
+ story_category = STORY_GOAL_BAD
+ tags = list(STORY_TAG_HUMOROUS)
+ typepath = /datum/round_event/zzzzzt
+
+ min_players = 2
+
+/datum/round_event/zzzzzt
+ STORYTELLER_EVENT
+
+ var/maximum_charge = 50 KILO JOULES
+ start_when = 10
+
+/datum/round_event/zzzzzt/__setup_for_storyteller(threat_points, ...)
+ . = ..()
+ maximum_charge = 50 KILO JOULES + ((threat_points/10) * 10 KILO JOULES)
+
+/datum/round_event/zzzzzt/__announce_for_storyteller()
+ priority_announce("Anomalous power surge detected in [station_name()]'s powernet. \
+ Crew members in close proximity to power cables may be at risk of electrocution.", "Zzzzzzzt")
+
+/datum/round_event/zzzzzt/__start_for_storyteller()
+ var/attempts = 0
+ var/obj/structure/cable/closest
+ for(var/mob/living/carbon/human/unluck in get_alive_station_crew(only_crew = FALSE))
+ if(attempts > 30)
+ return
+ if(!unluck.client)
+ continue
+ if(unluck.stat == DEAD)
+ continue
+ if(!(unluck.mind.assigned_role.job_flags & JOB_CREW_MEMBER))
+ continue
+ if(engaged_role_play_check(unluck, station = TRUE, dorms = TRUE))
+ continue
+ closest = pick_closest_cable(unluck)
+ var/power_sources = 0
+ for(var/obj/machinery/power/smes/cell in SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/power/smes))
+ if(cell.powernet == closest.powernet)
+ power_sources += 1
+ if(!closest || power_sources == 0)
+ attempts++
+ continue
+ if(!closest)
+ return // This time - no bad luck for you
+
+ INVOKE_ASYNC(src, PROC_REF(do_zzzt), closest)
+
+
+/datum/round_event/zzzzzt/proc/do_zzzt(obj/structure/cable/source)
+ set waitfor = FALSE
+ var/list/closest_turfs = RANGE_TURFS(2, source)
+ var/safe_delay = 3 SECONDS
+ while(safe_delay > 0)
+ do_sparks(1, TRUE, pick(closest_turfs))
+ safe_delay -= 1
+ stoplag()
+
+ for(var/obj/machinery/power/smes/cell in SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/power/smes))
+ if(cell.powernet == source.powernet)
+ cell.adjust_charge(-maximum_charge)
+ if(cell.charge <= 0)
+ explosion(cell, 0, 0, 3, 2)
+
+ for(var/mob/living/living_mob in range(source, 2))
+ if(living_mob.stat == DEAD)
+ continue
+ // Zap them!
+ living_mob.electrocute_act(rand(60-70), src, 1.0, SHOCK_NOGLOVES | SHOCK_TESLA)
+ source.Beam(living_mob, "lightning1", emissive = FALSE)
+
+ sleep(5)
+ explosion(source, 0, 0, 2, 3)
+ qdel(source)
+
+
+/datum/round_event/zzzzzt/proc/pick_closest_cable(mob/living/carbon/human/bad_luck)
+ var/obj/structure/cable/closest_cable
+ var/closest_dist = INFINITY
+ for(var/turf/check_turf in RANGE_TURFS(10, bad_luck))
+ var/obj/structure/cable/cab = locate() in check_turf.contents
+ if(!cab)
+ continue
+ var/dist = get_dist(bad_luck, cab)
+ if(dist < closest_dist)
+ closest_dist = dist
+ closest_cable = cab
+ return closest_cable
diff --git a/tff_modular/modules/storytellers/events/storyteller_event_control.dm b/tff_modular/modules/storytellers/events/storyteller_event_control.dm
new file mode 100644
index 00000000000..9760e882505
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/storyteller_event_control.dm
@@ -0,0 +1,559 @@
+/datum/round_event_control
+ // Universal id of this event
+ var/id
+ // A storyteller category of this event
+ var/story_category = STORY_GOAL_NEVER
+ // Is control of this event was overrided by storyteller
+ var/storyteller_override = FALSE
+ // A universal tags that helps storyteller to predict event behevour
+ var/list/tags = NONE
+ // A storyteller weight override for event selection
+ var/story_weight = STORY_GOAL_BASE_WEIGHT // Low weight by default
+ // A storyteller priority override for event selection
+ var/story_prioty = STORY_GOAL_BASE_PRIORITY
+ // Minimum threat level required to select this goal
+ var/requierd_threat_level = STORY_GOAL_NO_THREAT
+ // Minimum round progress (from 0..1) required to select this goal
+ var/required_round_progress = STORY_ROUND_PROGRESSION_START
+
+ VAR_FINAL/additional_arguments = list()
+ // Should this event be allowed to select by storyteller
+ var/enabled = TRUE
+
+// Is this event avaible for selection on storyteller
+/datum/round_event_control/proc/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ if(!can_spawn_event(inputs.player_count(), allow_magic = FALSE))
+ return FALSE
+ if(occurrences >= max_occurrences)
+ return FALSE
+ if(get_story_weight(inputs, storyteller) <= 0)
+ return FALSE
+ if(wizardevent != SSevents.wizardmode)
+ return FALSE
+ if(inputs.player_count() < min_players)
+ return FALSE
+ if(holidayID && !check_holidays(holidayID))
+ return FALSE
+ if(!valid_for_map())
+ return FALSE
+ if((storyteller.get_effective_threat() < requierd_threat_level) && !has_tag(STORY_TAG_ROUNDSTART))
+ return FALSE
+ if(storyteller.round_progression < required_round_progress && !has_tag(STORY_TAG_ROUNDSTART))
+ return FALSE
+ return TRUE
+
+// Return weight of this event for storyteller
+/datum/round_event_control/proc/get_story_weight(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return story_weight
+
+// Returns priority of this event for sroyteller. Priority is important for events with same weight
+/datum/round_event_control/proc/get_story_priority(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return story_prioty
+
+// Can storyteller fire this event just right now
+/datum/round_event_control/proc/can_fire_now(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ return TRUE
+
+/datum/round_event_control/proc/pre_storyteller_run(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ return
+
+// Run after storyteller plan this event
+/datum/round_event_control/proc/on_planned(fire_time)
+ return
+
+/datum/round_event_control/proc/run_event_as_storyteller(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ pre_storyteller_run(inputs, storyteller, threat_points)
+
+ var/used_threath_points = FALSE
+ if(typepath) // For events that spawn a round_event datum
+ var/datum/round_event/round_event = new typepath(TRUE, src)
+ if(SEND_SIGNAL(SSdcs, COMSIG_GLOB_STORYTELLER_RUN_EVENT, round_event) && CANCEL_STORYTELLER_EVENT)
+ return FALSE
+ round_event.current_players = inputs.player_count()
+ round_event.__register()
+ if(is_storyteller_event(round_event))
+ round_event.__setup_for_storyteller(threat_points, additional_arguments, inputs, storyteller)
+ used_threath_points = TRUE
+ else
+ round_event.setup()
+ SSblackbox.record_feedback("tally", "event_ran", 1, "[round_event]")
+ testing("[time2text(world.time, "hh:mm:ss", 0)] [round_event.type]")
+
+ occurrences += 1
+ triggering = TRUE
+ storyteller_override = TRUE
+
+ log_storyteller("[storyteller.name] run event [name]")
+ deadchat_broadcast("[storyteller.name] has just fired [name] event [used_threath_points ? "with [threat_points] threat points" : "" ]", message_type=DEADCHAT_ANNOUNCEMENT)
+ message_admins("[span_notice("[storyteller.name] fired event:")] [name || id], with [threat_points] threat points")
+ return TRUE
+
+/// Check how well event tags match desired tags (works with both bitfield and text tags)
+/datum/round_event_control/proc/check_tags(list/story_tags)
+ if(!story_tags || !length(story_tags))
+ return STORY_TAGS_DIFFERENT
+ if(!tags)
+ return STORY_TAGS_DIFFERENT
+
+ var/total_tags = length(story_tags)
+ var/right_tags = 0
+
+ for(var/tag in story_tags)
+ if(has_tag(tag))
+ right_tags += 1
+
+ if(right_tags == total_tags)
+ return STORY_TAGS_MATCH
+ else if(right_tags >= total_tags / 2)
+ return STORY_TAGS_MOST_MATCH
+ else if(right_tags > 0)
+ return STORY_TAGS_SOME_MATCH
+ else
+ return STORY_TAGS_DIFFERENT
+
+/// Universal tag check - works with both bitfield and text tags
+/datum/round_event_control/proc/has_tag(tag)
+ if(!tags)
+ return FALSE
+
+ // Text tags (new system)
+ if(istype(tags, /list))
+ return (tag in tags)
+
+ // Bitfield tags (old system)
+ if(isnum(tags) && isnum(tag))
+ return (tags & tag) != 0
+
+ return FALSE
+
+/// Check if event has any of the provided tags
+/datum/round_event_control/proc/has_any_tag(list/tag_list)
+ if(!tags || !tag_list || !length(tag_list))
+ return FALSE
+
+ for(var/tag in tag_list)
+ if(has_tag(tag))
+ return TRUE
+
+ return FALSE
+
+/// Check if event has all of the provided tags
+/datum/round_event_control/proc/has_all_tags(list/tag_list)
+ if(!tags || !tag_list || !length(tag_list))
+ return FALSE
+
+ for(var/tag in tag_list)
+ if(!has_tag(tag))
+ return FALSE
+
+ return TRUE
+
+/datum/round_event_control/proc/get_readable_tags()
+ var/list/readable_tags = list()
+ for(var/tag in tags)
+ var/tag_name = replacetext(tag, "_", " ")
+ tag_name = replacetext(tag_name, "STORY", "")
+ var/list/words = splittext(tag_name, " ")
+ var/final_tag = ""
+ for(var/word in words)
+ word = LOWER_TEXT(word)
+ final_tag += word + " "
+ readable_tags += final_tag
+ return readable_tags
+
+
+/datum/round_event_control/antagonist
+ story_category = STORY_GOAL_ANTAGONIST
+ // Antag datum type (e.g., /datum/antagonist/traitor)
+ var/datum/antagonist/antag_datum_type = null
+ // Display name
+ var/antag_name = "Antagonist"
+ // List of objective types or proc path
+ var/antag_objectives = null
+ // Pref role flag
+ var/role_flag = ROLE_SLEEPER_AGENT
+ // Excluded jobs (e.g., list(JOB_AI))
+ var/restricted_roles = list()
+ // Preferred jobs (only these allowed; e.g., list(JOB_CAPTAIN))
+ var/preferred_roles = list()
+ // Is living with mindshield can roll this antag
+ var/allow_mindshield = FALSE
+ var/max_candidates = 1
+ var/min_candidates = 1
+ var/ghost_candidates = TRUE
+ var/crew_candidates = TRUE
+ var/announce_to_ghosts = TRUE
+ var/admin_cancel_delay = 30 SECONDS
+ var/story_weight_multiplier = 1.0
+ var/signup_atom_appearance = /obj/item/paper
+ // Proc path post-spawn
+ var/post_spawn_callback
+ // Admin cancel flag
+ var/canceled = FALSE
+
+ // Internal
+ var/list/candidates = list()
+ var/candidate_selected = FALSE
+ var/list/spawned_antags = list()
+ var/datum/callback/admin_cancel_callback
+
+ var/delayed = FALSE
+ var/can_load_character = FALSE
+
+/datum/round_event_control/antagonist/New()
+ . = ..()
+ if(max_candidates < min_candidates)
+ max_candidates = min_candidates
+
+// Availability with antag checks
+/datum/round_event_control/antagonist/is_avaible(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if(!.)
+ return FALSE
+
+ var/list/potential_candidates = get_potential_candidates(inputs, storyteller)
+ if(length(potential_candidates) < min_candidates)
+ return FALSE
+ return TRUE
+
+// Potential candidates pool
+/datum/round_event_control/antagonist/proc/get_potential_candidates(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ var/list/candidates_list = list()
+ var/list/living_crew = get_alive_station_crew()
+
+ if(crew_candidates)
+ for(var/mob/living/player in living_crew)
+ if(can_be_candidate(player, inputs, storyteller))
+ candidates_list += player
+
+ if(ghost_candidates)
+ for(var/mob/dead/observer/ghost_player in GLOB.player_list)
+ if(can_be_candidate(ghost_player, inputs, storyteller))
+ candidates_list += ghost_player
+ return candidates_list
+
+// Candidate eligibility
+/datum/round_event_control/antagonist/proc/can_be_candidate(mob/candidate, datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ if(!istype(candidate) || !candidate.client || !candidate.key)
+ return FALSE
+ if(canceled)
+ return FALSE
+ var/static/list/cached_bans = list()
+ if(!cached_bans[candidate.key])
+ if(is_banned_from(candidate, role_flag))
+ cached_bans[candidate.key] = "banned"
+ return FALSE
+ else
+ cached_bans[candidate.key] = "not_banned"
+ else if(cached_bans[candidate.key] == "banned")
+ return FALSE
+
+ if(!(role_flag in candidate.client.prefs.be_special))
+ return FALSE
+
+ if(isliving(candidate))
+ var/mob/living/L = candidate
+ if(L.stat == DEAD || L.mind?.has_antag_datum(antag_datum_type))
+ return FALSE
+ var/datum/job/job = L.mind?.assigned_role
+ if(job && (job.type in restricted_roles))
+ return FALSE
+ if(length(preferred_roles) && !(job?.type in preferred_roles))
+ return FALSE
+ if((locate(/obj/item/implant/mindshield) in L.implants) && !allow_mindshield)
+ return FALSE
+ else if(isobserver(candidate))
+ return TRUE
+ return TRUE
+
+// Story weight with impact
+/datum/round_event_control/antagonist/get_story_weight(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ var/base_weight = ..()
+ var/potential_impact = estimate_antag_impact(inputs, storyteller)
+ return base_weight * story_weight_multiplier * potential_impact
+
+// Impact estimate
+/datum/round_event_control/antagonist/proc/estimate_antag_impact(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ var/crew_count = inputs.get_entry(STORY_VAULT_CREW_ALIVE_COUNT) || 0
+ return min(crew_count / 20, 2.0)
+
+/datum/round_event_control/antagonist/pre_storyteller_run(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ . = ..()
+ delayed = TRUE
+ candidates = list()
+ var/admin_msg = span_danger("[storyteller.name] is spawning [antag_name] event in [admin_cancel_delay / 10] seconds. CANCEL")
+ message_admins(admin_msg)
+ candidates = poll_candidates_for_antag(inputs, storyteller, candidates)
+ // Set up admin cancel callback if needed
+ if(admin_cancel_delay > 0)
+ admin_cancel_callback = CALLBACK(src, PROC_REF(admin_cancel_event), inputs, storyteller)
+ addtimer(admin_cancel_callback, admin_cancel_delay)
+ after_delay(inputs, storyteller)
+
+/datum/round_event_control/antagonist/proc/after_delay(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ // Clean up admin cancel callback
+ if(admin_cancel_callback)
+ deltimer(admin_cancel_callback)
+ admin_cancel_callback = null
+
+ // Validate candidates
+ var/list/valid_candidates = list()
+ for(var/mob/candidate in candidates)
+ if(candidate && !QDELETED(candidate) && can_be_candidate(candidate, inputs, storyteller))
+ valid_candidates += candidate
+
+ candidates = valid_candidates
+ candidate_selected = (length(candidates) >= min_candidates)
+ delayed = FALSE
+
+/datum/round_event_control/antagonist/proc/admin_cancel_event(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ if(!candidate_selected)
+ return
+
+/datum/round_event_control/antagonist/Topic(href, href_list)
+ if(href_list["cancel_antag"])
+ canceled = TRUE
+ message_admins("[key_name_admin(usr)] canceled [antag_name] spawn by [SSstorytellers?.active.name || "storyteller"].")
+ return TRUE
+
+/datum/round_event_control/antagonist/proc/poll_candidates_for_antag(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, list/current_candidates)
+ var/list/all_candidates = get_potential_candidates(inputs, storyteller)
+ if(!length(all_candidates))
+ return list()
+
+ var/list/selected = list()
+
+ // Ghost signup
+ if(announce_to_ghosts && ghost_candidates)
+ var/ghost_votes = SSpolling.poll_ghost_candidates(
+ question = "[storyteller.name] summons a [antag_name]! Volunteer?",
+ role = role_flag,
+ check_jobban = TRUE,
+ role_name_text = antag_name,
+ amount_to_pick = max_candidates,
+ poll_time = admin_cancel_delay,
+ alert_pic = signup_atom_appearance
+ )
+
+ if(ghost_votes)
+ if(islist(ghost_votes))
+ for(var/mob/ghost as anything in ghost_votes)
+ if(ghost && (ghost in all_candidates) && can_be_candidate(ghost, inputs, storyteller))
+ selected += ghost
+ else if(ghost_votes && istype(ghost_votes, /mob))
+ var/mob/ghost = ghost_votes
+ if((ghost in all_candidates) && can_be_candidate(ghost, inputs, storyteller))
+ selected += ghost
+
+ // Crew signup if we need more candidates
+ if(crew_candidates && length(selected) < min_candidates)
+ var/list/crew_list = list()
+ for(var/mob/candidate in all_candidates)
+ if(isliving(candidate) && !(candidate in selected))
+ crew_list += candidate
+
+ if(length(crew_list))
+ var/question = "[storyteller.name] calls to your hidden depths. Become a [antag_name] and twist the tale?"
+ var/list/goal_list = list("Yes", "No")
+ var/list/crew_votes = SSstorytellers.ask_crew_for_goals(
+ question_text = question,
+ goal_list = goal_list,
+ poll_time = admin_cancel_delay,
+ group = crew_list,
+ ignore_category = role_flag,
+ alert_pic = signup_atom_appearance
+ )
+ var/yes_crew = crew_votes["Yes"]
+ if(islist(yes_crew))
+ for(var/mob/living/L in yes_crew)
+ if(L && !(L in selected) && can_be_candidate(L, inputs, storyteller))
+ selected += L
+ log_storyteller("[key_name(L)] accepted [storyteller.name]'s call to become [antag_name].")
+ else if(yes_crew && istype(yes_crew, /mob/living))
+ var/mob/living/L = yes_crew
+ if(!(L in selected) && can_be_candidate(L, inputs, storyteller))
+ selected += L
+ log_storyteller("[key_name(L)] accepted [storyteller.name]'s call to become [antag_name].")
+
+ // Select final candidates up to max_candidates
+ var/list/final_candidates = list()
+ var/list/selected_copy = selected.Copy()
+ while(length(final_candidates) < max_candidates && length(selected_copy))
+ var/mob/picked = pick_n_take(selected_copy)
+ if(picked && can_be_candidate(picked, inputs, storyteller) && !(picked in final_candidates))
+ final_candidates += picked
+
+ // Ensure we have at least min_candidates if possible
+ if(length(final_candidates) < min_candidates && length(selected_copy))
+ while(length(final_candidates) < min_candidates && length(selected_copy))
+ var/mob/picked = pick_n_take(selected_copy)
+ if(picked && can_be_candidate(picked, inputs, storyteller) && !(picked in final_candidates))
+ final_candidates += picked
+
+ return final_candidates
+
+
+/datum/round_event_control/antagonist/proc/create_ruleset_body(datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ // Default implementation - create human at safe spawn location
+ var/turf/spawn_turf = get_safe_random_station_turf()
+ if(!spawn_turf)
+ return null
+ return new /mob/living/carbon/human(spawn_turf)
+
+/datum/round_event_control/antagonist/valid_for_map()
+ return TRUE
+
+/datum/round_event_control/antagonist/proc/generate_objectives(datum/mind/candidate, datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ if(islist(antag_objectives))
+ for(var/objective_type in antag_objectives)
+ var/datum/objective/objective = new objective_type(candidate)
+ candidate.objectives += objective
+ else if(antag_objectives)
+ call(candidate, antag_objectives)(inputs, storyteller)
+
+// Run: spawn with checks
+/datum/round_event_control/antagonist/run_event_as_storyteller(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ . = ..()
+ run_antagonist_event(inputs, storyteller, threat_points)
+ return
+
+/datum/round_event_control/antagonist/proc/run_antagonist_event(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, threat_points)
+ if(!candidate_selected || !length(candidates))
+ deadchat_broadcast("[storyteller.name]'s [antag_name] event failed to spawn: no candidates selected", message_type=DEADCHAT_ANNOUNCEMENT)
+ message_admins("[storyteller.name]'s [antag_name] event failed to spawn: no valid candidates.")
+ return FALSE
+
+ if(canceled)
+ deadchat_broadcast("[storyteller.name]'s [antag_name] event was canceled", message_type=DEADCHAT_ANNOUNCEMENT)
+ message_admins("[storyteller.name]'s [antag_name] event canceled by admin.")
+ canceled = FALSE
+ return FALSE
+
+ if(!antag_datum_type)
+ stack_trace("[antag_name] event has no antag_datum_type set!")
+ message_admins("[storyteller.name]'s [antag_name] event failed: no antag_datum_type configured.")
+ return FALSE
+
+ var/spawned_count = 0
+ for(var/mob/candidate as anything in candidates)
+ if(!candidate || QDELETED(candidate))
+ continue
+ if(!can_be_candidate(candidate, inputs, storyteller))
+ continue
+
+ var/datum/mind/antag_mind = candidate.mind
+ if(!antag_mind)
+ if(!candidate.key)
+ continue
+ antag_mind = new /datum/mind(candidate.key)
+ antag_mind.current = candidate
+
+ if(!antag_mind.current)
+ antag_mind.current = candidate
+
+ antag_mind.active = TRUE
+
+ // Handle observer candidates - create body
+ var/mob/living/final_mob = candidate
+ if(isobserver(candidate))
+ var/mob/living/new_mob = create_ruleset_body(inputs, storyteller)
+ if(!new_mob || QDELETED(new_mob))
+ log_storyteller("[antag_name] event failed to create body for [key_name(candidate)]")
+ continue
+ final_mob = new_mob
+ antag_mind.transfer_to(final_mob)
+
+ // Add antag datum
+ if(!antag_mind.add_antag_datum(antag_datum_type))
+ log_storyteller("[antag_name] event failed to add antag datum to [key_name(final_mob)]")
+ continue
+
+ // Generate objectives if configured
+ if(antag_objectives)
+ generate_objectives(antag_mind, inputs, storyteller)
+
+ spawned_antags += antag_mind
+ spawned_count++
+ log_game("[key_name(final_mob)] became [antag_name] via [storyteller.name]")
+ message_admins("[key_name(final_mob)] became [antag_name] via [storyteller.name], [ADMIN_VERBOSEJMP(final_mob)]")
+
+
+ if(spawned_count == 0)
+ deadchat_broadcast("[storyteller.name]'s [antag_name] event failed: no valid candidates spawned", message_type=DEADCHAT_ANNOUNCEMENT)
+ message_admins("[storyteller.name]'s [antag_name] event failed: no valid candidates spawned.")
+ return FALSE
+
+ // Post-spawn callbacks
+ if(post_spawn_callback)
+ call(src, post_spawn_callback)(inputs, storyteller, spawned_antags)
+ after_antagonist_spawn(inputs, storyteller, spawned_antags)
+
+ return TRUE
+
+
+/datum/round_event_control/antagonist/proc/after_antagonist_spawn(datum/storyteller_inputs/inputs, datum/storyteller/storyteller, list/spawned_antags)
+ if(can_load_character)
+ for(var/datum/mind/antag_mind in spawned_antags)
+ if(antag_mind.current)
+ var/datum/action/storyteller_loadprefs/ability = new()
+ ability.Grant(antag_mind.current)
+ return
+
+
+/datum/round_event_control/antagonist/from_ghosts
+ crew_candidates = FALSE
+ ghost_candidates = TRUE
+ can_load_character = TRUE
+ signup_atom_appearance = /obj/item/paper
+
+/datum/round_event_control/antagonist/from_living
+ ghost_candidates = FALSE
+ crew_candidates = TRUE
+ var/list/blacklisted_roles = list()
+
+/datum/round_event_control/antagonist/from_living/can_be_candidate(mob/candidate, datum/storyteller_inputs/inputs, datum/storyteller/storyteller)
+ . = ..()
+ if(!.)
+ return FALSE
+
+ if(!(candidate.mind?.assigned_role.job_flags & JOB_CREW_MEMBER))
+ return FALSE
+ if(candidate.mind.assigned_role.title in blacklisted_roles)
+ return FALSE
+ if(candidate.is_antag())
+ return FALSE
+ return TRUE
+
+/datum/action/storyteller_loadprefs
+ name = "Load current character"
+ desc = "Loads your current preferences on current mob"
+ background_icon_state = "bg_agent"
+ button_icon_state = "ghost"
+ show_to_observers = FALSE
+
+ var/confirm_time = 30 SECONDS
+
+/datum/action/storyteller_loadprefs/Grant(mob/grant_to)
+ . = ..()
+ to_chat(grant_to, span_notice("Use the [name] action to load your current character preferences."))
+ addtimer(CALLBACK(src, PROC_REF(Remove), grant_to), confirm_time)
+
+/datum/action/storyteller_loadprefs/Trigger(mob/clicker, trigger_flags)
+ . = ..()
+ if(!clicker.client)
+ return
+ var/client/client = clicker.client
+ if(!ishuman(clicker))
+ return
+ var/ask = tgui_alert(clicker, "Are you sure you want to load your current character preferences?", "Load current character", list("Yes", "Nevermind"))
+ if(ask != "Yes")
+ return
+ client?.prefs?.apply_prefs_to(clicker)
+ SSquirks.AssignQuirks(clicker, client)
+ var/name_ask = tgui_alert(clicker, "Would you like to generate a random name for your character?", "Generate random name", list("Yes", "No"))
+ if(name_ask == "Yes")
+ clicker.generate_random_mob_name()
+ var/msg = span_notice("[key_name(clicker)] has used the [name] ability to apply their character preferences to their current mob. [ADMIN_VERBOSEJMP(clicker)]")
+ message_admins(msg)
+ Remove(clicker)
+
diff --git a/tff_modular/modules/storytellers/events/~round_event.dm b/tff_modular/modules/storytellers/events/~round_event.dm
new file mode 100644
index 00000000000..b919910874d
--- /dev/null
+++ b/tff_modular/modules/storytellers/events/~round_event.dm
@@ -0,0 +1,115 @@
+/datum/round_event
+ //Should event be avaible for random selection? by default? FALSE - It's mean only storyteller can handle with it.
+ var/allow_random = TRUE
+ // Is this event being run by the storyteller system?
+ var/storyteller_implementation = FALSE
+
+ VAR_PRIVATE/datum/storyteller_inputs/___storyteller_inputs = null
+
+ VAR_PRIVATE/datum/storyteller/___storyteller = null
+
+ VAR_PRIVATE/___additional_arguments = list()
+
+// Overrading control of the event to the storyteller system
+/datum/round_event/proc/__setup_for_storyteller(threat_points, ...)
+ SHOULD_CALL_PARENT(TRUE)
+ if(storyteller_implementation)
+ if(islist(___additional_arguments))
+ ___additional_arguments = args[2]
+ else
+ ___additional_arguments = list(args[2])
+ ___storyteller_inputs = args[3]
+ ___storyteller = args[4]
+ else
+ setup()
+
+/datum/round_event/proc/get_executer()
+ PROTECTED_PROC(TRUE)
+ RETURN_TYPE(/datum/storyteller)
+
+ return ___storyteller
+
+/datum/round_event/proc/get_inputs()
+ PROTECTED_PROC(TRUE)
+ RETURN_TYPE(/datum/storyteller_inputs)
+
+ return ___storyteller_inputs
+
+/datum/round_event/proc/get_additional_arguments()
+ PROTECTED_PROC(TRUE)
+ RETURN_TYPE(/list)
+
+ return ___additional_arguments
+
+
+/datum/round_event/proc/__announce_for_storyteller()
+
+
+/datum/round_event/proc/__start_for_storyteller()
+
+
+/datum/round_event/proc/__process_for_storyteller(seconds_per_tick)
+ SHOULD_NOT_OVERRIDE(TRUE)
+ if(!processing)
+ return
+
+ if(activeFor == start_when)
+ processing = FALSE
+ if(storyteller_implementation)
+ __start_for_storyteller()
+ else
+ start()
+ processing = TRUE
+
+ if(activeFor == announce_when && prob(announce_chance))
+ processing = FALSE
+ if(storyteller_implementation)
+ __announce_for_storyteller()
+ else
+ announce()
+ processing = TRUE
+
+ if(start_when < activeFor && activeFor < end_when)
+ processing = FALSE
+ if(storyteller_implementation)
+ __storyteller_tick(seconds_per_tick)
+ else
+ tick()
+ processing = TRUE
+
+ if(activeFor == end_when && end_when >= start_when)
+ processing = FALSE
+ if(storyteller_implementation)
+ __end_for_storyteller()
+ else
+ end()
+ processing = TRUE
+
+ // Everything is done, let's clean up.
+ if(activeFor >= end_when && activeFor >= announce_when && activeFor >= start_when)
+ processing = FALSE
+ if(storyteller_implementation)
+ __kill_for_storyteller()
+ else
+ kill()
+ ___deregister()
+ activeFor++
+
+
+
+/datum/round_event/proc/__end_for_storyteller()
+
+
+/datum/round_event/proc/__kill_for_storyteller()
+
+
+/datum/round_event/proc/__register()
+ if(src in SSevents.running)
+ SSevents.running -= src
+ SSstorytellers.register_active_event(src)
+
+/datum/round_event/proc/___deregister()
+ SSstorytellers.unregister_active_event(src)
+
+/datum/round_event/proc/__storyteller_tick(seconds_per_tick)
+
diff --git a/tff_modular/modules/storytellers/metrics/antagonist/_tracker.dm b/tff_modular/modules/storytellers/metrics/antagonist/_tracker.dm
new file mode 100644
index 00000000000..615060a0385
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/antagonist/_tracker.dm
@@ -0,0 +1,111 @@
+#define TRACKED_TIME "tracked_time"
+#define TRACKED_VICTIM "tracked_victim"
+
+/datum/component/antag_metric_tracker
+ var/mob/living/tracked_mob
+ var/datum/mind/tracked_mind
+
+ var/list/tracked_victims = list()
+ var/kills = 0
+ var/lifetime = 0
+ var/is_dead = FALSE
+ var/tracking = FALSE
+
+/datum/component/antag_metric_tracker/Initialize(datum/mind/_tracked_mind)
+ if(!istype(_tracked_mind))
+ return COMPONENT_INCOMPATIBLE
+ tracked_mind = _tracked_mind
+ tracked_mob = tracked_mind.current
+ if(!tracked_mob || !isliving(tracked_mob))
+ return COMPONENT_INCOMPATIBLE
+
+ START_PROCESSING(SSdcs, src)
+ tracking = TRUE
+
+/datum/component/antag_metric_tracker/RegisterWithParent()
+ RegisterSignal(tracked_mind, COMSIG_MIND_TRANSFERRED, PROC_REF(on_mind_transferred))
+ RegisterSignal(tracked_mind, COMSIG_QDELETING, PROC_REF(stop_tracking))
+ if(tracked_mob)
+ RegisterSignal(tracked_mob, COMSIG_LIVING_DEATH, PROC_REF(on_death))
+ RegisterSignal(tracked_mob, COMSIG_QDELETING, PROC_REF(on_mob_qdel))
+
+/datum/component/antag_metric_tracker/UnregisterFromParent()
+ UnregisterSignal(tracked_mind, list(COMSIG_MIND_TRANSFERRED, COMSIG_QDELETING))
+ if(tracked_mob)
+ UnregisterSignal(tracked_mob, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING))
+ tracked_mob = null
+
+/datum/component/antag_metric_tracker/proc/register_victim(mob/living/victim)
+ if(victim == tracked_mob || !victim.ckey || !victim.client)
+ return
+ var/ckey = victim.ckey
+ if(ckey in tracked_victims)
+ var/list/entry = tracked_victims[ckey]
+ var/mob/living/old = entry[TRACKED_VICTIM]
+ if(old && old != victim)
+ UnregisterSignal(old, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING))
+ entry[TRACKED_VICTIM] = victim
+ else
+ tracked_victims[ckey] = list(TRACKED_VICTIM = victim)
+
+ RegisterSignal(victim, COMSIG_LIVING_DEATH, PROC_REF(on_victim_killed))
+ RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_lost))
+
+/datum/component/antag_metric_tracker/proc/on_victim_killed(mob/living/victim)
+ SIGNAL_HANDLER
+ if(victim.ckey && unregister_victim(victim.ckey))
+ kills++
+
+/datum/component/antag_metric_tracker/proc/on_victim_lost(mob/living/victim)
+ SIGNAL_HANDLER
+ if(victim.ckey)
+ unregister_victim(victim.ckey)
+
+/datum/component/antag_metric_tracker/proc/unregister_victim(ckey)
+ if(!(ckey in tracked_victims))
+ return FALSE
+ var/list/entry = tracked_victims[ckey]
+ var/mob/living/victim = entry[TRACKED_VICTIM]
+ if(victim)
+ UnregisterSignal(victim, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING))
+ tracked_victims -= ckey
+ return TRUE
+
+/datum/component/antag_metric_tracker/proc/on_mind_transferred(datum/source, mob/living/previous_body)
+ SIGNAL_HANDLER
+ if(tracked_mob)
+ UnregisterSignal(tracked_mob, list(COMSIG_LIVING_DEATH, COMSIG_QDELETING))
+ tracked_mob = tracked_mind.current
+ if(tracked_mob)
+ RegisterSignal(tracked_mob, COMSIG_LIVING_DEATH, PROC_REF(on_death))
+ RegisterSignal(tracked_mob, COMSIG_QDELETING, PROC_REF(on_mob_qdel))
+ tracked_victims.Cut()
+ kills = 0
+
+/datum/component/antag_metric_tracker/proc/on_death(mob/living/source)
+ SIGNAL_HANDLER
+ is_dead = TRUE
+
+/datum/component/antag_metric_tracker/proc/on_mob_qdel(mob/living/source)
+ SIGNAL_HANDLER
+ tracked_mob = null
+
+/datum/component/antag_metric_tracker/proc/stop_tracking(datum/source)
+ SIGNAL_HANDLER
+ tracked_mob = null
+ Destroy()
+
+/datum/component/antag_metric_tracker/process(delta_time)
+ if(!tracking || !tracked_mob || QDELETED(tracked_mob) || !isliving(tracked_mob) || tracked_mob.client?.is_afk())
+ return
+ lifetime += delta_time
+
+/datum/component/antag_metric_tracker/Destroy(force)
+ STOP_PROCESSING(SSdcs, src)
+ for(var/ckey in tracked_victims)
+ unregister_victim(ckey)
+ UnregisterFromParent()
+ return ..()
+
+#undef TRACKED_TIME
+#undef TRACKED_VICTIM
diff --git a/tff_modular/modules/storytellers/metrics/antagonist/effectivity.dm b/tff_modular/modules/storytellers/metrics/antagonist/effectivity.dm
new file mode 100644
index 00000000000..1ed4ed9e959
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/antagonist/effectivity.dm
@@ -0,0 +1,210 @@
+// Базовый /datum/antagonist
+
+/datum/antagonist
+ var/initial_weight = STORY_DEFAULT_ANTAG_WEIGHT
+ var/datum/component/antag_metric_tracker/tracker
+
+/datum/antagonist/proc/get_effectivity()
+ if(antag_flags & ANTAG_FAKE || !tracker)
+ return 0.5
+
+ var/datum/component/antag_metric_tracker/T = tracker
+ if(!T || T.is_dead)
+ return 0.3
+
+ var/time_factor = T.lifetime > 0 ? clamp(T.lifetime / (world.time * 0.5), 0, 1) : 0
+ var/kill_factor = clamp(T.kills / 4, 0, 1)
+
+ return round(0.5 + time_factor * 0.75 + kill_factor * 0.75, 0.1)
+
+/datum/antagonist/proc/get_weight()
+ if(antag_flags & ANTAG_FAKE || !tracker)
+ return initial_weight * 0.5
+
+ return initial_weight * get_effectivity()
+
+
+/datum/antagonist/on_gain()
+ . = ..()
+#ifdef UNIT_TESTS
+ return
+#endif
+ if(antag_flags & ANTAG_FAKE)
+ return
+ var/mob/living/L = owner?.current
+ if(istype(L))
+ tracker = owner.GetComponent(/datum/component/antag_metric_tracker)
+ if(!tracker)
+ tracker = owner.AddComponent(/datum/component/antag_metric_tracker, owner)
+
+
+/datum/antagonist/abductor
+ initial_weight = STORY_MINOR_ANTAG_WEIGHT * 1.4
+
+/datum/antagonist/abductor/get_effectivity()
+ var/datum/team/abductor_team/team = get_team()
+ if(!team || team.abductees.len <= 0)
+ return 0.5
+
+ var/total_abductees = max(1, team.abductees.len)
+ return round(0.5 + (2 / total_abductees) * 0.5, 2)
+
+
+/datum/antagonist/blob
+ initial_weight = STORY_MAJOR_ANTAG_WEIGHT * 0.8
+
+/datum/antagonist/blob/get_effectivity()
+ if(!isovermind(owner?.current))
+ return 0.5
+
+ var/mob/eye/blob/overmind = owner.current
+ var/total_blobs = max(1, overmind.blobs_legit.len)
+ var/total_points = max(1, overmind.blob_points)
+ var/core_health = overmind.blob_core ? (overmind.blob_core.max_integrity / max(1, overmind.blob_core.integrity_failure)) : 0
+
+ var/tracker_factor = tracker ? clamp(tracker.lifetime / 600, 0, 1.5) : 0.5 // ~10 минут = 1.0
+
+ return clamp(0.25 + (total_blobs / 10)*0.35 + (total_points / 100)*0.25 + core_health*0.15 + tracker_factor*0.2, 0, 2)
+
+/datum/antagonist/blob/get_weight()
+ if(!isovermind(owner?.current))
+ return 0.5
+ var/mob/eye/blob/overmind = owner.current
+ return clamp(1 + (overmind.blobs_legit.len / 5), 1, STORY_MAJOR_ANTAG_WEIGHT * 2)
+
+
+/datum/antagonist/brother
+ initial_weight = STORY_MINOR_ANTAG_WEIGHT
+
+/datum/antagonist/brother/get_effectivity()
+ var/datum/team/brother_team/team = get_team()
+ if(!team || team.brothers_left <= 0)
+ return 0.5
+
+ var/total_brothers = max(1, team.brothers_left)
+ if(total_brothers == 1)
+ return 0.5
+ return clamp(0.5 + (2 / total_brothers) * 0.5, 0.1, 2)
+
+
+/datum/antagonist/changeling
+ initial_weight = STORY_MEDIUM_ANTAG_WEIGHT
+
+/datum/antagonist/changeling/get_effectivity()
+ if(!tracker || tracker.lifetime <= 0)
+ return 0.5
+
+ var/absorb_factor = absorbed_count > 0 ? (true_absorbs / absorbed_count) : 0
+ var/power_factor = length(purchased_powers) > 0 ? 1 : 0
+
+ var/base = 0.5 + 0.5 * absorb_factor * power_factor
+ base = clamp(base, 0, 1.5)
+
+ return round(base / (tracker.lifetime * 0.01), 2)
+
+
+/datum/antagonist/cult
+ initial_weight = STORY_MAJOR_ANTAG_WEIGHT
+
+/datum/antagonist/cult/get_effectivity()
+ var/datum/team/cult/team = get_team()
+ if(!team || team.size_at_maximum <= 0)
+ return 0.5
+
+ var/base = 0.25 + (team.size_at_maximum / 10)
+ if(team.cult_risen)
+ base += 0.4
+ if(team.cult_ascendent)
+ base += 0.5
+
+ base = clamp(base, 0, 2)
+ return base / max(1, team.size_at_maximum)
+
+
+/datum/antagonist/cult/get_weight()
+ var/datum/team/cult/team = get_team()
+ if(!team)
+ return 0.5
+ return clamp(1 + (team.size_at_maximum / 2), 1, STORY_DEFAULT_ANTAG_WEIGHT * 5)
+
+
+/datum/antagonist/heretic
+ initial_weight = STORY_MEDIUM_ANTAG_WEIGHT
+
+/datum/antagonist/heretic/get_effectivity()
+ if(ascended)
+ return 2.0
+ var/sacrifice_ratio = total_sacrifices > 0 ? high_value_sacrifices / total_sacrifices : 0
+ return clamp(sacrifice_ratio * 2, 0, 2)
+
+/datum/antagonist/heretic/get_weight()
+ return ascended ? STORY_MAJOR_ANTAG_WEIGHT : initial_weight * get_effectivity()
+
+
+/datum/antagonist/malf_ai
+ initial_weight = STORY_MAJOR_ANTAG_WEIGHT * 1.2
+
+/datum/antagonist/malf_ai/get_effectivity()
+ if(!isAI(owner?.current) || !tracker || tracker.lifetime <= 0)
+ return 0.5
+
+ var/mob/living/silicon/ai/ai = owner.current
+ var/hacked_apcs = length(ai.hacked_apcs)
+ var/hacked_borgs = length(ai.connected_robots)
+
+ if(hacked_apcs <= 0)
+ return 0.5
+
+ var/activity = (hacked_apcs * 0.1) + (hacked_borgs * 0.3)
+ return clamp(0.5 + activity / (tracker.lifetime * 0.01), 0, 2)
+
+
+/datum/antagonist/nightmar
+ initial_weight = STORY_MEDIUM_ANTAG_WEIGHT * 0.7
+
+/datum/antagonist/nightmare/get_effectivity()
+ var/base = ..()
+ if(!tracker)
+ return base
+ return clamp(base + (tracker.lifetime * 0.005) + (tracker.kills * 0.15), 0, 2)
+
+
+
+/datum/antagonist/ninja
+ initial_weight = STORY_MEDIUM_ANTAG_WEIGHT * 1.2
+
+/datum/antagonist/ninja/get_effectivity()
+ var/base = ..()
+ if(!tracker)
+ return base
+ return clamp(base + (tracker.lifetime * 0.008), 0, 2)
+
+
+/datum/antagonist/nukeop
+ initial_weight = STORY_MAJOR_ANTAG_WEIGHT * 1.5
+
+/datum/antagonist/nukeop/get_effectivity()
+ var/base = 1
+ if(!tracker)
+ return base * 0.5
+
+ var/datum/team/nuclear/nuke_team = get_team()
+ var/war_declared = FALSE
+ if(nuke_team)
+ var/obj/item/nuclear_challenge/challenge = nuke_team.war_button_ref?.resolve()
+ war_declared = !!challenge?.declaring_war
+
+ return clamp(base + (tracker.lifetime * 0.008) + (tracker.kills * 0.18) + (war_declared ? 0.5 : 0), 0, 2)
+
+
+/datum/antagonist/traitor
+ initial_weight = STORY_MINOR_ANTAG_WEIGHT * 1.1
+
+/datum/antagonist/traitor/get_effectivity()
+ if(!tracker || tracker.lifetime < 1)
+ return 0.5
+
+ if(tracker.lifetime < 20 MINUTES)
+ return 0.5
+
+ return clamp(0.5 + (tracker.kills * 0.2) + (tracker.lifetime / (world.time || 1)) * 0.1, 0, 2)
diff --git a/tff_modular/modules/storytellers/metrics/antagonist/metric.dm b/tff_modular/modules/storytellers/metrics/antagonist/metric.dm
new file mode 100644
index 00000000000..e6ab023cb89
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/antagonist/metric.dm
@@ -0,0 +1,33 @@
+/datum/storyteller_metric/antagonist_activity
+ name = "Antagonist Activity"
+
+/datum/storyteller_metric/antagonist_activity/can_perform_now(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ return inputs.antag_count() > 0
+
+/datum/storyteller_metric/antagonist_activity/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ var/total_lifetime = 0
+ var/total_kills = 0
+ var/alive_antags = 0
+ var/total_weight = 0
+ var/total_influence = 0
+
+ for(var/datum/antagonist/antag in GLOB.antagonists)
+ var/datum/mind/M = antag.owner
+ if(!M?.current || !isliving(M.current))
+ continue
+ var/datum/component/antag_metric_tracker/T = M.GetComponent(/datum/component/antag_metric_tracker)
+ if(M.current.stat != DEAD)
+ alive_antags++
+ var/weight = antag.get_weight()
+ total_weight += weight
+ total_influence += weight * antag.get_effectivity()
+
+ if(!T)
+ continue
+ total_lifetime += T.lifetime
+ total_kills += T.kills
+
+ inputs.set_entry(STORY_VAULT_ANTAG_WEIGHT, total_weight ? total_weight / max(1, alive_antags) : 0)
+ inputs.set_entry(STORY_VAULT_ANTAG_INFLUENCE, total_weight ? total_influence / total_weight : 0)
+ inputs.set_entry(STORY_VAULT_ANTAGONIST_PRESENCE, alive_antags >= 3 ? 3 : (alive_antags >= 1 ? 1 : 0))
+ ..()
diff --git a/tff_modular/modules/storytellers/metrics/crew.dm b/tff_modular/modules/storytellers/metrics/crew.dm
new file mode 100644
index 00000000000..31ee7da35c6
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/crew.dm
@@ -0,0 +1,12 @@
+// Empty metric placeholders - can be implemented later if needed
+/datum/storyteller_metric/crew_persistence
+ name = "Crew Persistence"
+
+/datum/storyteller_metric/crew_persistence/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ ..()
+
+/datum/storyteller_metric/station_important_areas
+ name = "Station Important Areas"
+
+/datum/storyteller_metric/station_important_areas/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ ..()
diff --git a/tff_modular/modules/storytellers/metrics/health.dm b/tff_modular/modules/storytellers/metrics/health.dm
new file mode 100644
index 00000000000..00318217745
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/health.dm
@@ -0,0 +1,236 @@
+// Threshold defines for health damage levels (avg damage per alive)
+#define HEALTH_LOW_THRESHOLD 70 // High damage → low health level
+#define HEALTH_DAMAGED_THRESHOLD 40 // Medium damage
+#define HEALTH_NORMAL_THRESHOLD 10 // Low damage
+
+// Threshold defines for wounding levels (avg wounds per alive)
+#define WOUNDING_CRITICAL_THRESHOLD 3 // Many wounds
+#define WOUNDING_MANY_THRESHOLD 2
+#define WOUNDING_SOME_THRESHOLD 1
+
+// Threshold defines for diseases ratio (infected / alive crew)
+#define DISEASES_OUTBREAK_THRESHOLD 0.5 // High infection
+#define DISEASES_MAJOR_THRESHOLD 0.25
+#define DISEASES_MINOR_THRESHOLD 0.05
+
+// Threshold defines for crew dead count (absolute dead)
+#define CREW_DEAD_MANY_THRESHOLD 15 // Many dead
+#define CREW_DEAD_SOME_THRESHOLD 5
+#define CREW_DEAD_FEW_THRESHOLD 0 // Greater than 0
+
+// Threshold defines for antag dead count (absolute dead)
+#define ANTAG_DEAD_MANY_THRESHOLD 7 // Many dead antags
+#define ANTAG_DEAD_SOME_THRESHOLD 3
+#define ANTAG_DEAD_FEW_THRESHOLD 0 // Greater than 0
+
+// Threshold defines for dead ratios (dead / total)
+#define DEAD_RATIO_EXTREME_THRESHOLD 0.6 // Extreme losses
+#define DEAD_RATIO_HIGH_THRESHOLD 0.3
+#define DEAD_RATIO_MODERATE_THRESHOLD 0.1
+
+#define DEAD_DAMAGE 200
+
+
+// Outputs to vault: health/wounding/diseases/dead levels + counts/avgs for balancer tension/goal selection
+/datum/storyteller_metric/crew_metrics
+ name = "Crew health metric"
+
+/datum/storyteller_metric/crew_metrics/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+
+ var/crew_health_level = STORY_VAULT_HEALTH_HEALTHY
+ var/antag_health_level = STORY_VAULT_HEALTH_HEALTHY
+ var/crew_wounding_level = STORY_VAULT_NO_WOUNDS
+ var/antag_wounding_level = STORY_VAULT_NO_WOUNDS
+ var/crew_diseases_level = STORY_VAULT_NO_DISEASES
+ var/crew_dead_count_level = STORY_VAULT_NO_DEAD
+ var/crew_dead_ratio_level = STORY_VAULT_LOW_DEAD_RATIO
+ var/antag_dead_count_level = STORY_VAULT_NO_DEAD
+ var/antag_dead_ratio_level = STORY_VAULT_LOW_DEAD_RATIO
+
+ var/alive_crew_count = 0
+ var/alive_antag_count = 0
+ var/dead_crew_count = 0
+ var/dead_antag_count = 0
+
+ var/total_crew_damage = 0
+ var/total_antag_damage = 0
+ var/total_crew_wounds = 0
+ var/total_antag_wounds = 0
+ var/total_infected_crew = 0
+
+ for(var/mob/living/L in get_alive_crew(FALSE))
+ if(!is_station_level(L.z))
+ continue
+
+ var/is_antag = L.is_antag() || FALSE
+ var/tot_damage = L.get_total_damage()
+ var/tot_wounds = 0
+
+ if(iscarbon(L))
+ var/mob/living/carbon/C = L
+ tot_wounds = length(C.all_wounds)
+ if(!is_antag && length(C.diseases))
+ total_infected_crew++
+
+ if(is_antag)
+ alive_antag_count++
+ total_antag_damage += tot_damage
+ total_antag_wounds += tot_wounds
+ else
+ alive_crew_count++
+ total_crew_damage += tot_damage
+ total_crew_wounds += tot_wounds
+
+ for(var/mob/living/L in get_dead_crew(FALSE))
+ if(!is_station_level(L.z))
+ continue
+
+ var/is_antag = L.is_antag() || FALSE
+
+ if(is_antag)
+ dead_antag_count++
+ total_antag_damage += DEAD_DAMAGE
+ else
+ dead_crew_count++
+ total_crew_damage += DEAD_DAMAGE
+
+
+ var/total_crew = max(alive_crew_count + dead_crew_count, 1)
+ var/total_antag = max(alive_antag_count + dead_antag_count, 1)
+
+ var/avg_crew_health_raw = clamp(100 - (total_crew_damage / total_crew), 0, 100)
+ var/avg_antag_health_raw = clamp(100 - (total_antag_damage / total_antag), 0, 100)
+
+ var/avg_crew_wounds = alive_crew_count ? (total_crew_wounds / alive_crew_count) : 0
+ var/avg_antag_wounds = alive_antag_count ? (total_antag_wounds / alive_antag_count) : 0
+
+
+ if(avg_crew_health_raw <= HEALTH_NORMAL_THRESHOLD)
+ crew_health_level = STORY_VAULT_HEALTH_LOW
+ else if(avg_crew_health_raw <= HEALTH_DAMAGED_THRESHOLD)
+ crew_health_level = STORY_VAULT_HEALTH_DAMAGED
+ else if(avg_crew_health_raw <= HEALTH_LOW_THRESHOLD)
+ crew_health_level = STORY_VAULT_HEALTH_NORMAL
+ else
+ crew_health_level = STORY_VAULT_HEALTH_HEALTHY
+
+ if(avg_antag_health_raw <= HEALTH_NORMAL_THRESHOLD)
+ antag_health_level = STORY_VAULT_HEALTH_LOW
+ else if(avg_antag_health_raw <= HEALTH_DAMAGED_THRESHOLD)
+ antag_health_level = STORY_VAULT_HEALTH_DAMAGED
+ else if(avg_antag_health_raw <= HEALTH_LOW_THRESHOLD)
+ antag_health_level = STORY_VAULT_HEALTH_NORMAL
+ else
+ antag_health_level = STORY_VAULT_HEALTH_HEALTHY
+
+ if(avg_crew_wounds >= WOUNDING_CRITICAL_THRESHOLD)
+ crew_wounding_level = STORY_VAULT_CRITICAL_WOUNDED
+ else if(avg_crew_wounds >= WOUNDING_MANY_THRESHOLD)
+ crew_wounding_level = STORY_VAULT_MANY_WOUNDED
+ else if(avg_crew_wounds >= WOUNDING_SOME_THRESHOLD)
+ crew_wounding_level = STORY_VAULT_SOME_WOUNDED
+ else
+ crew_wounding_level = STORY_VAULT_NO_WOUNDS
+
+ if(avg_antag_wounds >= WOUNDING_CRITICAL_THRESHOLD)
+ antag_wounding_level = STORY_VAULT_CRITICAL_WOUNDED
+ else if(avg_antag_wounds >= WOUNDING_MANY_THRESHOLD)
+ antag_wounding_level = STORY_VAULT_MANY_WOUNDED
+ else if(avg_antag_wounds >= WOUNDING_SOME_THRESHOLD)
+ antag_wounding_level = STORY_VAULT_SOME_WOUNDED
+ else
+ antag_wounding_level = STORY_VAULT_NO_WOUNDS
+
+ var/infected_ratio = alive_crew_count ? (total_infected_crew / alive_crew_count) : 0
+
+ if(infected_ratio >= DISEASES_OUTBREAK_THRESHOLD)
+ crew_diseases_level = STORY_VAULT_OUTBREAK
+ else if(infected_ratio >= DISEASES_MAJOR_THRESHOLD)
+ crew_diseases_level = STORY_VAULT_MAJOR_DISEASES
+ else if(infected_ratio >= DISEASES_MINOR_THRESHOLD)
+ crew_diseases_level = STORY_VAULT_MINOR_DISEASES
+ else
+ crew_diseases_level = STORY_VAULT_NO_DISEASES
+
+
+ if(dead_crew_count > CREW_DEAD_MANY_THRESHOLD)
+ crew_dead_count_level = STORY_VAULT_MANY_DEAD
+ else if(dead_crew_count > CREW_DEAD_SOME_THRESHOLD)
+ crew_dead_count_level = STORY_VAULT_SOME_DEAD
+ else if(dead_crew_count > CREW_DEAD_FEW_THRESHOLD)
+ crew_dead_count_level = STORY_VAULT_FEW_DEAD
+ else
+ crew_dead_count_level = STORY_VAULT_NO_DEAD
+
+ if(dead_antag_count > ANTAG_DEAD_MANY_THRESHOLD)
+ antag_dead_count_level = STORY_VAULT_MANY_DEAD
+ else if(dead_antag_count > ANTAG_DEAD_SOME_THRESHOLD)
+ antag_dead_count_level = STORY_VAULT_SOME_DEAD
+ else if(dead_antag_count > ANTAG_DEAD_FEW_THRESHOLD)
+ antag_dead_count_level = STORY_VAULT_FEW_DEAD
+ else
+ antag_dead_count_level = STORY_VAULT_NO_DEAD
+
+
+ var/crew_dead_ratio = dead_crew_count / total_crew
+ if(crew_dead_ratio > DEAD_RATIO_EXTREME_THRESHOLD)
+ crew_dead_ratio_level = STORY_VAULT_EXTREME_DEAD_RATIO
+ else if(crew_dead_ratio > DEAD_RATIO_HIGH_THRESHOLD)
+ crew_dead_ratio_level = STORY_VAULT_HIGH_DEAD_RATIO
+ else if(crew_dead_ratio > DEAD_RATIO_MODERATE_THRESHOLD)
+ crew_dead_ratio_level = STORY_VAULT_MODERATE_DEAD_RATIO
+ else
+ crew_dead_ratio_level = STORY_VAULT_LOW_DEAD_RATIO
+
+ var/antag_dead_ratio = dead_antag_count / total_antag
+ if(antag_dead_ratio > DEAD_RATIO_EXTREME_THRESHOLD)
+ antag_dead_ratio_level = STORY_VAULT_EXTREME_DEAD_RATIO
+ else if(antag_dead_ratio > DEAD_RATIO_HIGH_THRESHOLD)
+ antag_dead_ratio_level = STORY_VAULT_HIGH_DEAD_RATIO
+ else if(antag_dead_ratio > DEAD_RATIO_MODERATE_THRESHOLD)
+ antag_dead_ratio_level = STORY_VAULT_MODERATE_DEAD_RATIO
+ else
+ antag_dead_ratio_level = STORY_VAULT_LOW_DEAD_RATIO
+
+
+ inputs.set_entry(STORY_VAULT_CREW_HEALTH, crew_health_level)
+ inputs.set_entry(STORY_VAULT_ANTAG_HEALTH, antag_health_level)
+ inputs.set_entry(STORY_VAULT_CREW_WOUNDING, crew_wounding_level)
+ inputs.set_entry(STORY_VAULT_ANTAG_WOUNDING, antag_wounding_level)
+ inputs.set_entry(STORY_VAULT_CREW_DISEASES, crew_diseases_level)
+ inputs.set_entry(STORY_VAULT_CREW_ALIVE_LEVEL, crew_dead_count_level)
+ inputs.set_entry(STORY_VAULT_CREW_DEAD_RATIO, crew_dead_ratio_level)
+ inputs.set_entry(STORY_VAULT_ANTAG_ALIVE_LEVEL, antag_dead_count_level)
+ inputs.set_entry(STORY_VAULT_ANTAG_DEAD_RATIO, antag_dead_ratio_level)
+
+ inputs.set_entry(STORY_VAULT_AVG_CREW_HEALTH, avg_crew_health_raw)
+ inputs.set_entry(STORY_VAULT_AVG_ANTAG_HEALTH, avg_antag_health_raw)
+ inputs.set_entry(STORY_VAULT_AVG_CREW_WOUNDS, avg_crew_wounds)
+ inputs.set_entry(STORY_VAULT_AVG_ANTAG_WOUNDS, avg_antag_wounds)
+
+ inputs.set_entry(STORY_VAULT_CREW_ALIVE_COUNT, alive_crew_count)
+ inputs.set_entry(STORY_VAULT_ANTAG_ALIVE_COUNT, alive_antag_count)
+ inputs.set_entry(STORY_VAULT_CREW_DEAD_COUNT, dead_crew_count)
+ inputs.set_entry(STORY_VAULT_ANTAG_DEAD_COUNT, dead_antag_count)
+
+ ..()
+
+#undef DEAD_DAMAGE
+#undef HEALTH_LOW_THRESHOLD
+#undef HEALTH_DAMAGED_THRESHOLD
+#undef HEALTH_NORMAL_THRESHOLD
+#undef WOUNDING_CRITICAL_THRESHOLD
+#undef WOUNDING_MANY_THRESHOLD
+#undef WOUNDING_SOME_THRESHOLD
+#undef DISEASES_OUTBREAK_THRESHOLD
+#undef DISEASES_MAJOR_THRESHOLD
+#undef DISEASES_MINOR_THRESHOLD
+#undef CREW_DEAD_MANY_THRESHOLD
+#undef CREW_DEAD_SOME_THRESHOLD
+#undef CREW_DEAD_FEW_THRESHOLD
+#undef ANTAG_DEAD_MANY_THRESHOLD
+#undef ANTAG_DEAD_SOME_THRESHOLD
+#undef ANTAG_DEAD_FEW_THRESHOLD
+#undef DEAD_RATIO_EXTREME_THRESHOLD
+#undef DEAD_RATIO_HIGH_THRESHOLD
+#undef DEAD_RATIO_MODERATE_THRESHOLD
diff --git a/tff_modular/modules/storytellers/metrics/research.dm b/tff_modular/modules/storytellers/metrics/research.dm
new file mode 100644
index 00000000000..638704ee7fa
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/research.dm
@@ -0,0 +1,49 @@
+
+#define STORY_RESEARCH_ADVANCED_THRESHOLD 50
+#define STORY_RESEARCH_HIGH_THRESHOLD 30
+#define STORY_RESEARCH_MODERATE_THRESHOLD 15
+
+// Metric for station research level: analyzes R&D progress (points, unlocked nodes, tiers)
+// Outputs to vault: research_progress 0-3 (low/moderate/high/advanced) for balancer modifiers
+/datum/storyteller_metric/research_level
+ name = "Research level check"
+
+/datum/storyteller_metric/research_level/can_perform_now(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ return SSresearch.initialized && length(SSresearch.techweb_nodes) > 0
+
+/datum/storyteller_metric/research_level/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ ..()
+
+ var/datum/techweb/station_tech
+ for(var/datum/techweb/web in SSresearch.techwebs)
+ if(istype(web, /datum/techweb/science))
+ station_tech = web
+ break
+
+ if(!station_tech)
+ inputs.set_entry(STORY_VAULT_RESEARCH_PROGRESS, STORY_VAULT_LOW_RESEARCH)
+ return
+
+ var/unlocked_nodes = length(station_tech.researched_nodes)
+ var/total_nodes = length(SSresearch.techweb_nodes)
+
+ if(total_nodes <= 0)
+ inputs.set_entry(STORY_VAULT_RESEARCH_PROGRESS, STORY_VAULT_LOW_RESEARCH)
+ return
+
+ var/unlocked_percent = (unlocked_nodes / total_nodes) * 100
+
+ var/progress_level = STORY_VAULT_LOW_RESEARCH
+ if(unlocked_percent >= STORY_RESEARCH_ADVANCED_THRESHOLD)
+ progress_level = STORY_VAULT_ADVANCED_RESEARCH
+ else if(unlocked_percent >= STORY_RESEARCH_HIGH_THRESHOLD)
+ progress_level = STORY_VAULT_HIGH_RESEARCH
+ else if(unlocked_percent >= STORY_RESEARCH_MODERATE_THRESHOLD)
+ progress_level = STORY_VAULT_MODERATE_RESEARCH
+
+ inputs.set_entry(STORY_VAULT_RESEARCH_PROGRESS, progress_level)
+
+
+#undef STORY_RESEARCH_ADVANCED_THRESHOLD
+#undef STORY_RESEARCH_HIGH_THRESHOLD
+#undef STORY_RESEARCH_MODERATE_THRESHOLD
diff --git a/tff_modular/modules/storytellers/metrics/resources.dm b/tff_modular/modules/storytellers/metrics/resources.dm
new file mode 100644
index 00000000000..9225c8f019f
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/resources.dm
@@ -0,0 +1,46 @@
+// Specialized check for station resources: ore silos and other resource-related objects
+/datum/storyteller_metric/resource_check
+ name = "Resource Check"
+
+ // List of machinery types to process
+ var/static/list/machinery_types = list(
+ /obj/machinery/ore_silo,
+ /obj/machinery/rnd/production,
+ /obj/machinery/autolathe
+ )
+
+/datum/storyteller_metric/resource_check/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ var/total_minerals = 0
+
+ // Iterate over all machinery types
+ for(var/machinery_type in machinery_types)
+ for(var/machinery in SSmachines.get_machines_by_type(machinery_type))
+ if(!istype(machinery, machinery_type))
+ continue
+
+ // Handle ore silos
+ if(istype(machinery, /obj/machinery/ore_silo))
+ var/obj/machinery/ore_silo/silo = machinery
+ if(silo.holds)
+ for(var/mat_type in silo.holds)
+ total_minerals += silo.holds[mat_type]
+ else
+ // Handle lathes and production machines
+ var/obj/machinery/lathe = machinery
+ var/datum/component/remote_materials/silo_link = lathe.GetComponent(/datum/component/remote_materials)
+ // Skip machines linked to silos (they don't store materials locally)
+ if(silo_link?.silo)
+ continue
+ var/datum/component/material_container/mat_con = lathe.GetComponent(/datum/component/material_container)
+ if(mat_con?.materials)
+ for(var/material in mat_con.materials)
+ total_minerals += mat_con.materials[material]
+
+ // Store total minerals in the vault
+ inputs.set_entry(STORY_VAULT_RESOURCE_MINERALS, total_minerals)
+
+ // Other resources, like cargo points
+ var/other_resources = SSshuttle?.points || 0
+ inputs.set_entry(STORY_VAULT_RESOURCE_OTHER, other_resources)
+ ..()
+
diff --git a/tff_modular/modules/storytellers/metrics/station_integrity.dm b/tff_modular/modules/storytellers/metrics/station_integrity.dm
new file mode 100644
index 00000000000..43b23052380
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/station_integrity.dm
@@ -0,0 +1,175 @@
+#define STORY_INTEGRITY_PENALTY_UNSAFE 20 //%
+#define STORY_INTEGRITY_PENALTY_FIRES 10
+
+/datum/storyteller_metric/station_integrity
+ name = "Station integrity"
+
+ var/static/list/ignored_areas = list(
+ /area/station/engineering/supermatter,
+ /area/station/science/ordnance,
+ /area/station/service/kitchen/coldroom,
+ /area/station/science/ordnance/bomb,
+ /area/station/medical/coldroom,
+ /area/station/tcommsat,
+ /area/station/solars,
+ )
+
+
+// Metric for station integrity: analyzes areas for damage, fires, breaches, etc.
+// Outputs to vault: raw integrity 0-100 and infra_damage 0-3
+/datum/storyteller_metric/station_integrity/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ var/list/to_analyze = get_areas(/area/station)
+ if(!length(to_analyze))
+ return
+
+ var/total_firespots_weight = 0 // Weighted fires (by area size with fire)
+ var/unsafe_areas = 0 // Count unsafe (for damage_level)
+ var/total_unsafe_area_size = 0 // Total size of unsafe areas
+ var/checked_areas = 0 // Total valid areas
+ var/total_area_size = 0 // Total size of valid areas
+ var/weighted_safe_score = 0 // Sum size of safe areas
+
+
+ for(var/area/station/station_area in to_analyze)
+ if(station_area in ignored_areas)
+ continue
+ if(station_area.outdoors || station_area.area_flags & NO_GRAVITY)
+ continue
+
+ var/area_size = station_area.areasize
+ total_area_size += area_size
+ checked_areas += 1
+
+ if(station_area.fire)
+ total_firespots_weight += area_size
+
+ if(!is_safe_area(station_area) || !length(station_area.air_vents))
+ unsafe_areas += 1
+ total_unsafe_area_size += area_size
+ else
+ weighted_safe_score += area_size
+
+
+ // Base integrity as % safe size * 100
+ var/safe_percentage = total_area_size > 0 ? (weighted_safe_score / total_area_size) : 0
+ var/base_integrity = safe_percentage * 100
+
+ // Penalties based on unsafe % size and weighted fires (no count-based, size-focused)
+ var/unsafe_percentage = total_area_size > 0 ? (total_unsafe_area_size / total_area_size) * 100 : 0
+ var/penalty_unsafe = unsafe_percentage * (STORY_INTEGRITY_PENALTY_UNSAFE / 100) // Scale penalty by % unsafe
+ // Normalize fire weight by total area size for penalty calculation
+ var/fire_weight_normalized = total_area_size > 0 ? (total_firespots_weight / total_area_size) : 0
+ var/penalty_fires = fire_weight_normalized * STORY_INTEGRITY_PENALTY_FIRES // Weighted by fire areas size
+ var/raw_integrity = clamp(base_integrity - penalty_unsafe - penalty_fires, 0, 100)
+
+ // Damage level based on % unsafe size + weighted fires (relative, not count)
+ var/damage_level = clamp((unsafe_percentage / 100 * STORY_VAULT_CRITICAL_DAMAGE) + (fire_weight_normalized * 1.5), 0, 3) // *1.5 tune for fires sensitivity
+
+
+ inputs.set_entry(STORY_VAULT_INFRA_DAMAGE, round(damage_level))
+ inputs.set_entry(STORY_VAULT_STATION_INTEGRITY, round(raw_integrity))
+ ..()
+
+
+
+#undef STORY_INTEGRITY_PENALTY_UNSAFE
+#undef STORY_INTEGRITY_PENALTY_FIRES
+
+
+
+// Thresholds for low power states (percentages)
+#define STORY_POWER_LOW_SMES_THRESHOLD 10 // Below this % SMES charge → penalty
+#define STORY_POWER_APC_FAILURE_RATIO 0.1 // Ratio for damage_level: failures / (checked * this)
+
+// Penalties for calculations (subtracted from raw_strength; scaled by size in code)
+#define STORY_POWER_PENALTY_LOW_SMES 10 // Fixed penalty if SMES < threshold
+#define STORY_POWER_PENALTY_PER_OFF_APC 5 // Per non-operating APC (scaled by area size)
+
+
+
+
+// Metric for station power grid: analyzes APCs and SMES for charge, operational status, etc. Weighted by area size.
+// Outputs to vault: raw power strength 0-100 (size-based) and power damage 0-3
+/datum/storyteller_metric/power_grid_check
+ name = "Station power grid"
+
+/datum/storyteller_metric/power_grid_check/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ var/list/apc_to_check = list()
+ var/list/smes_to_check = list()
+
+ for(var/obj/machinery/power/power_machine as anything in SSmachines.get_machines_by_type_and_subtypes(/obj/machinery/power))
+ if(!is_station_level(power_machine.z))
+ continue
+ if(istype(power_machine, /obj/machinery/power/apc))
+ apc_to_check += power_machine
+ else if(istype(power_machine, /obj/machinery/power/smes))
+ smes_to_check += power_machine
+
+ if(!length(apc_to_check) && !length(smes_to_check))
+ inputs.set_entry(STORY_VAULT_POWER_GRID_STRENGTH, 0)
+ inputs.set_entry(STORY_VAULT_POWER_GRID_DAMAGE, 3)
+ return ..()
+
+
+ var/total_apc_charge = 0 // Weighted charge sum
+ var/operating_apc_weight = 0 // Weighted operating (by area size)
+ var/total_apc_size = 0 // Total valid APC areas size
+ var/total_off_apc_size = 0 // Total size of off/failing APC areas (for penalties/damage)
+ var/total_smes_charge = 0 // Sum SMES charge
+ var/total_smes_capacity = 0 // Sum SMES capacity
+
+
+ for(var/obj/machinery/power/apc/APC in apc_to_check)
+ if(!APC.area) continue
+ var/area_size = APC.area.areasize
+ total_apc_size += area_size
+
+
+ var/charge_percent = APC.cell ? APC.cell.percent() : 0
+ total_apc_charge += charge_percent * area_size
+
+ // Operating weighted (if operating, add size; else add to off_size)
+ if(APC.operating && !APC.failure_timer)
+ operating_apc_weight += area_size
+ else
+ total_off_apc_size += area_size
+
+
+ for(var/obj/machinery/power/smes/SMES in smes_to_check)
+ total_smes_charge += SMES.charge
+
+ var/total_capacity = 0
+ for(var/obj/item/stock_parts/power_store/power_cell in SMES.component_parts)
+ total_capacity += power_cell.maxcharge
+ total_smes_capacity += total_capacity
+
+
+ var/avg_apc_charge = total_apc_size > 0 ? (total_apc_charge / total_apc_size) : 0
+ var/apc_operating_percent = total_apc_size > 0 ? (operating_apc_weight / total_apc_size) * 100 : 0
+ var/apc_strength = (avg_apc_charge * 0.6) + (apc_operating_percent * 0.4)
+
+
+ var/smes_percent = total_smes_capacity > 0 ? (total_smes_charge / total_smes_capacity) * 100 : 0
+
+ var/raw_strength = (apc_strength * 0.7) + (smes_percent * 0.3)
+
+
+ var/penalty_low_smes = smes_percent < STORY_POWER_LOW_SMES_THRESHOLD ? STORY_POWER_PENALTY_LOW_SMES : 0
+ var/off_percentage = total_apc_size > 0 ? (total_off_apc_size / total_apc_size) * 100 : 0
+ var/penalty_off_apc = off_percentage * (STORY_POWER_PENALTY_PER_OFF_APC / 100) // Scale by % off size
+ raw_strength = clamp(raw_strength - penalty_low_smes - penalty_off_apc, 0, 100)
+
+
+ var/damage_level = clamp((off_percentage / 100 * STORY_VAULT_CRITICAL_DAMAGE) \
+ + ((100 - smes_percent) / 100), 0, 3)
+
+
+ inputs.set_entry(STORY_VAULT_POWER_GRID_STRENGTH, round(raw_strength))
+ inputs.set_entry(STORY_VAULT_POWER_GRID_DAMAGE, round(damage_level))
+
+ ..()
+
+#undef STORY_POWER_LOW_SMES_THRESHOLD
+#undef STORY_POWER_APC_FAILURE_RATIO
+#undef STORY_POWER_PENALTY_LOW_SMES
+#undef STORY_POWER_PENALTY_PER_OFF_APC
diff --git a/tff_modular/modules/storytellers/metrics/station_strength.dm b/tff_modular/modules/storytellers/metrics/station_strength.dm
new file mode 100644
index 00000000000..d2d6d40fc13
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/station_strength.dm
@@ -0,0 +1,104 @@
+/datum/storyteller_metric/crew_strength
+ name = "Overall Station strength"
+
+/datum/storyteller_metric/crew_strength/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ var/total_crew_count = 0
+ var/total_security = 0
+ var/total_security_gear_points = 0
+ var/total_crew_weight = 0
+ var/total_gear_points = 0
+
+
+ for(var/mob/living/carbon/human/crew in get_alive_crew())
+ if(crew.is_antag())
+ continue
+ var/datum/job/job = crew.mind.assigned_role
+ if(!job)
+ continue
+ var/is_security = (/datum/job_department/security in job.departments_list)
+ total_crew_count += 1
+ if(is_security)
+ total_security += 1
+
+
+ var/gear_score = 0
+ var/mod = 1
+
+ // Job importance modifiers (cumulative)
+ if(job.story_tags & STORY_JOB_IMPORTANT)
+ mod += 0.3 // Important jobs add value
+ if(job.story_tags & STORY_JOB_HEAVYWEIGHT)
+ mod += 0.5 // Heavyweight jobs are more valuable
+ if(job.story_tags & STORY_JOB_COMBAT)
+ mod += 0.25 // Combat roles add effectiveness
+ if(job.story_tags & STORY_JOB_ANTAG_MAGNET)
+ mod += 0.4 // Antag magnets are important targets
+ if(job.story_tags & STORY_JOB_SECURITY)
+ mod += 0.35 // Security adds direct combat value
+
+ // Health modifier - healthy crew members are more effective
+ var/health_mod = 1.0
+ if(crew.health > 80)
+ health_mod = 1.2 // Very healthy crew
+ else if(crew.health > 50)
+ health_mod = 1.0 // Normal health
+ else if(crew.health > 25)
+ health_mod = 0.8 // Injured crew
+ else
+ health_mod = 0.5 // Critically injured
+
+ // Equipment scoring (improved)
+ for(var/obj/item/item in get_inventory(crew, TRUE))
+ if(istype(item, /obj/item/gun))
+ gear_score += 6 // Guns are powerful tools
+ else if(istype(item, /obj/item/melee))
+ gear_score += 4 // Melee weapons add combat value
+ else if(istype(item, /obj/item/stack/medical))
+ gear_score += 1.5 // Medical supplies are valuable
+ else if(istype(item, /obj/item/clothing/suit/armor))
+ gear_score += 3 // Armor provides protection value
+ else if(istype(item, /obj/item/clothing/head/helmet))
+ gear_score += 2 // Helmets add protection
+
+ // MOD suits provide significant bonus
+ if(istype(crew.back, /obj/item/mod))
+ gear_score += 5
+
+ // Experience and skill modifiers (if available)
+ var/skill_mod = 1.0
+ if(job.story_tags & STORY_JOB_IMPORTANT)
+ skill_mod = 1.15 // Important crew members are more valuable
+ if(job.story_tags & STORY_JOB_HEAVYWEIGHT)
+ skill_mod = max(skill_mod, 1.2) // Heavyweight jobs are highly valuable
+
+ total_gear_points += gear_score
+
+ // Calculate final crew weight: (base job weight * modifiers) + equipment, scaled by health and skill
+ var/base_weight = job.story_weight * mod
+ var/final_weight = (base_weight + gear_score) * health_mod * skill_mod
+ total_crew_weight += clamp(round(final_weight), 1, STORY_MAJOR_ANTAG_WEIGHT * 1.5)
+ if(is_security)
+ total_security_gear_points += gear_score
+
+
+ if(total_crew_count > 0)
+ inputs.set_entry(STORY_VAULT_CREW_READINESS, clamp((total_gear_points / total_crew_count) * 0.3, 0, 3))
+ inputs.set_entry(STORY_VAULT_CREW_WEIGHT, total_crew_weight)
+ if(total_security > 0)
+ inputs.set_entry(STORY_VAULT_SECURITY_STRENGTH, clamp((total_security_gear_points / total_security) * 1.3, 0, 3))
+ inputs.set_entry(STORY_VAULT_SECURITY_COUNT, total_security)
+
+ var/alert_level = SSsecurity_level.current_security_level || STORY_VAULT_GREEN_ALERT // 0 green -> 3 delta
+ var/real_level
+ if(istype(alert_level, /datum/security_level/green))
+ real_level = STORY_VAULT_GREEN_ALERT
+ else if(istype(alert_level, /datum/security_level/blue))
+ real_level = STORY_VAULT_BLUE_ALERT
+ else if(istype(alert_level, /datum/security_level/red))
+ real_level = STORY_VAULT_RED_ALERT
+ else if(istype(alert_level, /datum/security_level/delta))
+ real_level = STORY_VAULT_DELTA_ALERT
+ else
+ real_level = STORY_VAULT_GREEN_ALERT
+ inputs.set_entry(STORY_VAULT_SECURITY_ALERT, real_level)
+ ..()
diff --git a/tff_modular/modules/storytellers/metrics/utility.dm b/tff_modular/modules/storytellers/metrics/utility.dm
new file mode 100644
index 00000000000..b8fd340aff6
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/utility.dm
@@ -0,0 +1,23 @@
+/datum/storyteller_metric/utility
+ name = "Utility metric"
+
+
+/datum/storyteller_metric/utility/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+
+ // Find ERT and deathsquad
+ var/deathsquad_on_station = FALSE
+ var/ert_on_station = FALSE
+ for(var/datum/antagonist/ert/ert_antag in GLOB.antagonists)
+ if(ert_antag.rip_and_tear)
+ deathsquad_on_station = TRUE
+ break
+ ert_on_station = TRUE
+ break
+
+ if(ert_on_station)
+ inputs.set_entry(STORY_VAULT_STATION_ALLIES, TRUE)
+ if(deathsquad_on_station)
+ inputs.set_entry(STORY_VAULT_DEATHSQUAD, TRUE)
+ ..()
+
+
diff --git a/tff_modular/modules/storytellers/metrics/~storyteller_metrics.dm b/tff_modular/modules/storytellers/metrics/~storyteller_metrics.dm
new file mode 100644
index 00000000000..5ffaa4c8a74
--- /dev/null
+++ b/tff_modular/modules/storytellers/metrics/~storyteller_metrics.dm
@@ -0,0 +1,49 @@
+/datum/storyteller_metric
+ var/name = "Generic check"
+
+
+/datum/storyteller_metric/proc/can_perform_now(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ return TRUE
+
+
+/datum/storyteller_metric/proc/perform(datum/storyteller_analyzer/anl, datum/storyteller/ctl, datum/storyteller_inputs/inputs, scan_flags)
+ set waitfor = FALSE
+
+ SHOULD_CALL_PARENT(TRUE)
+ if(anl)
+ anl.try_stop_analyzing(src)
+
+
+/datum/storyteller_metric/proc/get_alive_crew(only_humans = TRUE, only_station = TRUE, only_with_mind = TRUE, no_afk = TRUE)
+ var/list/to_check = SSstorytellers.simulation ? GLOB.alive_mob_list : GLOB.alive_player_list
+ if(!length(to_check))
+ return list()
+
+ var/list/result
+ for(var/mob/living/L as anything in to_check)
+ if(only_humans && !(ishuman(L)))
+ continue
+ if(only_station && !is_station_level(L.z))
+ continue
+ if(only_with_mind && !L.mind)
+ continue
+ if(L.client && L?.client.is_afk())
+ continue
+ LAZYADD(result, L)
+ return result
+
+/datum/storyteller_metric/proc/get_dead_crew(only_humans = TRUE, only_station = TRUE, only_with_mind = TRUE)
+ var/list/to_check = SSstorytellers.simulation ? GLOB.dead_mob_list : GLOB.dead_mob_list
+ if(!length(to_check))
+ return list()
+
+ var/list/result
+ for(var/mob/living/L as anything in to_check)
+ if(only_humans && !(ishuman(L)))
+ continue
+ if(only_station && !is_station_level(L.z))
+ continue
+ if(only_with_mind && !L.mind)
+ continue
+ LAZYADD(result, L)
+ return result
diff --git a/tff_modular/modules/storytellers/overrides/jobs.dm b/tff_modular/modules/storytellers/overrides/jobs.dm
new file mode 100644
index 00000000000..4f3be671389
--- /dev/null
+++ b/tff_modular/modules/storytellers/overrides/jobs.dm
@@ -0,0 +1,51 @@
+/datum/job
+ // Storyteller flags
+ var/story_tags = NONE
+
+ var/story_weight = 0
+
+/datum/job_department
+ var/job_story_flags = NONE
+ var/job_weight_override = 0
+
+/datum/job_department/proc/add_storyweight(datum/job/job)
+ if(job.story_weight < job_weight_override)
+ job.story_weight = job_weight_override
+ if(job_story_flags)
+ job.story_tags = job.story_tags | job_story_flags
+
+/datum/job_department/command
+ job_weight_override = STORY_HEAD_JOB_WEIGHT
+ job_story_flags = STORY_JOB_IMPORTANT
+
+/datum/job_department/security
+ job_weight_override = STORY_SECURITY_JOB_WEIGHT
+ job_story_flags = STORY_JOB_IMPORTANT|STORY_JOB_COMBAT|STORY_JOB_SECURITY
+
+/datum/job_department/medical
+ job_weight_override = STORY_MEDICAL_JOB_WEIGHT
+ job_story_flags = STORY_JOB_IMPORTANT
+
+/datum/job_department/engineering
+ job_weight_override = STORY_ENGINEER_JOB_WEIGHT
+
+/datum/job_department/science
+ job_weight_override = STORY_UNIMPORTANT_JOB_WEIGHT // Science guys don't important for threat progression
+
+/datum/job_department/service
+ job_weight_override = STORY_UNIMPORTANT_JOB_WEIGHT
+// Heads
+
+//Most important first
+/datum/job/captain
+ story_tags = STORY_JOB_ANTAG_MAGNET|STORY_JOB_IMPORTANT|STORY_JOB_HEAVYWEIGHT
+ story_weight = STORY_HEAD_JOB_WEIGHT * 2
+
+/datum/job/head_of_security
+ story_tags = STORY_JOB_ANTAG_MAGNET|STORY_JOB_IMPORTANT|STORY_JOB_HEAVYWEIGHT
+ story_weight = STORY_HEAD_JOB_WEIGHT * 2
+
+/datum/job/chief_medical_officer
+ story_tags = STORY_JOB_IMPORTANT
+ story_weight = STORY_HEAD_JOB_WEIGHT
+
diff --git a/tff_modular/modules/storytellers/overrides/nuke_disk.dm b/tff_modular/modules/storytellers/overrides/nuke_disk.dm
new file mode 100644
index 00000000000..746dd2b9d3e
--- /dev/null
+++ b/tff_modular/modules/storytellers/overrides/nuke_disk.dm
@@ -0,0 +1,40 @@
+/obj/item/disk/nuclear
+ VAR_PRIVATE/secure_time = 0
+ VAR_PRIVATE/secure = FALSE
+ VAR_PRIVATE/loneop_called = FALSE
+
+/obj/item/disk/nuclear/secured_process(last_move)
+ secure_time++
+ if(secure_time >= 10)
+ secure = TRUE
+
+/obj/item/disk/nuclear/unsecured_process(last_move)
+ if(secure)
+ secure_time = max(0, secure_time - 1)
+ if(secure_time == 0)
+ secure = FALSE
+
+ var/turf/new_turf = get_turf(src)
+ if((last_move < world.time - 500 SECONDS && !secure) || (isspaceturf(new_turf) && prob(20)) && loc == new_turf)
+ secure_time = 0
+ var/datum/storyteller/ctl = SSstorytellers?.active
+ if(!ctl)
+ return
+ ask_to_storyteller(ctl)
+
+/obj/item/disk/nuclear/proc/ask_to_storyteller(datum/storyteller/ctl)
+ if(HAS_TRAIT(ctl, STORYTELLER_TRAIT_NO_ANTAGS) && !loneop_called)
+ return
+ var/datum/round_event_control/antagonist/from_ghosts/loneop/loneop = locate() in SSstorytellers.events_by_id
+ if(ctl.planner.is_event_in_timeline(loneop))
+ return
+ var/offset = ctl.planner.get_next_event_delay(loneop, ctl)
+ if(ctl.planner.try_plan_event(loneop, offset))
+ loneop_called = TRUE
+ message_admins("The nuclear authentication disk has been left unsecured! And [ctl.name] deploy lone operative.")
+ secure = TRUE
+ secure_time += 2 MINUTES
+
+
+/obj/item/disk/nuclear/proc/is_secure()
+ return secure
diff --git a/tff_modular/modules/storytellers/~subsystem/_SSstorytellers.dm b/tff_modular/modules/storytellers/~subsystem/_SSstorytellers.dm
new file mode 100644
index 00000000000..efeb22cd867
--- /dev/null
+++ b/tff_modular/modules/storytellers/~subsystem/_SSstorytellers.dm
@@ -0,0 +1,693 @@
+#define FIRE_PRIORITY_STORYTELLERS 101
+#define STORYTELLER_JSON_PATH "config/storyteller/storytellers.json"
+#define STORYTELLER_ICONS_PATH "config/storytellers_icons/"
+#define STORYTELLER_EVENT_CONFIG_PATH "config/storyteller/events"
+#define STORYTELLER_VOTE_CACHE "data/storyteller_vote_cache.json"
+
+SUBSYSTEM_DEF(storytellers)
+ name = "AI Storytellers"
+ runlevels = RUNLEVEL_GAME
+
+ wait = 10
+ var/hard_debug = FALSE
+ var/simulation = FALSE
+ var/selected_id
+ // Difficulty selected on vote
+ var/selected_difficulty
+
+ var/current_vote_duration = 60 SECONDS
+ var/vote_start_time = 0 // Global vote start time
+
+ VAR_PRIVATE/list/storyteller_vote_cache
+
+ var/last_selected_id = ""
+
+ var/vote_active = FALSE
+ /// Active storyteller instance
+ var/datum/storyteller/active
+
+ VAR_PRIVATE/list/active_events = list()
+
+ VAR_PRIVATE/list/simulated_atoms = list()
+
+ VAR_PRIVATE/list/processed_metrics = list()
+
+ var/list/storyteller_vote_uis = list()
+
+ var/list/events_by_category = list()
+ /// Goal registry built from subtypes
+ var/list/events_by_id = list()
+ /// Root goals without a valid parent
+ var/list/events_roots = list()
+ /// Loaded storyteller data from JSON: id -> assoc list(name, desc, mood_type, base_think_delay, etc.)
+ var/list/storyteller_data = list()
+
+ /// Config-value: should storyteller overrides dynamic
+ var/storyteller_replace_dynamic = TRUE
+ /// Config-value: should storyteller helps to antagonist
+ var/storyteller_helps_antags = FALSE
+ /// Config-value: should storyteller speak with station
+ var/storyteller_allows_speech = TRUE
+
+ dependencies = list(
+ /datum/controller/subsystem/events,
+ /datum/controller/subsystem/processing/station,
+ /datum/controller/subsystem/dynamic,
+ )
+
+/datum/controller/subsystem/storytellers/Initialize()
+ // Load storyteller data from JSON
+ load_storyteller_data()
+ events_by_id = list()
+ events_roots = list()
+ events_by_category = list()
+ collect_available_goals()
+ load_event_config()
+ load_vote_cahce()
+
+ storyteller_replace_dynamic = config.Get(/datum/config_entry/flag/storyteller_replace_dynamic) || TRUE
+ storyteller_helps_antags = config.Get(/datum/config_entry/flag/storyteller_helps_antags) || FALSE
+ storyteller_allows_speech = config.Get(/datum/config_entry/flag/storyteller_allows_speech) || TRUE
+
+ RegisterSignal(SSdcs, COMSIG_GLOB_CLIENT_CONNECT, PROC_REF(on_login))
+
+#ifdef DEBUG
+ hard_debug = TRUE
+#endif
+
+ return SS_INIT_SUCCESS
+
+/datum/controller/subsystem/storytellers/Destroy()
+ . = ..()
+ qdel(active)
+ QDEL_LIST(simulated_atoms)
+ QDEL_LIST(active_events)
+ QDEL_LIST(processed_metrics)
+ QDEL_LIST(storyteller_vote_uis)
+ QDEL_LIST(events_by_id)
+
+/// Initializes the active storyteller from selected_id (JSON profile), applying parsed data for adaptive behavior.
+/// Delegates creation to create_storyteller_from_data() for modularity; kicks off round analysis/planning.
+/// Ensures chain starts with 3+ events, biased by profile (e.g., low tension for chill).
+/datum/controller/subsystem/storytellers/proc/load_storyteller()
+ if(active)
+ message_admins(span_notice("Storyteller already initialized, deleting."))
+ qdel(active)
+
+ if(!selected_id || !storyteller_data[selected_id])
+ log_storyteller("Failed to load storyteller: invalid ID [selected_id]")
+ var/id = pick(storyteller_data)
+ message_admins(span_bolditalic("Failed to load storyteller! Selected random storyteller"))
+ active = create_storyteller_from_data(id)
+ active.difficulty_multiplier = 1.0
+ return
+
+ active = create_storyteller_from_data(selected_id)
+ active.difficulty_multiplier = clamp(selected_difficulty, 0.3, 5.0)
+
+/datum/controller/subsystem/storytellers/proc/initialize_storyteller()
+ if(!active)
+ load_storyteller()
+ active.initialize()
+
+
+/datum/controller/subsystem/storytellers/proc/load_storyteller_data()
+ storyteller_data = list()
+
+ if(!fexists(STORYTELLER_JSON_PATH))
+ log_storyteller("Storyteller JSON not found at [STORYTELLER_JSON_PATH], using defaults.")
+ return
+
+ var/json_text = rustg_file_read(STORYTELLER_JSON_PATH)
+ var/list/loaded = json_decode(json_text)
+ if(!islist(loaded))
+ log_storyteller("Invalid JSON in storyteller data: [json_text]")
+ return
+
+ for(var/list/entry in loaded)
+ var/id = entry["id"]
+ if(!id || !istext(id))
+ log_storyteller("Skipping invalid entry without valid 'id' string.")
+ continue
+
+ var/list/parsed = list()
+ parsed["name"] = istext(entry["name"]) ? entry["name"] : "Unnamed Storyteller"
+ parsed["desc"] = istext(entry["desc"]) ? entry["desc"] : "No description provided."
+ parsed["base_cost_multiplier"] = isnum(entry["base_cost_multiplier"]) ? clamp(entry["base_cost_multiplier"], 0.1, 5.0) : 1.0
+ parsed["player_antag_balance"] = isnum(entry["player_antag_balance"]) ? clamp(entry["player_antag_balance"], 0, 100) : STORY_DEFAULT_PLAYER_ANTAG_BALANCE
+ parsed["ooc_desc"] = istext(entry["ooc_desc"]) ? entry["ooc_desc"] : "No description provided."
+ parsed["ooc_difficulty"] = istext(entry["ooc_difficulty"]) ? entry["ooc_difficulty"] : "Default"
+ parsed["portait_path"] = istext(entry["portait_path"]) ? STORYTELLER_ICONS_PATH + entry["portait_path"] : ""
+ parsed["logo_path"] = istext(entry["logo_path"]) ? STORYTELLER_ICONS_PATH + entry["logo_path"] : ""
+
+ var/mood_str = entry["mood_type"]
+ parsed["mood_path"] = text2path(mood_str)
+ if(!ispath(parsed["mood_path"], /datum/storyteller_mood))
+ log_storyteller("Invalid mood_type '[mood_str]' for [id], using default.")
+ parsed["mood_path"] = /datum/storyteller_mood
+
+ var/behevour_str = entry["behevour_type"]
+ parsed["behevour_path"] = text2path(behevour_str)
+ if(!ispath(parsed["behevour_path"], /datum/storyteller_behevour))
+ log_storyteller("Invalid behevour_type '[behevour_str]' for [id], using default.")
+ parsed["behevour_path"] = /datum/storyteller_behevour
+
+
+ parsed["base_think_delay"] = isnum(entry["base_think_delay"]) ? max(0, entry["base_think_delay"] SECONDS) : STORY_THINK_BASE_DELAY
+ parsed["average_event_interval"] = isnum(entry["average_event_interval"]) ? max(0, entry["average_event_interval"] MINUTES) : 15 MINUTES
+ parsed["grace_period"] = isnum(entry["grace_period"]) ? max(0, entry["grace_period"] MINUTES) : STORY_GRACE_PERIOD
+ parsed["mood_update_interval"] = isnum(entry["mood_update_interval"]) ? max(0, entry["mood_update_interval"] MINUTES) : STORY_RECALC_INTERVAL
+
+
+ // Non-time numerics
+ parsed["recent_damage_threshold"] = isnum(entry["recent_damage_threshold"]) ? max(0, entry["recent_damage_threshold"]) : STORY_RECENT_DAMAGE_THRESHOLD
+ parsed["threat_growth_rate"] = isnum(entry["threat_growth_rate"]) ? clamp(entry["threat_growth_rate"], 0, 10) : STORY_THREAT_GROWTH_RATE
+ parsed["adaptation_decay_rate"] = isnum(entry["adaptation_decay_rate"]) ? clamp(entry["adaptation_decay_rate"], 0, 1) : STORY_ADAPTATION_DECAY_RATE
+ parsed["target_tension"] = isnum(entry["target_tension"]) ? clamp(entry["target_tension"], 0, 100) : STORY_TARGET_TENSION
+ parsed["max_threat_scale"] = isnum(entry["max_threat_scale"]) ? max(0, entry["max_threat_scale"]) : STORY_MAX_THREAT_SCALE
+ parsed["repetition_penalty"] = isnum(entry["repetition_penalty"]) ? clamp(entry["repetition_penalty"], 0, 2) : STORY_REPETITION_PENALTY
+
+ // Placeholder lists: welcome and round speech
+ parsed["storyteller_welcome_speech"] = islist(entry["welcome_speech"]) ? entry["welcome_speech"] : list()
+ parsed["storyteller_round_speech"] = islist(entry["round_speech"]) ? entry["round_speech"] : list()
+ parsed["personality_traits"] = islist(entry["personality_traits"]) ? entry["personality_traits"] : list()
+ storyteller_data[id] = parsed
+
+ if(hard_debug)
+ log_storyteller("Loaded [length(storyteller_data)] storytellers from JSON.")
+
+
+/// Creates a new /datum/storyteller instance from JSON data (or default if null), applying all parsed fields.
+/// Modular extraction: overrides vars (pacing, threat, adaptation), instantiates mood, sets speech lists.
+/// Returns tuned instance for planner chain-building; logs profile for debug, announces welcome speech.
+/datum/controller/subsystem/storytellers/proc/create_storyteller_from_data(id, make_new = TRUE)
+ var/datum/storyteller/new_st
+ if(!make_new && active)
+ new_st = active
+ else
+ new_st = new /datum/storyteller(id) // Base with ID for logging
+
+ if((!id || !storyteller_data[id]) && make_new)
+ // Default fallback
+ new_st.name = "Default Storyteller"
+ new_st.desc = "A generic storyteller managing station events and goals."
+ new_st.mood = new /datum/storyteller_mood()
+ if(hard_debug)
+ log_storyteller("Created default storyteller.")
+ return new_st
+
+ var/list/data = storyteller_data[id]
+ new_st.id = id
+ new_st.name = data["name"]
+ new_st.desc = data["desc"]
+ new_st.player_antag_balance = data["player_antag_balance"]
+
+ // Core pacing/threat vars
+ new_st.base_think_delay = data["base_think_delay"]
+ new_st.average_event_interval = data["average_event_interval"]
+ new_st.grace_period = data["grace_period"]
+ new_st.mood_update_interval = data["mood_update_interval"]
+ new_st.recent_damage_threshold = data["recent_damage_threshold"]
+ new_st.threat_growth_rate = data["threat_growth_rate"]
+ new_st.adaptation_decay_rate = data["adaptation_decay_rate"]
+ new_st.target_tension = data["target_tension"]
+ new_st.max_threat_scale = data["max_threat_scale"]
+ new_st.repetition_penalty = data["repetition_penalty"]
+ new_st.ooc_desc = data["ooc_desc"]
+ new_st.ooc_difficulty = data["ooc_difficulty"]
+ new_st.portrait_path = data["portait_path"]
+ new_st.logo_path = data["logo_path"]
+
+ var/mood = data["mood_path"]
+ if(ispath(mood, /datum/storyteller_mood))
+ new_st.mood = new mood
+
+ var/behevour = data["behevour_path"]
+ if(ispath(behevour, /datum/storyteller_behevour))
+ new_st.behevour = new behevour(new_st)
+ else
+ new_st.behevour = new /datum/storyteller_behevour(new_st)
+
+ var/list/traits = data["personality_traits"]
+ if(length(traits))
+ for(var/trait in traits)
+ ADD_TRAIT(new_st, trait, "storyteller_mind")
+
+ // new_st.storyteller_welcome_speech = data["storyteller_welcome_speech"]
+ // new_st.storyteller_round_speech = data["storyteller_round_speech"]
+
+ /* TODO storytellers welcome speach!
+ if(length(new_st.storyteller_welcome_speech))
+ var/welcome_msg = pick(new_st.storyteller_welcome_speech)
+ to_chat(world, span_big(welcome_msg))
+ log_storyteller("[new_st.name] welcomes: [welcome_msg]")
+ */
+ if(hard_debug)
+ log_storyteller("Created [new_st.name] ([id]): pace=[new_st.mood.pace], threat_growth=[new_st.threat_growth_rate], tension=[new_st.target_tension]")
+
+ return new_st
+
+
+/datum/controller/subsystem/storytellers/proc/set_storyteller(id)
+ var/datum/storyteller/new_teller = create_storyteller_from_data(id)
+ if(!new_teller)
+ return FALSE
+ qdel(active)
+ active = new_teller
+ active.difficulty_multiplier = selected_difficulty
+ active.initialize()
+ return TRUE
+
+/datum/controller/subsystem/storytellers/fire(resumed)
+ if(active)
+ active.think()
+ for(var/datum/round_event/evt in active_events)
+ if(!evt || QDELETED(evt))
+ active_events -= evt
+ continue
+ evt.__process_for_storyteller(world.tick_lag)
+ for(var/datum/storyteller_analyzer/AN in processed_metrics)
+ if(!AN || QDELETED(AN))
+ processed_metrics -= AN
+ continue
+ AN.process(world.tick_lag)
+ for(var/datum/storyteller_poll/running_poll as anything in currently_polling)
+ if(running_poll.time_left() <= 0)
+ polling_finished(running_poll)
+
+/datum/controller/subsystem/storytellers/proc/setup_game()
+
+#ifdef UNIT_TESTS //Storyteller thinking disabled during testing, it's handle by unit test
+ return
+#endif
+
+ disable_dynamic()
+ disable_ICES()
+
+ if(vote_active)
+ end_vote()
+
+ return TRUE
+
+/datum/controller/subsystem/storytellers/proc/post_setup()
+
+#ifdef UNIT_TESTS //Storyteller thinking disabled during testing, it's handle by unit test
+ return
+#endif
+
+ initialize_storyteller()
+
+/datum/controller/subsystem/storytellers/proc/disable_dynamic()
+ if(!storyteller_replace_dynamic)
+ return
+ SSdynamic.flags = SS_NO_FIRE
+ SSdynamic.antag_events_enabled = FALSE
+ // TODO: add ability to completely disable dynamic by adding all rulesets to admin-disabled
+ message_admins(span_bolditalic("Dynamic was disabled by Storyteller!"))
+
+/datum/controller/subsystem/storytellers/proc/disable_ICES()
+ SSevents.flags = SS_NO_FIRE
+ message_admins(span_bolditalic("ICES and random events were disabled by Storyteller"))
+
+/datum/controller/subsystem/storytellers/proc/collect_available_goals()
+ events_by_id = list()
+ events_by_category = list()
+ events_roots = list()
+
+ // Initialize categories as empty lists with bitflags as keys
+ events_by_category["GOAL_RANDOM"] = list()
+ events_by_category["GOAL_GOOD"] = list()
+ events_by_category["GOAL_BAD"] = list()
+ events_by_category["GOAL_NEUTRAL"] = list()
+ events_by_category["GOAL_UNCATEGORIZED"] = list()
+ events_by_category["GOAL_ANTAGONIST"] = list()
+
+ for(var/control_type in typesof(/datum/round_event_control))
+ var/datum/round_event_control/event_control = new control_type()
+
+ if(!event_control.valid_for_map())
+ continue // Skip invalid for map
+ if(event_control.story_category & STORY_GOAL_NEVER)
+ continue // Skip never goals
+ if(istype(event_control, /datum/round_event_control/wizard))
+ continue
+ if(!event_control.id)
+ WARNING("Storyteller event control [event_control.name] has no ID using name instead.")
+ event_control.id = event_control.name
+ if(events_by_id[event_control.id]) // Prevent duplicates
+ WARNING("Duplicate event control ID [event_control.id] for [event_control.name], skipping.")
+ continue
+ events_by_id[event_control.id] = event_control
+
+ if(!event_control.story_category) // Use story_category instead of category
+ WARNING("Storyteller event control [event_control.id] has no story_category, assigning random.")
+ event_control.story_category = STORY_GOAL_RANDOM
+
+ // Assign to all matching categories (bitflags allow multiple)
+ if(event_control.story_category & STORY_GOAL_RANDOM)
+ events_by_category["GOAL_RANDOM"] += event_control
+ else if(event_control.story_category & STORY_GOAL_GOOD)
+ events_by_category["GOAL_GOOD"] += event_control
+ else if(event_control.story_category & STORY_GOAL_BAD)
+ events_by_category["GOAL_BAD"] += event_control
+ else if(event_control.story_category & STORY_GOAL_NEUTRAL)
+ events_by_category["GOAL_NEUTRAL"] += event_control
+ else if(event_control.story_category & STORY_GOAL_ANTAGONIST)
+ events_by_category["GOAL_ANTAGONIST"] += event_control
+
+
+ // Collect roots: no parent or invalid parent (round_event_control doesn't use parent_id, so skip this)
+ for(var/id in events_by_id)
+ var/datum/round_event_control/event_control = events_by_id[id]
+ // round_event_control doesn't have parent_id, so all are roots
+ events_roots += event_control
+
+ if(hard_debug)
+ log_storyteller("Collected [length(events_by_id)] goals, [length(events_roots)] roots.")
+
+
+/datum/controller/subsystem/storytellers/proc/load_event_config()
+ var/list/json_files = list()
+ var/list/loaded = list()
+
+ for(var/file in flist(STORYTELLER_EVENT_CONFIG_PATH))
+ if(file == "." || file == "..")
+ continue
+ if(!findtext(file, ".json"))
+ continue
+ json_files += file
+
+ for(var/json_file in json_files)
+ var/json_text = rustg_file_read("[STORYTELLER_EVENT_CONFIG_PATH][json_file]")
+ var/list/current_loaded = json_decode(json_text)
+ if(!islist(current_loaded))
+ stack_trace("Invalid JSON in storyteller event config: [json_file]")
+ continue
+ loaded |= current_loaded
+
+ for(var/id in loaded)
+ var/datum/round_event_control/event = events_by_id[id]
+ if(!event)
+ stack_trace("Invalid event ID [id] in storyteller event config, skipping.")
+ continue
+ for(var/event_variable in loaded[id])
+ if(!(event_variable in event.vars))
+ stack_trace("Invalid event configuration variable [event_variable] in variable changes for [id].")
+ continue
+ if(event_variable == "id")
+ stack_trace("Cannot override event ID in configuration for [id], skipping.")
+ continue
+ if(event_variable == "story_category")
+ stack_trace("Cannot override event story_category in configuration for [id], skipping.")
+ continue
+ if(event_variable == "tags")
+ stack_trace("Cannot override event tags in configuration for [id], skipping.")
+ continue
+ event.vars[event_variable] = loaded[id][event_variable]
+
+/datum/controller/subsystem/storytellers/proc/reroll_antagonist(antag_name)
+
+
+/datum/controller/subsystem/storytellers/proc/load_vote_cahce()
+ if(rustg_file_exists(STORYTELLER_VOTE_CACHE))
+ storyteller_vote_cache = json_decode(file2text(STORYTELLER_VOTE_CACHE))
+ if(length(storyteller_vote_cache))
+ last_selected_id = storyteller_vote_cache[1]
+ else
+ storyteller_vote_cache = list()
+
+/datum/controller/subsystem/storytellers/proc/write_cache()
+ storyteller_vote_cache = list()
+ storyteller_vote_cache += active ? active.id : "n/a"
+ rustg_file_write(json_encode(storyteller_vote_cache), STORYTELLER_VOTE_CACHE)
+
+/datum/controller/subsystem/storytellers/proc/filter_goals(category = null, list/required_tags = null, minimum_match_category = STORY_TAGS_SOME_MATCH)
+ var/list/result = list()
+ if(!islist(required_tags))
+ required_tags = list(required_tags)
+
+ var/list/events_to_check = list()
+ var/category_str
+ if(category)
+ if(category & STORY_GOAL_RANDOM)
+ category_str = "GOAL_RANDOM"
+ else if(category & STORY_GOAL_GOOD)
+ category_str = "GOAL_GOOD"
+ else if(category & STORY_GOAL_BAD)
+ category_str = "GOAL_BAD"
+ else if(category & STORY_GOAL_NEUTRAL)
+ category_str = "GOAL_NEUTRAL"
+ else if(category & STORY_GOAL_ANTAGONIST)
+ category_str = "GOAL_ANTAGONIST"
+ else
+ category_str = "GOAL_RANDOM"
+ else
+ category_str = "GOAL_RANDOM" // Default to uncategorized if none specified
+
+ events_to_check = _list_copy(events_by_category[category_str])
+ if(!events_to_check)
+ stack_trace("SSstorytelers: no events found for category [category_str]")
+ return list()
+
+ for(var/datum/round_event_control/evt in events_to_check)
+ if(!evt.enabled)
+ continue
+ if(!evt.valid_for_map())
+ continue
+ if(required_tags)
+ if(!evt.tags)
+ continue
+ var/match = evt.check_tags(required_tags)
+ if(match <= minimum_match_category)
+ continue
+ result += evt
+ return result
+
+/datum/controller/subsystem/storytellers/proc/filter_goals_hard(category = null, list/required_tags = null)
+ return filter_goals(category, required_tags, STORY_TAGS_MOST_MATCH)
+
+/datum/controller/subsystem/storytellers/proc/filter_goals_exact(category = null, list/required_tags = null)
+ return filter_goals(category, required_tags, STORY_TAGS_MATCH)
+
+/datum/controller/subsystem/storytellers/proc/get_event_by_id(id)
+ return events_by_id[id]
+
+/// Convenience method to get root goals by category, tags, and subtype
+/datum/controller/subsystem/storytellers/proc/get_root_goals(category = null, required_tags = null, subtype = null, all_tags_required = FALSE)
+ return filter_goals(category, required_tags, subtype, all_tags_required, FALSE)
+
+/// Convenience method to get goals by category and subtype
+/datum/controller/subsystem/storytellers/proc/get_events_by_category_and_subtype(category, subtype)
+ return filter_goals(category, null, subtype, FALSE, TRUE)
+
+/// Convenience method to get goals by tags
+/datum/controller/subsystem/storytellers/proc/get_goals_by_tags(required_tags, all_tags_required = FALSE)
+ return filter_goals(null, required_tags, null, all_tags_required, TRUE)
+
+/datum/controller/subsystem/storytellers/proc/register_active_event(datum/round_event/E)
+ if(!E || QDELETED(E))
+ return
+ active_events += E
+
+/datum/controller/subsystem/storytellers/proc/unregister_active_event(datum/round_event/E)
+ if(!E || QDELETED(E) || !(E in active_events))
+ return
+ active_events -= E
+
+/datum/controller/subsystem/storytellers/proc/register_analyzer(datum/storyteller_analyzer/A)
+ if(!A || QDELETED(A))
+ return
+ processed_metrics += A
+
+/datum/controller/subsystem/storytellers/proc/unregister_analyzer(datum/storyteller_analyzer/A)
+ if(!A || QDELETED(A))
+ return
+ processed_metrics -= A
+
+
+#define STORY_TRAIT_IM_SIMULATION "simulation_mob"
+
+ADMIN_VERB(storyteller_simulation, R_ADMIN, "Storyteller - Simulation", "Simulate round", ADMIN_CATEGORY_STORYTELLER)
+ var/ask = tgui_alert(usr, "Do you want to perform simulation?", "Storyteller - simulation", list("Process", "Nevermind"))
+ if(ask != "Process")
+ return
+
+
+ var/list/variants = list(
+ "simulate manifest",
+ "simulate antagonist",
+ "simulate influence",
+ "abort",
+ )
+ var/chosen = tgui_input_list(usr, "Pick variant of simulation.", "Storyteller - simulation", variants)
+ if(chosen == "abort")
+ return
+
+ SSstorytellers.hard_debug = TRUE
+ SSstorytellers.simulation = TRUE
+
+ message_admins("[key_name_admin(user)]is starting storyteller round simulation with: [chosen] mode.")
+ if(chosen == "simulate manifest")
+ var/gear_ask = tgui_alert(usr, "Should we add some gear to crew?", "Storyteller - simulation", list("Yes", "Nevermind"))
+ SSstorytellers.simulate_crew_activity(gear_ask == "Yes")
+
+/datum/controller/subsystem/storytellers/proc/generate_random_key(length = 16)
+ var/static/list/to_pick = list(
+ "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
+ "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
+ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"
+ )
+ var/key = ""
+ for(var/i = 0 to length)
+ key += pick(to_pick)
+ key += num2text(rand(16, 1024))
+ return key
+
+
+/datum/controller/subsystem/storytellers/proc/simulate_crew_activity(add_gear)
+ if(!usr)
+ return
+ var/obj/effect/landmark/observer_start/observer_point = locate(/obj/effect/landmark/observer_start) in GLOB.landmarks_list
+ var/turf/destination = get_turf(observer_point)
+ if(!destination)
+ to_chat(usr, "Failed to find the observer spawn to send the dummies.")
+ return
+
+ var/list/possible_species = list(
+ /datum/species/human = 70,
+ /datum/species/teshari = 20,
+ /datum/species/moth = 10,
+ )
+ var/number_made = 0
+ for(var/rank in SSjob.name_occupations)
+ var/datum/job/job = SSjob.get_job(rank)
+ if(!(job.job_flags & JOB_CREW_MEMBER))
+ continue
+
+ var/mob/dead/new_player/new_guy = new()
+ new_guy.mind_initialize()
+ new_guy.mind.name = "[rank] Dummy"
+
+ if(!SSjob.assign_role(new_guy, job, do_eligibility_checks = FALSE))
+ qdel(new_guy)
+ to_chat(usr, "[rank] wasn't able to be spawned.")
+ continue
+ var/mob/living/carbon/human/character = new(destination)
+ character.name = new_guy.mind.name
+ new_guy.mind.transfer_to(character)
+ qdel(new_guy)
+
+ character.set_species(pick_weight(possible_species))
+ SSjob.equip_rank(character, job)
+ job.after_latejoin_spawn(character)
+
+ SSticker.minds += character.mind
+ if(ishuman(character))
+ GLOB.manifest.inject(character)
+
+ number_made++
+ CHECK_TICK
+ LAZYADD(simulated_atoms, WEAKREF(character))
+
+ to_chat(usr, "[number_made] crewmembers have been created.")
+
+
+#undef STORY_TRAIT_IM_SIMULATION
+
+
+/datum/config_entry/flag/storyteller_replace_dynamic
+ default = TRUE
+
+/datum/config_entry/flag/storyteller_helps_antags
+ default = FALSE
+
+/datum/config_entry/flag/storyteller_allows_speech
+ default = TRUE
+
+// Population threshold config entries
+/datum/config_entry/number/story_population_threshold_low
+ default = 10
+ integer = TRUE
+ min_val = 1
+
+/datum/config_entry/number/story_population_threshold_medium
+ default = 21
+ integer = TRUE
+ min_val = 1
+
+/datum/config_entry/number/story_population_threshold_high
+ default = 32
+ integer = TRUE
+ min_val = 1
+
+/datum/config_entry/number/story_population_threshold_full
+ default = 51
+ integer = TRUE
+ min_val = 1
+
+// Population factor config entries
+/datum/config_entry/number/story_population_factor_low
+ default = 0.3
+ min_val = 0.1
+ max_val = 2.0
+ integer = FALSE
+
+/datum/config_entry/number/story_population_factor_medium
+ default = 0.5
+ min_val = 0.1
+ max_val = 2.0
+ integer = FALSE
+
+/datum/config_entry/number/story_population_factor_high
+ default = 0.8
+ min_val = 0.1
+ max_val = 2.0
+ integer = FALSE
+
+/datum/config_entry/number/story_population_factor_full
+ default = 1.0
+ min_val = 0.1
+ max_val = 2.0
+ integer = FALSE
+
+/datum/config_entry/number/story_population_smooth_weight
+ default = 0.4
+ min_val = 0.0
+ max_val = 1.0
+ integer = FALSE
+
+/datum/config_entry/number/story_population_history_max
+ default = 20
+ integer = TRUE
+ min_val = 5
+ max_val = 100
+
+// Planner multi-event config entries
+/datum/config_entry/number/story_max_consecutive_events
+ default = 3
+ integer = TRUE
+ min_val = 1
+ max_val = 10
+
+/datum/config_entry/number/story_multi_event_tension_threshold
+ default = 40
+ integer = TRUE
+ min_val = 0
+ max_val = 100
+
+/datum/config_entry/number/story_multi_event_threat_threshold
+ default = 500
+ integer = TRUE
+ min_val = 0
+
+/datum/config_entry/number/strong_security_count
+ default = 8
+ integer = TRUE
+ min_val = 2
+
+
+#undef FIRE_PRIORITY_STORYTELLERS
+#undef STORYTELLER_JSON_PATH
+#undef STORYTELLER_ICONS_PATH
+#undef STORYTELLER_EVENT_CONFIG_PATH
+#undef STORYTELLER_VOTE_CACHE
diff --git a/tff_modular/modules/storytellers/~subsystem/storyteller_poll.dm b/tff_modular/modules/storytellers/~subsystem/storyteller_poll.dm
new file mode 100644
index 00000000000..3cce67d68a4
--- /dev/null
+++ b/tff_modular/modules/storytellers/~subsystem/storyteller_poll.dm
@@ -0,0 +1,319 @@
+/datum/controller/subsystem/storytellers
+ var/list/currently_polling = list()
+
+/datum/controller/subsystem/storytellers/proc/ask_crew_for_goals(
+ question_text,
+ list/goal_list,
+ poll_time = 30 SECONDS,
+ list/group = null,
+ ignore_category = null,
+ alert_pic
+)
+ if(!question_text || !length(goal_list))
+ return list()
+
+ if(isnull(group))
+ group = list()
+ for(var/mob/living/L in GLOB.alive_player_list)
+ if(!L.client || !L.mind || L.stat == DEAD)
+ continue
+ group += L
+
+ if(!length(group))
+ return list()
+
+ log_ghost_poll("Storyteller goal poll started.", data = list(
+ "poll question" = question_text,
+ "poll duration" = DisplayTimeText(poll_time),
+ ))
+
+ var/datum/storyteller_poll/new_poll = new(question_text, poll_time, ignore_category)
+ new_poll.goal_options = goal_list.Copy()
+ for(var/goal in new_poll.goal_options)
+ new_poll.votes[goal] = list()
+ LAZYADD(currently_polling, new_poll)
+
+ var/category = "[new_poll.poll_key]_poll_alert"
+
+ for(var/mob/candidate_mob as anything in group)
+ if(!candidate_mob.client)
+ continue
+ if(!is_eligible(candidate_mob, null, null, ignore_category))
+ continue
+
+ window_flash(candidate_mob.client)
+ var/atom/movable/screen/alert/poll_alert/current_alert = LAZYACCESS(candidate_mob.alerts, category)
+ var/alert_time = poll_time
+ var/datum/storyteller_poll/alert_poll = new_poll
+ if(current_alert && current_alert.timeout > (world.time + poll_time - world.tick_lag))
+ alert_time = current_alert.timeout - world.time + world.tick_lag
+ alert_poll = current_alert.poll
+
+ // Send them an on-screen alert
+ var/atom/movable/screen/alert/poll_alert/storyteller/poll_alert_button = candidate_mob.throw_alert(category, /atom/movable/screen/alert/poll_alert/storyteller, timeout_override = alert_time, no_anim = TRUE)
+ if(!poll_alert_button)
+ continue
+
+ new_poll.alert_buttons += poll_alert_button
+ new_poll.RegisterSignal(poll_alert_button, COMSIG_QDELETING, TYPE_PROC_REF(/datum/storyteller_poll, clear_alert_ref))
+
+ poll_alert_button.icon = ui_style2icon(candidate_mob.client?.prefs?.read_preference(/datum/preference/choiced/ui_style))
+ poll_alert_button.desc = "[question_text]"
+ poll_alert_button.show_time_left = TRUE
+ poll_alert_button.storyteller_poll = alert_poll
+ poll_alert_button.set_role_overlay()
+ poll_alert_button.update_stacks_overlay()
+ poll_alert_button.update_candidates_number_overlay()
+ poll_alert_button.update_signed_up_overlay()
+
+ // Image to display (optional, set to null or provide an alert_pic if needed)
+ var/image/poll_image
+ if(ispath(alert_pic, /atom) || isatom(alert_pic))
+ poll_image = new /mutable_appearance(alert_pic)
+ poll_image.pixel_z = 0
+ else if(!isnull(alert_pic))
+ poll_image = null
+ else
+ poll_image = image('icons/effects/effects.dmi', icon_state = "static")
+
+ if(poll_image)
+ poll_image.layer = FLOAT_LAYER
+ poll_image.plane = poll_alert_button.plane
+ poll_alert_button.add_overlay(poll_image)
+
+ // Chat message
+ var/act_jump = ""
+ var/custom_link_style_start = ""
+ var/custom_link_style_end = "style='color:DodgerBlue;font-weight:bold;-dm-text-outline: 1px black'"
+ var/act_choices = ""
+ for(var/goal in goal_list)
+ act_choices += "[custom_link_style_start]\[[goal]\] "
+ var/act_never = ""
+ if(ignore_category)
+ act_never = "[custom_link_style_start]\[Never For This Round\]"
+
+ if(!duplicate_message_check(alert_poll)) //Only notify people once. They'll notice if there are multiple and we don't want to spam people.
+
+ // ghost poll prompt sound handling (adapted for crew, enable if desired)
+ var/polling_sound_pref = candidate_mob.client?.prefs.read_preference(/datum/preference/choiced/sound_ghost_poll_prompt)
+ var/polling_sound_volume = candidate_mob.client?.prefs.read_preference(/datum/preference/numeric/sound_ghost_poll_prompt_volume)
+ if(polling_sound_pref != GHOST_POLL_PROMPT_DISABLED && polling_sound_volume)
+ var/polling_sound
+ if(polling_sound_pref == GHOST_POLL_PROMPT_1)
+ polling_sound = 'sound/misc/prompt1.ogg'
+ else
+ polling_sound = 'sound/misc/prompt2.ogg'
+ SEND_SOUND(candidate_mob, sound(polling_sound, volume = polling_sound_volume))
+
+ var/surrounding_icon
+ // if(chat_text_border_icon) // Add if a border icon is desired
+ // var/image/surrounding_image
+ // if(!ispath(chat_text_border_icon))
+ // var/mutable_appearance/border_image = chat_text_border_icon
+ // surrounding_image = border_image
+ // else
+ // surrounding_image = image(chat_text_border_icon)
+ // surrounding_icon = icon2html(surrounding_image, candidate_mob, extra_classes = "bigicon")
+ var/final_message = boxed_message("[surrounding_icon] [span_ooc(question_text)] [surrounding_icon]\n[act_jump] [act_choices] [act_never]")
+ to_chat(candidate_mob, final_message)
+
+ // Start processing it so it updates visually the timer
+ START_PROCESSING(SSprocessing, poll_alert_button)
+
+ // Sleep until the time is up
+ UNTIL(new_poll.finished)
+ new_poll.trim_votes()
+
+ return new_poll.votes
+
+
+/datum/storyteller_poll
+ var/poll_key = "storyteller_goal"
+ var/question
+ var/start_time
+ var/duration
+ var/finished = FALSE
+ var/ignore_category
+ var/list/goal_options = list()
+ var/list/votes = list()
+ var/list/alert_buttons = list()
+
+/datum/storyteller_poll/New(question, poll_time, ignore_category)
+ src.question = question
+ src.duration = poll_time
+ src.start_time = world.time
+ src.ignore_category = ignore_category
+ poll_key = "storyteller_[ckey(question)]_poll"
+ addtimer(CALLBACK(SSstorytellers, PROC_REF(polling_finished), src), duration)
+
+/datum/storyteller_poll/proc/time_left()
+ return max(0, start_time + duration - world.time)
+
+/datum/storyteller_poll/proc/trim_votes()
+ // Trim ineligible voters.
+ for(var/goal in votes)
+ var/list/voters = votes[goal]
+ if(!islist(voters))
+ continue
+ for(var/mob/M in voters.Copy())
+ if(!SSstorytellers.is_eligible(M, null, null, ignore_category))
+ voters -= M
+
+/datum/storyteller_poll/proc/clear_alert_ref(datum/source)
+ alert_buttons -= source
+
+/datum/storyteller_poll/proc/polling_finished()
+
+/datum/storyteller_poll/Topic(href, list/href_list)
+ . = ..()
+ var/mob/usr_mob = locate(href_list["mob_ref"])
+ if(!usr_mob || !usr_mob.client || time_left() <= 0)
+ return
+
+ if(href_list["select_goal"])
+ var/goal = href_list["select_goal"]
+ if(!(goal in goal_options))
+ return
+ // Prevent duplicate votes but allow changing vote
+ for(var/other_goal in votes)
+ votes[other_goal] -= usr_mob
+ votes[goal] += usr_mob
+ to_chat(usr_mob, span_notice("You voted for [goal]."))
+ for(var/atom/movable/screen/alert/poll_alert/linked_button as anything in alert_buttons)
+ linked_button.update_candidates_number_overlay()
+
+ if(href_list["never"])
+ if(ignore_category)
+ GLOB.poll_ignore[ignore_category] |= usr_mob.ckey
+ to_chat(usr_mob, span_notice("You will no longer be considered for this poll category this round."))
+
+/datum/controller/subsystem/storytellers/proc/polling_finished(datum/storyteller_poll/finishing_poll)
+ currently_polling -= finishing_poll
+ finishing_poll.finished = TRUE
+ QDEL_IN(finishing_poll, 0.5 SECONDS)
+
+/datum/controller/subsystem/storytellers/proc/duplicate_message_check(datum/storyteller_poll/poll_to_check)
+ for(var/datum/storyteller_poll/running_poll as anything in currently_polling)
+ if((running_poll.poll_key == poll_to_check.poll_key && running_poll != poll_to_check) && running_poll.time_left() > 0)
+ return TRUE
+ return FALSE
+
+/datum/controller/subsystem/storytellers/proc/is_eligible(mob/potential_candidate, role, check_jobban, the_ignore_category)
+ if(isnull(potential_candidate.key) || isnull(potential_candidate.client))
+ return FALSE
+ if(the_ignore_category)
+ if(potential_candidate.ckey in GLOB.poll_ignore[the_ignore_category])
+ return FALSE
+ return TRUE
+
+/atom/movable/screen/alert/poll_alert/storyteller
+ name = "Storyteller Poll Alert"
+ var/datum/storyteller_poll/storyteller_poll = null
+
+/atom/movable/screen/alert/poll_alert/storyteller/Initialize(mapload)
+ . = ..()
+
+/atom/movable/screen/alert/poll_alert/storyteller/set_role_overlay()
+ var/role_or_only_question = storyteller_poll?.question ? copytext(storyteller_poll.question, 1, 25) : "Vote?"
+ role_overlay = new
+ role_overlay.screen_loc = screen_loc
+ role_overlay.maptext = MAPTEXT("[full_capitalize(role_or_only_question)]")
+ role_overlay.maptext_width = 128
+ role_overlay.transform = role_overlay.transform.Translate(-128, 0)
+ add_overlay(role_overlay)
+
+/atom/movable/screen/alert/poll_alert/storyteller/Click(location, control, params)
+ SHOULD_CALL_PARENT(FALSE)
+ if(!usr || !GET_CLIENT(usr) || usr != owner)
+ return FALSE
+ var/list/modifiers = params2list(params)
+ if(LAZYACCESS(modifiers, SHIFT_CLICK))
+ to_chat(usr, boxed_message(jointext(examine(usr), "\n")))
+ return FALSE
+ if(!storyteller_poll)
+ return
+ if(LAZYACCESS(modifiers, ALT_CLICK) && storyteller_poll.ignore_category)
+ set_never_round()
+ return
+ handle_vote()
+
+/atom/movable/screen/alert/poll_alert/storyteller/proc/handle_vote()
+ if(!storyteller_poll || storyteller_poll.time_left() <= 0)
+ to_chat(owner, span_danger("Sorry, the poll has ended!"))
+ SEND_SOUND(owner, 'sound/machines/buzz/buzz-sigh.ogg')
+ return FALSE
+
+ var/list/options = storyteller_poll.goal_options.Copy()
+ if(storyteller_poll.ignore_category)
+ options += "Never for this round"
+
+ var/goal = tgui_input_list(owner, "Choose a goal to vote for", "Storyteller Poll", options)
+ if(!goal)
+ return
+
+ if(goal == "Never for this round")
+ if(storyteller_poll.ignore_category)
+ GLOB.poll_ignore[storyteller_poll.ignore_category] |= owner.ckey
+ to_chat(owner, span_notice("You will no longer be considered for this poll category this round."))
+ return
+
+ // Change vote or set new
+ for(var/other_goal in storyteller_poll.votes)
+ storyteller_poll.votes[other_goal] -= owner
+ storyteller_poll.votes[goal] += owner
+ to_chat(owner, span_notice("You voted for [goal]."))
+ update_voted_overlay()
+
+/atom/movable/screen/alert/poll_alert/storyteller/set_never_round()
+ if(!storyteller_poll?.ignore_category)
+ return
+ var/category = storyteller_poll.ignore_category
+ if(!(owner.ckey in GLOB.poll_ignore[category]))
+ GLOB.poll_ignore[category] |= owner.ckey
+ color = "red"
+ to_chat(owner, span_notice("You will no longer be considered for [category] polls this round."))
+ else
+ GLOB.poll_ignore[category] -= owner.ckey
+ color = initial(color)
+ to_chat(owner, span_notice("You will again be considered for [category] polls this round."))
+ update_voted_overlay()
+
+/atom/movable/screen/alert/poll_alert/storyteller/add_context(atom/source, list/context, obj/item/held_item, mob/user)
+ . = ..()
+ context[SCREENTIP_CONTEXT_LMB] = "Vote Poll"
+ if(storyteller_poll?.ignore_category)
+ var/selected_never = (owner.ckey in GLOB.poll_ignore[storyteller_poll.ignore_category])
+ context[SCREENTIP_CONTEXT_ALT_LMB] = selected_never ? "Cancel Never" : "Never For This Round"
+ return CONTEXTUAL_SCREENTIP_SET
+
+/atom/movable/screen/alert/poll_alert/storyteller/update_signed_up_overlay()
+ update_voted_overlay()
+
+/atom/movable/screen/alert/poll_alert/storyteller/proc/update_voted_overlay()
+ cut_overlay(signed_up_overlay)
+ if(storyteller_poll && has_voted(owner))
+ add_overlay(signed_up_overlay)
+
+/atom/movable/screen/alert/poll_alert/storyteller/proc/has_voted(mob/check_mob)
+ if(!storyteller_poll)
+ return FALSE
+ for(var/goal in storyteller_poll.votes)
+ var/list/voters = storyteller_poll.votes[goal]
+ if(check_mob in voters)
+ return TRUE
+ return FALSE
+
+/atom/movable/screen/alert/poll_alert/storyteller/update_candidates_number_overlay()
+ return
+
+/atom/movable/screen/alert/poll_alert/storyteller/update_stacks_overlay()
+ return
+
+/atom/movable/screen/alert/poll_alert/storyteller/handle_sign_up()
+ handle_vote()
+
+/atom/movable/screen/alert/poll_alert/storyteller/jump_to_jump_target()
+ return
+
+/atom/movable/screen/alert/poll_alert/storyteller/Topic(href, href_list)
+ return
diff --git a/tff_modular/modules/storytellers/~subsystem/storyteller_ui.dm b/tff_modular/modules/storytellers/~subsystem/storyteller_ui.dm
new file mode 100644
index 00000000000..18c48b4e456
--- /dev/null
+++ b/tff_modular/modules/storytellers/~subsystem/storyteller_ui.dm
@@ -0,0 +1,290 @@
+ADMIN_VERB(storyteller_admin, R_ADMIN, "Storyteller UI", "Open the storyteller admin panel.", ADMIN_CATEGORY_STORYTELLER)
+ var/datum/storyteller_admin_ui/ui = new
+ ui.ui_interact(usr)
+
+/datum/storyteller_admin_ui
+ /// cached reference to storyteller
+ var/datum/storyteller/ctl
+ var/view_only = FALSE
+
+/datum/storyteller_admin_ui/New()
+ . = ..()
+ ctl = SSstorytellers?.active
+
+/datum/storyteller_admin_ui/ui_state(mob/user)
+ if(view_only)
+ return GLOB.always_state
+ return ADMIN_STATE(R_ADMIN)
+
+
+/datum/storyteller_admin_ui/ui_interact(mob/user, datum/tgui/ui)
+ ctl = SSstorytellers?.active
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "Storyteller")
+ ui.open()
+
+/datum/storyteller_admin_ui/ui_static_data(mob/user)
+ var/list/data = list()
+
+ var/list/moods = list()
+ for(var/mood_type in subtypesof(/datum/storyteller_mood))
+ if(mood_type == /datum/storyteller_mood)
+ continue
+ var/datum/storyteller_mood/mood = mood_type
+ moods += list(list(
+ "id" = "[mood_type]",
+ "name" = initial(mood.name),
+ "pace" = initial(mood.pace),
+ "threat" = initial(mood.aggression),
+ ))
+ data["available_moods"] = moods
+
+ var/list/candidates = list()
+ for(var/id in SSstorytellers.storyteller_data)
+ var/list/storyteller_data = SSstorytellers.storyteller_data[id]
+ if(!storyteller_data)
+ continue
+ candidates += list(list(
+ "name" = storyteller_data["name"],
+ "id" = id,
+ ))
+ data["candidates"] = candidates
+
+ var/list/goals = list()
+ for(var/id_key in SSstorytellers.events_by_id)
+ var/datum/round_event_control/evt = SSstorytellers.events_by_id[id_key]
+ if(!evt)
+ continue
+ goals += list(list(
+ "id" = evt.id,
+ "name" = evt.name || evt.id,
+ "desc" = evt.description,
+ "is_antagonist" = (evt.story_category & STORY_GOAL_ANTAGONIST),
+ ))
+ data["available_goals"] = goals
+
+ return data
+
+/datum/storyteller_admin_ui/ui_assets(mob/user)
+ return list(
+ get_asset_datum(/datum/asset/simple/storyteller_portraits_icons),
+ )
+
+/datum/storyteller_admin_ui/ui_data(mob/user)
+ var/list/data = list()
+ ctl = SSstorytellers?.active
+ if(!ctl)
+ data["name"] = "No storyteller"
+ return data
+
+ data["id"] = ctl.id
+ data["name"] = ctl.name
+ data["desc"] = ctl.desc
+ data["ooc_desc"] = ctl.ooc_desc
+ data["ooc_difficulty"] = ctl.ooc_difficulty
+ data["population_factor"] = ctl.population_factor
+ data["threat_points"] = ctl.threat_points
+ if(ctl.mood)
+ data["mood"] = list(
+ "id" = "[ctl.mood.type]",
+ "name" = ctl.mood.name,
+ "pace" = ctl.mood.pace,
+ "threat" = ctl.mood.get_threat_multiplier(),
+ )
+
+ var/list/upcoming = ctl.planner.get_upcoming_events(10)
+ data["upcoming_goals"] = list()
+ for(var/offset in upcoming)
+ var/list/entry = ctl.planner.get_entry_at(offset)
+ if(!entry || !entry["event"])
+ continue
+ var/datum/round_event_control/evt = entry["event"]
+ if(!evt)
+ continue
+ var/storyteller_implementation = FALSE
+ if(evt?.typepath)
+ if(istype(evt?.typepath, /datum/round_event))
+ var/datum/round_event/to_check = evt?.typepath
+ storyteller_implementation = to_check::storyteller_implementation
+ data["upcoming_goals"] += list(list(
+ "id" = evt.id,
+ "name" = evt.name || evt.id,
+ "desc" = evt.description,
+ "fire_time" = entry["fire_time"],
+ "category" = entry["category"],
+ "status" = entry["status"],
+ "weight" = evt.get_story_weight(ctl.inputs, ctl),
+ "storyteller_implementation" = storyteller_implementation,
+ "is_antagonist" = (evt.story_category & STORY_GOAL_ANTAGONIST),
+ ))
+
+ data["effective_threat_level"] = ctl.get_effective_threat()
+ data["target_tension"] = ctl.target_tension
+ data["round_progression"] = ctl.round_progression
+ data["threat_level"] = ctl.threat_points
+ data["next_think_time"] = ctl.next_think_time
+ data["next_antag_wave_time"] = ctl.next_atnag_balance_check_time
+ data["base_think_delay"] = ctl.base_think_delay
+ data["average_event_interval"] = ctl.average_event_interval
+ data["player_count"] = ctl.inputs.player_count()
+ data["antag_count"] = ctl.inputs.antag_count()
+ data["player_antag_balance"] = ctl.player_antag_balance
+ data["difficulty_multiplier"] = ctl.difficulty_multiplier
+ data["event_difficulty_modifier"] = ctl.difficulty_multiplier
+ data["current_tension"] = ctl.current_tension
+ data["can_force_event"] = TRUE
+ data["current_world_time"] = world.time
+
+ var/list/events = list()
+ for(var/id in ctl.recent_events)
+ if(!id || !ctl.recent_events[id])
+ continue
+ var/list/details = ctl.recent_events[id]
+ if(!details || !length(details))
+ continue
+ var/list/event_data = details[1]
+ events += list(list(
+ "fired_at" = event_data["fired_at"] || world.time,
+ "desc" = event_data["desc"],
+ "status" = event_data["status"],
+ "id" = event_data["id"],
+ ))
+
+ if(events.len)
+ events = sortTim(events, GLOBAL_PROC_REF(cmp_time_keys_asc), "fired_at")
+
+ data["recent_events"] = events.len ? events.Copy(1, min(51, events.len + 1)) : list()
+
+ return data
+
+/datum/storyteller_admin_ui/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return
+ if(!check_rights(R_ADMIN) || view_only)
+ return TRUE
+ ctl = SSstorytellers?.active
+ if(!ctl)
+ return TRUE
+
+ switch(action)
+ if("force_think")
+ message_admins("[key_name_admin(usr)] forced [ctl.name] to think now")
+ ctl.next_think_time = world.time + 1 SECONDS
+ return TRUE
+ if("trigger_event")
+ ctl.trigger_random_event(ctl.inputs.vault, ctl.inputs, ctl)
+ return TRUE
+ if("force_fire_next")
+ var/list/entry = ctl.planner.get_closest_entry()
+ if(entry)
+ var/fire_time = entry["fire_time"]
+ var/new_fire_time = entry["fire_time"] = world.time + 1 SECONDS
+ ctl.planner.reschedule_event(fire_time, new_fire_time)
+ return TRUE
+ if("reschedule_chain")
+ message_admins("[key_name_admin(usr)] forced reschedule event chain for [ctl.name]")
+ ctl.planner.recalculate_plan(ctl, ctl.inputs, ctl.balancer.make_snapshot(ctl.inputs), TRUE)
+ return TRUE
+ if("set_storyteller")
+ var/storyteller_id = params["id"]
+ if(!storyteller_id)
+ return TRUE
+ if(SSstorytellers.set_storyteller(storyteller_id))
+ ctl = SSstorytellers.active
+ message_admins("[key_name_admin(usr)] is changed storyteller to [ctl.name].")
+ if("set_mood")
+ var/mood_id = params["id"]
+ if(mood_id)
+ var/path = text2path(mood_id)
+ if(ispath(path, /datum/storyteller_mood))
+ ctl.mood = new path
+ ctl.schedule_next_think()
+ return TRUE
+ if("set_pace")
+ var/pace = clamp(text2num(params["pace"]), 0.1, 3.0)
+ if(ctl.mood)
+ ctl.mood.pace = pace
+ ctl.schedule_next_think()
+ return TRUE
+ if("reanalyse")
+ ctl.run_metrics()
+ return TRUE
+ if("replan")
+ ctl.planner.recalculate_plan(ctl, ctl.inputs, ctl.balancer.make_snapshot(ctl.inputs), TRUE)
+ return TRUE
+ if("set_difficulty")
+ var/value = clamp(text2num(params["value"]), 0.1, 5.0)
+ ctl.difficulty_multiplier = value
+ return TRUE
+ if("set_target_tension")
+ var/value = clamp(text2num(params["value"]), 0, 100)
+ ctl.target_tension = value
+ return TRUE
+ if("set_think_delay")
+ var/value = max(0, round(text2num(params["value"])))
+ ctl.base_think_delay = value
+ ctl.schedule_next_think()
+ return TRUE
+ if("set_average_event_interval")
+ ctl.average_event_interval = round(text2num(params["average_event_interval"]))
+ return TRUE
+ if("set_grace_period")
+ var/value = max(0, round(text2num(params["value"])))
+ ctl.grace_period = value
+ return TRUE
+ if("set_repetition_penalty")
+ var/value = clamp(text2num(params["value"]), 0, 2)
+ ctl.repetition_penalty = value
+ return TRUE
+ if("insert_goal_to_chain")
+ var/id = params["id"]
+ if(!id)
+ return TRUE
+ var/datum/round_event_control/evt = SSstorytellers.events_by_id[id]
+ if(istype(evt))
+ var/datum/round_event_control/new_event_control = new evt.type
+ ctl.planner.add_next_event(ctl, evt = new_event_control, silence = TRUE)
+ message_admins("[key_name_admin(usr)] is planned event [evt.name || evt.id] for [ctl.name]")
+ return TRUE
+ if("trigger_goal")
+ var/fire_offset = params["offset"]
+ if(!fire_offset)
+ return TRUE
+ var/datum/round_event_control/evt = ctl.planner.get_entry_at(fire_offset)["event"]
+ ctl.planner.reschedule_event(fire_offset, world.time + 1 SECONDS)
+ message_admins("[key_name_admin(usr)] triggered [ctl.name] event [evt.name || evt.id]")
+ return TRUE
+ if("remove_goal")
+ var/fire_offset = params["offset"]
+ if(!fire_offset)
+ return TRUE
+ if(!ctl.planner.get_entry_at(fire_offset)["event"])
+ return TRUE
+ var/datum/round_event_control/evt = ctl.planner.get_entry_at(fire_offset)["event"]
+ ctl.planner.cancel_event(fire_offset)
+ message_admins("[key_name_admin(usr)] canceled [ctl.name] event [evt.name || evt.id]")
+ return TRUE
+ if("toggle_debug")
+ SSstorytellers.hard_debug = !SSstorytellers.hard_debug
+ message_admins("[key_name_admin(usr)] toggle stortyteller debug mode: [SSstorytellers.hard_debug ? "ENABLED" : "DISABLED"]")
+ return TRUE
+ if("force_check_atnagoinst")
+ if(HAS_TRAIT(ctl, STORYTELLER_TRAIT_NO_ANTAGS))
+ var/ask = tgui_alert(usr, "[ctl.name] has the NO_ANTAGS trait! force them to spawn antags?", "Continue?", list("Confirm", "Cancel"))
+ if(ask != "Confirm")
+ return TRUE
+ message_admins("[key_name_admin(usr)] forced antagonist spawn check for [ctl.name].")
+ ctl.check_and_spawn_antagonists(force = TRUE)
+ return TRUE
+ if("reload_event_config")
+ SSstorytellers.load_event_config()
+ message_admins("[key_name_admin(usr)] reloaded storyteller event configuration.")
+ if("reload_storyteller_config")
+ SSstorytellers.load_storyteller_data()
+ message_admins("[key_name_admin(usr)] reloaded storyteller configuration.")
+ if("reload_current_storyteller")
+ SSstorytellers.create_storyteller_from_data(ctl.id, FALSE)
+ message_admins("[key_name_admin(usr)] reloaded config of current storyteller ([ctl.name]).")
+ return FALSE
diff --git a/tff_modular/modules/storytellers/~subsystem/storyteller_vote.dm b/tff_modular/modules/storytellers/~subsystem/storyteller_vote.dm
new file mode 100644
index 00000000000..7d2868dde51
--- /dev/null
+++ b/tff_modular/modules/storytellers/~subsystem/storyteller_vote.dm
@@ -0,0 +1,413 @@
+ADMIN_VERB(storyteller_vote, R_ADMIN | R_DEBUG, "Storyteller - Start Vote", "Start a global storyteller vote.", ADMIN_CATEGORY_STORYTELLER)
+ if (tgui_alert(usr, "Start global vote?", "Storyteller Vote", list("Yes", "No")) == "No")
+ return
+ var/duration = tgui_input_number(usr, "Duration in seconds:", "Vote Duration", 60, 240, 60)
+ SSstorytellers.start_vote(duration SECONDS)
+
+ADMIN_VERB(storyteller_end_vote, R_ADMIN | R_DEBUG, "Storyteller - End Vote", "End vote early.", ADMIN_CATEGORY_STORYTELLER)
+ SSstorytellers.end_vote()
+
+/datum/asset/simple/storyteller_logo_icons
+ assets = list()
+
+/datum/asset/simple/storyteller_logo_icons/New()
+ for(var/storyteller_id in SSstorytellers.storyteller_data)
+ var/list/storyteller_data = SSstorytellers.storyteller_data[storyteller_id]
+ if(!storyteller_data)
+ continue
+ var/asset_id = storyteller_id + "_logo" + ".png"
+ var/path = storyteller_data["logo_path"]
+ assets[asset_id] = path
+ ..()
+
+
+
+/datum/asset/simple/storyteller_portraits_icons
+ assets = list()
+
+/datum/asset/simple/storyteller_portraits_icons/New()
+ GLOB.asset_datums[type] = src
+ for(var/storyteller_id in SSstorytellers.storyteller_data)
+ var/list/storyteller_data = SSstorytellers.storyteller_data[storyteller_id]
+ if(!storyteller_data)
+ continue
+ var/asset_id = storyteller_id + "_portrait" + ".png"
+ var/path = storyteller_data["portait_path"]
+ assets[asset_id] = path
+ ..()
+
+/datum/action/storyteller_vote
+ name = "Vote for storyteller!"
+ button_icon_state = "vote"
+ show_to_observers = FALSE
+
+/datum/action/storyteller_vote/IsAvailable(feedback = FALSE)
+ return TRUE
+
+/datum/action/storyteller_vote/Grant(mob/grant_to)
+ . = ..()
+ RegisterSignal(SSstorytellers, COMSIG_STORYTELLER_VOTE_END, PROC_REF(on_vote_ended), TRUE)
+
+/datum/action/storyteller_vote/Trigger(mob/clicker, trigger_flags)
+ . = ..()
+ if(!.)
+ return
+ if(!SSstorytellers.vote_active)
+ Remove(owner)
+ return
+ var/datum/storyteller_vote_ui/ui = SSstorytellers.storyteller_vote_uis[clicker.client]
+ if (!ui)
+ ui = new(clicker.client, SSstorytellers.current_vote_duration)
+ ui.ui_interact(clicker)
+
+/datum/action/storyteller_vote/Topic(href, href_list)
+ . = ..()
+ if(.)
+ return
+ if(href_list["open_ui"])
+ Trigger(owner)
+
+/datum/action/storyteller_vote/proc/on_vote_ended()
+ SIGNAL_HANDLER
+ Remove(owner)
+
+/datum/action/storyteller_vote/Remove(mob/removed_from)
+ if(removed_from.persistent_client)
+ removed_from.persistent_client.player_actions -= src
+
+ else if(removed_from.ckey)
+ var/datum/persistent_client/persistent_client = GLOB.persistent_clients_by_ckey[removed_from.ckey]
+ persistent_client?.player_actions -= src
+
+ UnregisterSignal(SSstorytellers, COMSIG_STORYTELLER_VOTE_END)
+ return ..()
+
+/datum/controller/subsystem/storytellers
+ var/vote_timer_id
+
+/datum/controller/subsystem/storytellers/proc/on_login(dcs, client/new_client)
+ SIGNAL_HANDLER
+ if(vote_active)
+ var/datum/action/storyteller_vote/vote_action = new()
+ vote_action.Grant(new_client.mob)
+ new_client.persistent_client.player_actions += vote_action
+ INVOKE_ASYNC(src, PROC_REF(send_vote_message), new_client, vote_action)
+
+/datum/controller/subsystem/storytellers/proc/start_vote(duration = 60 SECONDS)
+ // Clears existing UIs to prevent duplicates or stale data
+ storyteller_vote_uis = list()
+ vote_active = TRUE
+ vote_start_time = world.time // Set global vote start time
+ to_chat(world, span_boldnotice("Storyteller voting has started!"))
+ current_vote_duration = duration
+ for (var/client/cln in GLOB.clients)
+ var/datum/action/storyteller_vote/vote_action = new()
+ vote_action.Grant(cln.mob)
+ cln.persistent_client.player_actions += vote_action
+ INVOKE_ASYNC(src, PROC_REF(send_vote_message), cln, vote_action)
+
+ vote_timer_id = addtimer(CALLBACK(src, PROC_REF(end_vote)), duration, TIMER_STOPPABLE)
+ SEND_SIGNAL(src, COMSIG_STORYTELLER_VOTE_START)
+
+/datum/controller/subsystem/storytellers/proc/send_vote_message(client/cln, datum/action/storyteller_vote/vote_action)
+ window_flash(cln)
+
+ // ghost poll prompt sound handling (adapted for vote)
+ var/polling_sound_pref = cln.prefs.read_preference(/datum/preference/choiced/sound_ghost_poll_prompt)
+ var/polling_sound_volume = cln.prefs.read_preference(/datum/preference/numeric/sound_ghost_poll_prompt_volume)
+ if(polling_sound_pref != GHOST_POLL_PROMPT_DISABLED && polling_sound_volume)
+ var/polling_sound
+ if(polling_sound_pref == GHOST_POLL_PROMPT_1)
+ polling_sound = 'sound/misc/prompt1.ogg'
+ else
+ polling_sound = 'sound/misc/prompt2.ogg'
+ SEND_SOUND(cln.mob, sound(polling_sound, volume = polling_sound_volume))
+
+ var/custom_link_style_start = ""
+ var/custom_link_style_end = "style='color:DodgerBlue;font-weight:bold;-dm-text-outline: 1px black'"
+
+ var/act_vote = "[custom_link_style_start]\[Vote Now\]"
+
+ var/surrounding_icon
+ var/final_message = boxed_message("[surrounding_icon] [span_ooc("Storyteller Vote Started! Vote for your preferred storyteller.")] [surrounding_icon]\n[act_vote]")
+ to_chat(cln.mob, final_message)
+
+
+/datum/controller/subsystem/storytellers/proc/end_vote()
+ if(!length(storyteller_vote_uis))
+ return
+
+ vote_active = FALSE
+ vote_start_time = 0 // Reset vote start time
+ deltimer(vote_timer_id)
+ var/list/tallies = list()
+ var/list/diff_tallies = list()
+ var/total_votes = 0
+ for(var/client/client in storyteller_vote_uis)
+ var/datum/storyteller_vote_ui/ui = storyteller_vote_uis[client]
+ for(var/ckey in ui.votes)
+ var/list/v = ui.votes[ckey]
+ var/id_str = v["storyteller"]
+ if (!id_str)
+ continue
+ tallies[id_str] = (tallies[id_str] || 0) + 1
+ if (v["difficulty"])
+ if(!diff_tallies[id_str])
+ diff_tallies[id_str] = list()
+ diff_tallies[id_str] += v["difficulty"]
+ total_votes++
+ SStgui.close_uis(ui.owner.mob, ui)
+ qdel(ui)
+
+ storyteller_vote_uis = list()
+ var/list/best_storytellers = list()
+ var/max_votes = 0
+ for (var/id_str in tallies)
+ var/count = tallies[id_str]
+ if (count > max_votes)
+ max_votes = count
+ best_storytellers = list(id_str)
+ else if (count == max_votes)
+ best_storytellers += id_str
+ var/selected_difficulty
+ if(!length(best_storytellers))
+ to_chat(world, span_boldnotice("No votes were cast! Random storyteller selected."))
+ selected_id = pick(list(storyteller_data))
+ selected_difficulty = 1.0
+ return
+
+ var/selected_id_str
+ if(length(best_storytellers) == 1)
+ selected_id_str = best_storytellers[1]
+ else
+ selected_id_str = pick(best_storytellers)
+ to_chat(world, span_announce("Tie broken randomly!"))
+
+ selected_id = selected_id_str
+ var/list/diffs_for_selected = diff_tallies[selected_id_str] || list()
+ if(!length(diffs_for_selected))
+ selected_difficulty = 1.0
+ else
+ var/list/diff_counts = list()
+ for(var/d in diffs_for_selected)
+ var/d_str = num2text(d)
+ diff_counts[d_str] = (diff_counts[d_str] || 0) + 1
+ var/max_count = 0
+
+ var/list/best_diffs = list()
+ for(var/d_str in diff_counts)
+ var/c = diff_counts[d_str]
+ if(c > max_count)
+ max_count = c
+ best_diffs = list(text2num(d_str))
+ else if(c == max_count)
+ best_diffs += text2num(d_str)
+ selected_difficulty = length(best_diffs) == 1 ? best_diffs[length(best_diffs)] : pick(best_diffs)
+ if(best_diffs.len > 1)
+ to_chat(world, span_announce("Difficulty tie broken randomly!"))
+
+ var/selected_name = find_candidate_name_global(selected_id_str)
+ to_chat(world, span_boldnotice("Storyteller selected: [selected_name] at majority-voted difficulty [round(selected_difficulty, 0.1)]."))
+ log_storyteller("Storyteller vote ended: [selected_id_str] (votes=[max_votes], majority_diff=[selected_difficulty]), total votes=[total_votes]")
+ SEND_SIGNAL(src, COMSIG_STORYTELLER_VOTE_END)
+
+ if(SSticker.current_state < GAME_STATE_PLAYING)
+ storyteller_vote_cache = list()
+ storyteller_vote_cache += selected_id
+ src.selected_difficulty = clamp(selected_difficulty, 0.3, 5.0)
+ write_cache()
+
+ if(SSticker.current_state != GAME_STATE_PLAYING)
+ return
+
+ if(!storyteller_data[selected_id])
+ log_storyteller("Vote failed: invalid ID [selected_id_str]")
+ to_chat(world, span_boldnotice("Vote failed! Default storyteller selected."))
+ if (active)
+ qdel(active)
+ active = new /datum/storyteller
+ active.difficulty_multiplier = 1.0
+ active.initialize()
+ return
+
+ if(active)
+ qdel(active)
+
+ active = create_storyteller_from_data(selected_id)
+ active.difficulty_multiplier = clamp(selected_difficulty, 0.3, 5.0)
+ active.initialize()
+
+/datum/storyteller_vote_ui/proc/find_candidate_name(id_str)
+ for (var/list/cand in candidates)
+ if (cand["id"] == id_str)
+ return cand["name"]
+ return "Unknown"
+
+/proc/get_avg(list/nums)
+ if (!length(nums))
+ return 1.0
+ var/sum = 0
+ for (var/n in nums)
+ sum += n
+ return sum / length(nums)
+
+/proc/find_candidate_name_global(id_str)
+ if(SSstorytellers.storyteller_data[id_str])
+ return SSstorytellers.storyteller_data[id_str]["name"]
+ return "Unknown"
+
+
+
+/datum/storyteller_vote_ui
+ var/list/candidates
+ var/list/votes = list() // ckey -> list("storyteller" = id_string, "difficulty" = num)
+ var/vote_end_time = 0
+ var/vote_duration = 60 SECONDS
+ var/client/owner
+
+/datum/storyteller_vote_ui/New(client/vote_client, duration = 60 SECONDS)
+ . = ..()
+ if (!vote_client)
+ qdel(src)
+ return
+ owner = vote_client
+ vote_duration = duration
+ vote_end_time = SSstorytellers.vote_start_time ? SSstorytellers.vote_start_time + duration : world.time + duration
+ candidates = list()
+ var/current_id
+ if(SSstorytellers.active)
+ current_id = SSstorytellers.active.id
+
+ if(SSstorytellers.last_selected_id && SSticker.current_state == GAME_STATE_PREGAME)
+ current_id = SSstorytellers.last_selected_id
+
+ for(var/id in SSstorytellers.storyteller_data)
+ if(current_id)
+ if(current_id == id) continue
+
+ var/list/data = SSstorytellers.storyteller_data[id]
+ candidates += list(list(
+ "id" = id,
+ "name" = data["name"],
+ "desc" = data["desc"],
+ "portrait_path" = data["portrait_path"],
+ "logo_path" = data["logo_path"],
+ "ooc_desc" = data["ooc_desc"],
+ "ooc_diff" = data["ooc_difficulty"],
+ ))
+ SSstorytellers.storyteller_vote_uis[owner] = src
+
+/datum/storyteller_vote_ui/Destroy()
+ SSstorytellers.storyteller_vote_uis -= owner
+ return ..()
+
+/datum/storyteller_vote_ui/ui_assets(mob/user)
+ return list(
+ get_asset_datum(/datum/asset/simple/storyteller_logo_icons),
+ get_asset_datum(/datum/asset/simple/storyteller_portraits_icons),
+ )
+
+
+/datum/storyteller_vote_ui/ui_state(mob/user)
+ return GLOB.always_state
+
+/datum/storyteller_vote_ui/ui_static_data(mob/user)
+ var/list/data = list()
+ data["storytellers"] = candidates
+ data["min_difficulty"] = 0.3
+ data["max_difficulty"] = 5.0
+ return data
+
+/datum/storyteller_vote_ui/ui_data(mob/user)
+ var/ckey = owner.ckey
+ var/list/personal_vote = votes[ckey] || list("storyteller" = null, "difficulty" = 1.0)
+
+ var/list/tallies = list()
+ var/list/difficulties = list()
+ for (var/client/client in SSstorytellers.storyteller_vote_uis)
+ var/datum/storyteller_vote_ui/ui = SSstorytellers.storyteller_vote_uis[client]
+
+ for (var/vote_ckey in ui.votes)
+ var/list/v = ui.votes[vote_ckey]
+ var/id_str = v["storyteller"]
+ if (!id_str)
+ continue
+ tallies[id_str] = (tallies[id_str] || 0) + 1
+ LAZYADD(difficulties[id_str], v["difficulty"])
+
+ var/list/top_tallies = list()
+ var/list/sorted_tallies = sortTim(tallies, /proc/cmp_numeric_dsc, TRUE)
+ for (var/i = 1 to min(3, length(sorted_tallies)))
+ var/id_str = sorted_tallies[i]
+ top_tallies += list(list(
+ "name" = find_candidate_name(id_str),
+ "count" = tallies[id_str],
+ "avg_diff" = length(difficulties[id_str]) ? get_avg(difficulties[id_str]) : 1.0
+ ))
+
+ var/list/data = list()
+ data["personal_selection"] = personal_vote["storyteller"]
+ data["personal_difficulty"] = personal_vote["difficulty"]
+ data["total_voters"] = length(GLOB.clients)
+ var/voted_count = 0
+ for (var/id_str in tallies)
+ voted_count += tallies[id_str]
+ data["voted_count"] = voted_count
+ var/global_vote_end_time = SSstorytellers.vote_start_time ? SSstorytellers.vote_start_time + SSstorytellers.current_vote_duration : vote_end_time
+ data["time_left"] = max(0, (global_vote_end_time - world.time))
+ data["top_tallies"] = top_tallies
+ data["is_open"] = world.time < global_vote_end_time && SSstorytellers.vote_active
+ data["debug_mode"] = SSstorytellers.hard_debug
+ data["admin_mode"] = check_rights(R_ADMIN, show_msg = FALSE)
+
+ var/can_vote = TRUE
+
+ if(!isAdminObserver(user))
+ if(isobserver(user))
+ can_vote = FALSE
+ var/area/my_area = get_area(user)
+ if(istype(my_area, /area/misc/hilbertshotel) || istype(my_area, /area/misc/hilbertshotelstorage))
+ can_vote = FALSE
+
+ data["can_vote"] = can_vote
+ return data
+
+/datum/storyteller_vote_ui/ui_interact(mob/user, datum/tgui/ui)
+ if (!owner)
+ return
+ ui = SStgui.try_update_ui(user, src, ui)
+ if (!ui)
+ ui = new(user, src, "StorytellerVote", "Storyteller Vote")
+ ui.open()
+
+/datum/storyteller_vote_ui/Destroy()
+ . = ..()
+ for(var/datum/action/action in owner.mob.actions)
+ if(istype(action, /datum/action/storyteller_vote) && !QDELETED(action))
+ action.Remove(owner.mob)
+
+/datum/storyteller_vote_ui/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if (.)
+ return
+ var/ckey = owner.ckey
+ switch (action)
+ if("select_storyteller")
+ var/id = params["id"]
+ var/list/personal = votes[ckey] || list()
+ personal["storyteller"] = id
+ votes[ckey] = personal
+ return TRUE
+ if("set_difficulty")
+ var/value = text2num(params["value"])
+ value = clamp(value, 0.3, 5.0)
+ var/list/personal = votes[ckey] || list()
+ personal["difficulty"] = value
+ votes[ckey] = personal
+ return TRUE
+ if("force_end_vote")
+ if(!check_rights(R_ADMIN))
+ return TRUE
+ SSstorytellers.end_vote()
+ message_admins("[key_name_admin(owner)] has forced the end of the storyteller vote.")
+ return FALSE
diff --git a/tgstation.dme b/tgstation.dme
index 6984691c622..3a041edb754 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -429,6 +429,11 @@
#include "code\__DEFINES\~ff_defines\_globalvars\logging.dm"
#include "code\__DEFINES\~ff_defines\_globalvars\lists\blooper.dm"
#include "code\__DEFINES\~ff_defines\_globalvars\traits\declarations.dm"
+#include "code\__DEFINES\~ff_defines\storyteller\_helpers.dm"
+#include "code\__DEFINES\~ff_defines\storyteller\event_defines.dm"
+#include "code\__DEFINES\~ff_defines\storyteller\signals.dm"
+#include "code\__DEFINES\~ff_defines\storyteller\storyteller.dm"
+#include "code\__DEFINES\~ff_defines\storyteller\~storyteller_vault.dm"
#include "code\__DEFINES\~nova_defines\_flags.dm"
#include "code\__DEFINES\~nova_defines\_organ_defines.dm"
#include "code\__DEFINES\~nova_defines\access.dm"
@@ -651,6 +656,7 @@
#include "code\__HELPERS\sorts\sort_instance.dm"
#include "code\__HELPERS\~ff_helpers\is_helpers.dm"
#include "code\__HELPERS\~ff_helpers\names.dm"
+#include "code\__HELPERS\~ff_helpers\storytellers.dm"
#include "code\__HELPERS\~nova_helpers\areas.dm"
#include "code\__HELPERS\~nova_helpers\is_helpers.dm"
#include "code\__HELPERS\~nova_helpers\level_traits.dm"
@@ -9705,6 +9711,65 @@
#include "tff_modular\modules\snowfall\snowfall.dm"
#include "tff_modular\modules\species\scream_and_laugh.dm"
#include "tff_modular\modules\splurt_genitals\genitals.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_analyzer.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_antags.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_behevour.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_inputs.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_log.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_mood.dm"
+#include "tff_modular\modules\storytellers\core\storyteller_planner.dm"
+#include "tff_modular\modules\storytellers\core\~storyteller.dm"
+#include "tff_modular\modules\storytellers\core\~storyteller_balancer.dm"
+#include "tff_modular\modules\storytellers\events\storyteller_event_control.dm"
+#include "tff_modular\modules\storytellers\events\~round_event.dm"
+#include "tff_modular\modules\storytellers\events\antagonist\majors.dm"
+#include "tff_modular\modules\storytellers\events\antagonist\minors.dm"
+#include "tff_modular\modules\storytellers\events\bad\brain_trauma.dm"
+#include "tff_modular\modules\storytellers\events\bad\brand_intelligence.dm"
+#include "tff_modular\modules\storytellers\events\bad\communications_blackout.dm"
+#include "tff_modular\modules\storytellers\events\bad\disease_outbreak.dm"
+#include "tff_modular\modules\storytellers\events\bad\electrical_storm.dm"
+#include "tff_modular\modules\storytellers\events\bad\firespread.dm"
+#include "tff_modular\modules\storytellers\events\bad\heart_attack.dm"
+#include "tff_modular\modules\storytellers\events\bad\ion_storm.dm"
+#include "tff_modular\modules\storytellers\events\bad\meteors.dm"
+#include "tff_modular\modules\storytellers\events\bad\sabotage_infrastructure.dm"
+#include "tff_modular\modules\storytellers\events\bad\spacevine.dm"
+#include "tff_modular\modules\storytellers\events\bad\supermatter_surge.dm"
+#include "tff_modular\modules\storytellers\events\bad\~generic_overrides.dm"
+#include "tff_modular\modules\storytellers\events\bad\raid\_raid.dm"
+#include "tff_modular\modules\storytellers\events\bad\raid\raider_ai.dm"
+#include "tff_modular\modules\storytellers\events\bad\raid\raider_mob.dm"
+#include "tff_modular\modules\storytellers\events\bad\raid\raider_team.dm"
+#include "tff_modular\modules\storytellers\events\good\aurora_caelus.dm"
+#include "tff_modular\modules\storytellers\events\good\cargo_pod.dm"
+#include "tff_modular\modules\storytellers\events\good\~generic_overrides.dm"
+#include "tff_modular\modules\storytellers\events\neutral\anomalies.dm"
+#include "tff_modular\modules\storytellers\events\neutral\carp_migration.dm"
+#include "tff_modular\modules\storytellers\events\neutral\gravgen_error.dm"
+#include "tff_modular\modules\storytellers\events\neutral\grid_check.dm"
+#include "tff_modular\modules\storytellers\events\neutral\market_crash.dm"
+#include "tff_modular\modules\storytellers\events\neutral\~generic_overrides.dm"
+#include "tff_modular\modules\storytellers\events\rimworld\phychic_wave.dm"
+#include "tff_modular\modules\storytellers\events\rimworld\psychic_drone.dm"
+#include "tff_modular\modules\storytellers\events\rimworld\zzzzzzt.dm"
+#include "tff_modular\modules\storytellers\metrics\crew.dm"
+#include "tff_modular\modules\storytellers\metrics\health.dm"
+#include "tff_modular\modules\storytellers\metrics\research.dm"
+#include "tff_modular\modules\storytellers\metrics\resources.dm"
+#include "tff_modular\modules\storytellers\metrics\station_integrity.dm"
+#include "tff_modular\modules\storytellers\metrics\station_strength.dm"
+#include "tff_modular\modules\storytellers\metrics\utility.dm"
+#include "tff_modular\modules\storytellers\metrics\~storyteller_metrics.dm"
+#include "tff_modular\modules\storytellers\metrics\antagonist\_tracker.dm"
+#include "tff_modular\modules\storytellers\metrics\antagonist\effectivity.dm"
+#include "tff_modular\modules\storytellers\metrics\antagonist\metric.dm"
+#include "tff_modular\modules\storytellers\overrides\jobs.dm"
+#include "tff_modular\modules\storytellers\overrides\nuke_disk.dm"
+#include "tff_modular\modules\storytellers\~subsystem\_SSstorytellers.dm"
+#include "tff_modular\modules\storytellers\~subsystem\storyteller_poll.dm"
+#include "tff_modular\modules\storytellers\~subsystem\storyteller_ui.dm"
+#include "tff_modular\modules\storytellers\~subsystem\storyteller_vote.dm"
#include "tff_modular\modules\streletz\code\clothing.dm"
#include "tff_modular\modules\tgmc_xenos\code\alien_egg.dm"
#include "tff_modular\modules\tgmc_xenos\code\base_nova_xeno.dm"
diff --git a/tgui/packages/tgui/interfaces/Storyteller/index.tsx b/tgui/packages/tgui/interfaces/Storyteller/index.tsx
new file mode 100644
index 00000000000..7f23cf62336
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Storyteller/index.tsx
@@ -0,0 +1,1110 @@
+import '../../styles/interfaces/StorytellerVote.scss';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ Box,
+ Button,
+ Divider,
+ Dropdown,
+ Input,
+ LabeledList,
+ ProgressBar,
+ Section,
+ Stack,
+ Table,
+ Tabs,
+ Tooltip,
+} from 'tgui-core/components';
+import { resolveAsset } from '../../assets';
+import { useBackend } from '../../backend';
+import { Window } from '../../layouts';
+import {
+ DIFFICULTY_LEVELS,
+ type StorytellerData,
+ type StorytellerGoal,
+ type StorytellerUpcomingGoal,
+ type scrollConfigProp,
+ TOOLTIPS,
+} from './types';
+
+const InputScrollApply = React.memo((props: scrollConfigProp) => {
+ const { value, setValue, onSet, min, max, step, delim = 1 } = props;
+
+ return (
+
+
+
+ setValue(String(Number(e.currentTarget.value) * delim))
+ }
+ style={{ width: '100%' }}
+ />
+
+ {Number(value) / delim}
+
+
+
+
+ );
+});
+
+const formatTime = (ticks?: number, current_time?: number) => {
+ if (!ticks && ticks !== 0) return '—';
+ const relative = current_time ? ticks - current_time : ticks;
+ const seconds = Math.floor(Math.abs(relative) / 10);
+ const minutes = Math.floor(seconds / 60);
+ const remainderSeconds = seconds % 60;
+ const sign = relative < 0 ? '-' : '';
+ if (minutes === 0) {
+ return `${sign}${seconds}s`;
+ }
+ return `${sign}${minutes}m ${sign}${remainderSeconds.toString().padStart(2, '0')}s`;
+};
+
+const ProgressRow = React.memo(
+ ({
+ label,
+ value,
+ color,
+ tooltip,
+ }: {
+ label: string;
+ value?: number;
+ color?: 'good' | 'average' | 'bad';
+ tooltip?: string;
+ }) => {
+ const pct = Math.max(0, Math.min(1, value ?? 0));
+ return (
+
+
+ {Math.round(pct * 100)}%
+
+
+ );
+ },
+);
+
+type UpcomingGoalItemProps = {
+ goal: StorytellerUpcomingGoal;
+ current_world_time?: number;
+ act: (action: string, params?: Record) => void;
+};
+
+const UpcomingGoalItem = React.memo(
+ ({ goal, current_world_time, act }: UpcomingGoalItemProps) => {
+ const [expanded, setExpanded] = useState(false);
+ const isAntag = goal.is_antagonist;
+ const isStoryteller = goal.storyteller_implementation;
+
+ const fireTimeText = useMemo(
+ () =>
+ goal.fire_time <= (current_world_time ?? 0)
+ ? 'Firing'
+ : formatTime(goal.fire_time, current_world_time),
+ [goal.fire_time, current_world_time],
+ );
+
+ const boxBgColor = useMemo(
+ () => (isAntag ? 'rgba(255, 60, 60, 0.2)' : 'rgba(120,120,120,0.15)'),
+ [isAntag],
+ );
+ const headerBgColor = useMemo(
+ () => (isAntag ? 'rgba(255, 50, 50, 0.3)' : 'rgba(180,180,180,0.1)'),
+ [isAntag],
+ );
+
+ const handleTrigger = useCallback(
+ () => act('trigger_goal', { offset: goal.fire_time }),
+ [act, goal.fire_time],
+ );
+ const handleRemove = useCallback(
+ () => act('remove_goal', { offset: goal.fire_time }),
+ [act, goal.fire_time],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ {fireTimeText}
+
+
+
+ {!expanded && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+ {expanded && (
+
+
+
+
+ {goal.status}
+
+
+
+ {goal.weight ?? '—'}
+
+
+
+
+ {goal.desc || 'No description available.'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ },
+);
+
+type GoalSearchDropdownProps = {
+ available_goals: StorytellerGoal[];
+ selectedGoal: string;
+ setSelectedGoal: (id: string) => void;
+ onInsert: () => void;
+};
+
+const GoalSearchDropdown = React.memo(
+ ({
+ available_goals,
+ selectedGoal,
+ setSelectedGoal,
+ onInsert,
+ }: GoalSearchDropdownProps) => {
+ const [searchTerm, setSearchTerm] = useState('');
+
+ const filteredGoals = useMemo(
+ () =>
+ available_goals.filter((goal) => {
+ if (!goal) return false;
+ if (!searchTerm) return true;
+ const text = String(goal.name ?? goal.id ?? '').toLowerCase();
+ return text.includes(searchTerm.toLowerCase());
+ }),
+ [available_goals, searchTerm],
+ );
+
+ const handleGoalSelect = useCallback(
+ (goal: StorytellerGoal) => {
+ setSelectedGoal(String(goal.id));
+ },
+ [setSelectedGoal],
+ );
+
+ return (
+
+
+
+
+ setSearchTerm(str)}
+ width="100%"
+ />
+
+
+
+
+
+
+
+
+ {filteredGoals.length ? (
+ filteredGoals.map((g) => (
+ handleGoalSelect(g)}
+ style={{
+ cursor: 'pointer',
+ opacity: selectedGoal === String(g.id) ? 1 : 0.7,
+ }}
+ >
+
+ {g.name ?? String(g.id)}
+ {g.is_antagonist ? (
+
+ (antagonist)
+
+ ) : (
+ ''
+ )}
+ {selectedGoal === String(g.id) && (
+
+ (selected)
+
+ )}
+
+
+ ))
+ ) : (
+
+ No events found.
+
+ )}
+
+
+
+ );
+ },
+);
+
+export const Storyteller = (props) => {
+ const { data, act } = useBackend();
+ const {
+ name,
+ desc,
+ mood,
+ id,
+ ooc_desc,
+ ooc_difficulty,
+ population_factor,
+ threat_points,
+ upcoming_goals = [],
+ next_think_time,
+ next_antag_wave_time,
+ base_think_delay,
+ average_event_interval,
+ threat_growth_rate,
+ grace_period,
+ target_tension,
+ recent_events = [],
+ player_count,
+ antag_count,
+ player_antag_balance,
+ event_difficulty_modifier,
+ candidates = [],
+ available_moods = [],
+ available_goals = [],
+ current_world_time,
+ } = data;
+
+ const [tab, setTab] = useState<
+ 'overview' | 'goals' | 'settings' | 'advanced' | 'logs'
+ >('overview');
+ const [selectedMood, setSelectedMood] = useState(mood?.id || '');
+ const [pace, setPace] = useState(String(mood?.pace ?? 1.0));
+ const [selectedGoal, setSelectedGoal] = useState('');
+ const [selectedCandidate, setSelectedCandidate] = useState('');
+
+ // Advanced parameter local states
+ const [difficulty, setDifficulty] = useState(
+ String(event_difficulty_modifier ?? 1.0),
+ );
+ const [difficultyLevel, setDifficultyLevel] = useState(() => {
+ // Find closest difficulty level to current value
+ // Filter by available levels if player_count is known, otherwise use all levels
+ const currentPlayerCount = player_count ?? 0;
+ const available =
+ currentPlayerCount > 0
+ ? DIFFICULTY_LEVELS.filter(
+ (level) =>
+ level.minPlayers === 0 || currentPlayerCount >= level.minPlayers,
+ )
+ : DIFFICULTY_LEVELS;
+ const closest = available.reduce((prev, curr) =>
+ Math.abs(curr.value - (event_difficulty_modifier ?? 1.0)) <
+ Math.abs(prev.value - (event_difficulty_modifier ?? 1.0))
+ ? curr
+ : prev,
+ );
+ return String(closest.value);
+ });
+ const [targetTension, setTargetTension] = useState(
+ String(target_tension ?? 50),
+ );
+ const [threatGrowthRate, setThreatGrowthRate] = useState(
+ String(threat_growth_rate ?? 1.0),
+ );
+ const [thinkDelay, setThinkDelay] = useState(String(base_think_delay ?? 0));
+ const [averageEventInterval, setAverageEventInterval] = useState(
+ String(average_event_interval ?? 0),
+ );
+ const [grace, setGrace] = useState(String(grace_period ?? 300));
+ const [repetitionPenalty, setRepetitionPenalty] = useState(
+ String(grace_period ?? 1),
+ );
+
+ const selectedDifficultyInfo = useMemo(
+ () =>
+ DIFFICULTY_LEVELS.find((l) => String(l.value) === difficultyLevel) ||
+ DIFFICULTY_LEVELS[2],
+ [difficultyLevel],
+ );
+
+ // Filter available difficulty levels based on player count
+ const availableDifficultyLevels = useMemo(
+ () =>
+ DIFFICULTY_LEVELS.filter(
+ (level) =>
+ level.minPlayers === 0 || (player_count ?? 0) >= level.minPlayers,
+ ),
+ [player_count],
+ );
+
+ // Auto-adjust difficulty level if current selection becomes unavailable
+ useEffect(() => {
+ const currentLevel = DIFFICULTY_LEVELS.find(
+ (l) => String(l.value) === difficultyLevel,
+ );
+ if (
+ currentLevel &&
+ currentLevel.minPlayers > 0 &&
+ (player_count ?? 0) < currentLevel.minPlayers
+ ) {
+ // Find the highest available difficulty level
+ const available = availableDifficultyLevels
+ .filter((l) => l.value <= currentLevel.value)
+ .sort((a, b) => b.value - a.value)[0];
+ if (available) {
+ setDifficultyLevel(String(available.value));
+ setDifficulty(String(available.value));
+ act('set_difficulty', { value: available.value });
+ }
+ }
+ }, [player_count, availableDifficultyLevels, difficultyLevel, act]);
+
+ const handleInsertGoal = useCallback(() => {
+ if (selectedGoal) {
+ act('insert_goal_to_chain', { id: selectedGoal });
+ }
+ }, [selectedGoal, act]);
+
+ const handleSetAverageEventInterval = useCallback(() => {
+ act('set_average_event_interval', {
+ min: Number(averageEventInterval) || 1000,
+ });
+ }, [averageEventInterval, act]);
+
+ const handleSetThreatGrowthRate = useCallback(() => {
+ act('set_threat_growth_rate', { value: Number(threatGrowthRate) || 1 });
+ }, [threatGrowthRate, act]);
+
+ const handleDifficultyChange = useCallback(
+ (value: string) => {
+ setDifficultyLevel(value);
+ setDifficulty(value);
+ const numValue = Number(value);
+ if (numValue) {
+ act('set_difficulty', { value: numValue });
+ }
+ },
+ [act],
+ );
+
+ return (
+
+
+
+
+ ({
+ value: c.id,
+ displayText: c.name,
+ }))}
+ placeholder="Select storyteller"
+ width="100%"
+ />
+
+
+
+
+
+ setTab('overview')}
+ >
+ Overview
+
+ setTab('goals')}
+ >
+ Chain
+
+ setTab('settings')}
+ >
+ Settings
+
+ setTab('advanced')}
+ >
+ Advanced
+
+ setTab('logs')}
+ >
+ Logs
+
+
+
+ {tab === 'overview' && (
+ <>
+
+ {name}
+
+ {desc}
+
+ {ooc_desc}
+
+
+ {ooc_difficulty}
+
+
+
+
+
+ setTab('settings')}
+ />
+ }
+ >
+ {mood ? `${mood.name} (×${mood.pace})` : '—'}
+
+
+ {data.population_factor ? `${data.population_factor}` : '—'}
+
+
+ {Number(threat_points) * 100 ?? '—'}
+
+ (data.target_tension ?? 50)
+ ? 'bad'
+ : 'good'
+ }
+ tooltip={TOOLTIPS.tension}
+ />
+
+ 7
+ ? 'bad'
+ : data.effective_threat_level || 0 > 3
+ ? 'average'
+ : 'good'
+ }
+ tooltip={TOOLTIPS.effectiveThreat}
+ />
+
+
+ {player_count ?? '—'} / {antag_count ?? '—'}
+
+
+
+ ×{event_difficulty_modifier ?? 1}
+
+
+ {(next_think_time || 0) <= (current_world_time ?? 0) ? (
+ Thinking
+ ) : (
+ formatTime(next_think_time, current_world_time)
+ )}
+
+
+ {next_antag_wave_time === -1 ? (
+ Unplanned
+ ) : null}
+ {(next_antag_wave_time || 0) <= (current_world_time ?? 0) ? (
+ Spawning
+ ) : (
+ formatTime(next_antag_wave_time, current_world_time)
+ )}
+
+
+
+ >
+ )}
+ {tab === 'goals' && (
+ <>
+
+
+ {upcoming_goals.length ? (
+
+ {upcoming_goals.map((g, i) => (
+
+ ))}
+
+ ) : (
+ No chain planned.
+ )}
+
+
+ act('reschedule_chain')}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {tab === 'settings' && (
+ <>
+
+
+
+
+
+ ({
+ value: m.id,
+ displayText: m.name,
+ }))}
+ placeholder="Select mood..."
+ width="100%"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {tab === 'advanced' && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({
+ value: String(level.value),
+ displayText:
+ level.minPlayers > 0
+ ? `${level.label} (${level.minPlayers}+)`
+ : level.label,
+ }))}
+ width="100%"
+ />
+
+
+ 0
+ ? `${selectedDifficultyInfo.tooltip} (Requires ${selectedDifficultyInfo.minPlayers}+ players)`
+ : selectedDifficultyInfo.tooltip
+ }
+ >
+
+ ×{Number(difficultyLevel).toFixed(1)}
+
+
+
+
+ {availableDifficultyLevels.length <
+ DIFFICULTY_LEVELS.length && (
+
+ Some difficulty levels require more players (Current:{' '}
+ {player_count ?? 0})
+
+ )}
+
+
+
+ act('set_target_tension', {
+ value: Number(targetTension) || 1,
+ })
+ }
+ />
+
+
+
+
+
+
+
+ >
+ )}
+ {tab === 'logs' && (
+
+ {recent_events.length ? (
+
+
+ Time
+ Description
+ Status
+ ID
+
+ {recent_events.map((ev, i) => (
+
+ {ev.fired_at || ' '}
+ {ev.desc}
+
+ {ev.status || '—'}
+
+ {ev.id || '—'}
+
+ ))}
+
+ ) : (
+ No logs.
+ )}
+
+ )}
+
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/Storyteller/types.tsx b/tgui/packages/tgui/interfaces/Storyteller/types.tsx
new file mode 100644
index 00000000000..c20d4c9844c
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/Storyteller/types.tsx
@@ -0,0 +1,179 @@
+import type { StringLike } from 'bun';
+import type { BooleanLike } from 'tgui-core/react';
+
+export type StorytellerGoal = {
+ id: string;
+ name?: string;
+ desc?: StringLike;
+ weight?: number;
+ is_antagonist?: BooleanLike;
+};
+
+export type DifficultyLevel = {
+ value: number;
+ label: string;
+ tooltip: string;
+ minPlayers: number;
+};
+
+export type StorytellerCandidates = {
+ name: string;
+ id: string;
+};
+
+export type StorytellerMood = {
+ id: string;
+ name: string;
+ pace: number;
+ threat?: number;
+};
+
+export type StorytellerEventLog = {
+ desc: string;
+ status?: string;
+ id?: string;
+ fired_at?: string;
+};
+
+export type StorytellerUpcomingGoal = {
+ id: string;
+ name?: string;
+ desc?: string;
+ storyteller_implementation?: BooleanLike;
+ fire_time: number;
+ category?: number;
+ status: string;
+ weight?: number;
+ progress?: number;
+ is_antagonist?: BooleanLike;
+};
+
+export type StorytellerData = {
+ id: string;
+ name: string;
+ desc?: string;
+ ooc_desc?: string;
+ ooc_difficulty?: string;
+ population_factor?: number;
+ threat_points?: number;
+ mood?: StorytellerMood;
+ upcoming_goals?: StorytellerUpcomingGoal[];
+ next_think_time?: number;
+ next_antag_wave_time?: number;
+ base_think_delay?: number;
+ average_event_interval?: number;
+ threat_growth_rate: number;
+ grace_period: number;
+ threat_level?: number;
+ effective_threat_level?: number;
+ round_progression?: number;
+ target_tension?: number;
+ current_tension?: number;
+ recent_events?: StorytellerEventLog[];
+ player_count?: number;
+ antag_count?: number;
+ player_antag_balance?: number;
+ event_difficulty_modifier?: number;
+ available_moods?: StorytellerMood[];
+ available_goals?: StorytellerGoal[];
+ candidates?: StorytellerCandidates[];
+ can_force_event?: BooleanLike;
+ current_world_time?: number;
+};
+
+export type scrollConfigProp = {
+ value: string;
+ setValue: (value: string) => void;
+ onSet: () => void;
+ max: number;
+ min: number;
+ step: number;
+ delim?: number;
+};
+
+export const DIFFICULTY_LEVELS: readonly DifficultyLevel[] = [
+ {
+ value: 0.3,
+ label: 'Extended',
+ tooltip: 'Peaceful mode - minimal threats, more positive events',
+ minPlayers: 0,
+ },
+ {
+ value: 0.7,
+ label: 'Adventure story',
+ tooltip: 'Easy mode - moderate events, balance between good and bad',
+ minPlayers: 0,
+ },
+ {
+ value: 1.0,
+ label: 'Strive to survive',
+ tooltip: 'Standard mode - balanced events and threats',
+ minPlayers: 15,
+ },
+ {
+ value: 2.0,
+ label: 'Blood and dust',
+ tooltip:
+ 'Hard mode - frequent threats and event escalation \n\n Recommended for: \n Experienced players who want to struggle to survive',
+ minPlayers: 30,
+ },
+ {
+ value: 5.0,
+ label: 'Losing is Fun',
+ tooltip:
+ 'Extreme mode - maximum difficulty and constant threats. \n\n Recommended for: \n \
+ Experienced players who want to face a brutal, unfair challenge where even \
+ great skill may not prevent death \n Lovers of tragedy \n Digital masochists',
+ minPlayers: 50,
+ },
+];
+
+// Tooltips for parameters in status and settings
+export const TOOLTIPS = {
+ // Status (Overview)
+ mood: 'The current mood of the storyteller. It acts as a modifier for event difficulty and directly influences the interval between events. Each mood has its own pace and aggression.',
+ tension:
+ 'Current round tension (0-100%). Shows the overall level of stress and threats on the station. Higher values may lead to more positive events for balance.',
+ targetTension:
+ 'Target tension (0-100%). The storyteller strives to maintain it by planning events: low values decrease the overall difficulty modifier, high values increase it. Values closer to 100 lead to extreme event difficulty.',
+ effectiveThreat:
+ 'Effective threat level (accounts for players and progress). Based on round time, antagonist activity, and overall tension. Determines the actual difficulty of events.',
+ roundProgression:
+ 'Round progression (0-1). When it reaches 1, nearly all events become available. By default, the maximum progression level is reached in two hours.',
+ playersAntags:
+ 'Number of players / antagonists. Current ratio of active players on the station (not in guest roles and not engaged in ERP) to active antagonists.',
+ balance:
+ 'Antagonist balance (0-100%). Below 40% — "boring," storyteller intensifies antagonist branches; above 60% — "chaos," adds neutral events for a breather.',
+ difficulty:
+ 'Event difficulty modifier (×1). Overall modifier that increases the threat points applied to an event. Affects the intensity of all events.',
+ nextThink:
+ 'Time until the storyteller\'s next "think." At this point, it analyzes the station and plans events.',
+ nextAntagWave:
+ "Time until the next antagonist wave. if unplanned then initial antagonists still doesn't selected!.",
+ populationFacotr:
+ 'Current storyteller population factor. It affect the number of threat points that events will receive, as well as the overall threat level and the number of events.',
+ threatPoints:
+ 'The number of threat points accumulated by storytellers passively increases over time and affects the difficulty of events and the amount of threat they will create.',
+ // Settings (Settings & Advanced)
+ moodSelect:
+ 'Mood selection to change the global objective style. Each mood affects planning pace: "Fast Chaos" — frequent antagonist branches, "Slow Schemer" — hidden sub-objectives.',
+ pace: 'Event pace multiplier (0.5-2.0). 0.5 — slows sub-objective intervals for deep roleplay; 2.0 — accelerates for dynamic rounds, like in RimWorld with "manic miner."',
+ reanalyse:
+ 'Reanalyze station: recalculates players, threats, and balance to adjust the plan. Use when there are significant changes on the station.',
+ replan:
+ 'Replan: launches the analyzer and rebuilds the event queue based on the obtained data. Use for a complete plan revision.',
+ difficultySlider:
+ 'Global difficulty multiplier (0.3-5.0). Increases the threat points received by the storyteller, as well as the amount of threat points allocated to events. High values make the round significantly harder.',
+ targetTensionSlider:
+ 'Target tension for auto-balance. The storyteller plans events to achieve it: low — peaceful sub-objectives, high — escalation of antagonist branches.',
+ threatGrowthRate:
+ 'Threat growth rate (0.1-5.0). Amount of threat points received by the storyteller per thinking cycle. High values lead to rapid threat accumulation.',
+ thinkDelay:
+ 'Think delay (seconds, 1-240). Frequent thinking — for dynamic branch planning; rare — for strategic approach, like in RimWorld with long-term threats.',
+ averageEventInterval:
+ 'Minimum event interval (seconds, 1-60). Short — for quick sub-objectives and high pace; long — for pauses to players in global objective branches.',
+ gracePeriod:
+ 'Grace period (seconds, 120-1200). Time after an event when repeats are not planned — prevents spamming sub-objectives in the chain.',
+ repetitionPenalty:
+ 'Repetition penalty (0.25-1.0). Reduces weight of repeating events in sub-objectives; 1.0 — no penalty, for cyclic global objectives like "infection."',
+} as const;
diff --git a/tgui/packages/tgui/interfaces/StorytellerVote/index.tsx b/tgui/packages/tgui/interfaces/StorytellerVote/index.tsx
new file mode 100644
index 00000000000..166ceb3bf7f
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/StorytellerVote/index.tsx
@@ -0,0 +1,543 @@
+import '../../styles/interfaces/StorytellerVote.scss';
+import { useEffect, useMemo, useState } from 'react';
+import {
+ Box,
+ Button,
+ Divider,
+ LabeledList,
+ NoticeBox,
+ ProgressBar,
+ Section,
+ Stack,
+ Tooltip,
+} from 'tgui-core/components';
+import type { BooleanLike } from 'tgui-core/react';
+import { resolveAsset } from '../../assets';
+import { useBackend } from '../../backend';
+import { Window } from '../../layouts';
+
+type Candidate = {
+ id: string;
+ name: string;
+ desc?: string;
+ ooc_desc?: string;
+ ooc_diff?: string;
+ portrait_path?: string;
+ logo_path?: string;
+};
+
+type TopTally = {
+ name: string;
+ count: number;
+ avg_diff: number;
+};
+
+type StorytellerVoteData = {
+ storytellers: Candidate[];
+ personal_selection?: string;
+ personal_difficulty: number;
+ total_voters: number;
+ voted_count: number;
+ time_left: number;
+ top_tallies: TopTally[];
+ is_open: BooleanLike;
+ can_vote: BooleanLike;
+ debug_mode: BooleanLike;
+ admin_mode: BooleanLike;
+};
+
+type StorytellerVoteConfig = {
+ min_difficulty?: number;
+ max_difficulty?: number;
+};
+
+type DifficultyLevel = {
+ value: number;
+ label: string;
+ tooltip: string;
+ minPlayers: number;
+};
+
+const DIFFICULTY_LEVELS: readonly DifficultyLevel[] = [
+ {
+ value: 0.3,
+ label: 'Extended',
+ tooltip: 'Peaceful mode - minimal threats, more positive events',
+ minPlayers: 0,
+ },
+ {
+ value: 0.7,
+ label: 'Adventure story',
+ tooltip: 'Easy mode - moderate events, balance between good and bad',
+ minPlayers: 0,
+ },
+ {
+ value: 1.0,
+ label: 'Strive to survive',
+ tooltip: 'Standard mode - balanced events and threats',
+ minPlayers: 15,
+ },
+ {
+ value: 2.0,
+ label: 'Blood and dust',
+ tooltip:
+ 'Hard mode - frequent threats and event escalation\n\nRecommended for:\nExperienced players who want to struggle to survive',
+ minPlayers: 30,
+ },
+ {
+ value: 5.0,
+ label: 'Losing is Fun',
+ tooltip:
+ 'Extreme mode - maximum difficulty and constant threats.\n\nRecommended for:\nExperienced players who want brutal unfair challenge\nLovers of tragedy\nDigital masochists',
+ minPlayers: 50,
+ },
+];
+
+export const StorytellerVote = () => {
+ const { data, act, config } = useBackend();
+ const { min_difficulty = 0.3, max_difficulty = 5.0 } =
+ config as StorytellerVoteConfig;
+
+ const {
+ storytellers = [],
+ personal_selection,
+ personal_difficulty = 1.0,
+ total_voters = 0,
+ voted_count = 0,
+ time_left = 0,
+ top_tallies = [],
+ is_open = false,
+ can_vote = false,
+ debug_mode = false,
+ admin_mode = false,
+ } = data;
+
+ const [selected, setSelected] = useState(personal_selection || '');
+
+ const availableDifficultyLevels = useMemo(
+ () =>
+ debug_mode
+ ? DIFFICULTY_LEVELS
+ : DIFFICULTY_LEVELS.filter(
+ (level) =>
+ level.minPlayers === 0 || total_voters >= level.minPlayers,
+ ),
+ [total_voters, debug_mode],
+ );
+
+ const [difficultyCheckboxes, setDifficultyCheckboxes] = useState<
+ Record
+ >(() => {
+ const initial: Record = {};
+ DIFFICULTY_LEVELS.forEach((level) => {
+ initial[String(level.value)] = false;
+ });
+ return initial;
+ });
+
+ const findClosestDifficultyLevel = useMemo(() => {
+ const available =
+ total_voters > 0 ? availableDifficultyLevels : DIFFICULTY_LEVELS;
+ return available.reduce((prev, curr) =>
+ Math.abs(curr.value - personal_difficulty) <
+ Math.abs(prev.value - personal_difficulty)
+ ? curr
+ : prev,
+ );
+ }, [personal_difficulty, availableDifficultyLevels, total_voters]);
+
+ useEffect(() => {
+ const closestValue = String(findClosestDifficultyLevel.value);
+ setDifficultyCheckboxes((prev) => ({
+ ...Object.fromEntries(Object.keys(prev).map((key) => [key, false])),
+ [closestValue]: true,
+ }));
+ }, [personal_difficulty, findClosestDifficultyLevel.value]);
+
+ useEffect(() => {
+ if (debug_mode) return;
+
+ const selectedValue = Object.keys(difficultyCheckboxes).find(
+ (key) => difficultyCheckboxes[key],
+ );
+
+ if (selectedValue) {
+ const currentLevel = DIFFICULTY_LEVELS.find(
+ (l) => String(l.value) === selectedValue,
+ );
+
+ if (
+ currentLevel &&
+ currentLevel.minPlayers > 0 &&
+ total_voters < currentLevel.minPlayers
+ ) {
+ const available = availableDifficultyLevels
+ .filter((l) => l.value <= currentLevel.value)
+ .sort((a, b) => b.value - a.value)[0];
+
+ if (available) {
+ const newValue = String(available.value);
+ setDifficultyCheckboxes((prev) => ({
+ ...Object.fromEntries(Object.keys(prev).map((key) => [key, false])),
+ [newValue]: true,
+ }));
+
+ if (is_open && can_vote) {
+ act('set_difficulty', { value: available.value });
+ }
+ }
+ }
+ }
+ }, [
+ total_voters,
+ availableDifficultyLevels,
+ difficultyCheckboxes,
+ is_open,
+ can_vote,
+ act,
+ debug_mode,
+ ]);
+
+ const select = (id: string) => {
+ if (!is_open) return;
+ setSelected(id);
+ if (can_vote) {
+ act('select_storyteller', { id });
+ }
+ };
+
+ const handleDifficultyCheckboxChange = (value: number, checked: boolean) => {
+ if (!is_open || !can_vote) return;
+
+ if (checked) {
+ setDifficultyCheckboxes((prev) => ({
+ ...Object.fromEntries(Object.keys(prev).map((key) => [key, false])),
+ [String(value)]: true,
+ }));
+
+ const clampedValue = Math.max(
+ min_difficulty,
+ Math.min(max_difficulty, value),
+ );
+ act('set_difficulty', { value: clampedValue });
+ }
+ };
+
+ const current = useMemo(
+ () => storytellers.find((c) => c.id === selected) || null,
+ [storytellers, selected],
+ );
+
+ const selectedDifficultyInfo = useMemo(() => {
+ const selectedValue = Object.keys(difficultyCheckboxes).find(
+ (key) => difficultyCheckboxes[key],
+ );
+ return selectedValue
+ ? DIFFICULTY_LEVELS.find((l) => String(l.value) === selectedValue) ||
+ DIFFICULTY_LEVELS[2]
+ : DIFFICULTY_LEVELS[2];
+ }, [difficultyCheckboxes]);
+
+ const timeDisplay = useMemo(() => {
+ if (time_left <= 0) return 'Ended';
+ const seconds = Math.ceil(time_left / 10);
+ const minutes = Math.floor(seconds / 60);
+ const remaining = seconds % 60;
+ return minutes > 0 ? `${minutes}m ${remaining}s` : `${seconds}s`;
+ }, [time_left]);
+
+ if (!is_open && top_tallies.length === 0) {
+ return (
+
+
+ Voting has ended. Check round logs for results!
+
+
+ );
+ }
+
+ return (
+
+
+ {!can_vote && (
+
+ You cannot participate in the vote, but you can still view the
+ candidates.
+
+ )}
+
+
+
+
+
+
+ {storytellers.length ? (
+
+ {storytellers.map((c) => (
+
+ {c.name}
+ {c.ooc_desc && {c.ooc_desc}}
+ {c.ooc_diff && (
+
+ Difficulty: {c.ooc_diff}
+
+ )}
+
+ }
+ >
+ select(c.id)}
+ />
+
+ ))}
+
+ ) : (
+ No storytellers provided.
+ )}
+
+
+
+
+
+
+ The AI storyteller creates events like grid check, resource
+ drops or enemy raids. Also they controls antagonist presence
+ in the round. Their choices will affect on round difficulty
+ and narrative.
+
+
+
+ {current ? (
+ <>
+ {current.name}
+
+
+ {current.desc || '—'}
+
+
+ {current.ooc_desc || '-'}
+
+
+ {current.ooc_diff || '-'}
+
+
+
+
+
+
+
+
+
+
+
+ {availableDifficultyLevels.map((level) => (
+
+ 0 && !debug_mode
+ ? `${level.tooltip} (Requires ${level.minPlayers}+ players)`
+ : level.tooltip
+ }
+ >
+
+
+ handleDifficultyCheckboxChange(
+ level.value,
+ !difficultyCheckboxes[
+ String(level.value)
+ ],
+ )
+ }
+ />
+
+
+ {level.label}
+
+ {level.minPlayers > 0 &&
+ !debug_mode && (
+
+ ({level.minPlayers}+ players)
+
+ )}
+
+
+
+
+ ))}
+
+
+
+ {availableDifficultyLevels.length <
+ DIFFICULTY_LEVELS.length &&
+ !debug_mode && (
+
+ Some difficulty levels require more players
+
+ )}
+
+
+
+
+ {selectedDifficultyInfo.minPlayers > 0 &&
+ !debug_mode
+ ? `${selectedDifficultyInfo.tooltip} (Requires ${selectedDifficultyInfo.minPlayers}+ players)`
+ : selectedDifficultyInfo.tooltip}
+
+ }
+ />
+
+
+
+
+ >
+ ) : (
+ Select a storyteller on the left.
+ )}
+
+
+
+
+
+
+ {voted_count}/{total_voters}
+
+
+
+
+ {timeDisplay}
+
+
+
+ {top_tallies.length > 0 && (
+ <>
+
+
+
+ {top_tallies.map((t, i) => (
+
+
+ {t.name}: {t.count} votes ({''}
+ {
+ DIFFICULTY_LEVELS.find(
+ (level) =>
+ level.value ===
+ Number(t.avg_diff.toFixed(1)),
+ )?.label
+ }
+ )
+
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+
+ {admin_mode && is_open ? (
+
+
+ act('force_end_vote')}
+ />
+
+
+ ) : (
+ ''
+ )}
+
+
+
+ );
+};
diff --git a/tgui/packages/tgui/styles/interfaces/StorytellerVote.scss b/tgui/packages/tgui/styles/interfaces/StorytellerVote.scss
new file mode 100644
index 00000000000..904e8cc6ec9
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/StorytellerVote.scss
@@ -0,0 +1,20 @@
+.theme-stortellerVote {
+ .TitleBar {
+ background: var(--background-color);
+ }
+
+ .Window {
+ background: var(--background-color);
+
+ &__content {
+ background-image: none;
+ }
+ }
+}
+
+.tooltip-content,
+.difficulty-tooltip {
+ white-space: pre-line;
+ line-height: 1.4;
+ padding: 2px 0;
+}