UO Unchained: UI_local_chat_journal_filtered.py

Created: 3 days ago on 09/13/2025, 03:01:43 AM
FileType: Razor Enhanced (Python)
Size: 93136
Category: UI

Description: a minimal journal gump focused on local chat and quest events

filters command words , colorized speakers and events persistent

""" UI Journal Filtered - a Razor Enhanced Python Script for Ultima Online a minimal journal gump focused on locally spoken and quest events filters command words , colorized speakers and events persistent using multiple filters to reduce to player and npc spoken words without "command words" names are colorized consistently by deterministic seed to visually separate only "Quest" and "Event" related system messages are whitelisted global chat may be displayed as well , tho is turned off by default many settings are toggleable for preference filters = common command words "bank" , "All guard me" , housing repeating messages known npcs are filtered out events = events are colorized and condensed into a single line harvest and kill global quests , virtue shrine corruption , danger zone rotation TROUBLESHOOTING: - if "import" errors , download iron python 3.4.2 and copy the files in its "Lib" folder into your RazorEnhanced "Lib" folder STATUS:: working VERSION::20250909 """ import time # timestamps and delays import random # jitter for desynchronize updates import re # regex parsing the text DEBUG_MODE = False CHAT_ORDER_TOP_NEW = True # True = newest at top; or False = oldest at top SHOW_SYSTEM = True SHOW_REGULAR = True SHOW_PARTY = True SHOW_GUILD = True SHOW_ALLIANCE = True SHOW_EMOTE = False SHOW_LABEL = False SHOW_FOCUS = False SHOW_SPELL = False SHOW_PLAYER_SELF_MESSAGES = True # show or hide messages spoken by the local player SHOW_OPTIONAL_NPC_ENABLED = False # show or hide messages from optional NPC list (False = hide, replacing previous FILTER_OPTIONAL_NPC_ENABLED=True) SHOW_MONSTER_NPC_ENABLED = False # show or hide messages from monster NPC list (False = hide) # System message SHOW_SYSTEM_QUEST_MESSAGES = True # quest-related non-global chat System notifications SHOW_SYSTEM_QUEST_PROGRESS = False # quest progress lines like "Quest Progress - City Cleanup: 11/20" SHOW_SYSTEM_WEEKLY_QUEST_MESSAGES = False # Weekly Quest announcements (lines containing 'Weekly Quest:') SHOW_SYSTEM_OTHER_MESSAGES = False # other non-global System lines # Global chat channels SHOW_SYSTEM_GLOBAL_MESSAGES = True # global chat routed through System: "System <General> Player : msg" SHOW_GLOBAL_CHAT_GENERAL = False # System: <General> Speaker : message SHOW_GLOBAL_CHAT_PVP = False # System: <PVP> Speaker : message SHOW_GLOBAL_CHAT_TRADE = False # System: <Trade> Speaker : message # Anti-spam , filters hotkey spam , numeric only messages SHOW_NUMERIC_ONLY_MESSAGES = False # messages that are only digits (e.g., accidental number spam) SHOW_LONG_ALNUM_TOKENS = False # messages that contain very long alphanumeric tokens (likely hotkey mash) LONG_ALNUM_TOKEN_THRESHOLD = 20 # tokens longer than this (A-Za-z0-9 only, no spaces) are considered spam when SHOW_LONG_ALNUM_TOKENS is False SHOW_PUNCT_NUM_ONLY_MESSAGES = False # messages that contain only punctuation/special characters and numbers (no letters) SHOW_TIMESTAMP = False # Show [HH:MM:SS] prefix; default off DEDUPLICATE_BY_TEXT = True # De-duplicate for simple anti spam SHOW_ROW_DUPLICATES = False # duplicate rows; False keeps spam suppressed SHOW_STATUS_EFFECT_LINES = False # asterisk-wrapped status/emote-like lines SHOW_BONDED_PET_LINES = False # lines from bonded pets or lines that are only a bonded tag """ Offline preview settings: when enabled, reads a plain text journal file and produces an HTML preview that simulates how this gump would render in-game after filtering. this is for testing the colors and formatting it is not representative of the actual gump because of the in game "channel" filtering , but lets us review how specific instances will be formatted """ OFFLINE_JOURNAL_SIMULATE = False # setting this to true will read external journal log then exit for testing purposes . OFFLINE_JOURNAL_INPUT_PATH = r"D:\ULTIMA\example\Data\Client\JournalLogs\2025_09_09_20_37_33_journal.txt" OFFLINE_JOURNAL_OUTPUT_PATH = r"d:\ULTIMA\SCRIPTS\RazorEnhanced_Python\data\journal_preview.html" #//==== GUMP ================================================================== # example=4294967295 # a high pseudo-random gump id to avoid other existing gump ids GUMP_ID = 3135545776 # Default gump position and sizing DEFAULT_GUMP_X = 200 DEFAULT_GUMP_Y = 0 DEFAULT_GUMP_WIDTH = 400 DEFAULT_GUMP_HEIGHT = 500 # TIMING UPDATE_INTERVAL_MS = 750 # SAFETY LIMITS MAX_HISTORY = 50 # Keep only the last N messages in memory and on-screen MIN_RESEND_MS = 750 # throttling ENABLE_JITTER = True # Optional jitter to desynchronize with other scripts JITTER_MS_MAX = 100 # UI SHAPE FILTER_PANEL_WIDTH = 115 JOURNAL_START_X = 20 JOURNAL_START_Y = 25 FILTER_SPACING_Y = 30 JOURNAL_ENTRY_HEIGHT = 20 # Chat COLORS ================================================================= CHAT_BG_DARK = True CHAT_TEXT_COLOR = "#C0C0C0" # light grey QUEST_TEXT_COLOR = "#1E90FF" # Muted gold for quest lines ( other options gold = #C2A14A , teal = #76D7C4 , blue = #3FA9FF) CHAT_BG_IMAGE_ID = None # maybe could use 2624; # None = no image (fully transparent via alpha region only). # Special event colors VIRTUE_ALERT_COLOR = "#E84827" # orange for shrine corruption/virtue attacks DANGER_ZONE_LABEL_COLOR = "#FF3333" # red for danger zone label # Alert highlighting ALERT_ATTACK_SUBSTRING = " is attacking you" ALERT_ATTACK_COLOR = "#FF3333" # Speaker colorization palette (bright, muted colors for readability) PALETTE_SPEAKER_COLORS = [ "#E6B422", # mustard gold "#85C1E9", # soft blue "#A3E4D7", # mint "#F5B7B1", # soft salmon "#D7BDE2", # lavender "#F7DC6F", # light yellow "#76D7C4", # teal "#F8C471", # orange tan "#BB8FCE", # purple "#73C6B6", # aqua "#F1948A", # coral "#7FB3D5", # steel blue "#82E0AA", # green "#F0B27A", # peach "#C39BD3", # violet ] # Optional global seed offset to bias speaker color selection (user-tunable) COLOR_SEED_OFFSET = 0 # speaker name color is a deterministic seed based on name , use this offset if you want different colors # VIRTUES are referenced by in world shrines where events may occur , virtues + chaos VIRTUES = { "honesty", "compassion", "valor", "justice", "sacrifice", "honor", "spirituality", "humility", "chaos" } # MASTERY_NAMES are referenced by golems in game , we use this to filter out their title messages # currently disabled ( not-filtered ) , as a player may actually just say the mastery name as a response MASTERY_NAMES = { "aero", "fira", "earth", "shadow", "blood", "doom", "fortune", "artisan", "bulwark", "poison", "lyric", "death", "druidic", "holy" } # Specific System lines to filter out entirely (case-insensitive, substring or regex) FILTER_SYSTEM_SUBSTRINGS = [ "[safe looting] you refuse to loot this corpse", "careful! you", # e.g., "System : Careful! You ..." "enhanced summons", # e.g., "System : [Enhanced Summons] ..." "enhanced sumons", # misspelling "a pixie is guarding you", ] FILTER_SYSTEM_PATTERNS = [ # universal regex patterns here , for now we are doing specific regex patterns after routing , like for global quests ] # Generic text filters (apply to any speaker/type) , # common NPC dialog , bank and vendor interactions FILTER_GENERIC_SUBSTRINGS = [ "you see nothing useful to carve from the corpse", "(summoned)", "the spell fizzles.","you cannot heal that.", "You have accepted quest:","You have accepted the quest:", "bank container has ", "Take a look at my goods", "Thou hast withdrawn gold from thy account. ", "Thou canst not withdraw so much at one time! ", "There's not enough room in your bankbox", "the purchased items were placed in you bank box.", "The total of thy purchase is", "i am dead and cannot do that.","I can't reach that.", "emptying the trashcan!", "It appears to be locked.","The lock quickly yields to your skill.", "The trash is full!", ] # exact matches , lowercase catches all , # mostly command words and bank interactions FILTER_GENERIC_EXACTS = [ "1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20", "all guard me", "all guard", "locked down!", "all kill", "all follow me", "all follow", "all stay","all come","all stop", "bank","claim","stable","stable all","bank claim !","bank i ban thee","claim list", "bank guards","bank vendor buy guards i ban thee", "guards open my bank","guards bank stable i ban thee", "vendor buy thee bank from thee guards!", "vendor buy thee bank from thee guards", "vendor buy guards bank","bank claim stable i ban thee","bank guards stable i ban thee", "banker bank","bank box please","i ban thee", "vendor buy bank guards", "bank vendor buy guards", "bank buy guards", "vendor buy bank","bank vendor buy","vendor buy bank i ban thee", "vendor buy bank guards i ban thee","vendor buy","guards","claim g", "bbbb", "bnak","bak","ban" "that is secure.","Locked down!","I can't reach that.", "vendor buy me a bank", "bank guards i ban thee","vendor buy bank i ban thee", "bank guard room claim list i ban thee", "You cannot heal yourself in your current state.","you cannot heal that target in their current state.", "(tame)","(bonded)", "i wish to lock this down","i wish to release this","i wish to secure this","(no longer locked down)", ] FILTER_GENERIC_PREFIXES = [ "insufficient mana.", "into your bank box i have placed", ] # Player bank command lines (numbers only), e.g., "withdraw 1000" WITHDRAW_ONLY_PATTERN = re.compile(r"^\s*withdraw\s+[\d,]+\s*$", re.IGNORECASE) DEPOSIT_ONLY_PATTERN = re.compile(r"^\s*deposit\s+[\d,]+\s*$", re.IGNORECASE) CHECK_ONLY_PATTERN = re.compile(r"^\s*check\s+[\d,]+\s*$", re.IGNORECASE) BALANCE_ONLY_PATTERN = re.compile(r"^\s*balance\s*$", re.IGNORECASE) STATEMENT_ONLY_PATTERN = re.compile(r"^\s*statement\s*$", re.IGNORECASE) # Speaker-based filters , adjust per shard FILTER_SPEAKER_EXACTS = [ "canute", # quest giver , we are letting other quest givers through but the daily quest giver "Canute" spams unique messages for each quest some one takes ] # Town NPCs , adjust per shard FILTER_SPEAKER_OPTIONAL_NPC = [ "ogden", # quest givers in brit bank "luffy", # quest givers in brit bank "cigal", # quest givers in brit bank "mariel", # thief trainer givers in brit bank "forsythe", #banker ] # talkative monsters like lizardmen are filtered from local chat by default FILTER_SPEAKER_MONSTER_NPC = [ "a lizardman berserker", # "a mephite lizardman", # "a lizardman warlock", # "a molten lizardman", # "a lizardman berserker", ] #//================================================================================== def debug_message(message): if DEBUG_MODE: try: Misc.SendMessage("[UI_journal_filtered] " + str(message)) except Exception: pass class JournalFilterUI: def __init__(self): # Window state self.gump_id = GUMP_ID self.gump_x = DEFAULT_GUMP_X self.gump_y = DEFAULT_GUMP_Y self.resize_width = DEFAULT_GUMP_WIDTH self.resize_height = DEFAULT_GUMP_HEIGHT # Toggles self.show_chat_panel = True # REGEX patterns to detect status effects # Examples: "* PlayerName begins to spasm uncontrollably *" # "* You begin to spasm uncontrollably *" # Other status effect style asterisks-wrapped lines escaped_player = re.escape(Player.Name) if hasattr(Player, 'Name') else r"Player" self.status_effect_patterns = [ re.compile(r"\*\s*(?:You|%s)\s+begins?\s+to\s+spasm\s+uncontrollably\s*\*" % escaped_player, re.IGNORECASE), # Generic asterisk-wrapped emotes that look like status effects re.compile(r"^\s*\*.+\*\s*$", re.IGNORECASE), ] # REGEX pattern with named groups and explicit channel detection. # Handles spacing around colons, optional channel tags, and both "System" and "Systems". # Examples of raw journal lines (all should match): # "System: <General> PlayerName : hello world" # "System <Trade> Alice: selling regs" # "Systems: <Help> Bob : need a rez" # "System: PlayerName : local system line" # Captures: # channel -> text inside < > if present, else None # speaker -> the token before the colon (player name) # message -> text after the last colon self.system_global_capturing_pattern = re.compile( r"^\s*System[s]?\s*:??\s*(?:<(?P<channel>[^>]+)>\s*)?(?P<speaker>[^:]+?)\s*:\s*(?P<message>.+)\s*$", re.IGNORECASE, ) # Processed journal cache self.filtered_entries = [] # [Type, Color, Name, Serial, Text] self.filtered_entries_with_time = [] # [Type, Color, Name, Serial, Text, Timestamp] self._seen_entry_keys = set() # to deduplicate across updates self._seen_text_norm = set() # for global text-based spam suppression # Timing self.update_interval_ms = UPDATE_INTERVAL_MS # Scrolling state (line-based) self.scroll_offset_lines = 0 # how many wrapped lines from the bottom we are offset self.stick_to_bottom = True # auto-follow newest lines unless user scrolls up # Render/processing tracking self._last_processed_ts = 0 # process only entries newer than this timestamp self._last_render_signature = None # signature of currently rendered visible content self._last_gump_send_ms = 0 # last time we sent the gump to the client # Span cache to speed up wrapping calculations self._span_cache = {} self._span_cache_max = 2000 # Pending state for shrine corruption -> virtue under attack combo self._shrine_corruption_pending_until_ms = 0 # Pending state for Danger Zones activation -> regions list combo self._danger_zones_pending_until_ms = 0 # Pending state for Global Quest name/objective combiner self._globalquest_name = '' self._globalquest_pending_until_ms = 0 self._globalquest_last_objective = '' self._globalquest_last_objective_until_ms = 0 # Basic clamp helpers to keep gump textures sane def _clamp_int(self, val, lo, hi, fallback): try: v = int(val) if v < lo: return lo if v > hi: return hi return v except Exception: return int(fallback) def _safe_rect(self, x, y, w, h, max_w=1200, max_h=1200): # Clamp width/height to avoid 0/negatives and excessively large textures sx = self._clamp_int(x, 0, 4096, 0) sy = self._clamp_int(y, 0, 4096, 0) sw = self._clamp_int(w, 1, int(max_w), 1) sh = self._clamp_int(h, 1, int(max_h), 1) return sx, sy, sw, sh #//======= Type-specific visibility hooks ===================== # These wrappers make it easy to add type-specific remapping, tinting, or extra filters later. def _allow_regular(self, entry): return SHOW_REGULAR def _allow_guild(self, entry): # maybe colorize guild chat words return SHOW_GUILD def _allow_alliance(self, entry): return SHOW_ALLIANCE def _allow_emote(self, entry): return SHOW_EMOTE def _allow_label(self, entry): return SHOW_LABEL def _allow_focus(self, entry): return SHOW_FOCUS def _allow_spell(self, entry): return SHOW_SPELL def _allow_party(self, entry): # maybe colorize party chat words return SHOW_PARTY #//======= Data processing ===================== def build_filtered_journal_entries(self): entries = Journal.GetJournalEntry(-1) # Ensure chronological order from oldest to newest entries = entries[::-1] debug_message(f" Processing {len(entries)} raw journal entries") new_count = 0 max_ts_seen = self._last_processed_ts for entry in entries: # Process only new entries by timestamp when possible try: if hasattr(entry, 'Timestamp') and entry.Timestamp is not None: # Allow processing when timestamp == last boundary to avoid missing multi-line events (e.g., Quest begin + objective) if float(entry.Timestamp) < float(self._last_processed_ts): continue except Exception: pass did_append, ts_candidate = self._process_entry(entry) if did_append: new_count += 1 try: if ts_candidate is not None and float(ts_candidate) > float(max_ts_seen): max_ts_seen = float(ts_candidate) except Exception: pass # Update last processed timestamp if we saw newer entries if max_ts_seen > self._last_processed_ts: self._last_processed_ts = max_ts_seen # Prune history and dedupe memory to the last MAX_HISTORY (live mode only) if not OFFLINE_JOURNAL_SIMULATE: try: if len(self.filtered_entries_with_time) > MAX_HISTORY: self.filtered_entries_with_time = self.filtered_entries_with_time[-MAX_HISTORY:] self.filtered_entries = [e[:5] for e in self.filtered_entries_with_time] # Rebuild seen cache conservatively from the retained segment self._seen_entry_keys.clear() for e in self.filtered_entries_with_time: try: ts = e[5] ser = e[3] key = (ts, ser, str(e[4])) self._seen_entry_keys.add(key) except Exception: continue except Exception: pass debug_message(f" Appended {new_count} entries; total filtered: {len(self.filtered_entries_with_time)}") return new_count # Core processing for a single entry. Returns (did_append: bool, ts_candidate: float|None) def _process_entry(self, entry): # Build a stable key for deduplication across updates try: key = (entry.Timestamp, int(entry.Serial), str(entry.Text)) except Exception: try: key = (entry.Timestamp, entry.Serial, str(entry.Text)) except Exception: key = (entry.Timestamp, str(entry.Text)) if key in self._seen_entry_keys: return False, getattr(entry, 'Timestamp', None) hide_entry = False fixed_type = None # Map speaking types into Regular and gate by per-type allow hooks if entry.Type in ('Yell', 'Whisper', 'Regular', 'Special', 'Encoded'): fixed_type = 'Regular' if not self._allow_regular(entry): hide_entry = True elif entry.Type == 'System': # handled in System block below pass elif entry.Type == 'Guild': if not self._allow_guild(entry): hide_entry = True elif entry.Type == 'Alliance': if not self._allow_alliance(entry): hide_entry = True elif entry.Type == 'Emote': if not self._allow_emote(entry): hide_entry = True elif entry.Type == 'Label': if not self._allow_label(entry): hide_entry = True elif entry.Type == 'Focus': if not self._allow_focus(entry): hide_entry = True elif entry.Type == 'Spell': if not self._allow_spell(entry): hide_entry = True elif entry.Type == 'Party': if not self._allow_party(entry): hide_entry = True # System-specific handling: centralized routing and display policy if not hide_entry and entry.Type == 'System': try: route_hide, new_entry = self._handle_system_entry(entry) if route_hide: hide_entry = True elif new_entry is not None: entry = new_entry except Exception: pass # Hide player messages if disabled if SHOW_PLAYER_SELF_MESSAGES is False: try: if entry.Name == Player.Name: hide_entry = True except Exception: pass # Speaker-based filtering (skip for Global chat which is routed differently) try: if not hide_entry and (entry.Type != 'Global') and entry.Name and isinstance(entry.Name, str): if entry.Name.strip().lower() in FILTER_SPEAKER_EXACTS: hide_entry = True elif (not SHOW_OPTIONAL_NPC_ENABLED) and (entry.Name.strip().lower() in FILTER_SPEAKER_OPTIONAL_NPC): hide_entry = True elif (not SHOW_MONSTER_NPC_ENABLED) and (entry.Name.strip().lower() in FILTER_SPEAKER_MONSTER_NPC): hide_entry = True except Exception: pass # Effective flags: in offline simulation, allow duplicates so the preview shows everything show_row_dupes = SHOW_ROW_DUPLICATES if not OFFLINE_JOURNAL_SIMULATE else True # Hide duplicates (exact row) when SHOW_ROW_DUPLICATES is False (suppress spam) base_row = [entry.Type, entry.Color, entry.Name, entry.Serial, entry.Text] if (not show_row_dupes) and (base_row in self.filtered_entries): hide_entry = True # Hide bonded pets (by name suffix) when SHOW_BONDED_PET_LINES is False (skip for Global) try: if SHOW_BONDED_PET_LINES is False and entry.Type != 'Global': if entry.Name and isinstance(entry.Name, str): lname = entry.Name.lower() if ('{bonded}' in lname) or ('[bonded]' in lname) or ('(bonded)' in lname): hide_entry = True except Exception: pass # Hide status effects when SHOW_STATUS_EFFECT_LINES is False (pattern-based) (skip for Global) try: if SHOW_STATUS_EFFECT_LINES is False and entry.Type != 'Global': text = entry.Text if isinstance(entry.Text, str) else str(entry.Text) if self._is_status_effect_line(text): hide_entry = True except Exception: pass # Hide bonded-only text when SHOW_BONDED_PET_LINES is False (skip for Global) try: if SHOW_BONDED_PET_LINES is False and entry.Type != 'Global': text = entry.Text if isinstance(entry.Text, str) else str(entry.Text) if self._is_bonded_only_text(text): hide_entry = True except Exception: pass # Generic content filters regardless of speaker/type try: # Apply numeric-only, long-token, and punct+num anti-spam universally (includes Global) try: text_raw = str(entry.Text) except Exception: text_raw = '' if (not SHOW_NUMERIC_ONLY_MESSAGES) and self._is_numeric_only_message(text_raw): hide_entry = True if (not hide_entry) and (not SHOW_LONG_ALNUM_TOKENS) and self._has_long_alnum_token(text_raw): hide_entry = True if (not hide_entry) and (not SHOW_PUNCT_NUM_ONLY_MESSAGES) and self._is_punct_num_only_message(text_raw): hide_entry = True # Apply the rest only to non-Global if (not hide_entry) and entry.Type != 'Global': low = (entry.Text if isinstance(entry.Text, str) else str(entry.Text)).strip().lower() for sub in FILTER_GENERIC_SUBSTRINGS: if str(sub).strip().lower() in low: hide_entry = True break if not hide_entry and low in FILTER_GENERIC_EXACTS: hide_entry = True if not hide_entry: for pre in FILTER_GENERIC_PREFIXES: if low.startswith(str(pre).strip().lower()): hide_entry = True break # Filter pure withdraw commands like "withdraw 5,000" (non-Global) if not hide_entry and entry.Type != 'Global': try: text_raw2 = str(entry.Text) if ( WITHDRAW_ONLY_PATTERN.match(text_raw2) or DEPOSIT_ONLY_PATTERN.match(text_raw2) or CHECK_ONLY_PATTERN.match(text_raw2) or BALANCE_ONLY_PATTERN.match(text_raw2) or STATEMENT_ONLY_PATTERN.match(text_raw2) ): hide_entry = True except Exception: pass except Exception: pass # Hide empty try: if len(str(entry.Text).strip()) == 0: hide_entry = True except Exception: hide_entry = True did_append = False if not hide_entry: row_type = fixed_type if fixed_type is not None else entry.Type row = [row_type, entry.Color, entry.Name, entry.Serial, entry.Text] row_with_time = row + [entry.Timestamp] # Global text-based dedupe try: text_val = entry.Text if isinstance(entry.Text, str) else str(entry.Text) text_norm = ' '.join(text_val.split()).strip().lower() except Exception: text_norm = None # Effective text-dedupe: disabled in offline simulation to keep full history in preview # and ALWAYS bypassed for Quest entries so repeated Quest lines are not suppressed. dedupe_by_text = DEDUPLICATE_BY_TEXT if not OFFLINE_JOURNAL_SIMULATE else False if (row_type == 'Quest'): # Always append Quest entries (no text-based dedupe suppression) self.filtered_entries.append(row) self.filtered_entries_with_time.append(row_with_time) did_append = True else: if dedupe_by_text and text_norm: if text_norm not in self._seen_text_norm: self._seen_text_norm.add(text_norm) self.filtered_entries.append(row) self.filtered_entries_with_time.append(row_with_time) did_append = True else: self.filtered_entries.append(row) self.filtered_entries_with_time.append(row_with_time) did_append = True # mark as seen regardless so we don't reprocess on the next tick try: self._seen_entry_keys.add(key) except Exception: pass return did_append, getattr(entry, 'Timestamp', None) def _is_status_effect_line(self, text): try: for pat in self.status_effect_patterns: if pat.search(text): return True except Exception: pass return False # Convert deprecated <BASEFONT COLOR> tags to browser-friendly <span style="color:"> for the offline preview def _html_for_browser(self, html_text): try: s = str(html_text) # Replace opening BASEFONT with span style s = re.sub(r"<\s*BASEFONT\s+COLOR=\"([^\"]+)\"\s*>", r"<span style=\"color: \1\">", s, flags=re.IGNORECASE) # Replace closing BASEFONT with </span> s = re.sub(r"<\s*/\s*BASEFONT\s*>", "</span>", s, flags=re.IGNORECASE) # If an outer span with chat color wraps the whole line and inner spans exist, drop the outer wrapper m = re.match(r"^\s*<span\s+style=\"color:\s*([^\"]+)\">(.*)</span>\s*$", s, flags=re.IGNORECASE|re.DOTALL) if m: outer_color = m.group(1).strip() inner = m.group(2) if outer_color.lower() == str(CHAT_TEXT_COLOR).lower() and re.search(r"<span\s+style=\"color:", inner, flags=re.IGNORECASE): # Let the container provide the chat color; keep inner colored spans intact s = inner # Unescape any stray backslash-escaped quotes that may prevent style parsing in some viewers s = s.replace('\\"', '"').replace("\\'", "'") return s except Exception: return html_text # Remove simple HTML color tags from a string to produce a plain-text version for span calculation def _strip_html_tags(self, text): try: s = str(text) # Remove BASEFONT tags s = re.sub(r"<\s*BASEFONT\s+COLOR=\"[^\"]+\"\s*>", "", s, flags=re.IGNORECASE) s = re.sub(r"<\s*/\s*BASEFONT\s*>", "", s, flags=re.IGNORECASE) # Remove span color tags s = re.sub(r"<\s*span\s+style=\"color:\s*[^\"]+\"\s*>", "", s, flags=re.IGNORECASE) s = re.sub(r"<\s*/\s*span\s*>", "", s, flags=re.IGNORECASE) # Collapse excess whitespace s = re.sub(r"\s+", " ", s).strip() return s except Exception: return text # Remove leading timestamp like "[09/08/2025 22 : 09:18]" (with optional spaces and NBSP) from a raw log line def _strip_leading_timestamp(self, line): try: s = str(line) # Normalize non-breaking/zero-width spaces that often appear in exported logs s = s.replace('\u00A0', ' ').replace('\xa0', ' ').replace('\u200b', '') # Pattern: [MM/DD/YYYY HH : MM(:SS)?] with variable spacing and optional seconds s = re.sub(r"^\s*\[\s*\d{2}/\d{2}/\d{4}\s+\d{1,2}\s*:\s*\d{2}(?:\s*:\s*\d{2})?\s*\]\s*", "", s) return s except Exception: return line # Offline: simulate rendering from a text log file and write an HTML preview # Expected input lines examples: # System: <Trade> Bokagsea : Book of Lost Knowledge # System: The following regions are now DANGER ZONES: Stygian Keep, Minoc # Alice : hello there # Bob: missing space but still handled # [emote] * You begin to spasm uncontrollably * def simulate_from_text_file(self, input_path, output_path): try: # Try UTF-8 first with open(input_path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() # If content appears to be UTF-16 (embedded nulls), reopen with utf-16-le if '\x00' in content: with open(input_path, 'r', encoding='utf-16-le', errors='ignore') as f2: content = f2.read() lines = content.splitlines() except Exception as e: try: # Fallback: direct utf-16-le with open(input_path, 'r', encoding='utf-16-le', errors='ignore') as f3: lines = f3.read().splitlines() except Exception as e2: debug_message(f"Offline simulate: cannot read file: {e} / {e2}") return # Reset internal state for a clean run self.filtered_entries = [] self.filtered_entries_with_time = [] self._seen_entry_keys = set() self._seen_text_norm = set() ts = 1.0 appended = 0 sys_count = 0 reg_count = 0 for raw in lines: s = str(raw).rstrip('\n') # Normalize and strip any leading timestamp prefix from exported journal logs s = self._strip_leading_timestamp(s) if not s: continue # Build a minimal entry-like object etype = 'Regular' name = '' text = s # Heuristic: System lines if s.strip().lower().startswith('system'): etype = 'System' sys_count += 1 else: # Try to split "Name : message" variants if ' : ' in s: parts = s.split(' : ', 1) name = parts[0].strip() text = parts[1] elif ':' in s: parts = s.split(':', 1) # avoid time-like prefixes by preferring short names if len(parts[0].strip()) <= 24: name = parts[0].strip() text = parts[1] reg_count += 1 entry = type('E', (), dict(Type=etype, Color=CHAT_TEXT_COLOR, Name=name, Serial=0, Text=text, Timestamp=ts)) ts += 1.0 did_append, _ = self._process_entry(entry) if did_append: appended += 1 # Write HTML preview using the same HTML snippets as gump try: out_lines = [] out_lines.append("<!doctype html>") out_lines.append("<html><head><meta charset='utf-8'><meta http-equiv='X-UA-Compatible' content='IE=edge'><title>Journal Preview</title></head><body>") out_lines.append("<div style='font-family: Verdana, Arial, sans-serif; font-size: 12px; background:#111; padding:10px; width:700px;'>") out_lines.append("<div style='color:#888; margin-bottom:8px;'>______ JOURNAL PREVIEW ______</div>") out_lines.append(f"<div style='color:#888; margin-bottom:8px;'>Parsed: System={sys_count} Regular-like={reg_count} Appended={appended}</div>") for e in self.filtered_entries_with_time: html_text, plain_text = self._build_entry_texts(e) out_lines.append(f"<div style='margin:2px 0;'>{self._html_for_browser(html_text)}</div>") out_lines.append("</div></body></html>") with open(output_path, 'w', encoding='utf-8') as f: f.write("\n".join(out_lines)) debug_message(f"Offline simulate: wrote {appended} entries to {output_path}") except Exception as e: debug_message(f"Offline simulate: write error {e}") # True if the entire text (after trimming) is just digits def _is_numeric_only_message(self, text): try: s = str(text).strip() return True if re.match(r"^\d+$", s) else False except Exception: return False # True if any token has only A-Za-z0-9 and length > threshold def _has_long_alnum_token(self, text, threshold=None): try: s = str(text) th = int(threshold) if threshold is not None else int(LONG_ALNUM_TOKEN_THRESHOLD) # Split on whitespace and punctuation, preserve alnum chunks tokens = re.findall(r"[A-Za-z0-9]+", s) for t in tokens: if len(t) > th: return True except Exception: pass return False # True if, after removing whitespace, the string has no letters A-Za-z # and contains at least one non-space character (i.e., only digits and punctuation/specials) def _is_punct_num_only_message(self, text): try: s = str(text) s_compact = re.sub(r"\s+", "", s) if len(s_compact) == 0: return False return re.search(r"[A-Za-z]", s_compact) is None except Exception: return False # Normalize Global Quest objective text by removing trailing qualifiers like 'worldwide'/'globally' # and collapsing redundant whitespace/punctuation. Examples: # - "Kill 350 Dragons worldwide" -> "Kill 350 Dragons" # - "Harvest 5000 Logs" -> unchanged def _normalize_globalquest_objective(self, obj_text): try: s = str(obj_text).strip() # Remove trailing qualifiers commonly seen in objectives s = re.sub(r"\s*(?:world\s*wide|worldwide|globally|across\s+the\s+realm|across\s+britannia)\s*$", "", s, flags=re.IGNORECASE) # Compact internal whitespace s = re.sub(r"\s+", " ", s).strip() # If pattern is Kill/Harvest N Thing(s), keep it as-is after trimming m_kill = re.match(r"^Kill\s+(\d+)\s+(.+)$", s, re.IGNORECASE) if m_kill: n = m_kill.group(1) thing = m_kill.group(2).strip() return f"Kill {n} {thing}" m_harv = re.match(r"^Harvest\s+(\d+)\s+(.+)$", s, re.IGNORECASE) if m_harv: n = m_harv.group(1) thing = m_harv.group(2).strip() return f"Harvest {n} {thing}" return s except Exception: return obj_text # Decide if a non-global System line should be considered a quest notification def _is_quest_system_text(self, s_lower): try: if s_lower is None: return False # Keywords and shard events that we always allow as quest-like if ('danger zone' in s_lower) or ('hellfire' in s_lower): return True virtue_alert = any(v in s_lower for v in VIRTUES) and ('attack' in s_lower) if virtue_alert: return True # Use word boundaries so 'question' does not count as 'quest' if re.search(r"\bquests?\b", s_lower, re.IGNORECASE): return True except Exception: pass return False # Route one System journal entry according to SHOW_SYSTEM_* flags. # Returns (hide: bool, new_entry: entry-like object or None) # # Examples from raw log and expected classification: # - "System: <General> Playername : speaking in general chat" # => Global (channel present), show if SHOW_SYSTEM_GLOBAL_MESSAGES is True; # - "System: <Trade> Alice : WTS Valorite Ingots" # => Global, show/hide per SHOW_SYSTEM_GLOBAL_MESSAGES. # - "System: Spirituality is currently under attack!" # => Non-global Quest (virtue alert), show per SHOW_SYSTEM_QUEST_MESSAGES. # - "System: You have accepted quest: The Lost Map" # => Non-global Quest (contains word 'quest'), show per SHOW_SYSTEM_QUEST_MESSAGES. # - "System: The trash is full!" # => Non-global Other; hidden unless SHOW_SYSTEM_OTHER_MESSAGES is True (and may also be filtered earlier by generic filters). def _handle_system_entry(self, entry): try: txt = entry.Text if isinstance(entry.Text, str) else str(entry.Text) s = str(txt) low = s.lower() # Check for Global format first m = self.system_global_capturing_pattern.match(s) if m: # Only treat as Global if a channel like <General>/<Trade>/<PVP> is present try: channel = (m.group('channel') or '').strip() except Exception: channel = '' if channel: if not SHOW_SYSTEM_GLOBAL_MESSAGES: if DEBUG_MODE: debug_message(f"Global suppressed by toggle: channel={channel}, speaker={m.group('speaker')}, msg={m.group('message')[:60] if m.group('message') else ''}") return True, None speaker = (m.group('speaker') or '').strip() # Enforce per-channel visibility ch_low = channel.lower() if ch_low == 'general' and not SHOW_GLOBAL_CHAT_GENERAL: return True, None if ch_low == 'pvp' and not SHOW_GLOBAL_CHAT_PVP: return True, None if ch_low == 'trade' and not SHOW_GLOBAL_CHAT_TRADE: return True, None message = (m.group('message') or '').strip() fixed_type = 'Global' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name=speaker or entry.Name, Serial=entry.Serial, Text=message, Timestamp=entry.Timestamp)) if DEBUG_MODE: debug_message(f"Classified Global: channel={channel}, speaker={speaker}, msg={message[:80]}") return False, new_entry # If no channel, fall through to non-global handling (Quest/Danger/etc.) # Non-global: specialized handling first (DANGER ZONES, Virtue Shrine events), then generic quest/other now_ms = int(time.time() * 1000) # Detect and hold the shrine corruption marker try: cleaned_marker = re.sub(r"^\s*System\s*:?:?\s*", "", s, flags=re.IGNORECASE).strip() except Exception: cleaned_marker = s.strip() # Weekly Quest announcements filter (non-global System lines that contain "Weekly Quest:") try: if (not SHOW_SYSTEM_WEEKLY_QUEST_MESSAGES) and re.search(r"\bweekly\s+quest\s*:\s*", cleaned_marker, re.IGNORECASE): return True, None except Exception: pass # Fallback Global routing: if the cleaned text still looks like '<Channel> Speaker : Message', treat as Global try: ch_m = re.match(r"^\s*<\s*(?P<channel>[^>]+)\s*>\s*(?P<speaker>[^:<>]+?)\s*:\s*(?P<message>.+)$", cleaned_marker) if ch_m: if not SHOW_SYSTEM_GLOBAL_MESSAGES: return True, None speaker = (ch_m.group('speaker') or '').strip() channel = (ch_m.group('channel') or '').strip() ch_low = channel.lower() if ch_low == 'general' and not SHOW_GLOBAL_CHAT_GENERAL: return True, None if ch_low == 'pvp' and not SHOW_GLOBAL_CHAT_PVP: return True, None if ch_low == 'trade' and not SHOW_GLOBAL_CHAT_TRADE: return True, None message = (ch_m.group('message') or '').strip() fixed_type = 'Global' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name=speaker or entry.Name, Serial=entry.Serial, Text=message, Timestamp=entry.Timestamp)) if DEBUG_MODE: debug_message(f"Fallback Classified Global: channel={channel}, speaker={speaker}, msg={message[:80]}") return False, new_entry except Exception: pass # Global Quest combiner: begin line + objective line to a single Quest message # # Expected inputs (non-channel System lines): # System: A new global quest has begun: Lumber Crisis! # System: Objective: Harvest 5000 Logs (Target: 5000) # System: Say [globalquest to view details! # -> Output: [QUEST] Lumber Crisis! Harvest 5000 Logs # # System: A new global quest has begun: Dragon Threat! # System: Objective: Kill 350 Dragons worldwide (Target: 350) # System: Say [globalquest to view details! # -> Output: [QUEST] Dragon Threat! Kill 350 Dragons # # System: A new global quest has begun: Shame Purification! # System: Objective: Kill 300 monsters in Shame (Target: 300) # System: Say [globalquest to view details! # -> Output: [QUEST] Shame Purification! Kill 300 monsters in Shame # # Notes: # - We intentionally ignore the "Say [globalquest ..." helper line. # - Objective parser ignores the "(Target: NNN)" suffix and trims qualifiers like "worldwide/globally". # - Pending window must span both lines; prefer >= 6000 ms if timestamps may differ. try: # Examples: "A new global quest has begun: Lumber Crisis!" # Allow optional trailing punctuation variations and spacing begin_m = re.match(r"^\s*A\s+new\s+global\s+quest\s+has\s+begun\s*:\s*(?P<name>.+?)\s*[!\.]?\s*$", cleaned_marker, re.IGNORECASE) if begin_m: # Hold quest name for a short time to await objective line self._globalquest_name = (begin_m.group('name') or '').strip() self._globalquest_pending_until_ms = now_ms + 3000 if DEBUG_MODE: debug_message(f"Quest begin: name='{self._globalquest_name}' pending 3s") # If we previously saw an objective first and it's still fresh, combine immediately try: if getattr(self, '_globalquest_last_objective_until_ms', 0) > now_ms and getattr(self, '_globalquest_last_objective', '').strip(): objective = self._normalize_globalquest_objective(self._globalquest_last_objective) # Clear stored objective state and pending window self._globalquest_last_objective = '' self._globalquest_last_objective_until_ms = 0 self._globalquest_pending_until_ms = 0 quest_name = self._globalquest_name self._globalquest_name = '' if not SHOW_SYSTEM_QUEST_MESSAGES: return True, None combined_name = quest_name if quest_name.endswith('!') else f"{quest_name}!" combined = f"{combined_name} {objective}" fixed_type = 'Quest' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[QUEST]', Serial=entry.Serial, Text=combined, Timestamp=entry.Timestamp)) return False, new_entry except Exception: pass return True, None # hide the begin line itself # Examples: "Objective: Harvest 5000 Logs (Target: 5000)" obj_m = re.match(r"^\s*Objective\s*:\s*(?P<obj>.+?)(?:\s*\(\s*Target\s*:\s*[^\)]*\))?\s*$", cleaned_marker, re.IGNORECASE) if obj_m and getattr(self, '_globalquest_pending_until_ms', 0) > now_ms: quest_name = getattr(self, '_globalquest_name', '').strip() # Clear pending state self._globalquest_pending_until_ms = 0 self._globalquest_name = '' if quest_name: if not SHOW_SYSTEM_QUEST_MESSAGES: return True, None objective = (obj_m.group('obj') or '').strip() objective = self._normalize_globalquest_objective(objective) # Ensure single trailing '!' after name and a single space before objective combined_name = quest_name if quest_name.endswith('!') else f"{quest_name}!" combined = f"{combined_name} {objective}" fixed_type = 'Quest' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[QUEST]', Serial=entry.Serial, Text=combined, Timestamp=entry.Timestamp)) return False, new_entry # Defensive: if Objective appears before Begin (rare), hold it briefly and try to pair if obj_m and getattr(self, '_globalquest_pending_until_ms', 0) <= now_ms: self._globalquest_last_objective = (obj_m.group('obj') or '').strip() self._globalquest_last_objective_until_ms = now_ms + 3000 if DEBUG_MODE: debug_message("Quest objective seen before begin; holding for 3s") return True, None except Exception: pass # DANGER ZONES handling # Accept variations like: [DANGER ZONES ACTIVATED], [Danger Zones Active], etc. if re.match(r"^\s*\[\s*danger\s+zone[s]?\s+activ\w*\s*\]\s*$", cleaned_marker, re.IGNORECASE): # arm a short pending window to catch the next regions list line self._danger_zones_pending_until_ms = now_ms + 2000 return True, None # hide the marker itself # Unconditional combined list handling: convert directly to Danger entry regardless of pending state dz_list_match = re.match(r"^\s*the\s+following\s+regions\s+are\s+now\s+danger\s+zone[s]?\s*:\s*(?P<list>.+)\s*$", cleaned_marker, re.IGNORECASE) if dz_list_match: regions_raw = dz_list_match.group('list') or '' regions = [r.strip() for r in regions_raw.split(',') if r.strip()] colored = self._colorize_regions(regions) fixed_type = 'Danger' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[DANGER ZONE]', Serial=entry.Serial, Text=colored, Timestamp=entry.Timestamp)) return False, new_entry # Fallback: if the line contains 'danger zone' and a colon-separated list, classify as Danger even without prior marker low_clean = cleaned_marker.lower() if ('danger zone' in low_clean) and (':' in cleaned_marker): try: list_part = cleaned_marker.split(':', 1)[1] except Exception: list_part = '' regions = [r.strip() for r in list_part.split(',') if r.strip()] if regions: colored = self._colorize_regions(regions) fixed_type = 'Danger' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[DANGER ZONE]', Serial=entry.Serial, Text=colored, Timestamp=entry.Timestamp)) return False, new_entry if re.match(r"^\s*\[\s*shrine\s+corruption\s*\]\s*$", cleaned_marker, re.IGNORECASE): # Hold for a short window so we can coalesce the next virtue attack line self._shrine_corruption_pending_until_ms = now_ms + 2000 return True, None # hide the marker itself # Hide Quest Progress lines if disabled try: if not SHOW_SYSTEM_QUEST_PROGRESS: # Hide any variant that includes 'Quest Progress:' (e.g., 'Global Quest Progress: ...') if re.search(r"\bquest\s+progress\s*:\s*", cleaned_marker, re.IGNORECASE): return True, None # Backward compatibility: also hide lines that start with 'Quest Progress' even without a colon if re.match(r"^\s*quest\s+progress\b", cleaned_marker, re.IGNORECASE): return True, None except Exception: pass # Virtue under attack line shrine_match = re.match(r"^\s*(?P<virtue>\w+)\s+is\s+currently\s+under\s+attack!\s*$", cleaned_marker, re.IGNORECASE) if shrine_match and (shrine_match.group('virtue') or '').strip().lower() in VIRTUES: virtue = shrine_match.group('virtue').strip() # If we recently saw the corruption marker, coalesce to a single shrine entry if now_ms < self._shrine_corruption_pending_until_ms: self._shrine_corruption_pending_until_ms = 0 # Show shrine alert as a unified entry regardless of pending marker fixed_type = 'Shrine' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[VIRTUE SHRINE]', Serial=entry.Serial, Text=f"{virtue} is under attack!", Timestamp=entry.Timestamp)) return False, new_entry # Generic classification: quest vs other (ensure Danger preference) # If a line mentions danger zone(s), prefer Danger handling above; do not treat as Quest here. if 'danger zone' in low: return True, None # already handled or will be ignored if unmatched is_quest = self._is_quest_system_text(low) try: cleaned = re.sub(r"^\s*System\s*:?:?\s*", "", s, flags=re.IGNORECASE).strip() except Exception: cleaned = s.strip() if is_quest: if not SHOW_SYSTEM_QUEST_MESSAGES: return True, None fixed_type = 'Quest' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[QUEST]', Serial=entry.Serial, Text=cleaned, Timestamp=entry.Timestamp)) return False, new_entry else: if not SHOW_SYSTEM_OTHER_MESSAGES: return True, None fixed_type = 'System' new_entry = type('E', (), dict(Type=fixed_type, Color=entry.Color, Name='[SYSTEM]', Serial=entry.Serial, Text=cleaned, Timestamp=entry.Timestamp)) return False, new_entry except Exception: return False, None # Colorize a list of region names with known colors or deterministic fallback def _colorize_regions(self, regions): try: out_parts = [] for name in regions: color = self._color_for_region(name) safe = str(name) out_parts.append(f"<BASEFONT COLOR=\"{color}\">{safe}</BASEFONT>") return " , ".join(out_parts) except Exception: try: return " , ".join([str(x) for x in regions]) except Exception: return "" def _color_for_region(self, name): # Known location colors; keys are lowercase KNOWN_LOCATION_COLORS = { 'minoc': '#85C1E9', # soft blue 'stygian keep': '#C39BD3', # violet 'deceit': '#BB8FCE', # purple 'eventide': '#76D7C4', # teal 'shadowkin camp': '#7FB3D5', 'britain': '#F8C471', 'yew': '#82E0AA', 'vesper': '#73C6B6', 'skara brae': '#F0B27A', 'trinsic': '#F1948A', } try: key = (name or '').strip().lower() if key in KNOWN_LOCATION_COLORS: return KNOWN_LOCATION_COLORS[key] # Deterministic fallback using speaker color palette hash # Reuse the same FNV-1a approach to pick a readable color from PALETTE_GOOD_COLORS h = 0x811C9DC5 for ch in key: h ^= ord(ch) h = (h * 0x01000193) & 0xFFFFFFFF idx = (h + int(COLOR_SEED_OFFSET)) % len(PALETTE_SPEAKER_COLORS) return PALETTE_SPEAKER_COLORS[idx] except Exception: return CHAT_TEXT_COLOR # Detect messages that are only a bonded tag like "[bonded]", "{bonded}", or even "{bonded)" def _is_bonded_only_text(self, text): try: if text is None: return False s = str(text).strip().lower() # Regex: optional single leading bracket/brace/paren, then bonded, then optional single trailing counterpart # Allows minor mismatches like {bonded) as requested if re.match(r"^\s*[\[\{\(]?\s*bonded\s*[\]\}\)]?\s*$", s, re.IGNORECASE): return True except Exception: pass return False # Deterministic color for a given speaker name using the preset palette def _color_for_speaker(self, name): try: if not name: return CHAT_TEXT_COLOR key = name.strip().lower() # FNV-1a 32-bit hash for deterministic index without hashlib h = 0x811C9DC5 for ch in key: h ^= ord(ch) h = (h * 0x01000193) & 0xFFFFFFFF idx = (h + int(COLOR_SEED_OFFSET)) % len(PALETTE_SPEAKER_COLORS) return PALETTE_SPEAKER_COLORS[idx] except Exception: return CHAT_TEXT_COLOR #//======= UI drawing ===================== def draw_gump(self): # Compute the lines to render and a signature for change detection # Compute paging using wrapped line spans usable_height = max(0, self.resize_height - JOURNAL_START_Y - JOURNAL_ENTRY_HEIGHT) available_lines = max(1, usable_height // JOURNAL_ENTRY_HEIGHT) # Auto-stick to bottom unless user scrolled up if self.stick_to_bottom: self.scroll_offset_lines = 0 # Determine visible window from bottom using scroll_offset_lines start_line_from_bottom = self.scroll_offset_lines end_line_from_bottom = self.scroll_offset_lines + available_lines # Collect just enough entries backwards to fill the window selected = [] # (index, span, html, plain) acc = 0 width = self.resize_width - 25 for idx in range(len(self.filtered_entries_with_time) - 1, -1, -1): entry = self.filtered_entries_with_time[idx] html_text, plain_text = self._build_entry_texts(entry) span = self._get_span_for_entry(entry, plain_text, width) next_acc = acc + span if next_acc > start_line_from_bottom: selected.append((idx, span, html_text, plain_text)) acc = next_acc if acc >= end_line_from_bottom: break if not CHAT_ORDER_TOP_NEW: selected.reverse() # Build a signature of visible content try: visible_plain = tuple(p for (_, _, _, p) in selected) except Exception: visible_plain = tuple() now_ms = int(time.time() * 1000) if ( self._last_render_signature is not None and visible_plain == self._last_render_signature and (now_ms - self._last_gump_send_ms) < MIN_RESEND_MS ): # No change and resend window not elapsed; skip sending return # Proceed to build and send the gump gump_data = Gumps.CreateGump(True, True, False, False) Gumps.AddPage(gump_data, 0) if self.show_chat_panel: total_render_lines = sum(span for _, span, _, _ in selected) total_render_height = (total_render_lines * JOURNAL_ENTRY_HEIGHT) + JOURNAL_ENTRY_HEIGHT bg_x, bg_y, bg_w, bg_h = self._safe_rect(JOURNAL_START_X, JOURNAL_START_Y, self.resize_width - 25, max(JOURNAL_ENTRY_HEIGHT, total_render_height)) if CHAT_BG_DARK: try: Gumps.AddAlphaRegion(gump_data, bg_x, bg_y, bg_w, bg_h) except Exception: pass if CHAT_BG_IMAGE_ID is not None: try: Gumps.AddImageTiled(gump_data, bg_x, bg_y, bg_w, bg_h, int(CHAT_BG_IMAGE_ID)) except Exception: pass title_html = "<BASEFONT COLOR=\"#333333\"> ________ L O C A L C H A T ________</BASEFONT>" tx, ty, tw, th = self._safe_rect(JOURNAL_START_X + 4, JOURNAL_START_Y + 2, self.resize_width - 35, JOURNAL_ENTRY_HEIGHT) Gumps.AddHtml(gump_data, tx, ty, tw, th, title_html, False, False) current_y = JOURNAL_START_Y + JOURNAL_ENTRY_HEIGHT for _, span, html_text, _ in selected: height_px = span * JOURNAL_ENTRY_HEIGHT rx, ry, rw, rh = self._safe_rect(JOURNAL_START_X, current_y, self.resize_width - 25, height_px) Gumps.AddHtml(gump_data, rx, ry, rw, rh, html_text, False, False) current_y += height_px else: Gumps.AddLabel(gump_data, 130, 0, 0, "Chat Hidden") try: Gumps.CloseGump(self.gump_id) Gumps.SendGump(self.gump_id, Player.Serial, 0, 0, gump_data.gumpDefinition, gump_data.gumpStrings) self._last_render_signature = visible_plain self._last_gump_send_ms = now_ms except Exception as e: # On failure, do not spam retries; wait until next update cycle debug_message(f" SendGump error: {e}") #//======= Input handling ===================== def handle_gump_response(self): gump_data = Gumps.GetGumpData(self.gump_id) if not gump_data: return debug_message(f" handle_gump_response buttonid={gump_data.buttonid}") def update(self): # Update loop tick: throttle, then refresh and draw conditionally try: pause_ms = int(self.update_interval_ms) if ENABLE_JITTER: try: pause_ms += int(random.randint(0, int(JITTER_MS_MAX))) except Exception: pass Misc.Pause(pause_ms) except Exception: pass # Refresh data new_count = self.build_filtered_journal_entries() # Only redraw/send gump if something changed if new_count > 0: self.draw_gump() def _build_entry_texts(self, entry): # Build both HTML display text and plain text for measurement for an entry row try: name_part = str(entry[2]) if entry[2] is not None else "" msg_part = str(entry[4]) ts_part = "" if SHOW_TIMESTAMP: tm_struct = time.localtime(entry[5]) time_str = time.strftime('%H:%M:%S', tm_struct) ts_part = f"[{time_str}] " speaker_color = self._color_for_speaker(name_part) colored_name_html = f"<BASEFONT COLOR=\"{speaker_color}\">{name_part}</BASEFONT>" if name_part else "" if str(entry[0]) == 'Global' and colored_name_html: display_text = f"{ts_part}[{name_part}] : {msg_part}" # Color only specific segments to avoid nested outer span masking inner spans in browser preview html_text = ( f"<BASEFONT COLOR=\"{CHAT_TEXT_COLOR}\">{ts_part}[</BASEFONT>" f"{colored_name_html}" f"<BASEFONT COLOR=\"{CHAT_TEXT_COLOR}\">] : {msg_part}</BASEFONT>" ) plain_text = display_text elif str(entry[0]) == 'Quest': display_text = f"{ts_part}[QUEST] {msg_part}" html_text = f"<BASEFONT COLOR=\"{QUEST_TEXT_COLOR}\">{display_text}</BASEFONT>" plain_text = display_text elif str(entry[0]) == 'Shrine': # Virtue shrine alerts use a unified orange color display_text = f"{ts_part}[VIRTUE SHRINE] {msg_part}" html_text = f"<BASEFONT COLOR=\"{VIRTUE_ALERT_COLOR}\">{display_text}</BASEFONT>" plain_text = display_text elif str(entry[0]) == 'Danger': # Danger zones: red label + pre-colored region list in message display_text = f"{ts_part}[DANGER ZONE] : {msg_part}" html_text = f"<BASEFONT COLOR=\"{DANGER_ZONE_LABEL_COLOR}\">{ts_part}[DANGER ZONE]</BASEFONT> : {msg_part}" plain_text = display_text else: if colored_name_html: display_text = f"{ts_part}{name_part} : {msg_part}" # Only emit a timestamp span if we actually show a timestamp ts_html = f"<BASEFONT COLOR=\"{CHAT_TEXT_COLOR}\">{ts_part}</BASEFONT>" if ts_part else "" # Speaker in own color; separator+message in base chat color html_text = f"{ts_html}{colored_name_html}<BASEFONT COLOR=\"{CHAT_TEXT_COLOR}\"> : {msg_part}</BASEFONT>" plain_text = display_text else: display_text = f"{ts_part}{msg_part}" html_text = f"<BASEFONT COLOR=\"{CHAT_TEXT_COLOR}\">{display_text}</BASEFONT>" plain_text = display_text # Alert highlighting: if text contains " is attacking you", color entire message bright red try: if ALERT_ATTACK_SUBSTRING.lower() in plain_text.lower(): html_text = f"<BASEFONT COLOR=\"{ALERT_ATTACK_COLOR}\">{plain_text}</BASEFONT>" except Exception: pass # Ensure plain_text contains no HTML tags so span calculation is based on visible characters try: plain_text = self._strip_html_tags(plain_text) except Exception: pass return html_text, plain_text except Exception: s = str(entry) return f"<BASEFONT COLOR=\"{CHAT_TEXT_COLOR}\">{s}</BASEFONT>", s def _estimate_line_span(self, plain_text, width_px): # Rough estimate of how many wrapped lines a piece of text will occupy try: # Approximate average character width in pixels for the gump font avg_char_px = 7.0 columns = max(1, int(width_px / avg_char_px)) length = max(1, len(plain_text)) # simple ceiling division span = (length + columns - 1) // columns return max(1, span) except Exception: return 1 def _get_span_for_entry(self, entry, plain_text, width_px): try: key = (entry[5], entry[3], hash(plain_text), int(width_px)) except Exception: key = (hash(str(entry)), int(width_px)) cached = self._span_cache.get(key) if cached: return cached span = max(1, self._estimate_line_span(plain_text, width_px)) # size-bound cache if len(self._span_cache) >= self._span_cache_max: try: self._span_cache.clear() except Exception: pass self._span_cache[key] = span return span def main(): debug_message(' Starting main()') ui = JournalFilterUI() if OFFLINE_JOURNAL_SIMULATE: # Run offline simulation and exit try: ui.simulate_from_text_file(OFFLINE_JOURNAL_INPUT_PATH, OFFLINE_JOURNAL_OUTPUT_PATH) except Exception as e: debug_message(f"Offline simulate failed: {e}") return # Live UI mode ui.build_filtered_journal_entries() ui.draw_gump() while True: ui.update() main()

Version History

Original Version Saved - 9/13/2025, 3:01:43 AM - 3 days ago

UI_local_chat_journal_filtered.py

No changes to display
View list of scripts
Disclaimer: This is a fan made site and is not directly associated with Ultima Online or UO staff.