124 Commits
Author SHA1 Message Date
Pakobbix 8b5cf75396 Merge pull request 'Fixes #18: Update Readme & AGENTS.md' (#19) from issue-18-update-readme-agents into main
Reviewed-on: #19
2026-07-12 15:06:50 +00:00
ARIA c938916fd8 Fixes #18: Update README & AGENTS.md documentation
- README: Add Equipment System feature, Code Quality, Performance,
  Memory Management, Testing, Development Commands, and Tech Stack sections
- AGENTS.md: Update source tree with CSS modules, equipment constants,
  renderUtils, settings consolidation; add development commands,
  performance optimizations, memory management notes, validator factory;
  update file reference table

Based on PRs: #1 #3 #6 #8 #10 #12 #14 #17
2026-07-12 17:03:37 +02:00
Pakobbix f00475cb65 Merge pull request 'Fixes #16: Code quality enhancement Part 3' (#17) from issue-16-code-quality-enhancements into main
Reviewed-on: #17
2026-07-12 13:31:05 +00:00
ARIA 28802b1ad7 Fixes #16: Code quality enhancements Part 3
- Consolidate settings defaults: config.js is now single source of truth,
  state.js imports from config.js (eliminates ~300 lines of duplication)
- Validator factory: createStringValidator() unifies sanitizeLocationName/
  sanitizeItemName with configurable rules (blocked names, max length, rejects)
- Refactor promptBuilder: Extract getCharacterCardsInfo into buildNarratorCardInfo,
  buildGroupCardInfo, buildSingleCardInfo, appendCharacterFields
- Error handling: jsonRepair.js silent failures now log to console.debug
- Testing: Fix ESM support (babel.config.json + jest.config.cjs transform),
  add security.test.js with 24 tests for validator factory and security utilities
- Total: 78 tests passing (was 54)
2026-07-12 15:24:58 +02:00
Pakobbix 5b9c1ca0f1 Merge pull request 'Fixes #13: CSS performance optimizations (minification + extended custom properties)' (#14) from issue-13-css-performance into main
Reviewed-on: #14
2026-07-12 13:06:04 +00:00
ARIA df7d3b3a38 Fixes #13: CSS performance optimizations
- Add CSS minification support to build script (--minify flag)
  - Pure JS minifier: removes comments, whitespace, redundant chars
  - Reports size reduction percentage
  - Preserves CSS custom properties, data URIs, strings

- Refactor _08_themes.css: extend CSS custom properties pattern
  - Define theme-specific properties per theme (--rpg-content-bg,
    --rpg-content-box-shadow, --rpg-header-text-shadow, etc.)
  - Replace triple-theme selector duplication with single [data-theme]
    selectors — children inherit variables automatically
  - Reduces themes.css from 379 to ~220 lines
  - Reduces style.css from 305KB to 294KB (3.8% reduction)

- Add build:css:min npm script for production builds
- Add 9 tests for CSS build: minification, size reduction, CSS
  variable preservation, data URI preservation, theme refactoring
2026-07-12 14:55:36 +02:00
Pakobbix 29f7865927 Merge pull request 'Fixes #11: Memory management enhancements' (#12) from issue-11-memory-management into main
Reviewed-on: #12
2026-07-12 12:44:06 +00:00
ARIA 9c0f09619d Fixes #11: Memory management enhancements
- Add clearDomCache() for jQuery object cache invalidation on panel rebuild
- Track all event handlers in on()/once() for complete cleanup by unregisterAllEvents()
- Migrate direct eventSource.on() calls to tracked onEvent() in index.js, injector.js, sillyTavernExpressions.js
- Add clearDebugLogs() called on CHAT_CHANGED to prevent memory accumulation
- Add trackJQueryHandler()/cleanupJQueryEvents() infrastructure for jQuery event cleanup
- Add comprehensive memory management tests (14 new tests, all passing)
2026-07-12 14:31:14 +02:00
Pakobbix 3c4e632906 Merge pull request 'Enhancing code quality part 2.2: Parser Performance Optimizations' (#10) from issue-9-parser-performance into main
Reviewed-on: #10
2026-07-12 12:01:59 +00:00
ARIA 4e4b2328ba Fixes #9: Parser performance optimizations
- Pre-compile all regex patterns as module-level constants in parser.js and jsonRepair.js
- Add format detection caching (lastDetectedFormat, formatCacheHits) to skip redundant checks
- Add clearFormatCache() export for cache invalidation
- Add comprehensive test suite (33 tests) for parser and jsonRepair modules
- Fix regex escaping in dynamic patterns (statRegex, fieldRegex)
- Improve toFieldKey() regex specificity
2026-07-12 13:58:21 +02:00
Pakobbix c9d604ab68 Merge pull request 'Rendering performance optimizations (Issue #7 part 2.1)' (#8) from issue-7-rendering-performance into main
Reviewed-on: #8
2026-07-12 11:33:38 +00:00
ARIA f8894a9f3c Fix: Scope event handlers to container in userStats.js
Fixes critical bug identified by Zephyre_Review: event handlers used
global jQuery selectors instead of container-scoped selectors, causing
potential event handler leaks and scope mismatches.
2026-07-12 13:14:19 +02:00
ARIA 24c964c38d Fixes #7: Rendering performance optimizations
- requestAnimationFrame batching: rerenderRpgState() now batches all
  render calls into a single animation frame, reducing reflow/repaint costs

- Lightweight change detection: Render functions use updateIfChanged() to
  skip DOM updates when content hasn't changed, and only re-bind event
  handlers when the DOM was actually updated

- CSS content-visibility: Added content-visibility: auto with
  contain-intrinsic-size to inventory and equipment containers, deferring
  rendering of off-screen content

New file: src/systems/rendering/renderUtils.js (rendering utilities module)
Updated: All render modules + sillytavern.js integration + style.css
2026-07-12 13:10:17 +02:00
Pakobbix 1f8591d4ec Merge pull request 'Enhancing code quality part 1' (#6) from issue-5-code-quality-part1 into main
Reviewed-on: #6
2026-07-12 10:58:21 +00:00
ARIA 7e4461823f Fix: Pass required arguments to setupPlotButtons
- setupPlotButtons requires handlePlotClick and handleEncounterClick callbacks
- Pass sendPlotProgression and openEncounterModal as arguments
- removeAlternatePresentCharactersPanel is already correctly imported
2026-07-12 12:29:06 +02:00
ARIA c14c1417c1 Fixes #5: Refactor code quality - Part 1
- Split index.js from 1,567 lines to 456 lines (73% reduction)
  - Extracted settings event listeners to src/systems/ui/settingsListeners.js
  - Extracted settings panel to src/core/settingsPanel.js
  - Simplified initUI() and main initialization block

- Modularized style.css into 28 logical CSS modules in src/styles/
  - Added build script (npm run build:css) to concatenate modules
  - Modules: panel, components, user-stats, info-box, thoughts, settings,
    themes, modals, mobile, inventory, quests, equipment, encounters,
    weather, music-player, FAB widgets, strip widgets, etc.

- Updated package.json with build:css script and type: module

No functional changes - pure refactoring for maintainability.
2026-07-12 12:24:21 +02:00
Pakobbix b99c884144 Merge pull request 'Fix #2: Increase equipment stat limit from +20 to +99' (#3) from issue-2-equipment-stat-limit into main
Reviewed-on: #3
2026-07-04 18:42:15 +00:00
ARIA 8626d0476f Fixes #2: increase equipment stat limit from +20 to +99
- Update HTML input max attribute from 20 to 99 in generateStatCheckboxes()
- Update JS clamping logic Math.min(20, val) to Math.min(99, val) in saveEquipmentItem()
2026-07-04 20:39:20 +02:00
ARIA 54e1b0c2b2 Fix equipment vanishing after save: preserve equipment in loadChatData 2026-07-03 12:07:46 +02:00
ARIA 9720a7befe Fix Equipment modal and json filter 2026-07-03 11:53:35 +02:00
ARIA 130998105a Added Agents.md 2026-07-03 11:24:11 +02:00
Pakobbix 411dc3eb9c Merge pull request 'feat: add Equipment system with slot validation and stat bonuses' (#1) from feature/equipment-system into main
Reviewed-on: #1
2026-07-03 09:16:57 +00:00
ARIA 10cfe581ac feat: add Equipment tab with slot-type validation
Add a new Equipment tab to manage player gear and stat bonuses.

Features:
- 19 equipment slots across 8 categories (helmet, necklace, body armor, gloves, pants, shoes, rings, accessories)
- Type-to-slot validation: each type has max equipped limits (1 helmet, 10 rings, 3 accessories, etc.)
- Auto-slot assignment: equipping a ring fills the first available ring slot
- Stat bonuses from equipped items display on RPG attributes (e.g. STR 10 +2)
- Create/edit modal with stat checkboxes per RPG attribute
- Inventory list for unequipped items

Architecture:
- Shared constants in src/systems/equipment/constants.js
- Category-based types (Ring, Accessory) with auto-slot assignment
- v7 migration converts legacy slot-specific types to generic categories
- Full i18n support for all UI strings

Files:
- New: src/systems/equipment/constants.js
- New: src/systems/interaction/equipmentActions.js
- New: src/systems/rendering/equipment.js
- Modified: state.js, persistence.js, template.html, index.js
- Modified: userStats.js, desktop.js, mobile.js, layout.js, modals.js
- Modified: apiClient.js, sillytavern.js, style.css, en.json
2026-07-03 11:11:23 +02:00
Spicy_Marinara 38fb3d8c51 Fix tracker issues and add deprecation notice 2026-05-04 13:08:52 +02:00
Spicy Marinara 70792f8a2a Merge pull request #143 from SpicyMarinara/SpicyMarinara-patch-1
Update README to mark project as deprecated
2026-05-04 13:02:16 +02:00
Spicy Marinara 4cd0b72472 Update README to mark project as deprecated
Removed version details and updated project status to deprecated.
2026-05-04 12:57:37 +02:00
Spicy Marinara 2d63e51962 Merge pull request #142 from CristianAUnisa/fix-parser
Harden parser handling for noisy and non-critical tracker responses
2026-05-04 12:21:14 +02:00
Spicy Marinara f142d04b48 Merge pull request #141 from CristianAUnisa/fix-injection
Fix tracker injection commit ordering and swipe source selection
2026-05-04 12:20:53 +02:00
CristianAUnisa 45c074499c Fix tracker injection context ordering and placeholder timing 2026-04-25 19:39:07 +02:00
CristianAUnisa ad66410f81 Harden parser handling for noisy and non-critical tracker responses 2026-04-25 17:35:23 +02:00
Spicy Marinara ed9c12e969 Merge pull request #135 from Tremendoussly/doom-lite-expression-sync-v2
Add optional alternate tracker displays and expression sync
2026-04-14 18:26:56 +02:00
Tremendoussly 60c430919b Handle swipe-deleted expression refresh and add zh-cn display strings 2026-04-14 17:43:57 +02:00
Tremendoussly 2ee081a619 Clean up post-merge delete handler artifacts 2026-04-14 17:08:42 +02:00
Tremendoussly 19ebf1a834 Merge upstream/main into doom-lite-expression-sync-v2 2026-04-14 16:21:07 +02:00
Spicy Marinara bda43208a2 Merge pull request #140 from jakstein/ofilter-fix
replace filter tags with ofilter to avoid HTML/SVG collision
2026-04-08 20:41:00 +02:00
jakstein 781b28e02b replace filter tags with ofilter to avoid HTML/SVG collision 2026-04-08 12:30:43 +02:00
Spicy Marinara e018cddb15 Merge pull request #136 from dd178/main
Add Simplified Chinese Support and Improve Parser Robustness
2026-04-07 13:34:45 +02:00
Spicy Marinara 05655fecf7 Merge branch 'main' into main 2026-04-02 20:41:21 +02:00
Spicy Marinara 4e36667890 Merge pull request #130 from Alamion/main
A few small FRs
2026-04-02 20:39:09 +02:00
Spicy Marinara c82eb03288 Merge pull request #129 from DAurielS/fix/swipe-tracker-state
Fix: Save tracker state per-swipe, per-message
2026-04-02 20:38:55 +02:00
dd178 96d589adc0 ```
feat(encounter): 添加战斗遭遇界面国际化支持和优化错误处理

- 添加新的中文翻译项包括战斗结果状态、错误消息、界面标签等
- 将硬编码的文本替换为国际化翻译调用
- 添加战斗遭遇初始化和处理过程中的错误处理消息
- 增加确认对话框的本地化文本

fix(regex): 更新正则表达式以支持Unicode字符

- 将多个文件中的ASCII限定正则表达式 /[^a-z0-9]+/g 替换为Unicode感知的
  /[^\p{L}\p{N}]+/gu 以正确处理非ASCII字符
- 修复jsonMigration.js中的字符过滤逻辑

feat(weather): 为中文添加天气模式识别规则

- 在WEATHER_PATTERNS_BY_LANGUAGE中为zh-cn语言添加完整的天气关键词模式
- 支持中文天气条件的自动识别和效果应用

style(fab): 添加nowrap样式防止文本换行

- 在FAB组件中添加white-space: nowrap样式属性
```
2026-03-23 03:27:12 +08:00
dd178 55aa2a1e6a ```
feat(i18n): 添加简体中文语言选项并扩展国际化支持

添加了简体中文(zh-cn)语言选项到设置页面的语言选择下拉菜单中。

同时新增了大量国际化字符串。

fix(parser): 提高解析器的鲁棒性

现在会遍历所有json对象检测统一格式,即使AI响应中包含多个JSON对象也能正确识别统一格式。
```
2026-03-22 14:07:11 +08:00
Tremendoussly 215a122797 Fix clear cache to remove per-swipe tracker data 2026-03-15 23:11:50 +01:00
Tremendoussly 1a11699522 Rename expression sync to thought-based expression portraits and clean up compatibility solutions 2026-03-15 22:18:25 +01:00
Tremendoussly c79c941871 Replace speaker-based expression sync with thoughts-driven sync 2026-03-15 21:38:45 +01:00
Tremendoussly 9ef5b16663 Break expression sync cycle and guard portrait lookup 2026-03-15 17:38:39 +01:00
Tremendoussly 1d297aeecf Gate below-chat expression sync and localize inline thoughts 2026-03-15 16:51:20 +01:00
Tremendoussly 4d2afafbaf Harden synced expression portrait URLs 2026-03-15 15:54:46 +01:00
TremendousslyandCopilot Autofix powered by AI 08097e8b41 Translate new display settings for fr, ru, and zh-tw
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-15 15:02:42 +01:00
Tremendoussly e81afa4d74 Align inline thoughts (alternate display) with SillyTavern message text spacing 2026-03-09 14:26:59 +01:00
Tremendoussly 0499cc6961 Keep Present Characters visible during pending swipes 2026-03-09 13:49:32 +01:00
Tremendoussly 51b5360a7c Changed the scrollbar for multiple characters present 2026-03-08 23:25:22 +01:00
Tremendoussly c73c0c2bb6 First attempt at adding expression sync 2026-03-08 23:25:22 +01:00
Tremendoussly 2f98686e60 Add optional below-chat Present Characters panel (#3) 2026-03-08 22:58:42 +01:00
Tremendoussly ae9e44eafb Alternate Thoughts Display (#2)
This PR adds an optional alternate display mode for RPG Companion thoughts.

When enabled, thoughts are shown as compact expandable cards directly below the relevant latest character message. When disabled, RPG Companion keeps its original corner/overlay thought bubbles, so the existing behavior is preserved unless the user explicitly switches modes.

The new display mode is built on top of RPG Companion’s existing thoughts system rather than replacing it. Thought UI now updates more reliably across new generations, swipe changes, message deletion, chat reload/re-entry, and live mode toggling, so thoughts stay attached to the correct visible message instead of lingering on stale UI. It also improves restoration of RPG Companion state after reopening a chat, making thoughts and related tracker data more consistent with the current chat view.
2026-03-08 19:54:38 +01:00
Alamion 933d78e192 feat: localization validator for missing internationalization
fix: trackerEditor.js doesn't have syntax issues anymore.
2026-03-02 20:54:15 +03:00
Daryl 03345b81f4 Update Refresh RPG Info buttons in index.js to call commitTrackerDataFromPriorMessage() before updateRPGData() 2026-02-26 17:59:45 -04:00
Daryl c307f1a1bc Revert Copilot mistake in inheriting prior swipe data; testing in practice reveals inheritance does not work after applying its suggestion. 2026-02-26 17:32:16 -04:00
Daryl c442314c10 Remove redundant data commit call in sillytavern.js to prevent n-2 tracker data commits in fresh message generations 2026-02-26 17:09:33 -04:00
Spicy MarinaraandCopilot ce48ac0c34 Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-26 01:48:15 +01:00
Spicy MarinaraandCopilot 8ea9044492 Update src/core/persistence.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-26 01:47:42 +01:00
Spicy MarinaraandCopilot 9213d264a0 Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-26 00:40:22 +01:00
Daryl StreeteandCopilot 32280d60ef Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 19:32:54 -04:00
Spicy MarinaraandCopilot 75c8f9b63a Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:51 +01:00
Spicy MarinaraandCopilot b1098a2721 Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:44 +01:00
Spicy MarinaraandCopilot 131b28fc1f Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:37 +01:00
Spicy MarinaraandCopilot 7305af8f88 Update src/systems/ui/trackerEditor.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:26 +01:00
Spicy MarinaraandCopilot 66a22c74d0 Update src/utils/transformations.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:58:02 +01:00
Spicy MarinaraandCopilot 5b2cb331c8 Update src/core/persistence.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:30 +01:00
Spicy MarinaraandCopilot bb202aca9c Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:24 +01:00
Spicy MarinaraandCopilot 733647084a Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:16 +01:00
Spicy MarinaraandCopilot ab848828e7 Update src/systems/integration/sillytavern.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 23:57:09 +01:00
Alamion 4d0de8419c fixes:
- now stats, attributes, characters stats have a changeable id
- now all additional promts are stacked in 2 lines
2026-02-23 21:51:02 +03:00
Daryl 76beb5dff4 Add inheritSwipeDataFromPriorMessage function to populate swipe slots with tracker data from prior messages in cases where tracker auto-update is disabled. This maintains the paradigm of every swipe having saved tracker data, which can then be regenerated by the user manually if they so choose. 2026-02-22 03:53:59 -04:00
Daryl 178ced00be Update widget displayed data when swiping 2026-02-22 00:35:26 -04:00
Daryl 979525e372 Add syncLastGeneratedDataFromSwipeStore function to manage swipe data retrieval and update lastGeneratedData on message changes 2026-02-21 23:47:47 -04:00
Daryl d96a199890 Implement separate generation ID to ensure that messages deleted during separate tracker generation do not attempt to apply the received data to a now non-existent message 2026-02-21 22:21:18 -04:00
Daryl 4b816dd1fd Add event handler for message deletion to sync tracker state and update UI to reflect the new most-recent message in chat 2026-02-21 21:45:47 -04:00
Daryl 8f2dbd2f88 Implement swipe data persistence between reloads and ensure all tracker data commits are based on prior assistant message when generating/swiping 2026-02-21 21:40:52 -04:00
Daryl f3e7518622 Enhance swipe data handling to correctly display swipe-specific tracker stats: add getSwipeData function and refactor commitTrackerData to utilize it 2026-02-21 20:40:33 -04:00
Spicy Marinara 502646bb92 Merge pull request #128 from Korddie/main
Add French translate
2026-02-19 18:52:14 +01:00
Spicy MarinaraandCopilot 98fd702175 Update src/i18n/fr.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 18:52:04 +01:00
rei 25aedce786 Add French translate
Add French translation and localize hardcoded strings :
Changes
1. Translation Files
Created
src/i18n/fr.json
: Contains all French translations for the extension.
Updated
src/i18n/en.json
: Added new keys for terms that were previously hardcoded (e.g., "Force", "Volonté", "Météo", "Locked", "Unlocked").
2. UI Updates
settings.html
: Added "Français" to the language selection dropdown.
3. Code Refactoring
src/systems/rendering/thoughts.js
: Replaced hardcoded strings ("Add Character", "Locked", "Click to edit") with i18n calls.
src/systems/rendering/userStats.js
: Replaced hardcoded tooltips and titles with i18n calls.
src/systems/rendering/infoBox.js
: Localized weather, location, and date widget texts.
src/systems/ui/trackerEditor.js
: Updated the "Reset to Defaults" logic to use localized names for stats (e.g., "Santé", "Force") based on the active language.
2026-02-18 03:56:18 +01:00
Spicy_Marinara 105e20e97a v3.7.2: Fix status field key generation for parenthetical names & scroll preservation
- Fix: Status fields with parenthetical descriptions (e.g., 'Conditions (up to 5 traits)') now use the base name for the JSON key ('conditions' instead of 'conditions_up_to_5_traits')
- Fix: Status field value templates no longer repeat the field name with numbered suffixes
- Fix: Editing fields in Present Characters no longer scrolls the panel to the top
- Updated jsonPromptHelpers.js, parser.js, and userStats.js to use new toFieldKey() helper
- Added scroll position preservation to renderThoughts() when re-rendering after field edits
2026-02-13 18:34:44 +01:00
Spicy_Marinara 5498c64f5d Opussy bug fix 2026-02-06 16:53:24 +01:00
Spicy_Marinara 5fa369e3d7 Update userStats.js 2026-02-04 10:28:49 +01:00
Spicy_Marinara 52be8dca1f Update README.md 2026-02-03 17:32:32 +01:00
Spicy_Marinara 32c4f67822 v3.7.1: Weather keywords, character stat editing fix, scroll bug fix, avatar layout
- Improved weather generation: Added hard templates for weather keywords to ensure LLM generates valid weather patterns that match dynamic effects
- Fixed character stat editing bug: Now properly handles array format stats from LLM (values no longer revert on blur)
- Fixed scroll/viewport bug: Mobile-only scrollIntoView prevents page jumping on desktop when editing fields
- Changed Present Characters avatar display: Avatar now aligned with name in header row, fields take full width below
- Updated descriptions and labels
2026-02-01 14:42:00 +01:00
Spicy_Marinara b61a426efe Update injector.js 2026-02-01 13:57:37 +01:00
Spicy_Marinara 2a77c091dd Add Jakstein to contributors list
Updated README.md and settings.html to include Jakstein as a contributor in the project acknowledgments.
2026-01-27 19:46:50 +01:00
Spicy_Marinara c0431a6117 Update contributors list 2026-01-27 14:38:48 +01:00
Spicy_Marinara 43610bf8b6 Merge branch 'main' of https://github.com/SpicyMarinara/rpg-companion-sillytavern 2026-01-27 14:36:21 +01:00
Spicy_Marinara 2a5b57087b Merge pr-109 into main: v3.7.0 2026-01-27 14:34:44 +01:00
Spicy_Marinara 653d23ef9a Merge main into pr-109 2026-01-27 14:34:39 +01:00
Spicy_Marinara ea81dd0634 v3.7.0: Merge PR #109 + opacity sliders + custom attributes fix + context instructions prompt + newline fixes 2026-01-27 14:33:36 +01:00
Spicy Marinara 7a3487c741 Merge pull request #109 from jakstein/omniscience-filter
Implementation of omniscience filter, ability to only reveal what player character can see without confusing the LLM.
2026-01-27 13:01:37 +01:00
Spicy_Marinara 6fc35e50a1 Refactor inventory lock logic to use item names
Updated inventory lock management and rendering to match items by name instead of index, improving reliability and consistency. Also adjusted quest rendering and parsing to handle locked quest objects with a value property.
2026-01-23 09:17:40 +01:00
Spicy_Marinara e82918004e v3.6.3: Fix relationship field to use correct nested format (relationship.status) 2026-01-20 21:51:41 +01:00
Spicy_Marinara f78c8a1b78 v3.6.2: Fix relationship field in context for manually added characters, add empty field placeholders and mobile support 2026-01-18 19:15:30 +01:00
Spicy_Marinara 2a48c30808 Update sillytavern.js 2026-01-17 21:34:53 +01:00
Spicy Marinara c5a9c8631f Merge pull request #115 from Olaroll/weather-pattern-fix
Fix weather pattern matching regression
2026-01-17 21:15:06 +01:00
Spicy Marinara 2623df4050 Merge pull request #117 from SpicyMarinara/revert-116-revert-111-main
Revert "Revert "internalization weatherEffects.js""
2026-01-17 21:14:55 +01:00
Spicy Marinara 03f21ef1ef Revert "Revert "internalization weatherEffects.js"" 2026-01-17 21:14:44 +01:00
Spicy Marinara 2e747bc8aa Merge pull request #116 from SpicyMarinara/revert-111-main
Revert "internalization weatherEffects.js"
2026-01-17 21:13:50 +01:00
Spicy Marinara d0dd8950a6 Revert "internalization weatherEffects.js" 2026-01-17 21:13:28 +01:00
Olari Tšernobrovkin 5ddc380dac Make constant's variable name consistent with the codebase 2026-01-17 20:03:34 +02:00
Olari Tšernobrovkin f4324a5d19 Fix weather pattern matching regression 2026-01-15 20:30:48 +02:00
Spicy Marinara 4612ed2108 Merge pull request #111 from IDeathByte/main
internalization weatherEffects.js
2026-01-15 11:04:53 +01:00
IDeathByte 0e988b201c Update weatherEffects.js
syntax fix
2026-01-15 11:38:26 +05:00
IDeathByte 7b4ebb8d76 internalization weatherEffects.js
update for russian support
2026-01-15 11:23:52 +05:00
jakstein 08474bd910 make the tags close with a slash for proper self termination 2026-01-14 22:40:58 +01:00
jakstein 0bb2085305 remove regex workaround and fix tag format 2026-01-14 22:08:51 +01:00
jakstein c6f13d18ff attempted slight improvement of the default prompt 2026-01-14 19:27:07 +01:00
jakstein 334f5fa5a3 roll back the default prompt, the new one was too cringy and aggresive 2026-01-14 19:16:40 +01:00
jakstein 5f9d67ebe8 attempt to fix trimming logic and improve prompt 2026-01-14 19:06:43 +01:00
jakstein 93c37c25d7 initial omniscience filter 2026-01-14 18:44:54 +01:00
Spicy Marinara 0499f2c43e Merge pull request #107 from tomt610/feature/improved-clear-weather-effects
Add sunrise/sunset effects and improve sun positioning
2026-01-14 00:51:16 +01:00
Spicy_Marinara 35bd55615b fixes 2026-01-13 23:24:40 +01:00
tomt610 f38f6850c3 Add sunrise/sunset effects and improve sun positioning
- Add sunrise (dawn 5-7 AM) with warm gradient, horizon glow, fading stars
- Add sunset (dusk 18-20) with orange gradient, horizon glow, emerging stars
- Widen sun arc from 5-85% to 3-92% for more dramatic sunset positioning
- Lower horizon position for setting/rising sun (35% to 40%)
- Fix mobile viewport with dvh/vw units for all overlay elements
- Reduce overlay opacity for subtler atmospheric effect
2026-01-13 20:26:55 +00:00
Spicy Marinara 989f511d01 Merge pull request #106 from tomt610/fix/stats-show-max-value-in-number-mode
Fix: Include max value in stats context when number mode is selected
2026-01-13 20:55:58 +01:00
tomt610 b827b77184 Fix: Include max value in stats context when number mode is selected 2026-01-13 19:47:14 +00:00
Spicy_Marinara 4f3d59bfb7 v3.6.1: Dynamic combat actions and bug fixes
- Added dynamic action updates: AI can now modify available attacks/items based on combat state
- Items decrease when used, abilities change based on status effects
- Fixed event delegation for encounter buttons to work reliably on mobile
- Fixed multiple JSON parsing validation errors
- Added proper dialogue handling in combat summaries
- UI now re-renders action buttons when actions change
- Improved prompt instructions for item quantities and dynamic actions
2026-01-13 19:22:01 +01:00
Spicy Marinara c18fd39283 Merge pull request #105 from IDeathByte/main
Add russian to settings.html
2026-01-13 15:45:34 +01:00
IDeathByte f5825a7a24 Add russian to settings.html 2026-01-13 19:00:26 +05:00
102 changed files with 32218 additions and 2966 deletions

No files matched your search

+1
View File
@@ -24,3 +24,4 @@ node_modules/
# Claude
CLAUDE.md
yarn.lock
+3
View File
@@ -0,0 +1,3 @@
{
"MD013": false
}
+400
View File
@@ -0,0 +1,400 @@
# RPG Companion for SillyTavern — Agent Instructions
## What this is
A **browser extension for SillyTavern** (AI roleplay frontend). It is NOT a standalone Node.js application. All JavaScript runs in the browser context inside SillyTavern. There is **no bundler** — JS files are loaded directly by SillyTavern's extension loader. CSS modules are concatenated via a build script.
---
## Quick start
- **Entry point**: `index.js` (loaded by SillyTavern via `manifest.json`)
- **CSS**: Modular — 28 files in `src/styles/`, concatenated via `npm run build:css` (or `npm run build:css:min` for production)
- **HTML templates**: `template.html` (main panel), `settings.html` (Extensions tab settings drawer)
- **Runtime**: Browser only. All code executes inside SillyTavern's page context.
- **jQuery**: Available globally as `$` / `jQuery`. Used extensively for DOM manipulation.
- **ES modules**: All `src/` files use `import`/`export` (browser ES module syntax).
- **Testing**: Jest + Babel ESM support — 78 tests (`npm test`)
---
## Manifest and loading
`manifest.json` tells SillyTavern how to load the extension:
```json
{
"display_name": "RPG Companion",
"loading_order": 100,
"js": "index.js",
"css": "style.css"
}
```
SillyTavern loads `index.js` and `style.css` relative to the extension directory. The extension name used internally is `third-party/rpg-companion-sillytavern`.
### Extension path detection
`src/core/config.js` auto-detects whether the extension is installed globally (`public/extensions/`) or per-user (`data/default-user/extensions/`) by inspecting `import.meta.url`. This determines template loading paths.
---
## SillyTavern integration
The extension imports from SillyTavern's global scripts using **relative paths** that assume the extension lives at:
- `scripts/extensions/third-party/rpg-companion-sillytavern/` (global install), or
- `data/default-user/extensions/third-party/rpg-companion-sillytavern/` (user install)
Key imports in `index.js`:
```js
import { getContext, renderExtensionTemplateAsync, extension_settings as st_extension_settings }
from '../../../extensions.js';
import { eventSource, event_types, substituteParams, chat, saveSettingsDebounced, ... }
from '../../../../script.js';
import { selected_group, getGroupMembers } from '../../../group-chats.js';
import { power_user } from '../../../power-user.js';
```
The deeper relative imports in `src/` files (e.g., `../../../../../../script.js`) follow the same pattern adjusted for directory depth.
### SillyTavern globals available at runtime
- `self.SillyTavern` — global namespace object
- `$` / `jQuery` — jQuery
- `toastr` — toast notification library
- `eventSource` — SillyTavern's event emitter
- `chat` — current chat message array
- `chat_metadata` — per-chat metadata store
- `characters` — loaded character array
- `getContext()` — returns current chat context
---
## Architecture
### Source tree (`src/`)
```
src/
├── core/ # Core infrastructure
│ ├── config.js # Extension name, folder path, default settings (single source of truth)
│ ├── state.js # Centralized state variables + getters/setters (imports defaults from config.js)
│ ├── persistence.js # Save/load settings, chat data, migrations
│ ├── events.js # SillyTavern event system wrapper
│ ├── i18n.js # Translation system (fetches JSON, applies data-i18n-key)
│ └── settingsPanel.js # Extracted settings panel UI (from index.js refactor)
├── i18n/ # Translation JSON files
│ ├── en.json # English (reference, 513 lines)
│ ├── fr.json # French
│ ├── ru.json # Russian
│ ├── zh-cn.json # Simplified Chinese
│ ├── zh-tw.json # Traditional Chinese
│ └── validator.js # Node.js script to validate translation consistency
├── styles/ # CSS modules (28 files, concatenated via build script)
│ ├── _01_variables.css # CSS custom properties
│ ├── _02_panel.css # Main panel layout
│ ├── _03_components.css # Buttons, inputs, common components
│ ├── _04_user-stats.css # User stats panel
│ ├── _05_info-box.css # Info box dashboard
│ ├── _06_thoughts.css # Character thoughts
│ ├── _07_settings.css # Settings modal
│ ├── _08_themes.css # Theme definitions (extended custom properties)
│ ├── _09_dice-modal.css # Dice roller modal
│ ├── _10_tracker-editor.css # Tracker configuration editor
│ ├── _11_settings-modal.css # Settings modal styles
│ ├── _12_thought-bubbles.css # Floating thought bubbles
│ ├── _13_mobile.css # Mobile responsive styles
│ ├── _14_inventory.css # Inventory views
│ ├── _15_inventory-views.css # Inventory detail views
│ ├── _16_desktop-tabs.css # Desktop tab navigation
│ ├── _17_quests.css # Quest tracking
│ ├── _18_equipment.css # Equipment grid
│ ├── _19_encounters.css # Combat encounters
│ ├── _20_plot-buttons.css # Plot progression buttons
│ ├── _21_music-player.css # Spotify music player
│ ├── _22_weather.css # Weather effects
│ ├── _23_holiday-promo.css # Holiday promotions
│ ├── _24_feature-toggles.css # Feature toggle switches
│ ├── _25_import-dialog.css # Import/export dialog
│ ├── _26_fab-widgets.css # Mobile FAB widgets
│ ├── _27_strip-widgets.css # Collapsed panel strip widgets
│ └── _28_equipment-modal.css # Equipment edit modal
├── systems/ # Feature systems (organized by concern)
│ ├── generation/ # AI prompt generation, parsing, API calls
│ │ ├── promptBuilder.js # Builds tracker prompts for AI (refactored with focused helpers)
│ │ ├── parser.js # Parses AI responses (~50 pre-compiled regex patterns)
│ │ ├── apiClient.js # Makes API calls for separate/external generation
│ │ ├── injector.js # Injects tracker prompts into generation context
│ │ ├── lockManager.js # Manages locked tracker fields
│ │ ├── suppression.js # Controls when to skip tracker injection
│ │ ├── encounterPrompts.js # Combat encounter prompt building
│ │ ├── inventoryParser.js # Parses inventory data from AI responses
│ │ └── jsonPromptHelpers.js # JSON formatting helpers for prompts
│ ├── rendering/ # UI rendering functions
│ │ ├── userStats.js # Renders user stats panel
│ │ ├── infoBox.js # Renders info box (date, weather, location, events)
│ │ ├── thoughts.js # Renders character thoughts + chat bubbles
│ │ ├── inventory.js # Renders inventory section
│ │ ├── equipment.js # Renders equipment section
│ │ ├── quests.js # Renders quests section
│ │ ├── musicPlayer.js # Renders Spotify music player
│ │ └── renderUtils.js # Rendering utilities (updateIfChanged cache, rAF batching)
│ ├── ui/ # UI components and helpers
│ │ ├── theme.js # Theme system (default, sci-fi, fantasy, cyberpunk, custom)
│ │ ├── modals.js # Settings modal, dice modal, deprecation modal
│ │ ├── layout.js # Panel positioning, collapse/expand, section visibility
│ │ ├── mobile.js # Mobile FAB button, mobile tabs, keyboard handling
│ │ ├── desktop.js # Desktop strip widgets
│ │ ├── trackerEditor.js # Tracker configuration editor
│ │ ├── promptsEditor.js # Custom prompt editor
│ │ ├── checkpointUI.js # Chapter checkpoint buttons
│ │ ├── encounterUI.js # Combat encounter modal
│ │ ├── snowflakes.js # Snowflake animation effect
│ │ ├── weatherEffects.js # Dynamic weather visual effects
│ │ ├── alternatePresentCharacters.js # Below-chat character panel
│ │ └── settingsListeners.js # Settings event listeners (extracted from index.js)
│ ├── equipment/ # Equipment system
│ │ └── constants.js # Equipment slot definitions, type limits, stat config
│ ├── integration/ # SillyTavern event integration
│ │ ├── sillytavern.js # Event handlers: message sent/received, swipe, etc.
│ │ └── thoughtBasedExpressions.js # Integrates with ST Character Expressions
│ ├── interaction/ # User interaction handlers
│ │ ├── inventoryActions.js # Inventory click/edit handlers
│ │ ├── equipmentActions.js # Equipment click/edit handlers
│ │ └── inventoryEdit.js # Inventory inline editing
│ └── features/ # Feature modules
│ ├── plotProgression.js # Plot progression buttons
│ ├── classicStats.js # D&D-style attribute buttons
│ ├── dice.js # Dice roller
│ ├── htmlCleaning.js # Regex-based HTML tag cleaning
│ ├── jsonCleaning.js # Regex-based JSON cleanup in messages
│ ├── musicPlayer.js # Spotify URL parsing and embedding
│ ├── chapterCheckpoint.js # Save/restore tracker state checkpoints
│ ├── avatarGenerator.js # Auto-generate character avatars
│ └── encounterState.js # Combat encounter state management
├── types/ # JSDoc type definitions
│ └── inventory.js # InventoryV2 type definition
└── utils/ # Utility functions
├── avatars.js # Avatar URL handling
├── imageUrls.js # Image URL utilities
├── itemParser.js # Item string parsing
├── jsonMigration.js # v2 to v3 JSON format migration
├── jsonRepair.js # JSON repair for malformed AI responses (14 pre-compiled regex)
├── migration.js # Settings migration helpers
├── presentCharacters.js # Present character data helpers
├── responseExtractor.js # Extract tracker data from AI responses
├── security.js # Input validation and sanitization (validator factory)
├── sillyTavernExpressions.js # ST expression mapping
├── thoughtBasedExpressionPortraits.js # Expression-to-portrait mapping
└── transformations.js # Data transformation utilities
```
### Data flow
1. **User sends message** → `MESSAGE_SENT` event fires
2. **Extension hooks** `onMessageSent` in `sillytavern.js`
3. **Together mode**: Tracker prompt is injected into the generation context via `injector.js`
4. **Separate mode**: After main response, `apiClient.js` makes a separate API call
5. **External API mode**: Uses user-configured external API endpoint
6. **AI responds** → `MESSAGE_RECEIVED` event fires
7. **Extension hooks** `onMessageReceived` → `parser.js` extracts tracker data
8. **Rendering**: `renderUserStats()`, `renderInfoBox()`, `renderThoughts()`, etc. update the UI (batched via rAF)
9. **Persistence**: Tracker data is saved to `chat_metadata` per chat, and per-swipe
### State management
All extension state lives in `src/core/state.js` as module-level `let` variables with getter/setter functions. Key state:
- `extensionSettings` — persisted settings object (merged with defaults on load)
- `lastGeneratedData` — tracker data from the last AI generation
- `committedTrackerData` — tracker data committed after a message
- `lastActionWasSwipe` — flag to handle swipe-specific logic
- `isGenerating` — flag to prevent re-entrant generation
- `pendingDiceRoll` — dice roll result to inject into next generation
- DOM element caches: `$panelContainer`, `$userStatsContainer`, etc.
**Settings consolidation**: `config.js` is the single source of truth for default settings. `state.js` imports defaults from `config.js` (~300 lines eliminated from duplication).
### Persistence
`src/core/persistence.js` handles:
- Extension settings: saved via SillyTavern's `saveSettingsDebounced()`
- Chat-specific data: saved in `chat_metadata.rpg_companion` per chat
- Per-swipe data: stored on each chat message object
- Settings versioning: automatic migration via `CURRENT_SETTINGS_VERSION` (currently 7)
- Deferred saves: batches chat data saves to avoid excessive writes
### Memory management
- `clearDomCache()`: Invalidates jQuery object cache on panel rebuild
- Event tracking: All `on()`/`once()` handlers tracked for cleanup via `unregisterAllEvents()`
- `clearDebugLogs()`: Called on `CHAT_CHANGED` to prevent unbounded debug log growth
### Performance optimizations
- **Rendering**: `requestAnimationFrame` batching in `rerenderRpgState()`, `updateIfChanged()` cache comparison in `renderUtils.js`, CSS `content-visibility: auto` on inventory/equipment containers
- **Parser**: ~64 regex patterns pre-compiled as module-level constants, format detection caching with `lastDetectedFormat` (3-hit threshold)
- **CSS**: Minification via `--minify` flag, extended CSS custom properties, theme refactoring (379 → ~220 lines)
---
## Development commands
```bash
# Build CSS from modular sources
npm run build:css # Concatenate CSS modules into style.css
npm run build:css:min # Minified production build (~50% smaller)
# Testing
npm test # Run Jest test suite (78 tests)
# Translation validation
npm run validate_locale # Watch mode: re-validate on file changes
npm run validate_locale_once # Single validation run
```
---
## i18n system
### How it works
1. Translation files are flat JSON in `src/i18n/{locale}.json`
2. Keys use dot notation: `"template.settingsModal.display.showUserStats"`
3. HTML elements use `data-i18n-key`, `data-i18n-title`, or `data-i18n-aria-label` attributes
4. `src/core/i18n.js` fetches translations via `fetch()` from the extension path
5. `applyTranslations()` walks the DOM and updates `textContent`, `title`, and `aria-label`
### Adding translations
1. Add keys to `src/i18n/en.json` (reference locale)
2. Add corresponding keys to all other locale files
3. Add `data-i18n-key="your.key.here"` to HTML elements in `template.html` or `settings.html`
4. For dynamically generated text, use `i18n.getTranslation('your.key.here')` in JS
### Validator
`src/i18n/validator.js` is a **Node.js script** (uses `require()`, not ES modules). It:
- Compares all locales against the reference locale for missing/extra keys
- Checks for type mismatches
- Checks for empty strings and null values
- Scans `.html`/`.js`/`.jsx` files for unlocalized text (text in tags without `data-i18n-key`)
---
## Key conventions
### jQuery usage
DOM manipulation uses jQuery throughout. Element caches are stored as jQuery objects with `$` prefix:
```js
$panelContainer = $('#rpg-companion-panel')
$userStatsContainer = $('#rpg-user-stats')
```
### Event system
All SillyTavern event registration goes through `src/core/events.js`:
```js
registerAllEvents({
[event_types.MESSAGE_SENT]: onMessageSent,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar],
});
```
### CSS naming
All CSS classes use `rpg-` prefix to avoid conflicts with SillyTavern and other extensions:
- `.rpg-panel`, `.rpg-section`, `.rpg-stat-bar`, `.rpg-btn-primary`, etc.
- CSS custom properties: `--rpg-bg`, `--rpg-accent`, `--rpg-text`, `--rpg-highlight`, `--rpg-border`, `--rpg-shadow`
### CSS build pipeline
CSS modules in `src/styles/` are concatenated in numeric order by `scripts/build-css.js`. Modules follow the naming convention `_NN_name.css` where NN is the load order. After modifying any CSS module, run `npm run build:css` to regenerate `style.css`.
### Validator factory
Input sanitization uses `createStringValidator()` from `src/utils/security.js` — a factory pattern that unifies `sanitizeLocationName`/`sanitizeItemName` with configurable rules (max length, allowed characters, etc.).
### Panel structure
The main panel (`template.html`) has these sections in order:
1. Collapse toggle button
2. Strip widget container (shown when panel is collapsed)
3. Game container with header
4. Dice roll display
5. Content box with sections: User Stats → Info Box → Thoughts → Inventory → Equipment → Quests → Music Player
6. Feature toggles row (HTML, Dialogue Coloring, Deception, Omniscience, CYOA, Spotify, Weather, Narrator, Auto Avatars)
7. Manual update button
8. Settings and Edit Trackers buttons
### Generation modes
Three modes control how tracker data is generated:
- **`together`**: Tracker prompt injected into main generation. Single API call, faster, but tracker JSON mixed in response.
- **`separate`**: Two API calls — one for roleplay, one for tracker data. Cleaner roleplay, slower.
- **`external`**: Uses user-configured external API (OpenAI-compatible endpoint) for tracker generation.
### Guided generation compatibility
The `skipInjectionsForGuided` setting controls tracker injection behavior during guided generations:
- `'none'` — always inject (default)
- `'impersonation'` — skip only for impersonation-style guided generations
- `'guided'` — skip for any guided/instruct or quiet_prompt generation
---
## Important gotchas
1. **No bundler**: All imports use relative paths that depend on the extension's directory structure within SillyTavern. Changing file locations requires updating import paths.
2. **Relative import depth matters**: Files in `src/` use deeper relative paths (`../../../../../../script.js`) than `index.js` (`../../../../script.js`). When moving files between directories, adjust accordingly.
3. **Settings merge**: On load, saved settings are deep-merged with defaults. `config.js` is the single source of truth for default settings — `state.js` imports from it. New settings fields should be added to `config.js`.
4. **Chat metadata**: Per-chat tracker data lives in `chat_metadata.rpg_companion`. Per-swipe data lives on individual chat message objects in the `swipe_store`.
5. **i18n fetch path**: Translations are fetched at `/scripts/extensions/third-party/rpg-companion-sillytavern/src/i18n/{lang}.json`. The hardcoded path in `i18n.js` must match the extension's actual URL path.
6. **Extension enable/disable**: The extension can be toggled from SillyTavern's Extensions tab. When disabled, all UI elements are removed from the DOM. When re-enabled, `initUI()` rebuilds everything.
7. **Mobile vs desktop**: Viewport breakpoint is 1000px. Below: FAB button + mobile tabs. Above: fixed panel + desktop tabs.
8. **External API key**: Stored in `localStorage` as `rpg_companion_external_api_key`, NOT in extension settings (for security).
9. **Dev dependencies**: `package.json` has devDependencies (Jest, Babel, chokidar, fs-extra, glob, execa) for testing and i18n validation. No runtime dependencies — the extension runs entirely in the browser.
10. **CSS rebuild required**: After modifying any file in `src/styles/`, run `npm run build:css` to regenerate `style.css`. SillyTavern loads `style.css`, not the individual modules.
11. **Deprecation status**: The README states this extension is deprecated in favor of Marinara Engine. The code shows a deprecation modal that displays on first load.
12. **ESM testing**: Jest uses Babel for ESM transformation (`babel.config.json` + `jest.config.cjs`). All test files use ES module syntax.
---
## File reference
| File | Purpose |
|------|---------|
| `index.js` | Main entry point. Imports, UI init, event registration, startup logic (~456 lines after refactor). |
| `manifest.json` | SillyTavern extension manifest (name, loading order, JS/CSS files). |
| `style.css` | Built CSS output (concatenated from `src/styles/` modules). Do not edit directly. |
| `template.html` | Main panel HTML template. Rendered by SillyTavern's `renderExtensionTemplateAsync`. |
| `settings.html` | Extensions tab settings drawer. Minimal — most settings are in the panel modal. |
| `scripts/build-css.js` | CSS build script. Concatenates modules, supports `--minify` flag. |
| `src/core/state.js` | Central state module. Imports defaults from config.js. All extension-wide variables. |
| `src/core/persistence.js` | Settings/chat data save/load, version migrations. |
| `src/core/config.js` | Extension name, path detection, default settings (single source of truth). |
| `src/core/events.js` | SillyTavern event system wrapper with bulk register/unregister. |
| `src/core/i18n.js` | Translation loader and DOM applier. |
| `src/core/settingsPanel.js` | Extracted settings panel UI (from index.js refactor, PR #6). |
| `src/systems/integration/sillytavern.js` | All SillyTavern event handlers (message lifecycle, swipes, chat changes). |
| `src/systems/generation/promptBuilder.js` | Builds AI prompts for tracker generation (refactored with focused helpers). |
| `src/systems/generation/parser.js` | Parses AI responses to extract tracker JSON (~50 pre-compiled regex patterns). |
| `src/systems/generation/apiClient.js` | API client for separate/external generation modes. |
| `src/systems/generation/injector.js` | Injects tracker prompts into SillyTavern's generation context. |
| `src/systems/rendering/renderUtils.js` | Rendering utilities: `updateIfChanged()` cache, rAF batching. |
| `src/systems/equipment/constants.js` | Equipment slot definitions, type limits, stat configuration. |
| `src/utils/security.js` | Input validation, sanitization, validator factory (`createStringValidator()`). |
| `src/utils/jsonRepair.js` | JSON repair for malformed AI responses (14 pre-compiled regex patterns). |
| `src/i18n/validator.js` | Node.js script for validating translation consistency. |
+58 -10
View File
@@ -7,14 +7,11 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
## 🆕 What's New
### v3.6.1
### DEPRECATED
- Fixed the bugs in the encounter system where you couldn't use the buttons after performing any custom action.
- Improved combat actions and made them dynamic, depending on the current situation.
- Added Russian as a supported language.
Moving on to developing the Marinara Engine frontend, the extension will now be maintained by the community!
**Special thanks to all the other contributors for this project:**
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
<https://github.com/Pasta-Devs/Marinara-Engine>
## 📥 Installation
@@ -24,7 +21,7 @@ Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungch
3. Go to Install extension
4. Copy-paste this link: https://github.com/SpicyMarinara/rpg-companion-sillytavern
4. Copy-paste this link: <https://github.com/SpicyMarinara/rpg-companion-sillytavern>
5. Press Install for all users/Install just for me
@@ -44,6 +41,7 @@ Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungch
- **🎭 Floating Thought Bubbles**: Optional thought bubbles positioned next to character avatars in chat
- **🎲 Classic RPG Stats**: STR, DEX, CON, INT, WIS, CHA attributes with dice roll support
- **📦 Advanced Inventory System**: Multi-location storage (On Person, Stored locations, Assets) with v2 format
- **⚔️ Equipment System**: 19 equipment slots (Helmet, Necklace, Body Armor, Gloves, Pants, Shoes, 10 Rings, 3 Accessories) with slot-type validation, stat bonuses (+99 max), and automatic slot assignment
- **🎯 Character Stats**: Track health, energy, or any custom stats for each present character with color interpolation
- **📜 Immersive HTML**: Enhance the immersion by including creative HTML/CSS/JS elements in your roleplay
- **➡️ Plot Progression**: Progress the plot with randomized events or natural progression with a click of a button
@@ -102,11 +100,13 @@ AI: Trackers + Full roleplay response
↓ Main chat shows clean roleplay text
Pros:
- Single API call
- Faster response
- Simpler setup
Cons:
- Tracker formatting mixed in AI response
- May affect roleplay quality slightly
@@ -130,11 +130,13 @@ AI: Separate call with just the tracker data
↓ Context summary injected into the next generation
Pros:
- Clean roleplay responses
- Better roleplay quality
- Contextual summary enhances immersion
Cons:
- Extra API call
- Slightly slower
@@ -166,16 +168,19 @@ You can edit most fields by clicking on them:
Access comprehensive customization through the Tracker Settings button:
**User Stats Configuration:**
- Add/remove custom stats with unique names
- Configure Status section (mood emoji + custom fields)
- Configure Skills section with custom skill fields
- Toggle RPG attributes display
**Info Box Configuration:**
- Enable/disable individual widgets (Date, Weather, Temperature, Time, Location, Recent Events)
- Choose temperature unit (Celsius/Fahrenheit)
**Present Characters Configuration:**
- Add custom character fields (appearance, action, demeanor, etc.)
- Configure relationship status options
- Enable character-specific stats tracking
@@ -202,11 +207,11 @@ This extension detects when a "guided generation" prompt is submitted (for examp
If you want tracker prompts to apply during a guided generation, run the update via separate generation or temporarily disable guided generation in the other extension.
There is a new setting "Skip Tracker & HTML Injections during Guided Generations" in the RPG Companion settings (Advanced section). It now supports three modes:
- none: never skip (always inject the tracker prompts as usual, default)
- impersonation: only skip when an impersonation-style guided generation is detected
- guided: skip whenever a guided `instruct` or `quiet_prompt` generation is detected
## 🎨 Themes
Choose from 6 beautiful themes:
@@ -222,6 +227,49 @@ Choose from 6 beautiful themes:
If you ever have an awesome idea to do your own SillyTavern extension, don't.
### Code Quality
- **index.js refactored**: 1567 lines → 456 lines (73% reduction) through modular extraction
- **CSS modularized**: 28 logical modules in `src/styles/` with automated build script
- **Settings consolidation**: `config.js` as single source of truth — state.js imports from it (~300 lines eliminated)
- **Validator factory pattern**: `createStringValidator()` unifies `sanitizeLocationName`/`sanitizeItemName`
- **Prompt builder refactored**: `getCharacterCardsInfo` extracted into focused helper functions
- **Error visibility**: `jsonRepair.js` silent failures now log to `console.debug`
### Performance
- **Rendering**: `requestAnimationFrame` batching in `rerenderRpgState()`, `updateIfChanged()` cache in `renderUtils.js`, CSS `content-visibility: auto` on inventory/equipment containers
- **Parser**: Regex pre-compilation (~64 patterns in `parser.js` + `jsonRepair.js` as module-level constants), format detection caching (`lastDetectedFormat` with 3-hit threshold), `clearFormatCache()` for invalidation
- **CSS**: Minification support (`--minify` flag in build script), extended CSS custom properties, theme refactoring (379 → ~220 lines), `style.css` reduced from 305.4 KB → 293.9 KB (3.8%), minified build adds ~50%+ further reduction
### Memory Management
- **`clearDomCache()`**: Invalidates jQuery object cache on panel rebuild to prevent stale references
- **Event tracking**: All `on()`/`once()` handlers tracked for cleanup via `unregisterAllEvents()`
- **`clearDebugLogs()`**: Called on `CHAT_CHANGED` to prevent unbounded debug log growth
### Testing
- **78 tests passing** (up from 54) using Jest with Babel ESM support
- ESM compatibility via `babel.config.json` + `jest.config.cjs` transform configuration
- Security-focused test suite (`security.test.js`, 24 tests)
### Development Commands
```bash
npm run build:css # Concatenate CSS modules
npm run build:css:min # Minified production build
npm test # Run Jest test suite
npm run validate_locale # Watch mode translation validation
npm run validate_locale_once
```
### Tech Stack
- `"type": "module"` — ES modules throughout
- Jest + Babel for testing with full ESM support
- Modular CSS build pipeline with minification support
## 🐛 Troubleshooting
### Extension doesn't appear
@@ -267,7 +315,7 @@ If you enjoy this extension, consider supporting development:
## 🙏 Credits
**Contributors:**
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, Tomt610, and Jakstein.
## 🚀 Planned Features
@@ -289,4 +337,4 @@ SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDea
Made with ❤️ by Marinara
PS I'm looking for a job or a sponsor to fund my custom AI frontend, contact me if interested:
mgrabower97@gmail.com
[mgrabower97@gmail.com](mailto:mgrabower97@gmail.com)
+1
View File
@@ -0,0 +1 @@
export const saveSettings = jest.fn();
+147
View File
@@ -0,0 +1,147 @@
/**
* Tests for CSS build script (scripts/build-css.js)
* Tests minification logic and build output.
*/
const fs = require('fs-extra');
const path = require('path');
const { execSync } = require('child_process');
const rootDir = path.resolve(__dirname, '..');
const stylesDir = path.join(rootDir, 'src', 'styles');
const outputFile = path.join(rootDir, 'style.css');
describe('CSS Build Script', () => {
let originalContent;
beforeAll(async () => {
// Save original style.css for restoration
if (await fs.pathExists(outputFile)) {
originalContent = await fs.readFile(outputFile, 'utf-8');
}
});
afterAll(async () => {
// Restore original style.css
if (originalContent) {
await fs.writeFile(outputFile, originalContent, 'utf-8');
}
});
test('styles directory contains CSS files', async () => {
const files = await fs.readdir(stylesDir);
const cssFiles = files.filter(f => f.endsWith('.css'));
expect(cssFiles.length).toBeGreaterThan(0);
expect(cssFiles).toContain('_variables.css');
expect(cssFiles).toContain('_08_themes.css');
});
test('build:css produces readable output with comments', () => {
const result = execSync('node scripts/build-css.js', { cwd: rootDir, encoding: 'utf-8' });
expect(result).toContain('Building CSS from src/styles/');
const content = fs.readFileSync(outputFile, 'utf-8');
// Readable build should contain section comments
expect(content).toContain('/* ============================================ */');
expect(content).toContain('/* _variables.css */');
});
test('build:css --minify produces minified output', () => {
const result = execSync('node scripts/build-css.js --minify', { cwd: rootDir, encoding: 'utf-8' });
expect(result).toContain('minified');
expect(result).toContain('Minification:');
const content = fs.readFileSync(outputFile, 'utf-8');
// Minified build should NOT contain section comments
expect(content).not.toContain('/* ============================================ */');
// Should not have multiple consecutive spaces (except in strings)
expect(content).not.toMatch(/[^\S\r\n]{2,}/);
// Should not have newlines between rules
const lines = content.split('\n');
expect(lines.length).toBeLessThan(1000); // Original is ~12000 lines
});
test('minified output is smaller than readable output', () => {
// Build readable
execSync('node scripts/build-css.js', { cwd: rootDir, encoding: 'utf-8' });
const readableStats = fs.statSync(outputFile);
const readableSize = readableStats.size;
// Build minified
execSync('node scripts/build-css.js --minify', { cwd: rootDir, encoding: 'utf-8' });
const minifiedStats = fs.statSync(outputFile);
const minifiedSize = minifiedStats.size;
expect(minifiedSize).toBeLessThan(readableSize);
// Expect at least 30% reduction (comments + whitespace)
const reduction = ((readableSize - minifiedSize) / readableSize) * 100;
expect(reduction).toBeGreaterThan(30);
// Restore readable version for other tests
execSync('node scripts/build-css.js', { cwd: rootDir, encoding: 'utf-8' });
});
test('minified output preserves CSS custom properties', () => {
execSync('node scripts/build-css.js --minify', { cwd: rootDir, encoding: 'utf-8' });
const content = fs.readFileSync(outputFile, 'utf-8');
// CSS variables should be preserved
expect(content).toContain('--rpg-bg');
expect(content).toContain('--rpg-accent');
expect(content).toContain('--rpg-text');
expect(content).toContain('--rpg-highlight');
expect(content).toContain('--rpg-border');
expect(content).toContain('--rpg-shadow');
});
test('minified output preserves data URIs', () => {
execSync('node scripts/build-css.js --minify', { cwd: rootDir, encoding: 'utf-8' });
const content = fs.readFileSync(outputFile, 'utf-8');
// Fantasy theme has SVG data URI — should be preserved
expect(content).toContain('data:image/svg+xml');
});
test('minified output preserves data-theme selectors', () => {
execSync('node scripts/build-css.js --minify', { cwd: rootDir, encoding: 'utf-8' });
const content = fs.readFileSync(outputFile, 'utf-8');
expect(content).toContain('[data-theme="sci-fi"]');
expect(content).toContain('[data-theme="fantasy"]');
expect(content).toContain('[data-theme="cyberpunk"]');
});
test('themes.css uses grouped [data-theme] selectors (no duplication)', async () => {
const themesContent = await fs.readFile(path.join(stylesDir, '_08_themes.css'), 'utf-8');
// Should use [data-theme] grouped selectors instead of repeating all 3 themes
expect(themesContent).toContain('.rpg-panel[data-theme] .rpg-tabs-nav');
expect(themesContent).toContain('.rpg-panel[data-theme] .rpg-tab-btn');
// Should NOT have the old pattern of listing all 3 themes for every element
const oldPattern = /\.rpg-panel\[data-theme="sci-fi"\] \.rpg-tabs-nav,\s*\.rpg-panel\[data-theme="fantasy"\] \.rpg-tabs-nav,\s*\.rpg-panel\[data-theme="cyberpunk"\] \.rpg-tabs-nav/;
expect(themesContent).not.toMatch(oldPattern);
});
test('themes.css defines extended custom properties per theme', async () => {
const themesContent = await fs.readFile(path.join(stylesDir, '_08_themes.css'), 'utf-8');
// Extended properties for content box
expect(themesContent).toContain('--rpg-content-bg');
expect(themesContent).toContain('--rpg-content-box-shadow');
expect(themesContent).toContain('--rpg-content-border');
// Extended properties for headers
expect(themesContent).toContain('--rpg-header-text-shadow');
expect(themesContent).toContain('--rpg-header-font-family');
// Extended properties for dividers
expect(themesContent).toContain('--rpg-divider-bg');
expect(themesContent).toContain('--rpg-divider-content');
// Extended properties for thoughts
expect(themesContent).toContain('--rpg-thoughts-bg');
});
});
+136
View File
@@ -0,0 +1,136 @@
import { repairJSON, extractJSONFromText, validateJSONSchema, safeParseJSON } from '../src/utils/jsonRepair.js';
describe('repairJSON', () => {
test('parses valid JSON', () => {
const input = '{"name": "test", "value": 42}';
const result = repairJSON(input);
expect(result).toEqual({ name: 'test', value: 42 });
});
test('handles trailing commas', () => {
const input = '{"name": "test", "value": 42,}';
const result = repairJSON(input);
expect(result).toEqual({ name: 'test', value: 42 });
});
test('handles markdown code fences', () => {
const input = '```json\n{"name": "test"}\n```';
const result = repairJSON(input);
expect(result).toEqual({ name: 'test' });
});
test('removes thinking tags', () => {
const input = '```json\n{"name": "test"}\n```';
const result = repairJSON(input);
expect(result).toEqual({ name: 'test' });
});
test('returns null for invalid input', () => {
expect(repairJSON(null)).toBeNull();
expect(repairJSON(123)).toBeNull();
expect(repairJSON('')).toBeNull();
expect(repairJSON('not json at all')).toBeNull();
});
test('handles JSON arrays', () => {
const input = '[1, 2, 3]';
const result = repairJSON(input);
expect(result).toEqual([1, 2, 3]);
});
test('handles nested objects', () => {
const input = '{"outer": {"inner": {"deep": true}}}';
const result = repairJSON(input);
expect(result).toEqual({ outer: { inner: { deep: true } } });
});
test('removes JavaScript comments', () => {
const input = '{"name": "test"} // comment';
const result = repairJSON(input);
expect(result).toEqual({ name: 'test' });
});
});
describe('extractJSONFromText', () => {
test('extracts from json code fence', () => {
const input = 'Here is some text\n```json\n{"key": "value"}\n```\nMore text';
const result = extractJSONFromText(input);
expect(result).toBe('{"key": "value"}');
});
test('extracts from generic code fence', () => {
const input = '```{"key": "value"}```';
const result = extractJSONFromText(input);
expect(result).toBe('{"key": "value"}');
});
test('extracts standalone JSON object', () => {
const input = 'Some text {"key": "value"} more text';
const result = extractJSONFromText(input);
expect(result).toBe('{"key": "value"}');
});
test('extracts standalone JSON array', () => {
const input = 'Some text [1, 2, 3] more text';
const result = extractJSONFromText(input);
expect(result).toBe('[1, 2, 3]');
});
test('returns null for no JSON', () => {
expect(extractJSONFromText('no json here')).toBeNull();
expect(extractJSONFromText(null)).toBeNull();
expect(extractJSONFromText(123)).toBeNull();
});
});
describe('validateJSONSchema', () => {
test('validates userStats schema', () => {
const valid = { stats: [{ id: 'health', name: 'Health', value: 100 }] };
const invalid = { stats: [{ id: 'health', name: 'Health' }] }; // missing value
expect(validateJSONSchema(valid, 'userStats')).toBe(true);
expect(validateJSONSchema(invalid, 'userStats')).toBe(false);
});
test('validates infoBox schema', () => {
const valid = { date: '2024-01-01' };
const invalid = { unrelated: 'data' };
// Note: validateJSONSchema returns the truthy field value, not strictly true
expect(validateJSONSchema(valid, 'infoBox')).toBeTruthy();
expect(validateJSONSchema(invalid, 'infoBox')).toBeFalsy();
});
test('validates characters schema', () => {
const valid = { characters: [{ name: 'Alice' }] };
const invalid = { characters: [{ noName: true }] };
expect(validateJSONSchema(valid, 'characters')).toBe(true);
expect(validateJSONSchema(invalid, 'characters')).toBe(false);
});
test('returns false for invalid types', () => {
expect(validateJSONSchema(null, 'userStats')).toBe(false);
expect(validateJSONSchema('string', 'userStats')).toBe(false);
expect(validateJSONSchema({}, 'unknown')).toBe(false);
});
});
describe('safeParseJSON', () => {
test('successfully parses and validates', () => {
const result = safeParseJSON('{"stats": [{"id": "health", "name": "Health", "value": 100}]}', 'userStats');
expect(result.success).toBe(true);
expect(result.data).toEqual({ stats: [{ id: 'health', name: 'Health', value: 100 }] });
expect(result.error).toBeNull();
});
test('returns error when no JSON found', () => {
const result = safeParseJSON('no json here');
expect(result.success).toBe(false);
expect(result.error).toBe('No JSON found in text');
});
test('returns error when schema validation fails', () => {
const result = safeParseJSON('{"unrelated": "data"}', 'userStats');
expect(result.success).toBe(false);
expect(result.error).toContain('does not match expected schema');
expect(result.data).not.toBeNull(); // Still returns data
});
});
+240
View File
@@ -0,0 +1,240 @@
/**
* Memory Management Tests
* Tests DOM cache invalidation, event listener cleanup, and debug log management
*/
describe('DOM Cache Invalidation', () => {
test('clearDomCache sets all jQuery references to null', () => {
// Simulate the clearDomCache logic
const domCache = {
$panelContainer: { isPanel: true },
$userStatsContainer: { isStats: true },
$infoBoxContainer: { isInfoBox: true },
$thoughtsContainer: { isThoughts: true },
$inventoryContainer: { isInventory: true },
$questsContainer: { isQuests: true },
$musicPlayerContainer: { isMusic: true },
$equipmentContainer: { isEquipment: true }
};
// Simulate clearDomCache
domCache.$panelContainer = null;
domCache.$userStatsContainer = null;
domCache.$infoBoxContainer = null;
domCache.$thoughtsContainer = null;
domCache.$inventoryContainer = null;
domCache.$questsContainer = null;
domCache.$musicPlayerContainer = null;
domCache.$equipmentContainer = null;
// Verify all are null
expect(domCache.$panelContainer).toBeNull();
expect(domCache.$userStatsContainer).toBeNull();
expect(domCache.$infoBoxContainer).toBeNull();
expect(domCache.$thoughtsContainer).toBeNull();
expect(domCache.$inventoryContainer).toBeNull();
expect(domCache.$questsContainer).toBeNull();
expect(domCache.$musicPlayerContainer).toBeNull();
expect(domCache.$equipmentContainer).toBeNull();
});
test('clearDomCache is idempotent (safe to call multiple times)', () => {
const cache = { $panelContainer: null };
// Call clear twice - should not throw
cache.$panelContainer = null;
cache.$panelContainer = null;
expect(cache.$panelContainer).toBeNull();
});
});
describe('Debug Log Memory Management', () => {
test('debugLogs array is capped at 100 entries', () => {
const debugLogs = [];
const MAX_LOGS = 100;
// Add 150 entries
for (let i = 0; i < 150; i++) {
debugLogs.push({ timestamp: new Date().toISOString(), message: `Log ${i}` });
if (debugLogs.length > MAX_LOGS) {
debugLogs.shift();
}
}
expect(debugLogs.length).toBe(100);
expect(debugLogs[0].message).toBe('Log 50'); // First 50 were shifted out
expect(debugLogs[99].message).toBe('Log 149');
});
test('clearDebugLogs empties the array', () => {
const debugLogs = [
{ timestamp: '2024-01-01T00:00:00Z', message: 'Log 1' },
{ timestamp: '2024-01-01T00:00:01Z', message: 'Log 2' },
{ timestamp: '2024-01-01T00:00:02Z', message: 'Log 3' }
];
// Simulate clearDebugLogs: debugLogs.length = 0
debugLogs.length = 0;
expect(debugLogs.length).toBe(0);
});
test('debugLogs cleared on chat change prevents memory accumulation', () => {
const debugLogs = [];
const MAX_LOGS = 100;
// Simulate multiple chat sessions
for (let chat = 0; chat < 5; chat++) {
// Clear on chat change
debugLogs.length = 0;
// Add logs during this chat session
for (let i = 0; i < 30; i++) {
debugLogs.push({ timestamp: new Date().toISOString(), message: `Chat ${chat} Log ${i}` });
if (debugLogs.length > MAX_LOGS) {
debugLogs.shift();
}
}
}
// After 5 chats with clearing, we should only have logs from the last chat
expect(debugLogs.length).toBe(30);
expect(debugLogs[0].message).toBe('Chat 4 Log 0');
});
});
describe('Event Listener Cleanup', () => {
test('registered handlers are tracked for cleanup', () => {
// Simulate the registeredHandlers Map from events.js
const registeredHandlers = new Map();
function trackHandler(eventType, handler) {
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
const handler1 = () => {};
const handler2 = () => {};
trackHandler('MESSAGE_SENT', handler1);
trackHandler('MESSAGE_SENT', handler2);
trackHandler('CHAT_CHANGED', handler1);
expect(registeredHandlers.get('MESSAGE_SENT').length).toBe(2);
expect(registeredHandlers.get('CHAT_CHANGED').length).toBe(1);
});
test('unregisterAllEvents clears all tracked handlers', () => {
const registeredHandlers = new Map();
function trackHandler(eventType, handler) {
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
trackHandler('MESSAGE_SENT', () => {});
trackHandler('CHAT_CHANGED', () => {});
// Simulate unregisterAllEvents
registeredHandlers.clear();
expect(registeredHandlers.size).toBe(0);
});
test('on() function tracks handlers for cleanup', () => {
const registeredHandlers = new Map();
// Simulate the on() function with tracking
function on(eventType, handler) {
// eventSource.on(eventType, handler); // Would call real event source
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
const handler = () => {};
on('GENERATE_BEFORE_COMBINE_PROMPTS', handler);
expect(registeredHandlers.has('GENERATE_BEFORE_COMBINE_PROMPTS')).toBe(true);
expect(registeredHandlers.get('GENERATE_BEFORE_COMBINE_PROMPTS').includes(handler)).toBe(true);
});
test('once() function tracks handlers for cleanup', () => {
const registeredHandlers = new Map();
// Simulate the once() function with tracking
function once(eventType, handler) {
// eventSource.once(eventType, handler); // Would call real event source
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
const handler = () => {};
once('TEXT_COMPLETION_SETTINGS_READY', handler);
expect(registeredHandlers.has('TEXT_COMPLETION_SETTINGS_READY')).toBe(true);
expect(registeredHandlers.get('TEXT_COMPLETION_SETTINGS_READY').includes(handler)).toBe(true);
});
test('multiple handlers for same event type are all tracked', () => {
const registeredHandlers = new Map();
function on(eventType, handler) {
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
const handlers = [() => {}, () => {}, () => {}];
handlers.forEach(h => on('CHAT_CHANGED', h));
expect(registeredHandlers.get('CHAT_CHANGED').length).toBe(3);
});
});
describe('jQuery Event Handler Tracking', () => {
test('tracked jQuery handlers can be cleaned up', () => {
const jqueryEventHandlers = [];
function trackJQueryHandler(event, selector, handler) {
jqueryEventHandlers.push({ event, selector, handler });
return handler;
}
const handler1 = () => {};
const handler2 = () => {};
trackJQueryHandler('click', '.rpg-item-remove', handler1);
trackJQueryHandler('click', '.rpg-equip-btn', handler2);
expect(jqueryEventHandlers.length).toBe(2);
// Simulate cleanup
jqueryEventHandlers.length = 0;
expect(jqueryEventHandlers.length).toBe(0);
});
test('trackJQueryHandler returns the handler for chaining', () => {
const jqueryEventHandlers = [];
function trackJQueryHandler(event, selector, handler) {
jqueryEventHandlers.push({ event, selector, handler });
return handler;
}
const handler = () => 'test';
const returned = trackJQueryHandler('click', '.selector', handler);
expect(returned).toBe(handler);
expect(returned()).toBe('test');
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Parser performance tests
* Tests regex pre-compilation and format caching optimizations
*/
// Test the pre-compiled regex patterns directly by importing them from parser.js
// We use dynamic import with mocking to avoid the SillyTavern dependency chain
describe('Parser regex pre-compilation', () => {
// Test that pre-compiled regex patterns produce correct results
// These patterns are now module-level constants in parser.js
test('emoji regex pattern works correctly', () => {
const EMOJI_RE = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
expect('😊 Hello'.match(EMOJI_RE)).toBeTruthy();
expect('Hello'.match(EMOJI_RE)).toBeNull();
});
test('thinking tag regex works correctly', () => {
const THINKING_TAG_RE = /<think>[\s\S]*?<\/think>/gi;
const THINKING_TAG_ALT_RE = /<thinking>[\s\S]*?<\/thinking>/gi;
const text1 = '<think>thinking content</think>actual content';
const text2 = '<thinking>thinking content</thinking>actual content';
expect(text1.replace(THINKING_TAG_RE, '')).toBe('actual content');
expect(text2.replace(THINKING_TAG_ALT_RE, '')).toBe('actual content');
});
test('JSON block regex works correctly', () => {
const JSON_BLOCK_RE = /```json\s*\n([\s\S]*?)```/g;
const text = '```json\n{"key": "value"}\n```';
const matches = [...text.matchAll(JSON_BLOCK_RE)];
expect(matches.length).toBe(1);
expect(matches[0][1].trim()).toBe('{"key": "value"}');
});
test('code block regex works correctly', () => {
const CODE_BLOCK_RE = /```([^`]+)```/g;
const text = '```Stats\n---\nHealth: 100%\n```';
const matches = [...text.matchAll(CODE_BLOCK_RE)];
expect(matches.length).toBe(1);
expect(matches[0][1].trim()).toBe('Stats\n---\nHealth: 100%');
});
test('XML trackers regex works correctly', () => {
const XML_TRACKERS_RE = /<trackers>([\s\S]*?)<\/trackers>/i;
const text = '<trackers>content here</trackers>';
const match = text.match(XML_TRACKERS_RE);
expect(match).toBeTruthy();
expect(match[1]).toBe('content here');
});
test('stats section detection regex works', () => {
const STATS_SECTION_RE = /Stats\s*\n\s*---/i;
expect('Stats\n---\nHealth: 100%'.match(STATS_SECTION_RE)).toBeTruthy();
expect('Info Box\n---\nDate: 2024'.match(STATS_SECTION_RE)).toBeNull();
});
test('info box section detection regex works', () => {
const INFOBOX_SECTION_RE = /Info Box\s*\n\s*---/i;
expect('Info Box\n---\nDate: 2024'.match(INFOBOX_SECTION_RE)).toBeTruthy();
expect('Stats\n---\nHealth: 100%'.match(INFOBOX_SECTION_RE)).toBeNull();
});
test('characters section detection regex works', () => {
const CHARACTERS_SECTION_RE = /Present Characters\s*\n\s*---/i;
expect('Present Characters\n---\nAlice'.match(CHARACTERS_SECTION_RE)).toBeTruthy();
expect('Stats\n---\nHealth: 100%'.match(CHARACTERS_SECTION_RE)).toBeNull();
});
test('RPG attribute regexes work', () => {
const RPG_STR_RE = /STR:\s*(\d+)/i;
const RPG_DEX_RE = /DEX:\s*(\d+)/i;
const RPG_LVL_RE = /LVL:\s*(\d+)/i;
expect('STR: 15'.match(RPG_STR_RE)?.[1]).toBe('15');
expect('DEX: 20'.match(RPG_DEX_RE)?.[1]).toBe('20');
expect('LVL: 10'.match(RPG_LVL_RE)?.[1]).toBe('10');
});
test('placeholder pattern regex works', () => {
const PLACEHOLDER_PATTERN_RE = /\[([A-Za-z\s\/]+)\]/g;
const text = '[Location] some text [Mood Emoji]';
const matches = [...text.matchAll(PLACEHOLDER_PATTERN_RE)];
expect(matches.length).toBe(2);
});
test('format marker regex works', () => {
const FORMAT_MARKER_RE = /FORMAT:\s*/gi;
expect('FORMAT: some text'.replace(FORMAT_MARKER_RE, '')).toBe('some text');
});
});
describe('Format cache logic', () => {
test('format cache hit threshold logic', () => {
// Simulate the format caching logic from parser.js
let lastDetectedFormat = null;
let formatCacheHits = 0;
const FORMAT_CACHE_HIT_THRESHOLD = 3;
// Initially no cache
expect(lastDetectedFormat).toBeNull();
expect(formatCacheHits).toBe(0);
// First detection
lastDetectedFormat = 'json';
formatCacheHits = 1;
expect(formatCacheHits >= FORMAT_CACHE_HIT_THRESHOLD).toBe(false);
// Second hit
formatCacheHits++;
expect(formatCacheHits >= FORMAT_CACHE_HIT_THRESHOLD).toBe(false);
// Third hit - now cache is trusted
formatCacheHits++;
expect(formatCacheHits >= FORMAT_CACHE_HIT_THRESHOLD).toBe(true);
// Cache clear
lastDetectedFormat = null;
formatCacheHits = 0;
expect(lastDetectedFormat).toBeNull();
expect(formatCacheHits).toBe(0);
});
});
describe('Regex performance verification', () => {
test('pre-compiled regex is faster than re-compiling', () => {
// Pre-compiled constant (module-level)
const PRECOMPILED_RE = /```json\s*\n([\s\S]*?)```/g;
// Simulate per-call compilation (old behavior)
const compilePerCall = (text) => {
const re = /```json\s*\n([\s\S]*?)```/g;
return [...text.matchAll(re)];
};
// Pre-compiled usage (new behavior)
const usePrecompiled = (text) => {
// Reset lastIndex for global regex
PRECOMPILED_RE.lastIndex = 0;
return [...text.matchAll(PRECOMPILED_RE)];
};
const testText = '```json\n{"key": "value"}\n```';
// Both should produce the same result
const compiled = compilePerCall(testText);
const precompiled = usePrecompiled(testText);
expect(compiled.length).toBe(precompiled.length);
expect(compiled[0][1]).toBe(precompiled[0][1]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import { createStringValidator, sanitizeLocationName, sanitizeItemName, validateStoredInventory, cleanItemString, MAX_ITEMS_PER_SECTION } from '../src/utils/security.js';
describe('createStringValidator', () => {
test('creates a validator that trims whitespace', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100,
checkBlockedNames: false
});
expect(validator(' hello ')).toBe('hello');
});
test('returns null for non-string input', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100
});
expect(validator(null)).toBeNull();
expect(validator(undefined)).toBeNull();
expect(validator(123)).toBeNull();
expect(validator('')).toBeNull();
});
test('truncates strings exceeding maxLength', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 5
});
const result = validator('hello world');
expect(result).toBe('hello');
expect(result.length).toBe(5);
});
test('rejects blocked property names when checkBlockedNames is true', () => {
const validator = createStringValidator({
label: 'property',
maxLength: 100,
checkBlockedNames: true
});
expect(validator('__proto__')).toBeNull();
expect(validator('constructor')).toBeNull();
expect(validator('prototype')).toBeNull();
});
test('allows blocked property names when checkBlockedNames is false', () => {
const validator = createStringValidator({
label: 'item',
maxLength: 100,
checkBlockedNames: false
});
// __proto__ is allowed when checkBlockedNames is false
expect(validator('__proto__')).toBe('__proto__');
});
test('rejects additional values via additionalRejects', () => {
const validator = createStringValidator({
label: 'item',
maxLength: 100,
additionalRejects: ['none', 'null', 'undefined'],
checkBlockedNames: false
});
expect(validator('none')).toBeNull();
expect(validator('NONE')).toBeNull();
expect(validator('None')).toBeNull();
expect(validator('Sword')).toBe('Sword');
});
test('is case-insensitive for blocked names', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100,
checkBlockedNames: true
});
expect(validator('__PROTO__')).toBeNull();
expect(validator('__Proto__')).toBeNull();
expect(validator('CONSTRUCTOR')).toBeNull();
});
test('returns valid input unchanged', () => {
const validator = createStringValidator({
label: 'test',
maxLength: 100
});
expect(validator('ValidName')).toBe('ValidName');
expect(validator('another-valid-name')).toBe('another-valid-name');
});
});
describe('sanitizeLocationName', () => {
test('allows valid location names', () => {
expect(sanitizeLocationName('Home')).toBe('Home');
expect(sanitizeLocationName('Tavern')).toBe('Tavern');
expect(sanitizeLocationName('Forest Clearing')).toBe('Forest Clearing');
});
test('blocks dangerous property names', () => {
expect(sanitizeLocationName('__proto__')).toBeNull();
expect(sanitizeLocationName('constructor')).toBeNull();
expect(sanitizeLocationName('prototype')).toBeNull();
});
test('truncates overly long names', () => {
const longName = 'A'.repeat(250);
const result = sanitizeLocationName(longName);
expect(result.length).toBe(200);
});
test('returns null for invalid input', () => {
expect(sanitizeLocationName(null)).toBeNull();
expect(sanitizeLocationName('')).toBeNull();
expect(sanitizeLocationName(' ')).toBeNull();
});
});
describe('sanitizeItemName', () => {
test('allows valid item names', () => {
expect(sanitizeItemName('Sword')).toBe('Sword');
expect(sanitizeItemName('Health Potion')).toBe('Health Potion');
});
test('rejects "none" as an item name', () => {
expect(sanitizeItemName('none')).toBeNull();
expect(sanitizeItemName('None')).toBeNull();
expect(sanitizeItemName('NONE')).toBeNull();
});
test('truncates overly long names', () => {
const longName = 'A'.repeat(600);
const result = sanitizeItemName(longName);
expect(result.length).toBe(500);
});
test('returns null for invalid input', () => {
expect(sanitizeItemName(null)).toBeNull();
expect(sanitizeItemName('')).toBeNull();
});
});
describe('validateStoredInventory', () => {
test('returns cleaned inventory object', () => {
const input = { Home: 'Sword, Shield' };
const result = validateStoredInventory(input);
expect(result).toEqual({ Home: 'Sword, Shield' });
});
test('removes dangerous keys', () => {
const input = { Home: 'Sword' };
// __proto__ as a key is blocked by sanitizeLocationName
Object.setPrototypeOf(input, Object.create(null));
input['__proto__'] = 'malicious';
const result = validateStoredInventory(input);
// Result should not have __proto__ as an own property
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false);
expect(result).toHaveProperty('Home');
});
test('returns empty object for invalid input', () => {
expect(validateStoredInventory(null)).toEqual({});
expect(validateStoredInventory(undefined)).toEqual({});
expect(validateStoredInventory('string')).toEqual({});
expect(validateStoredInventory([])).toEqual({});
});
test('skips non-string values', () => {
const input = { Home: 123, Barn: 'Hay' };
const result = validateStoredInventory(input);
expect(result).not.toHaveProperty('Home');
expect(result).toHaveProperty('Barn');
});
});
describe('cleanItemString', () => {
test('returns clean item string', () => {
expect(cleanItemString('Sword, Shield')).toBe('Sword, Shield');
});
test('item sanitizer does not block __proto__ (by design - checkBlockedNames is false)', () => {
// sanitizeItemName has checkBlockedNames: false, so __proto__ is allowed as item name
// Only sanitizeLocationName blocks __proto__ (for object keys)
const result = cleanItemString('Sword, Shield');
expect(result).toContain('Sword');
});
test('strips markdown formatting', () => {
const result = cleanItemString('**Sword**, *Shield*');
expect(result).not.toContain('**');
expect(result).not.toContain('*');
});
});
describe('MAX_ITEMS_PER_SECTION', () => {
test('is a positive number', () => {
expect(MAX_ITEMS_PER_SECTION).toBeGreaterThan(0);
expect(typeof MAX_ITEMS_PER_SECTION).toBe('number');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"presets": [
["@babel/preset-env", {
"targets": {
"node": "current"
}
}]
]
}
+149 -999
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'],
transform: {
'^.+\\.js$': 'babel-jest',
},
transformIgnorePatterns: ['/node_modules/'],
};
+22
View File
@@ -0,0 +1,22 @@
// Jest setup file - set up mocks before tests run
const mockState = {
extensionSettings: {
debugMode: false,
userStats: {},
trackerConfig: {},
quests: { main: '', optional: [] }
},
FEATURE_FLAGS: { useNewInventory: false },
addDebugLog: () => {}
};
const mockPersistence = {
saveSettings: () => {}
};
const mockInventoryParser = {
extractInventory: () => null
};
// We can't easily mock ESM imports in Jest without transform
// Instead, we'll create mock files that Jest can use
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Marinara",
"version": "3.6.1",
"version": "3.7.4",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+8231
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "rpg-companion-sillytavern",
"version": "3.7.4",
"description": "RPG Companion extension for SillyTavern",
"main": "index.js",
"type": "module",
"scripts": {
"build:css": "node scripts/build-css.js",
"build:css:min": "node scripts/build-css.js --minify",
"test": "jest",
"validate_locale": "node src/i18n/validator.js --watch",
"validate_locale_once": "node src/i18n/validator.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@jest/globals": "^30.4.1",
"chokidar": "^5.0.0",
"execa": "^9.6.1",
"fs-extra": "^11.3.3",
"glob": "^13.0.6",
"jest": "^30.4.2"
}
}
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* CSS Build Script
* Concatenates modular CSS files from src/styles/ into style.css
*
* Usage:
* node scripts/build-css.js # Development build (readable)
* node scripts/build-css.js --minify # Production build (minified)
*
* File order matters! Files are concatenated in alphabetical order.
* Prefix files with numbers to control order: _01_variables.css, _02_panel.css, etc.
*/
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const stylesDir = path.join(rootDir, 'src', 'styles');
const outputFile = path.join(rootDir, 'style.css');
const shouldMinify = process.argv.includes('--minify');
/**
* Minify CSS by removing comments, unnecessary whitespace, and redundant characters.
* Safe for CSS custom properties, strings, and data URIs.
*/
function minifyCSS(css) {
let result = css;
// Remove CSS comments (/* ... */) — but not inside strings
result = result.replace(/\/\*[\s\S]*?\*\//g, '');
// Remove lines that are only whitespace after comment removal
result = result.replace(/^\s*[\r\n]/g, '\n');
// Collapse multiple newlines into one
result = result.replace(/\n{2,}/g, '\n');
// Remove spaces around structural characters outside of strings
result = result.replace(/(\{|\}|;|:|,)/g, '$1');
// Remove space before {
result = result.replace(/\s+\{/g, '{');
// Remove space after {
result = result.replace(/\{\s+/g, '{');
// Remove space before }
result = result.replace(/\s+\}/g, '}');
// Remove space after }
result = result.replace(/\}\s+/g, '}');
// Remove space around :
result = result.replace(/\s*:\s*/g, ':');
// Remove space around ;
result = result.replace(/;\s*/g, ';');
// Remove space around ,
result = result.replace(/,\s*/g, ',');
// Remove trailing semicolon before }
result = result.replace(/;\}/g, '}');
// Collapse multiple spaces into one (preserves spaces inside strings roughly)
result = result.replace(/[ \t]+/g, ' ');
// Remove leading/trailing whitespace
result = result.trim();
return result;
}
async function buildCSS() {
console.log(`Building CSS from src/styles/...${shouldMinify ? ' (minified)' : ' (readable)'}`);
// Read all CSS files from src/styles/
const files = await fs.readdir(stylesDir);
const cssFiles = files
.filter(f => f.endsWith('.css'))
.sort() // Alphabetical order (use _01_, _02_ prefix for control)
.map(f => path.join(stylesDir, f));
if (cssFiles.length === 0) {
console.error('No CSS files found in src/styles/');
process.exit(1);
}
console.log(`Found ${cssFiles.length} CSS files:`);
cssFiles.forEach(f => console.log(` - ${path.basename(f)}`));
// Read and concatenate all files
const contents = [];
for (const file of cssFiles) {
const content = await fs.readFile(file, 'utf-8');
const basename = path.basename(file);
if (shouldMinify) {
// In minified mode, skip comment separators
contents.push(content);
} else {
contents.push(`/* ============================================ */`);
contents.push(`/* ${basename} */`);
contents.push(`/* ============================================ */`);
contents.push('');
contents.push(content);
contents.push('');
}
}
let combined = contents.join('\n');
// Apply minification if requested
if (shouldMinify) {
const beforeSize = combined.length;
combined = minifyCSS(combined);
const afterSize = combined.length;
const reduction = (((beforeSize - afterSize) / beforeSize) * 100).toFixed(1);
console.log(`\n🗜️ Minification: ${beforeSize.toLocaleString()} → ${afterSize.toLocaleString()} bytes (${reduction}% reduction)`);
}
// Write to style.css
await fs.writeFile(outputFile, combined, 'utf-8');
const stats = await fs.stat(outputFile);
const lines = combined.split('\n').length;
const sizeKB = (stats.size / 1024).toFixed(1);
console.log(`\n✅ Built ${outputFile}`);
console.log(` Lines: ${lines}`);
console.log(` Size: ${sizeKB} KB`);
}
buildCSS().catch(err => {
console.error('Build failed:', err);
process.exit(1);
});
+17 -7
View File
@@ -14,27 +14,36 @@
<label for="rpg-companion-language-select" data-i18n-key="settings.language.label">Language</label>
<select id="rpg-companion-language-select" class="text_pole">
<option value="en" data-i18n-key="settings.language.option.en">English</option>
<option value="zh-cn" data-i18n-key="settings.language.option.zh-cn">简体中文</option>
<option value="zh-tw" data-i18n-key="settings.language.option.zh-tw">繁體中文</option>
<option value="ru" data-i18n-key="settings.language.option.ru">Русский</option>
<option value="fr" data-i18n-key="settings.language.option.fr">Français</option>
</select>
</div>
<small class="notes" data-i18n-key="settings.note">Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.</small>
<small class="notes" data-i18n-key="settings.note">Toggle to enable/disable the RPG Companion extension.
Configure additional settings within the panel itself.</small>
<div style="margin-top: 10px; display: flex; gap: 10px;">
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<a href="https://discord.com/invite/KdAkTg94ME" target="_blank" class="menu_button"
style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-brands fa-discord"></i>&nbsp;Discord
</a>
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button" style="flex: 1; text-align: center; text-decoration: none;">
<a href="https://ko-fi.com/marinara_spaghetti" target="_blank" class="menu_button"
style="flex: 1; text-align: center; text-decoration: none;">
<i class="fa-solid fa-heart"></i>&nbsp;Support
</a>
</div>
<div style="margin-top: 15px; text-align: center; opacity: 0.7; font-size: 0.8em; line-height: 1.5;">
<div style="margin-bottom: 5px;">
<i class="fa-solid fa-microchip"></i> <strong data-i18n="settings.recommendedModels.title">Recommended Models:</strong>
<i class="fa-solid fa-microchip"></i> <strong
data-i18n="settings.recommendedModels.title">Recommended Models:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;" data-i18n="settings.recommendedModels.description">
For the extension to work properly, <strong>it is not recommended to use any models below 20B, especially if they're old.</strong> It works best with the SOTA models such as Deepseek, Claude, GPT, or Gemini.
For the extension to work properly, <strong>it is not recommended to use any models below 20B,
especially if they're old.</strong> It works best with the SOTA models such as Deepseek, Claude,
GPT, or Gemini.
</div>
</div>
@@ -43,12 +52,13 @@
<i class="fa-solid fa-users"></i> <strong>Contributors:</strong>
</div>
<div style="opacity: 0.8; font-size: 0.9em;">
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte,
Chungchandev, Joenunezb, Amauragis, Tomt610, and Jakstein.
</div>
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.6.1
v3.7.2
</div>
</div>
</div>
+286 -16
View File
@@ -13,53 +13,115 @@ export const extensionName = 'third-party/rpg-companion-sillytavern';
* This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations
*/
const currentScriptPath = import.meta.url;
const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\data\\');
const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\\\data\\\\');
export const extensionFolderPath = isUserExtension
? `data/default-user/extensions/${extensionName}`
: `scripts/extensions/${extensionName}`;
/**
* Default extension settings
* Default extension settings — single source of truth for all setting defaults.
* Imported by state.js to initialize extensionSettings.
*/
export const defaultSettings = {
settingsVersion: 6, // Version number for settings migrations
enabled: true,
autoUpdate: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showAlternatePresentCharactersPanel: false,
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showEquipment: true, // Show equipment section
showQuests: true, // Show quests section
showLockIcons: true, // Show lock/unlock icons on tracker items
showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
customContextInstructionsPrompt: '', // Custom context instructions prompt text (empty = use default)
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
customHtmlPrompt: '', // Custom HTML prompt text (empty = use default)
enableDialogueColoring: false, // Enable dialogue coloring prompt injection
customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default)
enableDeceptionSystem: false, // Enable deception tracking with <lie> tags
customDeceptionPrompt: '', // Custom deception prompt text (empty = use default)
enableOmniscienceFilter: false, // Enable omniscience filter with <ofilter> tags
customOmnisciencePrompt: '', // Custom omniscience filter prompt text (empty = use default)
enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices
customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default)
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
// Controls when the extension skips injecting tracker instructions/examples/HTML
// into generations that appear to be user-injected instructions. Valid values:
// - 'none' -> never skip (legacy behavior: always inject)
// - 'guided' -> skip for any guided / instruct or quiet_prompt generation
// - 'impersonation' -> skip only for impersonation-style guided generations
// This setting helps compatibility with other extensions like GuidedGenerations.
skipInjectionsForGuided: 'none',
enablePlotButtons: true, // Show plot progression buttons above chat input
saveTrackerHistory: false, // Save tracker data in chat history for each message
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
weatherBackground: true, // Show weather effects in background (behind chat)
weatherForeground: false, // Show weather effects in foreground (on top of chat)
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
showHtmlToggle: true, // Show Immersive HTML toggle in main panel
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
showDeceptionToggle: true, // Show Deception System toggle in main panel
showOmniscienceToggle: true, // Show Omniscience Filter toggle in main panel
showCYOAToggle: true, // Show CYOA toggle in main panel
showSpotifyToggle: true, // Show Spotify Music toggle in main panel
showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel
showNarratorMode: true, // Show Narrator Mode toggle in main panel
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
enableRandomizedPlot: true, // Show randomized plot progression button above chat input
enableNaturalPlot: true, // Show natural plot progression button above chat input
// History persistence settings - inject selected tracker data into historical messages
historyPersistence: {
enabled: false, // Master toggle for history persistence feature
messageCount: 5, // Number of messages to include (0 = all available)
injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message'
contextPreamble: '', // Optional custom preamble text (empty = use default short one)
sendAllEnabledOnRefresh: false // If true, sends all enabled stats from preset instead of only persistInHistory-enabled stats on Refresh RPG Info
},
panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: {
bg: '#1a1a2e',
bgOpacity: 100,
accent: '#16213e',
accentOpacity: 100,
text: '#eaeaea',
highlight: '#e94560'
textOpacity: 100,
highlight: '#e94560',
highlightOpacity: 100
},
statBarColorLow: '#cc3333', // Color for low stat values (red)
statBarColorLowOpacity: 100,
statBarColorHigh: '#33cc66', // Color for high stat values (green)
statBarColorHighOpacity: 100,
enableAnimations: true, // Enable smooth animations for stats and content updates
mobileFabPosition: {
top: 'calc(var(--topBarBlockSize) + 60px)',
right: '12px'
}, // Saved position for mobile FAB button
// Mobile FAB widget display options (8-position system around the button)
mobileFabWidgets: {
enabled: true, // Master toggle for FAB widgets
weatherIcon: { enabled: true, position: 0 }, // Weather emoji (☀️, 🌧️, etc.)
weatherDesc: { enabled: true, position: 1 }, // Weather description text
clock: { enabled: true, position: 2 }, // Current time display
date: { enabled: true, position: 3 }, // Date display
location: { enabled: true, position: 4 }, // Location name
stats: { enabled: true, position: 5 }, // All stats as compact numbers
attributes: { enabled: true, position: 6 } // Compact RPG attributes display
},
// Desktop strip widget display options (shown in collapsed panel strip)
desktopStripWidgets: {
enabled: true, // Master toggle for strip widgets (enabled by default)
weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.)
clock: { enabled: true }, // Current time display
date: { enabled: true }, // Date display
location: { enabled: true }, // Location name
stats: { enabled: true }, // All stats as compact numbers
attributes: { enabled: true } // Compact RPG attributes display
},
userStats: {
health: 100,
satiety: 100,
@@ -68,14 +130,162 @@ export const defaultSettings = {
arousal: 0,
mood: '😐',
conditions: 'None',
/** @type {InventoryV2} */
skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
},
equipment: {
items: [], // Array of {id, name, type, slot, stats: {str: 2, dex: 1, ...}, description}
slots: {
helmet: null,
ring1: null,
ring2: null,
ring3: null,
ring4: null,
ring5: null,
ring6: null,
ring7: null,
ring8: null,
ring9: null,
ring10: null,
necklace: null,
bodyArmor: null,
pants: null,
shoes: null,
gloves: null,
accessory1: null,
accessory2: null,
accessory3: null
}
}
},
statNames: {
health: 'Health',
satiety: 'Satiety',
energy: 'Energy',
hygiene: 'Hygiene',
arousal: 'Arousal'
},
// Tracker customization configuration
trackerConfig: {
userStats: {
// Stats display mode: 'percentage' or 'number'
statsDisplayMode: 'percentage',
// Array of custom stats (allows add/remove/rename)
customStats: [
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 }
],
// RPG Attributes (customizable D&D-style attributes)
showRPGAttributes: true,
showLevel: true, // Show/hide level in UI and prompts
alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls
rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
],
// Status section config
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions'], // User can edit what to track
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills', // User-editable
customFields: [], // Array of skill names
persistInHistory: false // Persist skills in historical messages
},
// Inventory persistence
inventoryPersistInHistory: false, // Persist inventory in historical messages
// Quests persistence
questsPersistInHistory: false // Persist quests in historical messages
},
infoBox: {
widgets: {
date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history
weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history
temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F'
time: { enabled: true, persistInHistory: true }, // Time enabled by default for history
location: { enabled: true, persistInHistory: true }, // Location enabled by default for history
recentEvents: { enabled: true, persistInHistory: false }
}
},
presentCharacters: {
// Fixed fields (always shown)
showEmoji: true,
showName: true,
// Relationship fields configuration
relationships: {
enabled: true,
// Relationship to emoji mapping (shown on character portraits)
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
}
},
// Legacy fields kept for backward compatibility
relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'],
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
},
// Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |)
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false }
],
// Thoughts configuration (separate line)
thoughts: {
enabled: true,
name: 'Thoughts',
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
enabled: false,
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
]
}
}
},
quests: {
main: "None", // Current main quest title
optional: [] // Array of optional quest titles
},
infoBox: JSON.stringify({
date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) },
weather: { emoji: '☀️', forecast: 'Clear skies' },
temperature: { value: 20, unit: 'C' },
time: { start: '00:00', end: '00:00' },
location: { value: 'Unknown Location' }
}, null, 2),
characterThoughts: JSON.stringify({
characters: []
}, null, 2),
level: 1, // User's character level
classicStats: {
str: 10,
dex: 10,
@@ -85,5 +295,65 @@ export const defaultSettings = {
cha: 10
},
lastDiceRoll: null, // Store last dice roll result
collapsedInventoryLocations: [] // Array of collapsed storage location names
showDiceDisplay: true, // Show the "Last Roll" display in the panel
collapsedInventoryLocations: [], // Array of collapsed storage location names
inventoryViewModes: {
onPerson: 'list', // 'list' or 'grid' view mode for On Person section
stored: 'list', // 'list' or 'grid' view mode for Stored section
assets: 'list' // 'list' or 'grid' view mode for Assets section
},
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// Combat encounter settings
encounterSettings: {
enabled: true, // Show Start Encounter button above chat input
historyDepth: 8, // Number of recent messages to include in combat initialization
autoSaveLogs: false // Save detailed combat logs to file
},
// Auto avatar generation settings
autoGenerateAvatars: true, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode
externalApiSettings: {
baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1")
// apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key')
model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation
},
// Lock state for tracker items (v3 JSON format feature)
lockedItems: {
stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"])
skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"])
inventory: {
onPerson: [], // Array of locked item indices (e.g., [0, 2])
clothing: [], // Array of locked item indices
stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]})
assets: [] // Array of locked asset indices
},
quests: {
main: false, // Boolean for main quest lock
optional: [] // Array of locked optional quest indices (e.g., [0, 2])
},
infoBox: {
date: false, // Boolean for date widget lock
weather: false, // Boolean for weather widget lock
temperature: false, // Boolean for temperature widget lock
time: false, // Boolean for time widget lock
location: false, // Boolean for location widget lock
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
},
// Preset management for tracker configurations
presetManager: {
// Map of preset ID to preset data (contains name and trackerConfig)
presets: {},
// Map of character/group entity to preset ID (e.g., "char_0": "preset_123", "group_abc": "preset_456")
// Note: This is stored separately and NOT exported with presets
characterAssociations: {},
// Currently active preset ID
activePresetId: null,
// Default preset ID (used when no character association exists)
defaultPresetId: null
}
};
+12
View File
@@ -12,6 +12,12 @@ import { eventSource, event_types } from '../../../../../../script.js';
*/
export function on(eventType, handler) {
eventSource.on(eventType, handler);
// Track for cleanup
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
/**
@@ -21,6 +27,12 @@ export function on(eventType, handler) {
*/
export function once(eventType, handler) {
eventSource.once(eventType, handler);
// Track for cleanup (one-time handlers should also be tracked)
if (!registeredHandlers.has(eventType)) {
registeredHandlers.set(eventType, []);
}
registeredHandlers.get(eventType).push(handler);
}
/**
+847 -41
View File
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
/**
* Settings Panel Module
* Manages the extension settings tab in SillyTavern's Extensions panel.
* Extracted from index.js to reduce main entry point size.
*/
import { extensionSettings } from './state.js';
import { saveSettings } from './persistence.js';
import { extensionName } from './config.js';
import { i18n } from './i18n.js';
/**
* Adds the extension settings to the Extensions tab.
* @param {Function} $ - jQuery function
* @param {Function} renderExtensionTemplateAsync - SillyTavern template renderer
* @param {Function} clearExtensionPrompts - Clear extension prompts from SillyTavern
* @param {Function} updateChatThoughts - Update thought bubbles in chat
* @param {Function} cleanupCheckpointUI - Remove checkpoint UI elements
* @param {Function} clearThoughtBasedExpressionsCache - Clear expression cache
* @param {Function} toggleDynamicWeather - Toggle weather effects
* @param {Function} initUI - Initialize the main UI panel
* @param {Function} loadChatData - Load chat-specific data
* @param {Function} scheduleChatStateRehydration - Schedule chat state rehydration
* @param {Function} initThoughtBasedExpressions - Initialize thought-based expressions
* @param {Function} injectCheckpointButton - Add checkpoint buttons
* @param {Function} updateAllCheckpointIndicators - Update checkpoint button states
* @param {Function} removeAlternatePresentCharactersPanel - Remove alt characters panel
*/
export async function addExtensionSettings($, renderExtensionTemplateAsync, clearExtensionPrompts, updateChatThoughts, cleanupCheckpointUI, clearThoughtBasedExpressionsCache, toggleDynamicWeather, initUI, loadChatData, scheduleChatStateRehydration, initThoughtBasedExpressions, injectCheckpointButton, updateAllCheckpointIndicators, removeAlternatePresentCharactersPanel) {
const settingsHtml = await renderExtensionTemplateAsync(extensionName, 'settings');
$('#extensions_settings2').append(settingsHtml);
// Enable/disable toggle
$('#rpg-extension-enabled').prop('checked', extensionSettings.enabled).on('change', async function() {
const wasEnabled = extensionSettings.enabled;
extensionSettings.enabled = $(this).prop('checked');
saveSettings();
if (!extensionSettings.enabled && wasEnabled) {
clearExtensionPrompts();
updateChatThoughts();
cleanupCheckpointUI();
clearThoughtBasedExpressionsCache();
toggleDynamicWeather(false);
$('#rpg-companion-panel').remove();
$('#rpg-mobile-toggle').remove();
$('#rpg-collapse-toggle').remove();
$('#rpg-plot-buttons').remove();
removeAlternatePresentCharactersPanel();
} else if (extensionSettings.enabled && !wasEnabled) {
await initUI();
loadChatData();
scheduleChatStateRehydration();
initThoughtBasedExpressions();
updateChatThoughts();
injectCheckpointButton();
updateAllCheckpointIndicators();
}
});
// Language selector
const langSelect = $('#rpg-companion-language-select');
if (langSelect.length) {
langSelect.val(i18n.currentLanguage);
langSelect.on('change', async function() {
const selectedLanguage = $(this).val();
await i18n.setLanguage(selectedLanguage);
i18n.applyTranslations(document.getElementById('extensions_settings2'));
});
}
}
+110 -300
View File
@@ -3,312 +3,16 @@
* Centralizes all extension state variables
*/
import { defaultSettings } from './config.js';
// Type imports
/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */
/**
* Extension settings - persisted to SillyTavern settings
* Initialized from config.js defaultSettings (single source of truth)
*/
export let extensionSettings = {
settingsVersion: 4, // Version number for settings migrations (v4 = FAB widgets enabled by default)
enabled: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately
showUserStats: true,
showInfoBox: true,
showCharacterThoughts: true,
showInventory: true, // Show inventory section (v2 system)
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
narratorMode: false, // Use character card as narrator instead of fixed character references
customNarratorPrompt: '', // Custom narrator mode prompt text (empty = use default)
enableHtmlPrompt: false, // Enable immersive HTML prompt injection
customHtmlPrompt: '', // Custom HTML prompt text (empty = use default)
enableDialogueColoring: false, // Enable dialogue coloring prompt injection
customDialogueColoringPrompt: '', // Custom dialogue coloring prompt text (empty = use default)
enableDeceptionSystem: false, // Enable deception tracking with <lie> tags
customDeceptionPrompt: '', // Custom deception prompt text (empty = use default)
enableCYOA: false, // Enable "Choose Your Own Adventure" formatting with action choices
customCYOAPrompt: '', // Custom CYOA prompt text (empty = use default)
enableSpotifyMusic: false, // Enable Spotify music integration (asks AI for Spotify URLs)
customSpotifyPrompt: '', // Custom Spotify prompt text (empty = use default)
enableDynamicWeather: true, // Enable dynamic weather effects based on Info Box weather field (v2: enabled by default)
weatherBackground: true, // Show weather effects in background (behind chat)
weatherForeground: false, // Show weather effects in foreground (on top of chat)
dismissedHolidayPromo: false, // User dismissed the holiday promotion banner
showHtmlToggle: true, // Show Immersive HTML toggle in main panel
showDialogueColoringToggle: true, // Show Dialogue Coloring toggle in main panel (enabled by default)
showDeceptionToggle: true, // Show Deception System toggle in main panel
showCYOAToggle: true, // Show CYOA toggle in main panel
showSpotifyToggle: true, // Show Spotify Music toggle in main panel
showDynamicWeatherToggle: true, // Show Dynamic Weather Effects toggle in main panel
showNarratorMode: true, // Show Narrator Mode toggle in main panel
showAutoAvatars: true, // Show Auto-generate Avatars toggle in main panel
skipInjectionsForGuided: 'none', // skip injections for instruct injections and quiet prompts (GuidedGenerations compatibility)
enableRandomizedPlot: true, // Show randomized plot progression button above chat input
enableNaturalPlot: true, // Show natural plot progression button above chat input
// History persistence settings - inject selected tracker data into historical messages
historyPersistence: {
enabled: false, // Master toggle for history persistence feature
messageCount: 5, // Number of messages to include (0 = all available)
injectionPosition: 'assistant_message_end', // 'user_message_end', 'assistant_message_end', 'extra_user_message', 'extra_assistant_message'
contextPreamble: '', // Optional custom preamble text (empty = use default short one)
sendAllEnabledOnRefresh: false // If true, sends all enabled stats from preset instead of only persistInHistory-enabled stats on Refresh RPG Info
},
panelPosition: 'right', // 'left', 'right', or 'top'
theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom
customColors: {
bg: '#1a1a2e',
accent: '#16213e',
text: '#eaeaea',
highlight: '#e94560'
},
statBarColorLow: '#cc3333', // Color for low stat values (red)
statBarColorHigh: '#33cc66', // Color for high stat values (green)
enableAnimations: true, // Enable smooth animations for stats and content updates
mobileFabPosition: {
top: 'calc(var(--topBarBlockSize) + 60px)',
right: '12px'
}, // Saved position for mobile FAB button
// Mobile FAB widget display options (8-position system around the button)
mobileFabWidgets: {
enabled: true, // Master toggle for FAB widgets
weatherIcon: { enabled: true, position: 0 }, // Weather emoji (☀️, 🌧️, etc.)
weatherDesc: { enabled: true, position: 1 }, // Weather description text
clock: { enabled: true, position: 2 }, // Current time display
date: { enabled: true, position: 3 }, // Date display
location: { enabled: true, position: 4 }, // Location name
stats: { enabled: true, position: 5 }, // All stats as compact numbers
attributes: { enabled: true, position: 6 } // Compact RPG attributes display
},
// Desktop strip widget display options (shown in collapsed panel strip)
desktopStripWidgets: {
enabled: true, // Master toggle for strip widgets (enabled by default)
weatherIcon: { enabled: true }, // Weather emoji (☀️, 🌧️, etc.)
clock: { enabled: true }, // Current time display
date: { enabled: true }, // Date display
location: { enabled: true }, // Location name
stats: { enabled: true }, // All stats as compact numbers
attributes: { enabled: true } // Compact RPG attributes display
},
userStats: JSON.stringify({
stats: [
{ id: 'health', name: 'Health', value: 100 },
{ id: 'satiety', name: 'Satiety', value: 100 },
{ id: 'energy', name: 'Energy', value: 100 },
{ id: 'hygiene', name: 'Hygiene', value: 100 },
{ id: 'arousal', name: 'Arousal', value: 0 }
],
status: {
mood: '😐',
conditions: 'None'
},
inventory: {
onPerson: [],
stored: []
},
quests: {
active: [],
completed: []
}
}, null, 2),
statNames: {
health: 'Health',
satiety: 'Satiety',
energy: 'Energy',
hygiene: 'Hygiene',
arousal: 'Arousal'
},
// Tracker customization configuration
trackerConfig: {
userStats: {
// Stats display mode: 'percentage' or 'number'
statsDisplayMode: 'percentage',
// Array of custom stats (allows add/remove/rename)
customStats: [
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false, maxValue: 100 },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false, maxValue: 100 }
],
// RPG Attributes (customizable D&D-style attributes)
showRPGAttributes: true,
showLevel: true, // Show/hide level in UI and prompts
alwaysSendAttributes: false, // If true, always send attributes; if false, only send with dice rolls
rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
],
// Status section config
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions'], // User can edit what to track
persistInHistory: false // Persist status in historical messages
},
// Optional skills field
skillsSection: {
enabled: false,
label: 'Skills', // User-editable
customFields: [], // Array of skill names
persistInHistory: false // Persist skills in historical messages
},
// Inventory persistence
inventoryPersistInHistory: false, // Persist inventory in historical messages
// Quests persistence
questsPersistInHistory: false // Persist quests in historical messages
},
infoBox: {
widgets: {
date: { enabled: true, format: 'Weekday, Month, Year', persistInHistory: true }, // Date enabled by default for history
weather: { enabled: true, persistInHistory: true }, // Weather enabled by default for history
temperature: { enabled: true, unit: 'C', persistInHistory: false }, // 'C' or 'F'
time: { enabled: true, persistInHistory: true }, // Time enabled by default for history
location: { enabled: true, persistInHistory: true }, // Location enabled by default for history
recentEvents: { enabled: true, persistInHistory: false }
}
},
presentCharacters: {
// Fixed fields (always shown)
showEmoji: true,
showName: true,
// Relationship fields configuration
relationships: {
enabled: true,
// Relationship to emoji mapping (shown on character portraits)
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
}
},
// Legacy fields kept for backward compatibility
relationshipFields: ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'],
relationshipEmojis: {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
},
// Custom fields (appearance, demeanor, etc. - shown after relationship, separated by |)
customFields: [
{ id: 'appearance', name: 'Appearance', enabled: true, description: 'Visible physical appearance (clothing, hair, notable features)', persistInHistory: false },
{ id: 'demeanor', name: 'Demeanor', enabled: true, description: 'Observable demeanor or emotional state', persistInHistory: false }
],
// Thoughts configuration (separate line)
thoughts: {
enabled: true,
name: 'Thoughts',
description: 'Internal Monologue (in first person from character\'s POV, up to three sentences long)',
persistInHistory: false
},
// Character stats toggle (optional feature)
characterStats: {
enabled: false,
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
]
}
}
},
quests: {
main: "None", // Current main quest title
optional: [] // Array of optional quest titles
},
infoBox: JSON.stringify({
date: { value: new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) },
weather: { emoji: '☀️', forecast: 'Clear skies' },
temperature: { value: 20, unit: 'C' },
time: { start: '00:00', end: '00:00' },
location: { value: 'Unknown Location' }
}, null, 2),
characterThoughts: JSON.stringify({
characters: []
}, null, 2),
level: 1, // User's character level
classicStats: {
str: 10,
dex: 10,
con: 10,
int: 10,
wis: 10,
cha: 10
},
lastDiceRoll: null, // Store last dice roll result
showDiceDisplay: true, // Show the "Last Roll" display in the panel
collapsedInventoryLocations: [], // Array of collapsed storage location names
inventoryViewModes: {
onPerson: 'list', // 'list' or 'grid' view mode for On Person section
stored: 'list', // 'list' or 'grid' view mode for Stored section
assets: 'list' // 'list' or 'grid' view mode for Assets section
},
npcAvatars: {}, // Store custom avatar images for NPCs (key: character name, value: base64 data URI)
// Combat encounter settings
encounterSettings: {
enabled: true, // Show Start Encounter button above chat input
historyDepth: 8, // Number of recent messages to include in combat initialization
autoSaveLogs: false // Save detailed combat logs to file
},
// Auto avatar generation settings
autoGenerateAvatars: true, // Master toggle for auto-generating avatars
avatarLLMCustomInstruction: '', // Custom instruction for LLM prompt generation
// External API settings for 'external' generation mode
externalApiSettings: {
baseUrl: '', // OpenAI-compatible API base URL (e.g., "https://api.openai.com/v1")
// apiKey is NOT stored here for security. It is stored in localStorage('rpg_companion_api_key')
model: '', // Model identifier (e.g., "gpt-4o-mini")
maxTokens: 8192, // Maximum tokens for generation
temperature: 0.7 // Temperature setting for generation
},
// Lock state for tracker items (v3 JSON format feature)
lockedItems: {
stats: [], // Array of locked stat IDs (e.g., ["health", "satiety"])
skills: [], // Array of locked skill names (e.g., ["Cooking", "Swordsmanship"])
inventory: {
onPerson: [], // Array of locked item indices (e.g., [0, 2])
clothing: [], // Array of locked item indices
stored: {}, // Object with location keys, each containing array of locked indices (e.g., {"Home": [0, 1]})
assets: [] // Array of locked asset indices
},
quests: {
main: false, // Boolean for main quest lock
optional: [] // Array of locked optional quest indices (e.g., [0, 2])
},
infoBox: {
date: false, // Boolean for date widget lock
weather: false, // Boolean for weather widget lock
temperature: false, // Boolean for temperature widget lock
time: false, // Boolean for time widget lock
location: false, // Boolean for location widget lock
recentEvents: false // Boolean for recent events widget lock
},
characters: {} // Object mapping character names to their locked fields (e.g., {"Sarah": {relationship: true, thoughts: false}})
},
// Preset management for tracker configurations
presetManager: {
// Map of preset ID to preset data (contains name and trackerConfig)
presets: {},
// Map of character/group entity to preset ID (e.g., "char_0": "preset_123", "group_abc": "preset_456")
// Note: This is stored separately and NOT exported with presets
characterAssociations: {},
// Currently active preset ID
activePresetId: null,
// Default preset ID (used when no character association exists)
defaultPresetId: null
}
};
export let extensionSettings = { ...defaultSettings };
/**
* Last generated data from AI response
@@ -349,6 +53,24 @@ export function clearSessionAvatarPrompts() {
sessionAvatarPrompts = {};
}
/**
* Per-chat storage for thought-based Character Expressions portraits.
* Maps normalized character names to the current below-chat portrait URL.
*/
export let thoughtBasedExpressionPortraits = {};
export function setThoughtBasedExpressionPortraits(portraits) {
thoughtBasedExpressionPortraits = portraits && typeof portraits === 'object' ? { ...portraits } : {};
}
export function getThoughtBasedExpressionPortrait(characterName) {
return thoughtBasedExpressionPortraits[characterName] || null;
}
export function clearThoughtBasedExpressionPortraits() {
thoughtBasedExpressionPortraits = {};
}
/**
* Tracks whether the last action was a swipe (for separate mode)
* Used to determine whether to commit lastGeneratedData to committedTrackerData
@@ -371,6 +93,32 @@ export let isPlotProgression = false;
*/
export let isAwaitingNewMessage = false;
/**
* Monotonically-increasing counter used to detect stale separate-mode tracker
* generation results. Incremented each time a new automated generation is
* triggered or a message deletion occurs so any in-flight (or pending) call
* from a previous generation can recognise that its result is no longer valid.
*/
let separateGenerationId = 0;
/**
* Returns the current separate generation ID.
* @returns {number}
*/
export function getSeparateGenerationId() {
return separateGenerationId;
}
/**
* Increments and returns the new separate generation ID.
* Call this when starting a new generation or when a deletion
* invalidates any pending/in-flight generation.
* @returns {number} The new ID
*/
export function incrementSeparateGenerationId() {
return ++separateGenerationId;
}
/**
* Temporary storage for pending dice roll (not saved until user clicks "Save Roll")
*/
@@ -418,6 +166,7 @@ export let $thoughtsContainer = null;
export let $inventoryContainer = null;
export let $questsContainer = null;
export let $musicPlayerContainer = null;
export let $equipmentContainer = null;
/**
* State setters - provide controlled mutation of state variables
@@ -510,3 +259,64 @@ export function setQuestsContainer($element) {
export function setMusicPlayerContainer($element) {
$musicPlayerContainer = $element;
}
export function setEquipmentContainer($element) {
$equipmentContainer = $element;
}
/**
* Clears all cached DOM element references.
* Call this when the panel is destroyed or rebuilt to prevent stale jQuery references.
*/
export function clearDomCache() {
$panelContainer = null;
$userStatsContainer = null;
$infoBoxContainer = null;
$thoughtsContainer = null;
$inventoryContainer = null;
$questsContainer = null;
$musicPlayerContainer = null;
$equipmentContainer = null;
}
/**
* Clears the debug logs array.
* Call this on chat change to prevent memory accumulation across sessions.
*/
export function clearDebugLogs() {
debugLogs.length = 0;
}
/**
* Tracks jQuery delegated event handlers for cleanup.
* Each handler is stored with its namespace for targeted unbinding.
*/
const jqueryEventHandlers = [];
/**
* Register a jQuery delegated event handler for tracking.
* @param {string} event - Event type with optional namespace (e.g., 'click.rpgCompanion')
* @param {string} selector - Delegated selector
* @param {Function} handler - Event handler function
* @returns {Function} The same handler for chaining
*/
export function trackJQueryHandler(event, selector, handler) {
jqueryEventHandlers.push({ event, selector, handler });
return handler;
}
/**
* Unbinds all tracked jQuery delegated event handlers.
* Call this when the extension is disabled or the panel is destroyed.
* @param {Function} $ - jQuery function (passed from caller)
*/
export function cleanupJQueryEvents($) {
for (const { event, selector, handler } of jqueryEventHandlers) {
if (handler) {
$(document).off(event, selector, handler);
} else {
$(document).off(event, selector);
}
}
jqueryEventHandlers.length = 0;
}
+279 -2
View File
@@ -1,8 +1,10 @@
{
"settings.language.label": "Language",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "Enable RPG Companion",
"settings.note": "Toggle to enable/disable the RPG Companion extension. Configure additional settings within the panel itself.",
"template.settingsTitle": "RPG Companion Settings",
@@ -34,6 +36,12 @@
"template.settingsModal.display.showInfoBoxNote": "Display location, time, weather, and recent events.",
"template.settingsModal.display.showPresentCharacters": "Show Present Characters",
"template.settingsModal.display.showPresentCharactersNote": "Display character portraits with their current thoughts and status.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Show Below-Chat Present Characters",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Display a compact Present Characters panel below the chat.",
"template.settingsModal.display.thoughtBasedExpressions": "Thought-Based Expressions",
"template.settingsModal.display.thoughtBasedExpressionsNote": "Use SillyTavern Character Expressions to classify each present character's thoughts for the below-chat panel. May increase token usage depending on the selected Classifier API.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Hide Default Expression Display",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Hide SillyTavern's built-in Character Expressions display.",
"template.settingsModal.display.narratorMode": "Narrator Mode",
"template.settingsModal.display.narratorModeNote": "Use character card as narrator. Infer characters from context instead of using fixed character references.",
"template.settingsModal.display.showInventory": "Show Inventory",
@@ -44,6 +52,8 @@
"template.settingsModal.display.showLockIconsNote": "Display lock/unlock icons on tracker items to prevent AI from modifying them.",
"template.settingsModal.display.showThoughtsInChat": "Show Thoughts",
"template.settingsModal.display.showThoughtsInChatNote": "Display character thoughts as overlay bubbles next to their messages.",
"template.settingsModal.display.showInlineThoughts": "Show Thoughts Below Message Text",
"template.settingsModal.display.showInlineThoughtsNote": "Switch between the default corner thought bubbles and thought cards below the message text.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Always Show Thought Bubble",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Auto-expand thought bubble without clicking the icon first",
"template.settingsModal.display.enableAnimations": "Enable Animations",
@@ -52,6 +62,10 @@
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Display a toggle button to enable/disable HTML formatting in messages.",
"template.settingsModal.display.showDialogueColoringToggle": "Show Colored Dialogues",
"template.settingsModal.display.showDialogueColoringToggleNote": "Display a toggle button to enable/disable colored dialogue formatting.",
"template.settingsModal.display.showDeceptionToggle": "Show Deception System",
"template.settingsModal.display.showDeceptionToggleNote": "Display a toggle button to enable/disable the Deception System for marking lies and deceptions.",
"template.settingsModal.display.showOmniscienceToggle": "Show Omniscience Filter",
"template.settingsModal.display.showOmniscienceToggleNote": "Display a toggle button to enable/disable the Omniscience Filter for filtering hidden events.",
"template.settingsModal.display.showSpotifyMusicToggle": "Show Spotify Music",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Display Spotify music player with AI-suggested scene-appropriate tracks.",
"template.settingsModal.display.showSnowflakesToggle": "Show Snowflakes Effect",
@@ -69,6 +83,12 @@
"template.settingsModal.display.showStartEncounterNote": "Display button to initiate interactive combat encounters.",
"template.settingsModal.display.showDiceDisplay": "Show Dice Roll Display",
"template.settingsModal.display.showDiceDisplayNote": "Display the \"Last Roll\" indicator in the panel.",
"template.settingsModal.display.showCYOAToggle": "Show CYOA",
"template.settingsModal.display.showCYOAToggleNote": "Display a toggle button to enable/disable \"Choose Your Own Adventure\" formatting instruction that makes the model produce five possible actions/dialogues for you to choose from at the end of the output.",
"template.settingsModal.display.weatherPosition.background": "Show in Background",
"template.settingsModal.display.weatherPosition.backgroundNote": "Display weather effects behind the chat (standard behavior).",
"template.settingsModal.display.weatherPosition.foreground": "Show in Foreground",
"template.settingsModal.display.weatherPosition.foregroundNote": "Display weather effects in front of the chat (experimental).",
"template.mainPanel.autoAvatars": "Auto Avatars",
"template.settingsModal.advancedTitle": "Advanced",
"template.settingsModal.advanced.encounterHistoryDepth": "Chat History Depth For Encounters:",
@@ -155,13 +175,16 @@
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI Instruction:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Character Stats",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Track Character Stats",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Create stats to track for each character (displayed as colored bars).",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Create stats to track for each character (displayed as colored numbers).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Add Character Stat",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Last Roll:",
"template.mainPanel.clearLastRoll": "Clear last roll",
"template.mainPanel.immersiveHtml": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Colored Dialogues",
"template.mainPanel.deceptionSystem": "Deception System",
"template.mainPanel.omniscienceFilter": "Omniscience Filter",
"template.mainPanel.cyoa": "CYOA",
"template.mainPanel.spotifyMusic": "Spotify Music",
"template.mainPanel.snowflakesEffect": "Snowflakes Effect",
"template.mainPanel.dynamicWeatherEffects": "Dynamic Weather",
@@ -180,6 +203,12 @@
"global.inventory": "Inventory",
"global.quests": "Quests",
"global.info": "Info",
"global.removeItem": "Remove item",
"global.clickToEdit": "Click to edit",
"global.collapseExpandPanel": "Collapse/Expand Panel",
"global.refreshRpgInfo": "Refresh RPG Info",
"global.showHideApiKey": "Show/Hide API Key",
"global.closeDialog": "Close dialog",
"infobox.noData.title": "No data yet",
"infobox.noData.instruction": "Generate a new response in the roleplay or switch to \"Separate Generation\" in Settings to access and click the \"Refresh RPG Info\" button",
"infobox.recentEvents.title": "Recent Events",
@@ -212,6 +241,13 @@
"inventory.assets.addAssetButton": "Add Asset",
"inventory.assets.addAssetPlaceholder": "Enter asset name...",
"inventory.assets.description": "Assets include vehicles (cars, motorcycles), property (homes, apartments), and major equipment (workshop tools, special items).",
"inventory.onPerson.addItemTitle": "Add new item",
"inventory.clothing.addItemTitle": "Add new clothing item",
"inventory.stored.addLocationTitle": "Add new storage location",
"inventory.stored.addItemToLocationTitle": "Add item to this location",
"inventory.stored.removeLocationTitle": "Remove this storage location",
"inventory.assets.addItemTitle": "Add new asset",
"inventory.assets.removeAssetTitle": "Remove asset",
"quests.section.main": "Main Quest",
"quests.section.optional": "Optional Quests",
"quests.main.title": "Main Quests",
@@ -224,6 +260,8 @@
"quests.optional.addQuestPlaceholder": "Enter optional quest title...",
"quests.optional.empty": "No active optional quests",
"quests.optional.hint": "Optional quests are side objectives that complement your main story.",
"quests.editQuestTitle": "Edit quest",
"quests.removeQuestTitle": "Complete/Remove quest",
"checkpoint.setChapterStart": "Set Chapter Start",
"checkpoint.clearChapterStart": "Clear Chapter Start",
"checkpoint.indicator": "Chapter Start",
@@ -232,5 +270,244 @@
"musicPlayer.noMusic": "AI will suggest music when appropriate for the scene",
"errors.parsingError": "RPG Companion Trackers' parsing error! The model returned an incorrect format. If the issue persists, consider changing the model for generations.",
"settings.recommendedModels.title": "Recommended Models",
"settings.recommendedModels.description": "For the extension to work properly, **it is not recommended to use any models below 20B, especially if they're old.** It works best with the SOTA models such as Deepseek, Claude, GPT, or Gemini."
"settings.recommendedModels.description": "For the extension to work properly, **it is not recommended to use any models below 20B, especially if they're old.** It works best with the SOTA models such as Deepseek, Claude, GPT, or Gemini.",
"thoughts.addCharacter": "Add Character",
"thoughts.locked": "Locked",
"thoughts.unlocked": "Unlocked",
"thoughts.clickToEdit": "Click to edit",
"thoughts.clickToUpload": "Click to upload avatar",
"thoughts.removeCharacter": "Remove character",
"thoughts.empty": "No character data generated yet",
"userStats.level": "LVL",
"userStats.clickToEditLevel": "Click to edit level",
"userStats.statsLocked": "Locked - AI cannot change stats",
"userStats.statsUnlocked": "Unlocked - AI can change stats",
"userStats.clickToEditStatName": "Click to edit stat name",
"userStats.clickToEditStatValue": "Click to edit",
"userStats.moodLocked": "Locked - AI cannot change mood",
"userStats.moodUnlocked": "Unlocked - AI can change mood",
"userStats.clickToEditEmoji": "Click to edit emoji",
"userStats.skillsLocked": "Locked - AI cannot change skills",
"userStats.skillsUnlocked": "Unlocked - AI can change skills",
"userStats.clickToEditSkills": "Click to edit skills",
"userStats.empty": "No statuses generated yet",
"infoBox.clickToEdit": "Click to edit",
"infoBox.locked": "Locked - AI cannot change this",
"infoBox.unlocked": "Unlocked - AI can change this",
"infoBox.weatherFallback": "Weather",
"infoBox.locationFallback": "Location",
"stats.health": "Health",
"stats.satiety": "Satiety",
"stats.energy": "Energy",
"stats.hygiene": "Hygiene",
"stats.arousal": "Arousal",
"stats.str": "STR",
"stats.dex": "DEX",
"stats.con": "CON",
"stats.int": "INT",
"stats.wis": "WIS",
"stats.cha": "CHA",
"stats.displayMode": "Display Mode:",
"stats.displayMode.percentage": "Percentage",
"stats.displayMode.number": "Number",
"dice.title": "Roll Dice",
"dice.numberOfDice": "Number of Dice:",
"dice.diceType": "Dice Type:",
"dice.rolling": "Rolling...",
"dice.result": "Result:",
"dice.saveRoll": "Save Roll",
"preset.createNewPresetTitle": "Create New Preset",
"preset.deleteCurrentPresetTitle": "Delete Current Preset",
"preset.setDefaultPresetTitle": "Set as Default Preset",
"preset.defaultPresetDescription": "This is the default preset",
"preset.label": "Preset:",
"preset.useThisPresetFor": "Use this preset for: ",
"stats.showLevel": "Show Level",
"dateFormat.weekdayMonthYear": "Weekday, Month, Year",
"dateFormat.dayNumericalMonthYear": "Day (Numerical), Month, Year",
"historyPersistence.tabTitle": "History Persistence",
"historyPersistence.settingsTitle": "History Persistence Settings",
"historyPersistence.enable": "Enable History Persistence",
"template.trackerEditorModal.tabs.historyPersistence": "History Persistence",
"historyPersistence.hint": "Inject selected tracker data into historical messages to help the AI maintain continuity for time-sensitive events, weather changes, and location tracking.",
"historyPersistence.sendAllEnabledStats": "Send All Enabled Stats on Refresh",
"historyPersistence.sendAllEnabledStatsHint": "When enabled, Refresh RPG Info will include all enabled stats from the preset in history context, ignoring the individual selections below.",
"historyPersistence.numberOfMessages": "Number of messages to include (0 = all available):",
"historyPersistence.injectionPosition": "Injection Position:",
"historyPersistence.injectionPosition.userMessageEnd": "End of the User's Message",
"historyPersistence.injectionPosition.assistantMessageEnd": "End of the Assistant's Message",
"historyPersistence.customContextPreamble": "Custom Context Preamble:",
"historyPersistence.customContextPreamblePlaceholder": "Context for that moment:",
"historyPersistence.userStatsSection": "User Stats",
"historyPersistence.userStatsHint": "Select which stats should be included in historical messages.",
"historyPersistence.statusSection": "Status (Mood/Conditions)",
"historyPersistence.inventory": "Inventory",
"historyPersistence.quests": "Quests",
"historyPersistence.infoBoxSection": "Info Box",
"historyPersistence.infoBoxHint": "Select which info box fields should be included in historical messages. These are recommended for time tracking.",
"historyPersistence.presentCharactersSection": "Present Characters",
"historyPersistence.presentCharactersHint": "Select which character fields should be included in historical messages.",
"historyPersistence.widget.date": "Date",
"historyPersistence.widget.weather": "Weather",
"historyPersistence.widget.temperature": "Temperature",
"historyPersistence.widget.time": "Time",
"historyPersistence.widget.location": "Location",
"historyPersistence.widget.recentEvents": "Recent Events",
"historyPersistence.thoughts": "Thoughts",
"historyPersistence.skills": "Skills",
"template.promptsEditor.button": "Customize Prompts",
"template.promptsEditor.buttonNote": "Edit all AI prompts used for generation, plot progression, and combat encounters.",
"template.promptsEditor.title": "Customize Prompts",
"template.promptsEditor.description": "Customize the AI prompts used throughout the extension. Leave fields empty to use defaults.",
"template.promptsEditor.restoreDefault": "Restore Default",
"template.promptsEditor.htmlPrompt.title": "HTML Prompt",
"template.promptsEditor.htmlPrompt.note": "Injected when \"Enable Immersive HTML\" is enabled. Affects all generation modes.",
"template.promptsEditor.dialogueColoringPrompt.title": "Dialogue Coloring Prompt",
"template.promptsEditor.dialogueColoringPrompt.note": "Injected when \"Enable Colored Dialogues\" is enabled. Affects all generation modes.",
"template.promptsEditor.deceptionPrompt.title": "Deception System Prompt",
"template.promptsEditor.deceptionPrompt.note": "Injected when \"Enable Deception System\" is enabled. Instructs AI to mark lies and deceptions with hidden tags.",
"template.promptsEditor.omnisciencePrompt.title": "Omniscience Filter Prompt",
"template.promptsEditor.omnisciencePrompt.note": "Injected when \"Enable Omniscience Filter\" is enabled. Instructs AI to separate information the player character cannot perceive into hidden <ofilter> tags.",
"template.promptsEditor.cyoaPrompt.title": "CYOA Prompt",
"template.promptsEditor.cyoaPrompt.note": "Injected when \"Enable CYOA\" is enabled. Instructs AI to end responses with numbered action choices. Uses very high priority (depth 102) to ensure it's the last instruction.",
"template.promptsEditor.spotifyPrompt.title": "Spotify Music Prompt",
"template.promptsEditor.spotifyPrompt.note": "Injected when \"Enable Spotify Music\" is enabled. Asks AI to suggest appropriate music for the scene.",
"template.promptsEditor.narratorPrompt.title": "Narrator Mode Prompt",
"template.promptsEditor.narratorPrompt.note": "Injected when \"Narrator Mode\" is enabled. Instructs AI to infer characters from context.",
"template.promptsEditor.contextPrompt.title": "Context Instructions Prompt",
"template.promptsEditor.contextPrompt.note": "Injected in Separate/External mode after the context summary. Tells the AI how to use the context.",
"template.promptsEditor.randomPlotPrompt.title": "Random Plot Progression Prompt",
"template.promptsEditor.randomPlotPrompt.note": "Injected when the \"Randomized Plot\" button is clicked. Introduces random elements to the story.",
"template.promptsEditor.naturalPlotPrompt.title": "Natural Plot Progression Prompt",
"template.promptsEditor.naturalPlotPrompt.note": "Injected when the \"Natural Plot\" button is clicked. Progresses the story naturally.",
"template.promptsEditor.avatarPrompt.title": "Avatar Generation Instruction",
"template.promptsEditor.avatarPrompt.note": "Instructions for LLM when generating avatar image prompts. Used by Auto-generate Missing Avatars feature.",
"template.promptsEditor.trackerPrompt.title": "Tracker Instructions",
"template.promptsEditor.trackerPrompt.note": "Instruction portion only (format specification is hardcoded). {userName} will be replaced with the user's name.",
"template.promptsEditor.trackerContinuationPrompt.title": "Tracker Continuation Instruction",
"template.promptsEditor.trackerContinuationPrompt.note": "Instructions added after tracker format specifications, telling the AI how to continue the narrative.",
"template.promptsEditor.combatPrompt.title": "Combat Narrative Style Instruction",
"template.promptsEditor.combatPrompt.note": "Writing style instructions for combat encounters. Includes prose quality guidelines and anti-repetition rules. {userName} will be replaced with the user's name.",
"template.settingsModal.mobileFabTitle": "Mobile Button Widgets",
"template.settingsModal.mobileFabNote": "Show compact info widgets around the floating button on mobile. Widgets are positioned automatically.",
"template.settingsModal.mobileFab.enabled": "Enable Floating Mobile Widgets",
"template.settingsModal.mobileFab.enabledNote": "Master toggle to show info widgets around the mobile floating button.",
"template.settingsModal.mobileFab.weatherIcon": "Weather Icon",
"template.settingsModal.mobileFab.weatherDesc": "Weather Description",
"template.settingsModal.mobileFab.clock": "Time/Clock",
"template.settingsModal.mobileFab.date": "Date",
"template.settingsModal.mobileFab.location": "Location",
"template.settingsModal.mobileFab.stats": "Stats (Health, Energy, etc.)",
"template.settingsModal.mobileFab.attributes": "RPG Attributes (STR, DEX, etc.)",
"template.settingsModal.desktopStripTitle": "Desktop Collapsed Strip Widgets",
"template.settingsModal.desktopStripNote": "Show compact info widgets in the collapsed panel strip on desktop. Displays stats vertically without needing to expand the panel.",
"template.settingsModal.desktopStrip.enabled": "Enable Strip Widgets",
"template.settingsModal.desktopStrip.enabledNote": "Shows widgets in the collapsed panel strip for quick access to stats.",
"template.settingsModal.desktopStrip.weatherIcon": "Weather Icon",
"template.settingsModal.desktopStrip.clock": "Time/Clock",
"template.settingsModal.desktopStrip.date": "Date",
"template.settingsModal.desktopStrip.location": "Location",
"template.settingsModal.desktopStrip.stats": "Stats (Health, Energy, etc.)",
"template.settingsModal.desktopStrip.attributes": "RPG Attributes (STR, DEX, etc.)",
"plotProgression.buttons.randomizedPlot": "Randomized Plot",
"plotProgression.buttons.naturalPlot": "Natural Plot",
"plotProgression.buttons.enterEncounter": "Enter Encounter",
"plotProgression.tooltips.randomizedPlot": "Generate a random plot twist or event",
"plotProgression.tooltips.naturalPlot": "Continue the story naturally without twists",
"plotProgression.tooltips.enterEncounter": "Enter combat encounter",
"encounter.configModal.title": "Configure Combat Narrative",
"encounter.configModal.combatNarrativeStyle": "Combat Narrative Style",
"encounter.configModal.combatSummaryStyle": "Combat Summary Style",
"encounter.configModal.labels.tense": "Tense:",
"encounter.configModal.labels.person": "Person:",
"encounter.configModal.labels.narration": "Narration:",
"encounter.configModal.labels.pointOfView": "Point of View:",
"encounter.configModal.options.present": "Present",
"encounter.configModal.options.past": "Past",
"encounter.configModal.options.firstPerson": "First Person",
"encounter.configModal.options.secondPerson": "Second Person",
"encounter.configModal.options.thirdPerson": "Third Person",
"encounter.configModal.options.omniscient": "Omniscient",
"encounter.configModal.options.limited": "Limited",
"encounter.configModal.placeholders.narrator": "narrator",
"encounter.configModal.rememberSettings": "Remember these settings for future encounters",
"encounter.configModal.buttons.proceed": "Proceed",
"encounter.ui.concludeEncounterTitle": "Conclude encounter early",
"encounter.ui.closeTitle": "Close (ends combat)",
"encounter.ui.initializingCombat": "Initializing combat...",
"encounter.ui.combatBegins": "Combat begins!",
"encounter.ui.allEnemies": "All Enemies",
"encounter.ui.areaOfEffect": "Area of Effect",
"encounter.ui.youHaveBeenDefeated": "You have been defeated...",
"encounter.ui.attacks": "Attacks",
"encounter.ui.items": "Items",
"encounter.ui.customAction": "Custom Action",
"encounter.ui.customActionPlaceholder": "Describe what you want to do...",
"encounter.ui.generatingCombatSummary": "Generating combat summary...",
"encounter.ui.pleaseWait": "Please wait...",
"encounter.ui.failedToCreateSummary": "Failed to create summary. You can close this window.",
"encounter.ui.wrongFormatDetected": "Wrong Format Detected",
"encounter.ui.concludeEncounterButton": "Conclude Encounter",
"encounter.ui.combatEncounterTitle": "Combat Encounter",
"encounter.ui.errorGeneratingCombatSummary": "Error generating combat summary.",
"encounter.ui.closeCombatWindow": "Close Combat Window",
"encounter.ui.combatLog": "Combat Log",
"encounter.ui.selectTarget": "Select Target",
"encounter.ui.submit": "Submit",
"encounter.ui.regenerate": "Regenerate",
"encounter.ui.or": "OR",
"global.locked": "Locked",
"global.unlocked": "Unlocked",
"global.confirm": "Confirm",
"global.equipment": "Equipment",
"equipment.title": "Equipment",
"equipment.createItem": "Create Equipment",
"equipment.createItemTitle": "Create Equipment",
"equipment.editItemTitle": "Edit Equipment",
"equipment.name": "Name",
"equipment.namePlaceholder": "Enter equipment name...",
"equipment.type": "Type",
"equipment.stats": "Stats",
"equipment.description": "Description",
"equipment.descriptionPlaceholder": "Enter description (optional)...",
"equipment.emptySlot": "Empty",
"equipment.unequip": "Unequip",
"equipment.equip": "Equip",
"equipment.editItem": "Edit item",
"equipment.deleteItem": "Delete item",
"equipment.inventoryTitle": "Inventory",
"equipment.slots.helmet": "Helmet",
"equipment.slots.necklace": "Necklace",
"equipment.slots.bodyArmor": "Body Armor",
"equipment.slots.gloves": "Gloves",
"equipment.slots.pants": "Pants",
"equipment.slots.shoes": "Shoes",
"equipment.slots.ring1": "Ring 1",
"equipment.slots.ring2": "Ring 2",
"equipment.slots.ring3": "Ring 3",
"equipment.slots.ring4": "Ring 4",
"equipment.slots.ring5": "Ring 5",
"equipment.slots.ring6": "Ring 6",
"equipment.slots.ring7": "Ring 7",
"equipment.slots.ring8": "Ring 8",
"equipment.slots.ring9": "Ring 9",
"equipment.slots.ring10": "Ring 10",
"equipment.slots.accessory1": "Accessory 1",
"equipment.slots.accessory2": "Accessory 2",
"equipment.slots.accessory3": "Accessory 3",
"equipment.types.helmet": "Helmet",
"equipment.types.necklace": "Necklace",
"equipment.types.bodyArmor": "Body Armor",
"equipment.types.gloves": "Gloves",
"equipment.types.pants": "Pants",
"equipment.types.shoes": "Shoes",
"equipment.types.ring": "Ring",
"equipment.types.accessory": "Accessory",
"template.settingsModal.display.showEquipment": "Show Equipment",
"template.settingsModal.display.showEquipmentNote": "Manage equipped gear and stat bonuses from items.",
"inventory.addItemPlaceholder": "Enter item name...",
"inventory.stored.removeLocationConfirm": "Remove \"{location}\"? This will delete all items stored there.",
"userStats.clickToEdit": "Click to edit",
"quests.main.addQuestTitle": "Add main quests",
"quests.optional.addQuestTitle": "Add optional quest"
}
+286
View File
@@ -0,0 +1,286 @@
{
"settings.language.label": "Langue",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "Activer RPG Companion",
"settings.note": "Basculez pour activer/désactiver l'extension RPG Companion. Configurez des paramètres supplémentaires dans le panneau lui-même.",
"template.settingsTitle": "Paramètres RPG Companion",
"template.settingsModal.themeTitle": "Thème",
"template.settingsModal.themeLabel": "Thème Visuel :",
"template.settingsModal.themeOptions.default": "Défaut",
"template.settingsModal.themeOptions.sciFi": "Sci-Fi (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Fantasy (Parchemin Rustique)",
"template.settingsModal.themeOptions.cyberpunk": "Cyberpunk (Grille Néon)",
"template.settingsModal.themeOptions.custom": "Personnalisé",
"template.settingsModal.themeOptions.custom.background": "Arrière-plan :",
"template.settingsModal.themeOptions.custom.accent": "Accent :",
"template.settingsModal.themeOptions.custom.text": "Texte :",
"template.settingsModal.themeOptions.custom.highlight": "Surlignage :",
"template.settingsModal.theme.statBarLow": "Couleur Barre Stat (Bas) :",
"template.settingsModal.theme.statBarLowNote": "Couleur lorsque les stats sont à 0%.",
"template.settingsModal.theme.statBarHigh": "Couleur Barre Stat (Haut) :",
"template.settingsModal.theme.statBarHighNote": "Couleur lorsque les stats sont à 100%.",
"template.settingsModal.displayTitle": "Options d'Affichage",
"template.settingsModal.displayNote": "Vous pouvez activer/désactiver l'extension complète RPG Companion dans l'onglet Extensions de SillyTavern.",
"template.settingsModal.display.panelPosition": "Position du Panneau :",
"template.settingsModal.display.panelPositionOptions.right": "Barre Latérale Droite",
"template.settingsModal.display.panelPositionOptions.left": "Barre Latérale Gauche",
"template.settingsModal.display.toggleAutoUpdate": "Mise à jour auto après messages",
"template.settingsModal.display.toggleAutoUpdateNote": "Rafraîchir automatiquement les infos RPG après chaque message.",
"template.settingsModal.display.showUserStats": "Afficher Stats Utilisateur",
"template.settingsModal.display.showUserStatsNote": "Activer les Stats Utilisateur pour suivre les statistiques, l'humeur, les attributs, les compétences, etc. de votre persona.",
"template.settingsModal.display.showInfoBox": "Afficher Boîte Info",
"template.settingsModal.display.showInfoBoxNote": "Afficher le lieu, l'heure, la météo et les événements récents.",
"template.settingsModal.display.showPresentCharacters": "Afficher Personnages Présents",
"template.settingsModal.display.showPresentCharactersNote": "Afficher les portraits des personnages avec leurs pensées actuelles et leur statut.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Afficher les personnages sous le chat",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Afficher un panneau compact des personnages présents sous le chat.",
"template.settingsModal.display.thoughtBasedExpressions": "Expressions basées sur les pensées",
"template.settingsModal.display.thoughtBasedExpressionsNote": "Utiliser Character Expressions de SillyTavern pour classifier les pensées de chaque personnage présent dans le panneau sous le chat. L'utilisation de tokens peut augmenter selon l'API de classification sélectionnée.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Masquer l'affichage d'expressions par défaut",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Masquer l'affichage intégré des expressions de personnage de SillyTavern.",
"template.settingsModal.display.narratorMode": "Mode Narrateur",
"template.settingsModal.display.narratorModeNote": "Utiliser la carte de personnage comme narrateur. Déduire les personnages du contexte au lieu d'utiliser des références de personnages fixes.",
"template.settingsModal.display.showInventory": "Afficher Inventaire",
"template.settingsModal.display.showInventoryNote": "Suivre les objets transportés, les vêtements portés, les objets stockés et les biens.",
"template.settingsModal.display.showQuests": "Afficher Quêtes",
"template.settingsModal.display.showQuestsNote": "Gérer les quêtes principales et optionnelles avec des objectifs.",
"template.settingsModal.display.showLockIcons": "Afficher Icônes Verrouillage",
"template.settingsModal.display.showLockIconsNote": "Afficher les icônes de verrouillage/déverrouillage sur les éléments de suivi pour empêcher l'IA de les modifier.",
"template.settingsModal.display.showThoughtsInChat": "Afficher Pensées",
"template.settingsModal.display.showThoughtsInChatNote": "Afficher les pensées des personnages sous forme de bulles superposées à côté de leurs messages.",
"template.settingsModal.display.showInlineThoughts": "Afficher les pensées sous le texte du message",
"template.settingsModal.display.showInlineThoughtsNote": "Basculer entre les bulles de pensée dans le coin par défaut et des cartes de pensée sous le texte du message.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Toujours Afficher Bulle Pensée",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Développer automatiquement la bulle de pensée sans cliquer sur l'icône d'abord",
"template.settingsModal.display.enableAnimations": "Activer Animations",
"template.settingsModal.display.enableAnimationsNote": "Transitions fluides pour les stats, les mises à jour de contenu et les lancers de dés.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Afficher HTML Immersif",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Afficher un bouton pour activer/désactiver le formatage HTML dans les messages.",
"template.settingsModal.display.showDialogueColoringToggle": "Afficher Dialogues Colorés",
"template.settingsModal.display.showDialogueColoringToggleNote": "Afficher un bouton pour activer/désactiver le formatage des dialogues colorés.",
"template.settingsModal.display.showDeceptionToggle": "Afficher Système Déception",
"template.settingsModal.display.showDeceptionToggleNote": "Afficher un bouton pour activer/désactiver le Système de Déception pour marquer les mensonges.",
"template.settingsModal.display.showOmniscienceToggle": "Afficher Filtre Omniscience",
"template.settingsModal.display.showOmniscienceToggleNote": "Afficher un bouton pour activer/désactiver le Filtre d'Omniscience pour filtrer les événements cachés.",
"template.settingsModal.display.showSpotifyMusicToggle": "Afficher Musique Spotify",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Afficher le lecteur Spotify avec des pistes suggérées par l'IA appropriées à la scène.",
"template.settingsModal.display.showSnowflakesToggle": "Afficher Effet Flocons",
"template.settingsModal.display.showDynamicWeatherToggle": "Afficher Effets Météo Dynamiques",
"template.settingsModal.display.showDynamicWeatherToggleNote": "Afficher un bouton pour activer/désactiver les effets météo animés.",
"template.settingsModal.display.showNarratorMode": "Afficher Mode Narrateur",
"template.settingsModal.display.showNarratorModeNote": "Afficher un bouton pour activer/désactiver le mode narrateur (déduire les personnages du contexte).",
"template.settingsModal.display.showAutoAvatars": "Afficher Génération Auto Avatars",
"template.settingsModal.display.showAutoAvatarsNote": "Afficher un bouton pour générer automatiquement des avatars pour les personnages sans image.",
"template.settingsModal.display.showRandomizedPlot": "Afficher Progression Intrigue Aléatoire",
"template.settingsModal.display.showRandomizedPlotNote": "Afficher un bouton pour des invites de progression d'intrigue générées aléatoirement par l'IA.",
"template.settingsModal.display.showNaturalPlot": "Afficher Progression Intrigue Naturelle",
"template.settingsModal.display.showNaturalPlotNote": "Afficher un bouton pour des invites de continuation narrative conscientes du contexte.",
"template.settingsModal.display.showStartEncounter": "Afficher Démarrer Rencontre",
"template.settingsModal.display.showStartEncounterNote": "Afficher un bouton pour initier des rencontres de combat interactives.",
"template.settingsModal.display.showDiceDisplay": "Afficher Lancer de Dés",
"template.settingsModal.display.showDiceDisplayNote": "Afficher l'indicateur \"Dernier Lancer\" dans le panneau.",
"template.mainPanel.autoAvatars": "Avatars Auto",
"template.settingsModal.advancedTitle": "Avancé",
"template.settingsModal.advanced.encounterHistoryDepth": "Profondeur Historique Rencontre :",
"template.settingsModal.advanced.encounterHistoryDepthNote": "Nombre de messages récents à inclure dans l'initialisation du combat.",
"template.settingsModal.advanced.autoSaveCombatLogs": "Sauvegarde Auto Journaux Combat",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "Sauvegarder les journaux de combat détaillés dans un fichier pour référence future et analyse.",
"template.settingsModal.advanced.clearCacheNote": "Efface les données de suivi validées et affichées pour votre chat actuellement actif.",
"template.settingsModal.advanced.generationMode": "Mode de Génération :",
"template.settingsModal.advanced.generationModeOptions.together": "Ensemble avec Génération Principale",
"template.settingsModal.advanced.generationModeOptions.separate": "Génération Séparée",
"template.settingsModal.advanced.generationModeNote": "Ensemble : Ajoute le suivi RPG au jeu de rôle principal. Séparé : Génère les données RPG séparément (manuel ou auto). Externe : Se connecte directement à un point de terminaison compatible OpenAI.",
"template.settingsModal.advanced.generationModeOptions.external": "API Externe",
"template.settingsModal.advanced.externalApi.title": "Paramètres API Externe",
"template.settingsModal.advanced.externalApi.baseUrl": "URL de base API",
"template.settingsModal.advanced.externalApi.baseUrlNote": "Point de terminaison compatible OpenAI (ex: OpenAI, OpenRouter, serveur LLM local).",
"template.settingsModal.advanced.externalApi.apiKey": "Clé API",
"template.settingsModal.advanced.externalApi.apiKeyNote": "Votre clé API pour le service externe.",
"template.settingsModal.advanced.externalApi.model": "Modèle",
"template.settingsModal.advanced.externalApi.modelNote": "Identifiant du modèle (ex: gpt-4o-mini, claude-3-haiku, mistral-7b).",
"template.settingsModal.advanced.externalApi.maxTokens": "Max Tokens",
"template.settingsModal.advanced.externalApi.temperature": "Température",
"template.settingsModal.advanced.externalApi.testConnection": "Tester Connexion",
"template.settingsModal.advanced.contextMessages": "Messages de Contexte :",
"template.settingsModal.advanced.contextMessagesNote": "Nombre de messages récents à inclure.",
"template.settingsModal.advanced.useSeparatePreset": "Utiliser modèle connecté au preset RPG Companion Trackers",
"template.settingsModal.advanced.useSeparatePresetNote": "Si activé, la génération de suivi utilisera le modèle du preset \"RPG Companion Trackers\" au lieu de votre modèle API principal. Le preset sera commuté automatiquement pendant la génération et restauré après. Sélectionnez le modèle souhaité dans ce preset et assurez-vous que l'option \"Lier les presets aux connexions API\" est activée (à côté des boutons import/export preset).",
"template.settingsModal.advanced.skipInjections": "Sauter Injections Pendant Générations Guidées :",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Ne jamais sauter",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Seulement sur demandes d'imitation",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Toujours pour invites guidées ou silencieuses",
"template.settingsModal.advanced.skipInjectionsNote": "Si défini, l'extension n'injectera pas les invites de suivi, exemples ou instructions HTML selon le mode sélectionné lorsqu'une génération guidée (via `instruct` ou `quiet_prompt`) est détectée. Utile lors de l'utilisation de GuidedGenerations ou extensions similaires.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Invite HTML Personnalisée :",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Restaurer Défaut",
"template.settingsModal.advanced.customHtmlPromptNote": "Personnalisez l'invite HTML injectée lorsque \"Activer HTML Immersif\" est activé. L'invite par défaut est affichée ci-dessus - vous pouvez l'éditer directement ou la remplacer entièrement. Cliquez sur \"Restaurer Défaut\" pour réinitialiser. Cela affecte tous les modes de génération (ensemble, séparé et progression d'intrigue).",
"template.settingsModal.advanced.clearCache": "Effacer Cache Extension",
"template.settingsModal.advanced.resetFabPositions": "Réinitialiser Positions Boutons",
"template.settingsModal.advanced.resetFabPositionsNote": "Réinitialise tous les boutons d'action flottants (bascule, rafraîchir, debug) à leurs positions par défaut en haut à gauche. Utile si les boutons sont hors écran.",
"template.trackerEditorModal.title": "Éditer Suivis",
"template.trackerEditorModal.tabs.userStats": "Stats Utilisateur",
"template.trackerEditorModal.tabs.infoBox": "Boîte Info",
"template.trackerEditorModal.tabs.presentCharacters": "Personnages Présents",
"template.trackerEditorModal.buttons.reset": "Réinitialiser",
"template.trackerEditorModal.buttons.cancel": "Annuler",
"template.trackerEditorModal.buttons.save": "Sauvegarder & Appliquer",
"template.trackerEditorModal.buttons.export": "Exporter",
"template.trackerEditorModal.buttons.import": "Importer",
"template.trackerEditorModal.messages.exportSuccess": "Preset de suivi exporté avec succès !",
"template.trackerEditorModal.messages.exportError": "Échec de l'exportation du preset. Vérifiez la console pour les détails.",
"template.trackerEditorModal.messages.importSuccess": "Preset de suivi importé avec succès !",
"template.trackerEditorModal.messages.importError": "Échec de l'importation du preset",
"template.trackerEditorModal.messages.importConfirm": "Ceci remplacera votre configuration actuelle de suivi. Continuer ?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "Stats Personnalisées",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "Ajouter Stat Perso",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "Attributs RPG",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "Activer Section Attributs RPG",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "Toujours Inclure Attributs dans Invite",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "Si désactivé, les attributs ne sont envoyés que lorsqu'un lancer de dé est actif.",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "Ajouter Attribut",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "Section Statut",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "Activer Section Statut",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "Afficher Emoji Humeur",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "Champs Statut (séparés par virgule) :",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "Section Compétences",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "Activer Section Compétences",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "Libellé Compétences :",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "Liste Compétences (séparées par virgule) :",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "Widgets",
"template.trackerEditorModal.infoBoxTab.dateWidget": "Date",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "Météo",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "Température",
"template.trackerEditorModal.infoBoxTab.timeWidget": "Heure",
"template.trackerEditorModal.infoBoxTab.locationWidget": "Lieu",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "Événements Récents",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "Champs Statut Relation",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "Activer Champs Statut Relation",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "Définir les types de relation avec les emojis correspondants affichés sur les portraits des personnages.",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "Nouvelle Relation",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "Champs Apparence/Comportement",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "Champs affichés sous le nom du personnage.",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "Ajouter Champ Perso",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "Configuration Pensées",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "Activer Pensées Personnage",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "Libellé Pensées :",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "Instruction IA :",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "Stats Personnage",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "Suivre Stats Personnage",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "Créer des stats à suivre pour chaque personnage (affichées comme nombres colorés).",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "Ajouter Stat Personnage",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "Dernier Lancer :",
"template.mainPanel.clearLastRoll": "Effacer dernier lancer",
"template.mainPanel.immersiveHtml": "HTML Immersif",
"template.mainPanel.coloredDialogues": "Dialogues Colorés",
"template.mainPanel.deceptionSystem": "Système Déception",
"template.mainPanel.omniscienceFilter": "Filtre Omniscience",
"template.mainPanel.spotifyMusic": "Musique Spotify",
"template.mainPanel.snowflakesEffect": "Effet Flocons",
"template.mainPanel.dynamicWeatherEffects": "Météo Dynamique",
"template.mainPanel.narratorMode": "Mode Narrateur",
"template.mainPanel.refreshRpgInfo": "Rafraîchir Infos RPG",
"template.mainPanel.updating": "Mise à jour...",
"template.mainPanel.editTrackersButton": "Éditer Suivis",
"template.mainPanel.settingsButton": "Paramètres",
"global.none": "Aucun",
"global.add": "Ajouter",
"global.cancel": "Annuler",
"global.listView": "Vue liste",
"global.gridView": "Vue grille",
"global.save": "Sauvegarder",
"global.status": "Statut",
"global.inventory": "Inventaire",
"global.quests": "Quêtes",
"global.info": "Info",
"infobox.noData.title": "Pas de données",
"infobox.noData.instruction": "Générez une nouvelle réponse dans le jeu de rôle ou basculez vers \"Génération Séparée\" dans les Paramètres pour accéder et cliquer sur le bouton \"Rafraîchir Infos RPG\"",
"infobox.recentEvents.title": "Événements Récents",
"infobox.recentEvents.addEventPlaceholder": "Ajouter événement...",
"inventory.section.onPerson": "Sur Soi",
"inventory.section.clothing": "Vêtements",
"inventory.section.stored": "Stocké",
"inventory.section.assets": "Biens",
"inventory.onPerson.empty": "Aucun objet porté",
"inventory.onPerson.title": "Objets Actuellement Portés",
"inventory.onPerson.addItemButton": "Ajouter Objet",
"inventory.onPerson.addItemPlaceholder": "Entrer nom objet...",
"inventory.clothing.empty": "Ne porte rien",
"inventory.clothing.title": "Vêtements & Armure",
"inventory.clothing.addItemButton": "Ajouter Vêtement",
"inventory.clothing.addItemPlaceholder": "Entrer vêtement...",
"inventory.stored.title": "Lieux de Stockage",
"inventory.stored.addLocationButton": "Ajouter Lieu",
"inventory.stored.addLocationPlaceholder": "Entrer nom lieu...",
"inventory.stored.saveButton": "Sauvegarder",
"inventory.stored.empty": "Aucun lieu de stockage. Cliquez sur \"Ajouter Lieu\" pour en créer un.",
"inventory.stored.noItems": "Aucun objet stocké ici",
"inventory.stored.addItemToLocationPlaceholder": "Entrer nom objet...",
"inventory.stored.addItemButton": "Ajouter Objet",
"inventory.stored.confirmRemoveLocationMessage": "Supprimer \"${location}\" ? Cela supprimera tous les objets stockés ici.",
"inventory.stored.confirmRemoveLocationConfirmButton": "Confirmer",
"inventory.assets.empty": "Aucun bien possédé",
"inventory.assets.title": "Véhicules, Propriétés & Possessions Majeures",
"inventory.assets.addAssetModalTitle": "Ajouter Bien",
"inventory.assets.addAssetButton": "Ajouter Bien",
"inventory.assets.addAssetPlaceholder": "Entrer nom bien...",
"inventory.assets.description": "Les biens incluent les véhicules (voitures, motos), les propriétés (maisons, appartements) et les équipements majeurs (outils d'atelier, objets spéciaux).",
"quests.section.main": "Quête Principale",
"quests.section.optional": "Quêtes Optionnelles",
"quests.main.title": "Quêtes Principales",
"quests.main.addQuestButton": "Ajouter Quête",
"quests.main.addQuestPlaceholder": "Entrer titre quête principale...",
"quests.main.empty": "Aucune quête principale active",
"quests.main.hint": "La quête principale représente votre objectif principal dans l'histoire.",
"quests.optional.title": "Quêtes Optionnelles",
"quests.optional.addQuestButton": "Ajouter Quête",
"quests.optional.addQuestPlaceholder": "Entrer titre quête optionnelle...",
"quests.optional.empty": "Aucune quête optionnelle active",
"quests.optional.hint": "Les quêtes optionnelles sont des objectifs secondaires qui complètent votre histoire principale.",
"checkpoint.setChapterStart": "Définir Début Chapitre",
"checkpoint.clearChapterStart": "Effacer Début Chapitre",
"checkpoint.indicator": "Début Chapitre",
"checkpoint.tooltip": "Les messages avant ce point sont exclus du contexte",
"musicPlayer.title": "Musique de Scène",
"musicPlayer.noMusic": "L'IA suggérera de la musique quand approprié pour la scène",
"errors.parsingError": "Erreur de parsing RPG Companion Trackers ! Le modèle a renvoyé un format incorrect. Si le problème persiste, envisagez de changer le modèle pour les générations.",
"settings.recommendedModels.title": "Modèles Recommandés",
"settings.recommendedModels.description": "Pour que l'extension fonctionne correctement, **il n'est pas recommandé d'utiliser des modèles de moins de 20B, surtout s'ils sont anciens.** Elle fonctionne mieux avec les modèles SOTA tels que Deepseek, Claude, GPT ou Gemini.",
"thoughts.addCharacter": "Ajouter un personnage",
"thoughts.locked": "Verrouillé",
"thoughts.unlocked": "Déverrouillé",
"thoughts.clickToEdit": "Cliquer pour modifier",
"thoughts.clickToUpload": "Cliquer pour télécharger un avatar",
"thoughts.removeCharacter": "Supprimer le personnage",
"userStats.level": "NIV",
"userStats.clickToEditLevel": "Cliquer pour modifier le niveau",
"userStats.statsLocked": "Verrouillé - L'IA ne peut pas modifier les stats",
"userStats.statsUnlocked": "Déverrouillé - L'IA peut modifier les stats",
"userStats.clickToEditStatName": "Cliquer pour modifier le nom",
"userStats.clickToEditStatValue": "Cliquer pour modifier",
"userStats.moodLocked": "Verrouillé - L'IA ne peut pas modifier l'humeur",
"userStats.moodUnlocked": "Déverrouillé - L'IA peut modifier l'humeur",
"userStats.clickToEditEmoji": "Cliquer pour modifier l'émoji",
"userStats.skillsLocked": "Verrouillé - L'IA ne peut pas modifier les compétences",
"userStats.skillsUnlocked": "Déverrouillé - L'IA peut modifier les compétences",
"userStats.clickToEditSkills": "Cliquer pour modifier les compétences",
"infoBox.clickToEdit": "Cliquer pour modifier",
"infoBox.locked": "Verrouillé - L'IA ne peut pas modifier ceci",
"infoBox.unlocked": "Déverrouillé - L'IA peut modifier ceci",
"infoBox.weatherFallback": "Météo",
"infoBox.locationFallback": "Lieu",
"stats.health": "Santé",
"stats.satiety": "Satiété",
"stats.energy": "Énergie",
"stats.hygiene": "Hygiène",
"stats.arousal": "Excitation",
"stats.str": "FOR",
"stats.dex": "DEX",
"stats.con": "CON",
"stats.int": "INT",
"stats.wis": "VOL",
"stats.cha": "CHA"
}
+9
View File
@@ -1,6 +1,7 @@
{
"settings.language.label": "Язык",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.extensionEnabled": "Включить RPG Companion",
@@ -34,6 +35,12 @@
"template.settingsModal.display.showInfoBoxNote": "Отображение локации, времени, погоды и недавних событий.",
"template.settingsModal.display.showPresentCharacters": "Показывать персонажей",
"template.settingsModal.display.showPresentCharactersNote": "Показывать портреты персонажей с их текущимы мыслями и статусом.",
"template.settingsModal.display.showBelowChatPresentCharacters": "Показывать персонажей под чатом",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "Показывать компактную панель персонажей под чатом.",
"template.settingsModal.display.thoughtBasedExpressions": "Выражения на основе мыслей",
"template.settingsModal.display.thoughtBasedExpressionsNote": "Использовать Character Expressions в SillyTavern для классификации мыслей каждого присутствующего персонажа в панели под чатом. Расход токенов может увеличиться в зависимости от выбранного API классификации.",
"template.settingsModal.display.hideDefaultExpressionDisplay": "Скрыть отображение выражений по умолчанию",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "Скрыть встроенное отображение выражений персонажей SillyTavern.",
"template.settingsModal.display.narratorMode": "Режим расказчика",
"template.settingsModal.display.narratorModeNote": "Использовать карточку персонажа в качестве расказчика. Персонажи берутся из контекста вместо фиксированных отсылок.",
"template.settingsModal.display.showInventory": "Показывать инвентарь",
@@ -44,6 +51,8 @@
"template.settingsModal.display.showLockIconsNote": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.",
"template.settingsModal.display.showThoughtsInChat": "Показывать мысли",
"template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.",
"template.settingsModal.display.showInlineThoughts": "Показывать мысли под текстом сообщения",
"template.settingsModal.display.showInlineThoughtsNote": "Переключает между стандартными угловыми пузырями мыслей и карточками мыслей под текстом сообщения.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок",
"template.settingsModal.display.enableAnimations": "Включить анимации",
+264
View File
@@ -0,0 +1,264 @@
const fs = require('fs-extra');
const path = require('path');
const chokidar = require('chokidar');
const glob = require('glob');
const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled');
function findUnlocalizedText() {
const srcArg = process.argv.find(arg => arg.startsWith('--src='));
const srcDir = srcArg ? srcArg.split('=')[1] : '.';
console.log(`\n🔎 Scanning for unlocalized text in ${srcDir}...`);
const files = glob.sync(`${srcDir}/**/*.{html,js,jsx}`, {
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
});
if (files.length === 0) {
console.log('⚠️ No .html/.js/.jsx files found');
return;
}
let totalFound = 0;
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const lines = content.split('\n');
const relPath = path.relative(process.cwd(), file);
// Searching for string number
lines.forEach((line, index) => {
let match;
const localPattern = /<([a-zA-Z][a-zA-Z0-9]*)(?:\s(?:[^>](?!data-i18n-key))*)?>([\p{L}\p{N}\s\-.,!?:'"()]+)<\/\1>/gu;
while ((match = localPattern.exec(line)) !== null) {
const text = match[2].trim();
if (!text) continue;
// Passing JSX expressions like {someVar}
if (text.includes('{') || text.includes('}')) continue;
// Passing if tag has data-i18n-key
if (match[0].includes('data-i18n-key')) continue;
console.log(` - ${relPath}:${index + 1} — <${match[1]}> "${text}"`);
totalFound++;
}
});
}
if (totalFound === 0) {
console.log('✅ No unlocalized text found!');
} else {
console.log(`\n📋 Found ${totalFound} potentially unlocalized text node(s)`);
}
}
// Function to validate translations
function validateTranslations() {
console.log('🔍 Validating translation files...');
// Parse --locales=en,fr argument
const localesArg = process.argv.find(arg => arg.startsWith('--locales='));
const selectedLocales = localesArg
? localesArg.split('=')[1].split(',').map(l => l.trim())
: null;
const files = fs.readdirSync(COMPILED_DIR)
.filter(file => file.endsWith('.json'))
.filter(file => {
const locale = path.basename(file, '.json');
return !selectedLocales || selectedLocales.includes(locale);
});
if (files.length === 0) {
console.log('⚠️ No compiled translation files found');
return;
}
// Load all translation data
const translations = {};
for (const file of files) {
const locale = path.basename(file, '.json');
const filePath = path.join(COMPILED_DIR, file);
translations[locale] = fs.readJsonSync(filePath);
}
// Get all locales
const locales = Object.keys(translations);
console.log(`📁 Found ${locales.length} locales: ${locales.join(', ')}`);
if (locales.length < 2) {
console.log('⚠️ Need at least 2 locales to compare');
return;
}
// Choose the first locale as reference
const referenceLocale = locales[0];
console.log(`🔑 Using ${referenceLocale} as reference locale`);
// Get all keys from reference locale
const referenceKeys = Object.keys(translations[referenceLocale]);
console.log(`🔢 Reference locale has ${referenceKeys.size} unique keys`);
// Track statistics
const stats = {
missingKeys: {},
extraKeys: {},
typeErrors: {}
};
// Initialize stats for each locale
for (const locale of locales) {
if (locale !== referenceLocale) {
stats.missingKeys[locale] = [];
stats.extraKeys[locale] = [];
stats.typeErrors[locale] = [];
}
}
// Check each locale against the reference
for (const locale of locales) {
if (locale === referenceLocale) continue;
const localeKeys = Object.keys(translations[locale]);
// Check for missing keys
for (const key of referenceKeys) {
if (!key in translations[locale]) {
stats.missingKeys[locale].push(key);
} else {
// Check for type mismatches
const refValue = translations[referenceLocale][key];
const localeValue = translations[locale][key];
if (typeof refValue !== typeof localeValue) {
stats.typeErrors[locale].push({
key,
refType: typeof refValue,
localeType: typeof localeValue
});
}
}
}
// Check for extra keys
for (const key of localeKeys) {
if (!key in translations[referenceLocale]) {
stats.extraKeys[locale].push(key);
}
}
}
// Print results
let hasIssues = false;
// Print missing keys
for (const locale in stats.missingKeys) {
const missing = stats.missingKeys[locale];
if (missing.length > 0) {
hasIssues = true;
console.log(`❌ ${locale} is missing ${missing.length} keys:`);
missing.forEach(key => {
console.log(` - ${key}`);
});
}
}
// Print extra keys
for (const locale in stats.extraKeys) {
const extra = stats.extraKeys[locale];
if (extra.length > 0) {
hasIssues = true;
console.log(`⚠️ ${locale} has ${extra.length} extra keys:`);
extra.forEach(key => {
console.log(` - ${key}`);
});
}
}
// Print type errors
for (const locale in stats.typeErrors) {
const typeErrors = stats.typeErrors[locale];
if (typeErrors.length > 0) {
hasIssues = true;
console.log(`⚠️ ${locale} has ${typeErrors.length} type mismatches:`);
typeErrors.forEach(err => {
console.log(` - ${err.key}: expected ${err.refType}, got ${err.localeType}`);
});
}
}
// Print empty values check if needed
console.log('\n📊 Checking for empty values...');
for (const locale of locales) {
checkEmptyValues(translations[locale], locale);
}
if (!hasIssues) {
console.log('✅ All locales have consistent structure!');
}
return hasIssues;
}
// Function to check for empty values
function checkEmptyValues(obj, locale, prefix = '') {
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (value === '') {
console.log(`⚠️ ${locale} has empty string at ${fullKey}`);
} else if (value === null) {
console.log(`⚠️ ${locale} has null value at ${fullKey}`);
} else if (typeof value === 'object' && !Array.isArray(value)) {
checkEmptyValues(value, locale, fullKey);
}
}
}
// Main function
function main() {
// Create compiled directory if it doesn't exist
fs.ensureDirSync(COMPILED_DIR);
// Run validation
validateTranslations();
// Find unlocalized text
findUnlocalizedText();
}
// Watch mode
if (process.argv.includes('--watch')) {
console.log('👀 Watching for changes...');
let debounceTimer;
const debounceDelay = 100;
// Initial validation
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
main();
}, debounceDelay);
// Watch for changes in the compiled directory
chokidar.watch(COMPILED_DIR, {
ignoreInitial: true,
ignored: /.*~$/, // Игнорировать скрытые файлы
}).on('all', (event, path) => {
if (event === 'change' || event === 'add' || event === 'unlink') {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`🔁 Detected changes in ${path} (${event}), revalidating...`);
main();
}, debounceDelay);
}
});
} else {
// Run once
main();
}
+493
View File
@@ -0,0 +1,493 @@
{
"settings.language.label": "语言",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "启用 RPG Companion",
"settings.note": "切换以启用/禁用 RPG Companion 扩展。其他设置可在面板内配置。",
"template.settingsTitle": "RPG Companion 设置",
"template.settingsModal.themeTitle": "主题",
"template.settingsModal.themeLabel": "视觉主题:",
"template.settingsModal.themeOptions.default": "默认",
"template.settingsModal.themeOptions.sciFi": "科幻 (合成波)",
"template.settingsModal.themeOptions.fantasy": "奇幻 (古朴羊皮纸)",
"template.settingsModal.themeOptions.cyberpunk": "赛博朋克 (霓虹网格)",
"template.settingsModal.themeOptions.custom": "自定义",
"template.settingsModal.themeOptions.custom.background": "背景:",
"template.settingsModal.themeOptions.custom.accent": "强调色:",
"template.settingsModal.themeOptions.custom.text": "文字:",
"template.settingsModal.themeOptions.custom.highlight": "高亮:",
"template.settingsModal.theme.statBarLow": "状态条颜色 (低):",
"template.settingsModal.theme.statBarLowNote": "数值为 0% 时的颜色。",
"template.settingsModal.theme.statBarHigh": "状态条颜色 (高):",
"template.settingsModal.theme.statBarHighNote": "数值为 100% 时的颜色。",
"template.settingsModal.displayTitle": "显示选项",
"template.settingsModal.displayNote": "您可以在 SillyTavern 的扩展标签页中启用/禁用整个 RPG Companion 扩展。",
"template.settingsModal.display.panelPosition": "面板位置:",
"template.settingsModal.display.panelPositionOptions.right": "右侧边栏",
"template.settingsModal.display.panelPositionOptions.left": "左侧边栏",
"template.settingsModal.display.toggleAutoUpdate": "消息后自动更新",
"template.settingsModal.display.toggleAutoUpdateNote": "每条消息后自动刷新 RPG 信息。",
"template.settingsModal.display.showUserStats": "显示用户数值",
"template.settingsModal.display.showUserStatsNote": "启用用户数值,跟踪您角色的数值、心情、属性、技能等。",
"template.settingsModal.display.showInfoBox": "显示信息框",
"template.settingsModal.display.showInfoBoxNote": "显示位置、时间、天气和最近事件。",
"template.settingsModal.display.showPresentCharacters": "显示在场角色",
"template.settingsModal.display.showPresentCharactersNote": "显示角色肖像及其当前想法和状态。",
"template.settingsModal.display.showBelowChatPresentCharacters": "显示聊天下方的在场角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方显示紧凑的在场角色面板。",
"template.settingsModal.display.thoughtBasedExpressions": "基于想法的表情",
"template.settingsModal.display.thoughtBasedExpressionsNote": "使用 SillyTavern Character Expressions 对聊天下方面板中每个在场角色的想法进行分类。Token 用量可能会因所选的分类 API 而增加。",
"template.settingsModal.display.hideDefaultExpressionDisplay": "隐藏默认表情显示",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "隐藏 SillyTavern 内置的角色表情显示。",
"template.settingsModal.display.narratorMode": "旁白模式",
"template.settingsModal.display.narratorModeNote": "使用角色卡作为旁白。根据上下文推断角色,而非使用固定的角色引用。",
"template.settingsModal.display.showInventory": "显示物品栏",
"template.settingsModal.display.showInventoryNote": "跟踪携带的物品、穿戴的衣物、存储的物品和资产。",
"template.settingsModal.display.showQuests": "显示任务",
"template.settingsModal.display.showQuestsNote": "管理带有目标的主要和可选任务。",
"template.settingsModal.display.showLockIcons": "显示锁定/解锁跟踪器",
"template.settingsModal.display.showLockIconsNote": "在跟踪器项目上显示锁定/解锁图标,以防止 AI 修改它们。",
"template.settingsModal.display.showThoughtsInChat": "显示想法",
"template.settingsModal.display.showThoughtsInChatNote": "将角色想法显示为其消息旁边的气泡。",
"template.settingsModal.display.showInlineThoughts": "在消息文本下方显示想法",
"template.settingsModal.display.showInlineThoughtsNote": "在默认角落想法气泡和显示在消息文本下方的想法卡片之间切换。",
"template.settingsModal.display.alwaysShowThoughtBubble": "始终显示想法气泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自动展开想法气泡,无需先点击图标",
"template.settingsModal.display.enableAnimations": "启用动画",
"template.settingsModal.display.enableAnimationsNote": "数值、内容更新和掷骰的平滑过渡。",
"template.settingsModal.display.showImmersiveHtmlToggle": "显示沉浸式 HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "显示一个切换按钮以启用/禁用消息中的 HTML 格式。",
"template.settingsModal.display.showDialogueColoringToggle": "显示彩色对话",
"template.settingsModal.display.showDialogueColoringToggleNote": "显示一个切换按钮以启用/禁用彩色对话格式。",
"template.settingsModal.display.showDeceptionToggle": "显示欺骗系统",
"template.settingsModal.display.showDeceptionToggleNote": "显示一个切换按钮以启用/禁用用于标记谎言和欺骗的欺骗系统。",
"template.settingsModal.display.showOmniscienceToggle": "显示全知过滤器",
"template.settingsModal.display.showOmniscienceToggleNote": "显示一个切换按钮以启用/禁用用于过滤隐藏事件的全知过滤器。",
"template.settingsModal.display.showSpotifyMusicToggle": "显示 Spotify 音乐",
"template.settingsModal.display.showSpotifyMusicToggleNote": "显示 Spotify 音乐播放器,带有 AI 推荐的适合场景的曲目。",
"template.settingsModal.display.showSnowflakesToggle": "显示雪花效果",
"template.settingsModal.display.showDynamicWeatherToggle": "显示动态天气效果",
"template.settingsModal.display.showDynamicWeatherToggleNote": "显示一个切换按钮以启用/禁用动画天气效果。",
"template.settingsModal.display.showNarratorMode": "显示旁白模式",
"template.settingsModal.display.showNarratorModeNote": "显示一个切换按钮以启用/禁用旁白模式(根据上下文推断角色)。",
"template.settingsModal.display.showAutoAvatars": "显示自动生成头像",
"template.settingsModal.display.showAutoAvatarsNote": "显示一个切换按钮以自动为没有图片的角色生成头像。",
"template.settingsModal.display.showRandomizedPlot": "显示随机化剧情推进",
"template.settingsModal.display.showRandomizedPlotNote": "显示用于 AI 生成的随机剧情推进提示的按钮。",
"template.settingsModal.display.showNaturalPlot": "显示自然剧情推进",
"template.settingsModal.display.showNaturalPlotNote": "显示用于上下文感知的叙事延续提示的按钮。",
"template.settingsModal.display.showStartEncounter": "显示开始遭遇",
"template.settingsModal.display.showStartEncounterNote": "显示按钮以启动交互式战斗遭遇。",
"template.settingsModal.display.showDiceDisplay": "显示掷骰显示",
"template.settingsModal.display.showDiceDisplayNote": "在面板中显示“上次掷骰”指示器。",
"template.settingsModal.display.showCYOAToggle": "显示选择冒险",
"template.settingsModal.display.showCYOAToggleNote": "显示一个切换按钮,用于启用/禁用“选择你自己的冒险”格式指令,该指令使模型在输出结束时生成五个可能的行动/对话供你选择。",
"template.settingsModal.display.weatherPosition.background": "在背景中显示",
"template.settingsModal.display.weatherPosition.backgroundNote": "在聊天背景中显示天气效果(标准行为)。",
"template.settingsModal.display.weatherPosition.foreground": "在前景中显示",
"template.settingsModal.display.weatherPosition.foregroundNote": "在聊天前景中显示天气效果(实验性)。",
"template.mainPanel.autoAvatars": "自动头像",
"template.settingsModal.advancedTitle": "高级",
"template.settingsModal.advanced.encounterHistoryDepth": "遭遇战的聊天历史深度:",
"template.settingsModal.advanced.encounterHistoryDepthNote": "包含在战斗初始化中的最近消息数量。",
"template.settingsModal.advanced.autoSaveCombatLogs": "自动保存战斗日志",
"template.settingsModal.advanced.autoSaveCombatLogsNote": "将详细战斗日志保存到文件以供将来参考和分析。",
"template.settingsModal.advanced.clearCacheNote": "清除当前活动聊天中已提交和显示的跟踪器数据。",
"template.settingsModal.advanced.generationMode": "生成模式:",
"template.settingsModal.advanced.generationModeOptions.together": "集成生成",
"template.settingsModal.advanced.generationModeOptions.separate": "单独生成",
"template.settingsModal.advanced.generationModeNote": "集成:将 RPG 跟踪添加到主要角色扮演中。单独:单独生成 RPG 数据(手动或自动)。外部:直接连接到 OpenAI 兼容端点。",
"template.settingsModal.advanced.generationModeOptions.external": "外部 API",
"template.settingsModal.advanced.externalApi.title": "外部 API 设置",
"template.settingsModal.advanced.externalApi.baseUrl": "API 基础 URL",
"template.settingsModal.advanced.externalApi.baseUrlNote": "OpenAI 兼容端点(例如 OpenAI、OpenRouter、本地 LLM 服务器)。",
"template.settingsModal.advanced.externalApi.apiKey": "API 密钥",
"template.settingsModal.advanced.externalApi.apiKeyNote": "您的外部服务 API 密钥。",
"template.settingsModal.advanced.externalApi.model": "模型",
"template.settingsModal.advanced.externalApi.modelNote": "模型标识符(例如 gpt-4o-mini、claude-3-haiku、mistral-7b)。",
"template.settingsModal.advanced.externalApi.maxTokens": "最大token数",
"template.settingsModal.advanced.externalApi.temperature": "温度",
"template.settingsModal.advanced.externalApi.testConnection": "测试连接",
"template.settingsModal.advanced.contextMessages": "上下文消息:",
"template.settingsModal.advanced.contextMessagesNote": "包含的最近消息数量。",
"template.settingsModal.advanced.useSeparatePreset": "使用连接到 RPG Companion Trackers 预设的模型",
"template.settingsModal.advanced.useSeparatePresetNote": "启用后,跟踪器生成将使用“RPG Companion Trackers”预设中的模型,而不是您的主 API 模型。预设将在生成期间自动切换并在之后恢复。在该预设中选择所需模型,并确保“将预设绑定到 API 连接”切换已打开(位于导入/导出预设按钮旁边)。",
"template.settingsModal.advanced.skipInjections": "在引导生成期间跳过注入:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "从不跳过",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "仅在模拟请求时",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "始终针对引导或静默提示",
"template.settingsModal.advanced.skipInjectionsNote": "设置后,当检测到引导生成(通过 `instruct` 或 `quiet_prompt`)时,扩展将不会根据所选模式注入跟踪器提示、示例或 HTML 指令。在使用 GuidedGenerations 或类似扩展时很有用。",
"template.settingsModal.advanced.customHtmlPromptTitle": "自定义 HTML 提示:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "恢复默认",
"template.settingsModal.advanced.customHtmlPromptNote": "自定义启用“启用沉浸式 HTML”时注入的 HTML 提示。默认提示显示在上面 - 您可以直接编辑或完全替换。点击“恢复默认”以重置。这会影响所有生成模式(同时、单独和剧情推进)。",
"template.settingsModal.advanced.clearCache": "清除扩展缓存",
"template.settingsModal.advanced.resetFabPositions": "重置按钮位置",
"template.settingsModal.advanced.resetFabPositionsNote": "将所有浮动操作按钮(切换、刷新、调试)重置为默认的左上角位置。如果按钮在屏幕外,这很有用。",
"template.trackerEditorModal.title": "编辑跟踪器",
"template.trackerEditorModal.tabs.userStats": "用户数值",
"template.trackerEditorModal.tabs.infoBox": "信息框",
"template.trackerEditorModal.tabs.presentCharacters": "在场角色",
"template.trackerEditorModal.buttons.reset": "重置",
"template.trackerEditorModal.buttons.cancel": "取消",
"template.trackerEditorModal.buttons.save": "保存并应用",
"template.trackerEditorModal.buttons.export": "导出",
"template.trackerEditorModal.buttons.import": "导入",
"template.trackerEditorModal.messages.exportSuccess": "跟踪器预设导出成功!",
"template.trackerEditorModal.messages.exportError": "跟踪器预设导出失败。请检查控制台以获取详细信息。",
"template.trackerEditorModal.messages.importSuccess": "跟踪器预设导入成功!",
"template.trackerEditorModal.messages.importError": "跟踪器预设导入失败",
"template.trackerEditorModal.messages.importConfirm": "这将替换您当前的跟踪器配置。继续吗?",
"template.trackerEditorModal.userStatsTab.customStatsTitle": "自定义数值",
"template.trackerEditorModal.userStatsTab.addCustomStatButton": "添加自定义数值",
"template.trackerEditorModal.userStatsTab.rpgAttributesTitle": "RPG 属性",
"template.trackerEditorModal.userStatsTab.enableRpgAttributes": "启用 RPG 属性部分",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributes": "始终在提示中包含属性",
"template.trackerEditorModal.userStatsTab.alwaysIncludeAttributesNote": "如果禁用,属性仅在掷骰活动时发送。",
"template.trackerEditorModal.userStatsTab.addAttributeButton": "添加属性",
"template.trackerEditorModal.userStatsTab.statusSectionTitle": "状态部分",
"template.trackerEditorModal.userStatsTab.enableStatusSection": "启用状态部分",
"template.trackerEditorModal.userStatsTab.showMoodEmoji": "显示心情表情",
"template.trackerEditorModal.userStatsTab.statusFieldsLabel": "状态字段(逗号分隔):",
"template.trackerEditorModal.userStatsTab.skillsSectionTitle": "技能部分",
"template.trackerEditorModal.userStatsTab.enableSkillsSection": "启用技能部分",
"template.trackerEditorModal.userStatsTab.skillsLabelLabel": "技能标签:",
"template.trackerEditorModal.userStatsTab.skillsListLabel": "技能列表(逗号分隔):",
"template.trackerEditorModal.infoBoxTab.widgetsTitle": "小部件",
"template.trackerEditorModal.infoBoxTab.dateWidget": "日期",
"template.trackerEditorModal.infoBoxTab.weatherWidget": "天气",
"template.trackerEditorModal.infoBoxTab.temperatureWidget": "温度",
"template.trackerEditorModal.infoBoxTab.timeWidget": "时间",
"template.trackerEditorModal.infoBoxTab.locationWidget": "位置",
"template.trackerEditorModal.infoBoxTab.recentEventsWidget": "最近事件",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusTitle": "关系状态字段",
"template.trackerEditorModal.presentCharactersTab.enableRelationshipStatus": "启用关系状态字段",
"template.trackerEditorModal.presentCharactersTab.relationshipStatusHint": "定义关系类型,并在角色肖像上显示相应的表情符号。",
"template.trackerEditorModal.presentCharactersTab.newRelationshipButton": "新关系",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorTitle": "外观/举止字段",
"template.trackerEditorModal.presentCharactersTab.appearanceDemeanorHint": "显示在角色名字下方的字段。",
"template.trackerEditorModal.presentCharactersTab.addCustomFieldButton": "添加自定义字段",
"template.trackerEditorModal.presentCharactersTab.thoughtsConfigTitle": "想法配置",
"template.trackerEditorModal.presentCharactersTab.enableCharacterThoughts": "启用角色想法",
"template.trackerEditorModal.presentCharactersTab.thoughtsLabelLabel": "想法标签:",
"template.trackerEditorModal.presentCharactersTab.aiInstructionLabel": "AI 指令:",
"template.trackerEditorModal.presentCharactersTab.characterStatsTitle": "角色数值",
"template.trackerEditorModal.presentCharactersTab.trackCharacterStats": "跟踪角色数值",
"template.trackerEditorModal.presentCharactersTab.characterStatsHint": "为每个角色创建要跟踪的数值(显示为彩色数字)。",
"template.trackerEditorModal.presentCharactersTab.addCharacterStatButton": "添加角色数值",
"template.mainPanel.title": "RPG Companion",
"template.mainPanel.lastRoll": "上次掷骰:",
"template.mainPanel.clearLastRoll": "清除上次掷骰",
"template.mainPanel.immersiveHtml": "沉浸式 HTML",
"template.mainPanel.coloredDialogues": "彩色对话",
"template.mainPanel.deceptionSystem": "欺骗系统",
"template.mainPanel.omniscienceFilter": "全知过滤器",
"template.mainPanel.cyoa": "选择冒险",
"template.mainPanel.spotifyMusic": "Spotify 音乐",
"template.mainPanel.snowflakesEffect": "雪花效果",
"template.mainPanel.dynamicWeatherEffects": "动态天气",
"template.mainPanel.narratorMode": "旁白模式",
"template.mainPanel.refreshRpgInfo": "刷新 RPG 信息",
"template.mainPanel.updating": "更新中...",
"template.mainPanel.editTrackersButton": "编辑跟踪器",
"template.mainPanel.settingsButton": "设置",
"global.none": "无",
"global.add": "添加",
"global.cancel": "取消",
"global.listView": "列表视图",
"global.gridView": "网格视图",
"global.save": "保存",
"global.status": "状态",
"global.inventory": "物品栏",
"global.quests": "任务",
"global.info": "信息",
"global.removeItem": "移除物品",
"global.clickToEdit": "点击编辑",
"global.collapseExpandPanel": "折叠/展开面板",
"global.refreshRpgInfo": "刷新RPG信息",
"global.showHideApiKey": "显示/隐藏API密钥",
"global.closeDialog": "关闭对话框",
"infobox.noData.title": "尚无数据",
"infobox.noData.instruction": "在角色扮演中生成新响应,或在设置中切换到“单独生成”以访问并点击“刷新 RPG 信息”按钮",
"infobox.recentEvents.title": "最近事件",
"infobox.recentEvents.addEventPlaceholder": "添加事件...",
"inventory.section.onPerson": "随身携带",
"inventory.section.clothing": "衣物",
"inventory.section.stored": "存储",
"inventory.section.assets": "资产",
"inventory.onPerson.empty": "未携带任何物品",
"inventory.onPerson.title": "当前携带的物品",
"inventory.onPerson.addItemButton": "添加物品",
"inventory.onPerson.addItemPlaceholder": "输入物品名称...",
"inventory.clothing.empty": "未穿戴任何衣物",
"inventory.clothing.title": "衣物和护甲",
"inventory.clothing.addItemButton": "添加衣物",
"inventory.clothing.addItemPlaceholder": "输入衣物物品...",
"inventory.stored.title": "存储位置",
"inventory.stored.addLocationButton": "添加位置",
"inventory.stored.addLocationPlaceholder": "输入位置名称...",
"inventory.stored.saveButton": "保存",
"inventory.stored.empty": "尚无存储位置。点击“添加位置”以创建一个。",
"inventory.stored.noItems": "此处未存储任何物品",
"inventory.stored.addItemToLocationPlaceholder": "输入物品名称...",
"inventory.stored.addItemButton": "添加物品",
"inventory.stored.confirmRemoveLocationMessage": "删除“${location}”?这将删除存储在该处的所有物品。",
"inventory.stored.confirmRemoveLocationConfirmButton": "确认",
"inventory.assets.empty": "未拥有任何资产",
"inventory.assets.title": "车辆、财产和主要所有物",
"inventory.assets.addAssetModalTitle": "添加资产",
"inventory.assets.addAssetButton": "添加资产",
"inventory.assets.addAssetPlaceholder": "输入资产名称...",
"inventory.assets.description": "资产包括车辆(汽车、摩托车)、财产(房屋、公寓)和主要设备(车间工具、特殊物品)。",
"inventory.onPerson.addItemTitle": "添加新物品",
"inventory.clothing.addItemTitle": "添加新衣物",
"inventory.stored.addLocationTitle": "添加新存储位置",
"inventory.stored.addItemToLocationTitle": "添加物品到此位置",
"inventory.stored.removeLocationTitle": "移除此存储位置",
"inventory.assets.addItemTitle": "添加新资产",
"inventory.assets.removeAssetTitle": "移除资产",
"quests.section.main": "主要任务",
"quests.section.optional": "可选任务",
"quests.main.title": "主要任务",
"quests.main.addQuestButton": "添加任务",
"quests.main.addQuestPlaceholder": "输入主要任务标题...",
"quests.main.empty": "无活跃的主要任务",
"quests.main.hint": "主要任务代表您在故事中的主要目标。",
"quests.optional.title": "可选任务",
"quests.optional.addQuestButton": "添加任务",
"quests.optional.addQuestPlaceholder": "输入可选任务标题...",
"quests.optional.empty": "无活跃的可选任务",
"quests.optional.hint": "可选任务是补充您主要故事的次要目标。",
"quests.editQuestTitle": "编辑任务",
"quests.removeQuestTitle": "完成/移除任务",
"checkpoint.setChapterStart": "设置章节开始",
"checkpoint.clearChapterStart": "清除章节开始",
"checkpoint.indicator": "章节开始",
"checkpoint.tooltip": "此点之前的消息从上下文中排除",
"musicPlayer.title": "场景音乐",
"musicPlayer.noMusic": "AI 将在适合场景时推荐音乐",
"errors.parsingError": "RPG Companion Trackers 解析错误!模型返回了错误的格式。如果问题持续存在,请考虑更换生成模型。",
"settings.recommendedModels.title": "推荐模型",
"settings.recommendedModels.description": "为使扩展正常工作,**不建议使用任何低于 20B 的模型,尤其是旧模型。** 它与 SOTA 模型(如 Deepseek、Claude、GPT 或 Gemini)配合最佳。",
"thoughts.addCharacter": "添加角色",
"thoughts.locked": "已锁定",
"thoughts.unlocked": "已解锁",
"thoughts.clickToEdit": "点击编辑",
"thoughts.clickToUpload": "点击上传头像",
"thoughts.removeCharacter": "移除角色",
"thoughts.empty": "尚无角色数据生成",
"userStats.level": "LVL",
"userStats.clickToEditLevel": "点击编辑等级",
"userStats.statsLocked": "已锁定 - AI 无法更改数值",
"userStats.statsUnlocked": "已解锁 - AI 可以更改数值",
"userStats.clickToEditStatName": "点击编辑数值名称",
"userStats.clickToEditStatValue": "点击编辑",
"userStats.moodLocked": "已锁定 - AI 无法更改心情",
"userStats.moodUnlocked": "已解锁 - AI 可以更改心情",
"userStats.clickToEditEmoji": "点击编辑表情",
"userStats.skillsLocked": "已锁定 - AI 无法更改技能",
"userStats.skillsUnlocked": "已解锁 - AI 可以更改技能",
"userStats.clickToEditSkills": "点击编辑技能",
"userStats.empty": "尚无数值生成",
"infoBox.clickToEdit": "点击编辑",
"infoBox.locked": "已锁定 - AI 无法更改此项",
"infoBox.unlocked": "已解锁 - AI 可以更改此项",
"infoBox.weatherFallback": "天气",
"infoBox.locationFallback": "位置",
"stats.health": "Health",
"stats.satiety": "Satiety",
"stats.energy": "Energy",
"stats.hygiene": "Hygiene",
"stats.arousal": "Arousal",
"stats.str": "STR",
"stats.dex": "DEX",
"stats.con": "CON",
"stats.int": "INT",
"stats.wis": "WIS",
"stats.cha": "CHA",
"stats.displayMode": "显示模式:",
"stats.displayMode.percentage": "百分比",
"stats.displayMode.number": "数值",
"dice.title": "掷骰子",
"dice.numberOfDice": "骰子数量:",
"dice.diceType": "骰子类型:",
"dice.rolling": "掷骰中...",
"dice.result": "结果:",
"dice.saveRoll": "保存掷骰",
"preset.createNewPresetTitle": "创建新预设",
"preset.deleteCurrentPresetTitle": "删除当前预设",
"preset.setDefaultPresetTitle": "设为默认预设",
"preset.defaultPresetDescription": "这是默认预设",
"preset.label": "预设:",
"preset.useThisPresetFor": "将此预设用于:",
"stats.showLevel": "显示等级",
"dateFormat.weekdayMonthYear": "星期,月份,年份",
"dateFormat.dayNumericalMonthYear": "日(数字),月份,年份",
"historyPersistence.tabTitle": "历史持久性",
"historyPersistence.settingsTitle": "历史持久性设置",
"historyPersistence.enable": "启用历史持久性",
"template.trackerEditorModal.tabs.historyPersistence": "历史持久性",
"historyPersistence.hint": "将选定的跟踪器数据注入历史消息中,帮助AI保持时间敏感事件、天气变化和位置跟踪的连续性。",
"historyPersistence.sendAllEnabledStats": "刷新时发送所有启用的数值",
"historyPersistence.sendAllEnabledStatsHint": "启用后,刷新RPG信息将在历史上下文中包含预设中的所有启用数值,忽略下面的单个选择。",
"historyPersistence.numberOfMessages": "包含的消息数量(0 = 所有可用):",
"historyPersistence.injectionPosition": "注入位置:",
"historyPersistence.injectionPosition.userMessageEnd": "用户消息末尾",
"historyPersistence.injectionPosition.assistantMessageEnd": "助手消息末尾",
"historyPersistence.customContextPreamble": "自定义上下文前言:",
"historyPersistence.customContextPreamblePlaceholder": "该时刻的上下文:",
"historyPersistence.userStatsSection": "用户数值",
"historyPersistence.userStatsHint": "选择哪些数值应包含在历史消息中。",
"historyPersistence.statusSection": "状态(心情/状况)",
"historyPersistence.inventory": "物品栏",
"historyPersistence.quests": "任务",
"historyPersistence.infoBoxSection": "信息框",
"historyPersistence.infoBoxHint": "选择哪些信息框字段应包含在历史消息中。这些字段推荐用于时间跟踪。",
"historyPersistence.presentCharactersSection": "当前角色",
"historyPersistence.presentCharactersHint": "选择哪些角色字段应包含在历史消息中。",
"historyPersistence.widget.date": "日期",
"historyPersistence.widget.weather": "天气",
"historyPersistence.widget.temperature": "温度",
"historyPersistence.widget.time": "时间",
"historyPersistence.widget.location": "位置",
"historyPersistence.widget.recentEvents": "近期事件",
"historyPersistence.thoughts": "想法",
"historyPersistence.skills": "技能",
"template.promptsEditor.button": "自定义提示",
"template.promptsEditor.buttonNote": "编辑用于生成、剧情推进和战斗遭遇的所有AI提示。",
"template.promptsEditor.title": "自定义提示",
"template.promptsEditor.description": "自定义整个扩展中使用的AI提示。留空字段以使用默认值。",
"template.promptsEditor.restoreDefault": "恢复默认",
"template.promptsEditor.htmlPrompt.title": "HTML提示",
"template.promptsEditor.htmlPrompt.note": "当“启用沉浸式HTML”启用时注入。影响所有生成模式。",
"template.promptsEditor.dialogueColoringPrompt.title": "对话着色提示",
"template.promptsEditor.dialogueColoringPrompt.note": "当“启用彩色对话”启用时注入。影响所有生成模式。",
"template.promptsEditor.deceptionPrompt.title": "欺骗系统提示",
"template.promptsEditor.deceptionPrompt.note": "当“启用欺骗系统”启用时注入。指示AI用隐藏标签标记谎言和欺骗行为。",
"template.promptsEditor.omnisciencePrompt.title": "全知过滤器提示",
"template.promptsEditor.omnisciencePrompt.note": "当“启用全知过滤器”启用时注入。指示AI将玩家角色无法感知的信息分离到隐藏的过滤器标签中。",
"template.promptsEditor.cyoaPrompt.title": "选择冒险提示",
"template.promptsEditor.cyoaPrompt.note": "当“启用选择冒险”启用时注入。指示AI在回复结尾提供带编号的动作选项。使用非常高的优先级(深度102)确保它是最后一条指令。",
"template.promptsEditor.spotifyPrompt.title": "Spotify音乐提示",
"template.promptsEditor.spotifyPrompt.note": "当“启用Spotify音乐”启用时注入。要求AI为场景推荐合适的音乐。",
"template.promptsEditor.narratorPrompt.title": "旁白模式提示",
"template.promptsEditor.narratorPrompt.note": "当“旁白模式”启用时注入。指示AI从上下文中推断角色信息。",
"template.promptsEditor.contextPrompt.title": "上下文指令提示",
"template.promptsEditor.contextPrompt.note": "在Separate/External模式中,上下文摘要后注入。告诉AI如何使用上下文。",
"template.promptsEditor.randomPlotPrompt.title": "随机剧情推进提示",
"template.promptsEditor.randomPlotPrompt.note": "当点击“随机剧情”按钮时注入。为故事引入随机元素。",
"template.promptsEditor.naturalPlotPrompt.title": "自然剧情推进提示",
"template.promptsEditor.naturalPlotPrompt.note": "当点击“自然剧情”按钮时注入。自然地推进故事发展。",
"template.promptsEditor.avatarPrompt.title": "头像生成指令",
"template.promptsEditor.avatarPrompt.note": "生成头像图像提示时给LLM的指令。用于“自动生成缺失头像”功能。",
"template.promptsEditor.trackerPrompt.title": "跟踪器指令",
"template.promptsEditor.trackerPrompt.note": "仅指令部分(格式规范已硬编码)。{userName}将被替换为用户名称。",
"template.promptsEditor.trackerContinuationPrompt.title": "跟踪器延续指令",
"template.promptsEditor.trackerContinuationPrompt.note": "在跟踪器格式规范后添加的指令,告诉AI如何继续叙事。",
"template.promptsEditor.combatPrompt.title": "战斗叙事风格指令",
"template.promptsEditor.combatPrompt.note": "战斗遭遇的写作风格指令。包括散文质量指南和防重复规则。{userName}将被替换为用户名称。",
"template.settingsModal.mobileFabTitle": "移动按钮小部件",
"template.settingsModal.mobileFabNote": "在移动设备上显示围绕浮动按钮的紧凑信息小部件。小部件自动定位。",
"template.settingsModal.mobileFab.enabled": "启用浮动移动小部件",
"template.settingsModal.mobileFab.enabledNote": "主开关,用于在移动浮动按钮周围显示信息小部件。",
"template.settingsModal.mobileFab.weatherIcon": "天气图标",
"template.settingsModal.mobileFab.weatherDesc": "天气描述",
"template.settingsModal.mobileFab.clock": "时间/时钟",
"template.settingsModal.mobileFab.date": "日期",
"template.settingsModal.mobileFab.location": "位置",
"template.settingsModal.mobileFab.stats": "数值(生命值、能量等)",
"template.settingsModal.mobileFab.attributes": "RPG属性(力量、敏捷等)",
"template.settingsModal.desktopStripTitle": "桌面折叠面板条小部件",
"template.settingsModal.desktopStripNote": "在桌面上的折叠面板条中显示紧凑信息小部件。垂直显示数值,无需展开面板。",
"template.settingsModal.desktopStrip.enabled": "启用面板条小部件",
"template.settingsModal.desktopStrip.enabledNote": "在折叠面板条中显示小部件,以便快速访问数值。",
"template.settingsModal.desktopStrip.weatherIcon": "天气图标",
"template.settingsModal.desktopStrip.clock": "时间/时钟",
"template.settingsModal.desktopStrip.date": "日期",
"template.settingsModal.desktopStrip.location": "位置",
"template.settingsModal.desktopStrip.stats": "数值(生命值、能量等)",
"template.settingsModal.desktopStrip.attributes": "RPG属性(力量、敏捷等)",
"plotProgression.buttons.randomizedPlot": "随机化剧情",
"plotProgression.buttons.naturalPlot": "自然剧情",
"plotProgression.buttons.enterEncounter": "进入遭遇战",
"plotProgression.tooltips.randomizedPlot": "生成随机剧情转折或事件",
"plotProgression.tooltips.naturalPlot": "无转折地自然延续故事",
"plotProgression.tooltips.enterEncounter": "进入战斗遭遇",
"encounter.configModal.title": "配置战斗叙事",
"encounter.configModal.combatNarrativeStyle": "战斗叙事风格",
"encounter.configModal.combatSummaryStyle": "战斗总结风格",
"encounter.configModal.labels.tense": "时态:",
"encounter.configModal.labels.person": "人称:",
"encounter.configModal.labels.narration": "叙述:",
"encounter.configModal.labels.pointOfView": "视角:",
"encounter.configModal.options.present": "现在时",
"encounter.configModal.options.past": "过去时",
"encounter.configModal.options.firstPerson": "第一人称",
"encounter.configModal.options.secondPerson": "第二人称",
"encounter.configModal.options.thirdPerson": "第三人称",
"encounter.configModal.options.omniscient": "全知视角",
"encounter.configModal.options.limited": "有限视角",
"encounter.configModal.placeholders.narrator": "叙述者",
"encounter.configModal.rememberSettings": "记住这些设置以供未来遭遇使用",
"encounter.configModal.buttons.proceed": "继续",
"encounter.ui.concludeEncounterTitle": "提前结束遭遇",
"encounter.ui.closeTitle": "关闭(结束战斗)",
"encounter.ui.initializingCombat": "正在初始化战斗...",
"encounter.ui.initializingCombatEncounter": "正在初始化战斗遭遇...",
"encounter.ui.combatBegins": "战斗开始!",
"encounter.ui.allEnemies": "所有敌人",
"encounter.ui.areaOfEffect": "范围效果",
"encounter.ui.youHaveBeenDefeated": "你已被击败...",
"encounter.ui.attacks": "攻击",
"encounter.ui.items": "物品",
"encounter.ui.customAction": "自定义动作",
"encounter.ui.customActionPlaceholder": "描述你想要做什么...",
"encounter.ui.generatingCombatSummary": "正在生成战斗总结...",
"encounter.ui.pleaseWait": "请稍候...",
"encounter.ui.failedToCreateSummary": "无法创建总结。你可以关闭此窗口。",
"encounter.ui.wrongFormatDetected": "检测到错误格式",
"encounter.ui.concludeEncounterButton": "结束遭遇",
"encounter.ui.combatEncounterTitle": "战斗遭遇",
"encounter.ui.errorGeneratingCombatSummary": "生成战斗总结时出错。",
"encounter.ui.closeCombatWindow": "关闭战斗窗口",
"encounter.ui.combatLog": "战斗日志",
"encounter.ui.selectTarget": "选择目标",
"encounter.ui.submit": "提交",
"encounter.ui.regenerate": "重新生成",
"encounter.ui.or": "或",
"encounter.ui.result.victory": "胜利",
"encounter.ui.result.defeat": "失败",
"encounter.ui.result.fled": "逃跑",
"encounter.ui.result.interrupted": "中断",
"encounter.ui.error.noResponse": "未收到AI响应。模型可能不可用。",
"encounter.ui.error.invalidJsonFormat": "检测到无效的JSON格式。AI返回了格式错误的数据。请确保最大响应长度至少设置为2048个token,否则模型可能会用完token并产生不完整的结构。",
"encounter.ui.error.failedToInitialize": "初始化战斗失败:",
"encounter.ui.error.errorProcessingAction": "处理动作时出错:",
"encounter.ui.combatSummaryAddedBy": "战斗总结已由{speakerName}添加到聊天。",
"encounter.ui.combatSummaryAdded": "战斗总结已添加到聊天。",
"encounter.ui.environment.default": "战斗竞技场",
"encounter.ui.enemiesTitle": "敌人",
"encounter.ui.partyTitle": "队伍",
"encounter.ui.hpSuffix": " HP",
"encounter.ui.playerSuffix": "(你)",
"encounter.ui.confirmConcludeEarly": "提前结束遭遇战并生成总结?",
"encounter.ui.confirmEndCombat": "确定要结束这场战斗遭遇战吗?",
"encounter.ui.enemyDefaultEmoji": "👹",
"encounter.ui.yourActions": "你的行动",
"encounter.ui.attackType.aoe": "范围效果",
"encounter.ui.attackType.both": "单体或范围",
"encounter.ui.attackType.single": "单体目标",
"encounter.ui.targetingAllEnemies": " targeting all enemies!",
"encounter.ui.on": " on ",
"encounter.ui.youPrefix": "你: ",
"global.locked": "已锁定",
"global.unlocked": "已解锁",
"global.confirm": "确认",
"inventory.addItemPlaceholder": "输入物品名称...",
"inventory.stored.removeLocationConfirm": "删除\"{location}\"?这将删除该位置存储的所有物品。",
"userStats.clickToEdit": "点击编辑",
"quests.main.addQuestTitle": "添加主线任务",
"quests.optional.addQuestTitle": "添加可选任务"
}
+10
View File
@@ -1,8 +1,10 @@
{
"settings.language.label": "語言",
"settings.language.option.en": "English",
"settings.language.option.zh-cn": "简体中文",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.language.option.fr": "Français",
"settings.extensionEnabled": "啟用 RPG Companion",
"settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。",
"template.settingsTitle": "RPG Companion 設定",
@@ -30,11 +32,19 @@
"template.settingsModal.display.showUserStats": "顯示 user 屬性",
"template.settingsModal.display.showInfoBox": "顯示資訊框",
"template.settingsModal.display.showPresentCharacters": "顯示在場角色",
"template.settingsModal.display.showBelowChatPresentCharacters": "顯示聊天下方的在場角色",
"template.settingsModal.display.showBelowChatPresentCharactersNote": "在聊天下方顯示精簡的在場角色面板。",
"template.settingsModal.display.thoughtBasedExpressions": "基於想法的表情",
"template.settingsModal.display.thoughtBasedExpressionsNote": "使用 SillyTavern Character Expressions 對聊天下方面板中每個在場角色的想法進行分類。Token 用量可能會依所選的分類 API 而增加。",
"template.settingsModal.display.hideDefaultExpressionDisplay": "隱藏預設表情顯示",
"template.settingsModal.display.hideDefaultExpressionDisplayNote": "隱藏 SillyTavern 內建的角色表情顯示。",
"template.settingsModal.display.showInventory": "顯示物品欄",
"template.settingsModal.display.showLockIcons": "顯示鎖定/解鎖追蹤器",
"template.settingsModal.display.showLockIconsNote": "在追蹤器項目上顯示鎖定/解鎖圖示,以防止 AI 修改它們。",
"template.settingsModal.display.showThoughtsInChat": "在聊天中顯示想法",
"template.settingsModal.display.showThoughtsInChatNote": "將角色想法顯示為其訊息旁的泡泡",
"template.settingsModal.display.showInlineThoughts": "在訊息文字下方顯示想法",
"template.settingsModal.display.showInlineThoughtsNote": "在預設角落想法泡泡與顯示在訊息文字下方的想法卡片之間切換。",
"template.settingsModal.display.alwaysShowThoughtBubble": "始終顯示想法泡泡",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "自動展開想法泡泡",
"template.settingsModal.display.enableAnimations": "啟用動畫",
+265
View File
@@ -0,0 +1,265 @@
/* Ensure chat stays centered when panel is visible */
body:has(.rpg-panel.rpg-position-right) #sheld {
margin: auto !important;
}
body:has(.rpg-panel.rpg-position-left) #sheld {
margin: auto !important;
}
/* ============================================
RPG COMPANION - GAME-LIKE UI - RESPONSIVE
============================================ */
/* CSS Variables for Theming - Default uses SillyTavern's variables */
.rpg-panel,
#rpg-thought-panel,
#rpg-thought-icon,
#rpg-alt-present-characters,
.rpg-mobile-toggle {
--rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9));
--rpg-accent: var(--black30a, rgba(22, 33, 62, 0.9));
--rpg-text: var(--SmartThemeBodyColor, #eaeaea);
--rpg-highlight: var(--SmartThemeQuoteColor, #e94560);
--rpg-border: var(--SmartThemeBorderColor, #4a7ba7);
--rpg-shadow: rgba(0, 0, 0, 0.5);
}
/* Main Panel Container - Fills available margin space */
.rpg-panel {
position: fixed;
top: var(--topBarBlockSize);
bottom: 0;
background: var(--rpg-bg);
box-shadow: 0 0 20px var(--rpg-shadow);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
box-sizing: border-box;
z-index: 1000;
color: var(--rpg-text);
display: flex;
flex-direction: column;
overflow: visible;
transition: width 0.3s ease, left 0.3s ease, right 0.3s ease, transform 0.3s ease;
}
/* Collapsed state - fixed width for collapse */
.rpg-panel.rpg-collapsed {
max-width: 2.5rem !important;
min-width: 2.5rem !important;
left: auto !important;
right: auto !important;
}
.rpg-panel.rpg-collapsed.rpg-position-right {
right: 0 !important;
}
.rpg-panel.rpg-collapsed.rpg-position-left {
left: 0 !important;
}
/* Fix positioning when collapsed - panel should stick to correct edge */
.rpg-panel.rpg-position-right.rpg-collapsed {
left: calc(100vw - 40px);
right: 0;
}
.rpg-panel.rpg-position-left.rpg-collapsed {
right: calc(100vw - 40px);
left: 0;
}
.rpg-panel.rpg-collapsed .rpg-game-container {
opacity: 0;
pointer-events: none;
}
/* Collapse/Expand Toggle Button */
.rpg-collapse-toggle {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 1.875rem;
height: 5rem;
background: var(--rpg-accent);
border: 2px solid var(--rpg-border);
color: var(--rpg-highlight);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
transition: all 0.3s ease;
font-size: clamp(1rem, 1.5vw, 1.5rem);
box-shadow: 0 0 10px var(--rpg-shadow);
pointer-events: auto;
}
.rpg-collapse-toggle:hover {
background: var(--rpg-highlight);
color: var(--rpg-bg);
box-shadow: 0 0 15px var(--rpg-highlight);
}
/* Position collapse button on the left side for right panel */
.rpg-panel.rpg-position-right .rpg-collapse-toggle {
left: -2rem;
border-radius: 0.5em 0 0 0.5em;
}
/* Position collapse button on the right side for left panel */
.rpg-panel.rpg-position-left .rpg-collapse-toggle {
right: -2rem;
border-radius: 0 0.5em 0.5em 0;
}
/* Hide collapse button for top position */
.rpg-panel.rpg-position-top .rpg-collapse-toggle {
display: none;
}
/* Right Position (Default) - Panel fills right margin space from chat edge to screen edge */
.rpg-panel.rpg-position-right {
right: 0;
width: calc(50vw - var(--sheldWidth) / 2);
border-left: 3px solid var(--rpg-border);
box-shadow: -5px 0 20px var(--rpg-shadow);
}
/* Left Position - Panel fills left margin space from screen edge to chat edge */
.rpg-panel.rpg-position-left {
left: 0;
width: calc(50vw - var(--sheldWidth) / 2);
border-right: 3px solid var(--rpg-border);
box-shadow: 5px 0 20px var(--rpg-shadow);
}
/* Top Position */
.rpg-panel.rpg-position-top {
left: 0;
right: 0;
bottom: auto;
width: 100%;
max-width: 100%;
height: auto;
max-height: 18.75rem;
border-bottom: 3px solid var(--rpg-border);
box-shadow: 0 5px 20px var(--rpg-shadow);
}
/* Top Position Layout Adjustments */
.rpg-panel.rpg-position-top .rpg-content-box {
display: flex;
flex-direction: row;
gap: 0.938em;
}
.rpg-panel.rpg-position-top .rpg-section {
flex: 1;
min-width: 0;
}
.rpg-panel.rpg-position-top .rpg-divider {
display: none;
}
.rpg-panel.rpg-position-top .rpg-stats-grid {
gap: 0.5em;
}
.rpg-panel.rpg-position-top .rpg-stat-row {
gap: 0.5em;
}
.rpg-panel.rpg-position-top .rpg-stat-label {
min-width: 5.625rem;
}
.rpg-panel.rpg-position-top .rpg-stat-bar {
height: 1.125rem;
}
/* Mobile Responsiveness */
@media (max-width: 1024px) {
.rpg-panel {
width: 20rem;
max-width: 35vw;
min-width: 16.25rem;
}
}
@media (max-width: 768px) {
.rpg-section-title {
font-size: 1em;
}
}
@media (max-width: 768px) {
/* Left position on mobile/tablet */
body.rpg-panel-position-left #rpg-companion-panel.rpg-panel,
#rpg-companion-panel.rpg-panel.rpg-position-left {
left: 0 !important;
right: auto !important;
width: 85% !important;
height: 100dvh !important;
top: 0 !important;
bottom: 0 !important;
}
/* Right position on mobile/tablet */
body.rpg-panel-position-right #rpg-companion-panel.rpg-panel,
#rpg-companion-panel.rpg-panel.rpg-position-right {
left: auto !important;
right: 0 !important;
width: 85% !important;
height: 100dvh !important;
top: 0 !important;
bottom: 0 !important;
}
/* Top position on mobile */
#rpg-companion-panel.rpg-panel.rpg-position-top {
left: 0 !important;
right: 0 !important;
width: 100% !important;
max-height: 40vh !important;
top: 0 !important;
bottom: auto !important;
}
}
.rpg-panel.rpg-position-top .rpg-stats-title {
font-size: clamp(1rem, 1.3vw, 1.3rem);
}
.rpg-panel.rpg-position-top .rpg-mood {
font-size: clamp(0.875rem, 1vw, 1rem);
padding: 0.375em;
}
.rpg-panel.rpg-position-top .rpg-classic-stats {
margin-top: 0.5em;
padding: 0.5em;
}
.rpg-panel.rpg-position-top .rpg-classic-stats-title {
font-size: clamp(0.875rem, 1vw, 1rem);
margin-bottom: 0.5em;
}
.rpg-panel.rpg-position-top .rpg-classic-stats-grid {
gap: 0.375em;
}
.rpg-panel.rpg-position-top .rpg-classic-stat {
padding: 0.25em;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-label {
font-size: clamp(0.75rem, 0.85vw, 0.875rem);
}
.rpg-panel.rpg-position-top .rpg-classic-stat-value {
font-size: clamp(1rem, 1.3vw, 1.3rem);
}
+318
View File
@@ -0,0 +1,318 @@
/* ============================================
ANIMATIONS
============================================ */
/* Only apply animations when enabled */
.rpg-panel.rpg-animations-enabled .rpg-stat-fill {
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.rpg-panel.rpg-animations-enabled .rpg-stat-bar {
transition: all 0.3s ease;
}
.rpg-panel.rpg-animations-enabled .rpg-info-content,
.rpg-panel.rpg-animations-enabled .rpg-thoughts-content {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.rpg-panel.rpg-animations-enabled .rpg-info-line {
animation: slideInFromLeft 0.5s ease-out backwards;
}
.rpg-panel.rpg-animations-enabled .rpg-info-line:nth-child(1) { animation-delay: 0.1s; }
.rpg-panel.rpg-animations-enabled .rpg-info-line:nth-child(2) { animation-delay: 0.2s; }
.rpg-panel.rpg-animations-enabled .rpg-info-line:nth-child(3) { animation-delay: 0.3s; }
.rpg-panel.rpg-animations-enabled .rpg-info-line:nth-child(4) { animation-delay: 0.4s; }
.rpg-panel.rpg-animations-enabled .rpg-info-line:nth-child(5) { animation-delay: 0.5s; }
.rpg-panel.rpg-animations-enabled .rpg-thoughts-overlay {
animation: fadeInScale 0.6s ease-out;
}
.rpg-panel.rpg-animations-enabled .rpg-mood-value {
transition: all 0.3s ease;
}
.rpg-panel.rpg-animations-enabled .rpg-dice-animation {
animation: none; /* Control separately */
}
/* Disable dice animation when animations are off */
.rpg-panel:not(.rpg-animations-enabled) .rpg-dice-rolling i {
animation: none !important;
}
.rpg-panel:not(.rpg-animations-enabled) .rpg-dice-result-value {
animation: none !important;
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-1.25rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Add updating class for smooth content changes */
.rpg-panel.rpg-animations-enabled .rpg-content-updating {
animation: contentPulse 0.5s ease;
}
@keyframes contentPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.rpg-panel.rpg-position-top .rpg-classic-stat-btn {
padding: 0.125em;
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
}
.rpg-panel.rpg-position-top .rpg-info-content,
.rpg-panel.rpg-position-top .rpg-thoughts-content {
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
max-height: 9.375rem;
}
/* Custom Scrollbar */
.rpg-panel::-webkit-scrollbar,
#rpg-panel-content::-webkit-scrollbar,
.rpg-content-box::-webkit-scrollbar {
width: 0.5rem;
}
.rpg-panel::-webkit-scrollbar-track,
#rpg-panel-content::-webkit-scrollbar-track,
.rpg-content-box::-webkit-scrollbar-track {
background: var(--rpg-accent);
}
.rpg-panel::-webkit-scrollbar-thumb,
#rpg-panel-content::-webkit-scrollbar-thumb,
.rpg-content-box::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 0.25em;
}
.rpg-panel::-webkit-scrollbar-thumb:hover,
#rpg-panel-content::-webkit-scrollbar-thumb:hover,
.rpg-content-box::-webkit-scrollbar-thumb:hover {
background: var(--rpg-border);
}
/* Game Container - Full height flex container */
.rpg-game-container {
padding: 0.75em;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
transition: opacity 0.3s ease;
}
/* Panel Content - Main scrollable area */
#rpg-panel-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375em;
overflow-y: hidden;
overflow-x: hidden;
min-height: 0;
}
/* Header - Fixed size, doesn't grow */
.rpg-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0;
margin-bottom: 0.5em;
padding: 0;
border-bottom: 2px solid var(--rpg-border);
flex-shrink: 0;
}
.rpg-panel-header h3 {
margin: 0;
font-size: clamp(1.25rem, 1.7vw, 1.75rem);
font-weight: bold;
display: flex;
align-items: center;
gap: 0.5em;
color: var(--rpg-highlight);
text-shadow: 0 0 8px var(--rpg-highlight);
letter-spacing: 0.031em;
border-left: none !important;
padding-left: 0 !important;
}
.rpg-panel-header h3 i {
border-left: none !important;
padding-left: 0 !important;
color: var(--rpg-highlight);
}
.rpg-btn-icon {
padding: 0.5em 0.75em;
background: var(--rpg-accent);
border: 2px solid var(--rpg-border);
border-radius: 0.5em;
cursor: pointer;
color: var(--rpg-text);
transition: all 0.3s ease;
}
.rpg-btn-icon:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
transform: scale(1.05);
}
/* Loading Indicator - Fixed size */
.rpg-loading {
text-align: center;
padding: 0.938em;
background: var(--rpg-accent);
border-radius: 0.75em;
color: var(--rpg-highlight);
font-size: clamp(1rem, 1.3vw, 1.3rem);
font-weight: bold;
animation: pulseGlow 1.5s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes pulseGlow {
0%, 100% {
opacity: 1;
box-shadow: 0 0 10px var(--rpg-highlight);
}
50% {
opacity: 0.7;
box-shadow: 0 0 20px var(--rpg-highlight);
}
}
/* Dice Roll Display - More compact */
.rpg-dice-display {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
padding: 0.5em;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5em;
border: 2px solid var(--rpg-border);
font-size: clamp(0.875rem, 1vw, 1rem);
font-weight: bold;
color: var(--rpg-text);
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
}
.rpg-dice-display:hover {
background: var(--rpg-accent);
border-color: var(--rpg-highlight);
transform: scale(1.02);
}
.rpg-dice-display i {
color: var(--rpg-highlight);
font-size: clamp(1rem, 1.3vw, 1.3rem);
}
/* Clear dice roll button */
.rpg-clear-dice-btn {
background: rgba(255, 0, 0, 0.2);
border: 1px solid rgba(255, 0, 0, 0.4);
border-radius: 0.25em;
color: #ff6b6b;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: bold;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
line-height: 1;
margin-left: auto;
}
.rpg-clear-dice-btn:hover {
background: rgba(255, 0, 0, 0.4);
border-color: #ff6b6b;
transform: scale(1.1);
}
.rpg-clear-dice-btn:active {
transform: scale(0.95);
}
/* Unified Content Box - Contains all sections, scales to fit viewport */
.rpg-content-box {
background: linear-gradient(135deg, var(--rpg-accent) 0%, var(--rpg-bg) 100%);
border: 2px solid var(--rpg-border);
border-radius: 0.625em;
padding: 0.5em;
box-shadow: 0 4px 18px var(--rpg-shadow), inset 0 0 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 0.375em;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Section Styling - Flexible to scale with available space */
.rpg-section {
margin: 0;
padding: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.rpg-section:empty {
display: none;
}
/* Divider - More compact */
.rpg-divider {
width: 100%;
height: 0.062rem;
background: linear-gradient(to right, transparent, var(--rpg-highlight), transparent);
margin: 0.5em 0;
position: relative;
flex-shrink: 0;
}
.rpg-divider::after {
display: none; /* Remove diamond decoration for more space */
}
+635
View File
@@ -0,0 +1,635 @@
/* ============================================
USER STATS SECTION
============================================ */
#rpg-user-stats {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: auto;
}
.rpg-stats-section {
text-align: center;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative; /* For absolute positioning of lock icon on mobile */
}
/* Hide the stats header - we'll integrate portrait/inventory into stats content */
.rpg-stats-header {
display: none;
}
.rpg-stats-header-left {
display: none;
}
.rpg-user-portrait {
width: clamp(24px, 4vh, 32px);
height: clamp(24px, 4vh, 32px);
border-radius: 50%;
border: 2px solid var(--rpg-highlight);
box-shadow: 0 0 8px var(--rpg-highlight);
object-fit: cover;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.rpg-user-portrait:hover {
transform: scale(1.1) rotate(5deg);
}
.rpg-stats-title {
display: none;
}
/* Inventory Box - Styled like a treasure chest */
.rpg-inventory-box {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: linear-gradient(135deg, var(--rpg-accent) 0%, var(--rpg-bg) 100%);
border: 2px solid var(--rpg-highlight);
border-radius: clamp(4px, 0.8vh, 6px);
padding: clamp(3px, 0.6vh, 5px);
max-height: clamp(35px, 5vh, 45px);
min-height: clamp(28px, 4vh, 35px);
box-shadow:
inset 0 2px 4px rgba(255, 255, 255, 0.1),
inset 0 -2px 4px rgba(0, 0, 0, 0.3),
0 2px 8px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* Add decorative chest details with pseudo-elements */
.rpg-inventory-box::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 60%;
height: 0.125rem;
background: var(--rpg-highlight);
opacity: 0.5;
}
.rpg-inventory-box::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 80%;
height: 0.062rem;
background: var(--rpg-highlight);
opacity: 0.3;
}
.rpg-inventory-items {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
color: var(--rpg-text);
line-height: 1.3;
overflow-y: auto;
flex: 1;
padding: clamp(1px, 0.3vh, 3px);
text-align: left;
}
.rpg-inventory-items::-webkit-scrollbar {
width: 0.188rem;
}
.rpg-inventory-items::-webkit-scrollbar-track {
background: var(--rpg-bg);
border-radius: 2px;
}
.rpg-inventory-items::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 2px;
}
.rpg-inventory-items::-webkit-scrollbar-thumb:hover {
background: var(--rpg-text);
}
/* Stats Content - Two-column layout */
.rpg-stats-content {
display: flex;
gap: clamp(4px, 0.8vh, 8px);
flex: 1;
min-height: 0;
overflow: hidden;
position: relative; /* For absolute positioning of lock icon */
}
/* Stats Left - Portrait, Bars and mood */
.rpg-stats-left {
flex: 1;
display: flex;
flex-direction: column;
gap: clamp(3px, 0.6vh, 6px);
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.rpg-stats-left::-webkit-scrollbar {
width: 0.188rem;
}
.rpg-stats-left::-webkit-scrollbar-track {
background: var(--rpg-bg);
border-radius: 2px;
}
.rpg-stats-left::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 2px;
}
.rpg-stats-left::-webkit-scrollbar-thumb:hover {
background: var(--rpg-text);
}
/* User info row (portrait, name, separator, level) */
.rpg-user-info-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375em;
font-size: clamp(0.5rem, 0.5vw, 0.625rem);
flex-shrink: 0;
}
/* User portrait */
.rpg-user-portrait {
width: clamp(12px, 1.8vw, 16px);
height: clamp(12px, 1.8vw, 16px);
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
/* User name and level - inline with portrait */
.rpg-user-name {
font-weight: 600;
font-size: 1em;
color: var(--rpg-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rpg-level-label {
font-size: 1em;
font-weight: 600;
color: var(--rpg-text-color);
opacity: 0.7;
}
.rpg-level-value {
font-size: 1em;
font-weight: 700;
color: var(--rpg-highlight-color);
padding: clamp(1px, 0.2vh, 2px) 0.375em;
background: var(--rpg-accent-color);
border-radius: clamp(2px, 0.3vh, 3px);
border: 1px solid var(--rpg-highlight-color);
min-width: 1.5em;
text-align: center;
cursor: text;
transition: all 0.2s ease;
}
.rpg-level-value:hover {
background: var(--rpg-highlight-color);
color: var(--rpg-bg-color);
}
.rpg-level-value:focus {
outline: 2px solid var(--rpg-highlight-color);
outline-offset: 1px;
background: var(--rpg-bg-color);
}
/* Portrait and Inventory row at top of stats-left */
.rpg-stats-left > .rpg-user-portrait,
.rpg-stats-left > .rpg-inventory-box {
flex-shrink: 0;
}
.rpg-stats-left > .rpg-user-portrait {
align-self: flex-start;
}
.rpg-stats-left > .rpg-inventory-box {
width: 100%;
}
/* Stats Right - Attributes */
.rpg-stats-right {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.rpg-stats-grid {
display: flex;
flex-direction: column;
gap: clamp(3px, 0.5vh, 6px);
flex: 1 1 auto;
position: relative; /* For lock icon positioning */
overflow: visible;
}
.rpg-stats-grid::-webkit-scrollbar {
width: 0.188rem;
}
.rpg-stats-grid::-webkit-scrollbar-track {
background: var(--rpg-bg);
border-radius: 2px;
}
.rpg-stats-grid::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 2px;
}
.rpg-stats-grid::-webkit-scrollbar-thumb:hover {
background: var(--rpg-text);
}
.rpg-classic-stats-grid::-webkit-scrollbar {
width: 0.188rem;
}
.rpg-classic-stats-grid::-webkit-scrollbar-track {
background: var(--rpg-bg);
border-radius: 2px;
}
.rpg-classic-stats-grid::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 2px;
}
.rpg-classic-stats-grid::-webkit-scrollbar-thumb:hover {
background: var(--rpg-text);
}
/* Section-level lock icon styling - appears on hover in top-right corner */
.rpg-section-lock-icon {
position: absolute;
top: 8px;
right: 8px;
font-size: 1rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, transform 0.1s ease;
user-select: none;
z-index: 10;
pointer-events: none;
}
/* Show lock icon on hover or when section is locked */
.rpg-stats-content:hover .rpg-section-lock-icon,
.rpg-dashboard-widget:hover .rpg-section-lock-icon,
.rpg-character-card:hover .rpg-section-lock-icon,
.rpg-item-card:hover .rpg-section-lock-icon,
.rpg-item-row:hover .rpg-section-lock-icon,
.rpg-quest-item:hover .rpg-section-lock-icon,
.rpg-section-lock-icon.locked {
opacity: 0.7;
pointer-events: auto;
}
.rpg-section-lock-icon:hover {
opacity: 1;
transform: scale(1.15);
}
/* Mobile: always show lock icons since there's no hover */
@media (max-width: 1000px) {
.rpg-section-lock-icon {
opacity: 0.6 !important;
pointer-events: auto !important;
}
.rpg-section-lock-icon.locked {
opacity: 0.9 !important;
}
}
/* Make containers relative for absolute positioning of lock icon */
.rpg-stats-content,
.rpg-dashboard-widget,
.rpg-character-content,
.rpg-item-card,
.rpg-item-row,
.rpg-quest-item {
position: relative;
}
.rpg-stat-row {
display: flex;
align-items: center;
gap: 0.25em;
flex: 1 1 0;
min-width: fit-content;
max-width: 100%;
width: 100%;
min-height: clamp(12px, 1.8vh, 16px);
transition: background-color 0.2s ease;
overflow: visible;
box-sizing: border-box;
}
/* Old per-field lock styling - remove */
.rpg-lock-icon {
display: none;
}
.rpg-stat-row.rpg-locked {
background-color: transparent;
border-left: none;
padding-left: 0;
}
.rpg-stat-label {
flex: 0 0 auto;
min-width: 3rem;
max-width: 30%;
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: 600;
text-align: left;
color: var(--rpg-text);
text-transform: uppercase;
letter-spacing: 0.019em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-stat-bar {
flex: 1;
min-width: 3.75rem;
max-width: 100%;
width: 100%;
height: clamp(9px, 1.3vh, 11px);
min-height: 9px;
position: relative;
border-radius: 0.375em;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
box-sizing: border-box;
}
.rpg-stat-fill {
position: absolute;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
/* Transition moved to .rpg-animations-enabled */
}
.rpg-stat-value {
color: #fff;
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: bold;
min-width: 1.875rem;
text-align: right;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
/* Compact layout for high zoom / small viewports */
@media (max-height: 750px), (max-width: 1500px) {
.rpg-stat-row {
position: relative;
min-height: clamp(16px, 2.5vh, 20px);
}
.rpg-stat-label {
position: absolute;
left: 0.5em;
top: 50%;
transform: translateY(-50%);
z-index: 2;
min-width: auto;
max-width: calc(100% - 3rem);
font-size: min(0.7vw, 0.875rem);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
overflow: hidden;
white-space: nowrap;
}
.rpg-stat-bar {
width: 100% !important;
max-width: 100% !important;
height: 100%;
min-height: clamp(16px, 2.5vh, 20px);
box-sizing: border-box;
}
.rpg-stat-value {
position: absolute;
right: 0.5em;
top: 50%;
transform: translateY(-50%);
z-index: 2;
min-width: auto;
font-size: clamp(0.75rem, 0.7vw, 0.875rem);
}
.rpg-mood {
font-size: clamp(0.625rem, 1vw, 0.75rem);
flex-shrink: 1;
min-width: 0;
}
.rpg-mood-emoji {
font-size: clamp(0.875rem, 2vw, 1.125rem);
}
.rpg-mood-conditions {
font-size: clamp(0.625rem, 1vw, 0.75rem);
word-wrap: break-word;
overflow-wrap: break-word;
}
}
.rpg-mood {
display: flex;
align-items: center;
gap: 0.375em;
font-size: clamp(0.5rem, 0.5vw, 0.625rem);
padding: clamp(4px, 0.6vh, 6px) 0.375em;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.25em;
border: 1px solid var(--rpg-border);
flex-shrink: 0;
position: relative; /* For lock icon positioning */
}
.rpg-mood-emoji {
font-size: clamp(12px, 1.8vw, 16px);
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.rpg-mood-conditions {
flex: 1;
min-width: 0;
line-height: 1.2;
color: var(--rpg-text);
font-weight: 600;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Skills Section */
.rpg-skills-section {
display: flex;
align-items: center;
gap: 0.375em;
font-size: clamp(0.5rem, 0.5vw, 0.625rem);
padding: clamp(4px, 0.6vh, 6px) 0.375em;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.25em;
border: 1px solid var(--rpg-border);
flex-shrink: 0;
margin-top: 0.375em;
position: relative; /* For lock icon positioning */
}
.rpg-skills-label {
font-weight: 700;
color: var(--rpg-highlight);
flex-shrink: 0;
}
.rpg-skills-value {
flex: 1;
min-width: 0;
line-height: 1.2;
color: var(--rpg-text);
font-weight: 600;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Classic RPG Stats - Will match height of stats box automatically */
.rpg-classic-stats {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.rpg-classic-stats-title {
text-align: center;
font-size: clamp(0.75rem, 0.85vw, 0.875rem);
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.037em;
color: var(--rpg-highlight);
margin-bottom: 0.25em;
flex-shrink: 0;
}
.rpg-classic-stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(2px, 0.4vh, 4px);
flex: 1;
grid-auto-rows: 1fr;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.rpg-classic-stat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: clamp(1px, 0.15vh, 2px);
padding: clamp(3px, 0.5vh, 5px);
background: rgba(0, 0, 0, 0.3);
border-radius: 0.25em;
border: 1px solid var(--rpg-border);
box-sizing: border-box;
}
.rpg-classic-stat-label {
font-size: clamp(0.5rem, 0.5vw, 0.625rem);
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.006em;
color: var(--rpg-text);
line-height: 1;
flex-shrink: 0;
}
.rpg-classic-stat-value {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: bold;
color: var(--rpg-highlight);
text-align: center;
line-height: 1;
flex-shrink: 0;
}
.rpg-classic-stat-buttons {
display: flex;
gap: clamp(3px, 0.4vh, 5px);
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 100%;
}
.rpg-classic-stat-btn {
width: clamp(18px, 2.2vh, 24px);
height: clamp(16px, 2vh, 20px);
padding: 0;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 2px;
color: var(--rpg-text);
font-size: clamp(9px, 1.1vw, 12px);
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
line-height: 1;
flex: 0 0 auto;
}
.rpg-classic-stat-btn:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
transform: scale(1.05);
}
.rpg-classic-stat-btn:active {
transform: scale(0.95);
}
+740
View File
@@ -0,0 +1,740 @@
/* ============================================
INFO BOX SECTION
============================================ */
.rpg-info-header {
font-size: clamp(1rem, 1.3vw, 1.3rem);
font-weight: bold;
margin-bottom: 0.625em;
color: var(--rpg-highlight);
text-shadow: 0 0 8px var(--rpg-highlight);
flex-shrink: 0;
}
.rpg-info-content {
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
line-height: 1.5;
text-align: left;
flex: 1;
min-height: 0;
overflow-y: auto;
}
/* ============================================
INFO BOX - VISUAL DASHBOARD
============================================ */
.rpg-info-section {
background: rgba(0, 0, 0, 0.2);
border: 2px solid var(--rpg-border);
border-radius: 0.75em;
padding: 0.375em;
margin-bottom: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.25em;
align-items: stretch;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
/* Scrollable content wrapper inside info section */
.rpg-info-content {
display: grid;
grid-template-rows: 80px 60px minmax(min-content, auto);
gap: 0.25em;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.rpg-info-content::-webkit-scrollbar {
width: 0.188rem;
}
.rpg-info-content::-webkit-scrollbar-track {
background: var(--rpg-bg);
border-radius: 2px;
}
.rpg-info-content::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 2px;
}
.rpg-info-content::-webkit-scrollbar-thumb:hover {
background: var(--rpg-text);
}
.rpg-dashboard {
display: flex;
gap: 0.25em;
flex: 1;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
}
/* Row 1: 4 equal-width widgets - more square aspect ratio */
.rpg-dashboard-row-1 {
flex: 0 0 auto;
min-height: 0;
height: clamp(60px, 10vh, 80px);
}
.rpg-dashboard-row-1 .rpg-dashboard-widget {
flex: 1 1 0;
min-width: 0;
height: 100%;
aspect-ratio: 1 / 1;
}
/* Row 2: Full-width location */
.rpg-dashboard-row-2 {
flex: 0 0 auto;
min-height: 0;
}
.rpg-dashboard-row-2 .rpg-dashboard-widget {
flex: 1;
width: 100%;
height: 100%;
}
.rpg-dashboard-widget {
background: rgba(0, 0, 0, 0.5);
border: 2px solid var(--rpg-border);
border-radius: 0.375em;
padding: 0.25em;
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.rpg-dashboard-widget:hover {
transform: translateY(-0.125rem);
box-shadow: 0 4px 12px var(--rpg-shadow);
}
/* Location widget - flexible height */
.rpg-location-widget {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.125em;
padding: 0.25em;
overflow: hidden;
}
/* Calendar Widget */
.rpg-calendar-widget {
padding: 0.188em;
container-type: size;
height: 100%;
overflow: hidden;
}
.rpg-calendar-top {
background: var(--rpg-highlight);
color: var(--rpg-bg);
font-size: clamp(0.5rem, 3.5cqh, 0.625rem);
font-weight: bold;
padding: 0.125em 0.375em;
border-radius: 3px 3px 0 0;
width: 100%;
text-align: center;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
max-width: 100%;
line-height: 1.2;
overflow: hidden;
}
.rpg-calendar-day {
background: rgba(255, 255, 255, 0.1);
color: var(--rpg-text);
font-size: clamp(0.625rem, 5cqh, 0.875rem);
font-weight: bold;
padding: 0.25em;
width: 100%;
text-align: center;
border: 2px solid var(--rpg-highlight);
border-top: none;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
line-height: 1.1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
}
.rpg-calendar-day-text {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.rpg-calendar-year {
font-size: clamp(0.5rem, 3.5cqh, 0.625rem);
color: var(--rpg-text);
opacity: 0.7;
margin-top: 0.062em;
width: 100%;
text-align: center;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
max-width: 100%;
line-height: 1.2;
overflow: hidden;
}
/* Minimal scrollbar styling for calendar day */
.rpg-calendar-day::-webkit-scrollbar {
width: 3px;
}
.rpg-calendar-day::-webkit-scrollbar-track {
background: transparent;
}
.rpg-calendar-day::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.rpg-calendar-day::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Weather Widget */
.rpg-weather-widget {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.188em;
padding: 0.25em;
max-width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;
container-type: size;
}
/* Weather Widget Icon */
.rpg-weather-icon {
font-size: clamp(0.875rem, 2.5vw, 1.25rem);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
flex-shrink: 0;
line-height: 1;
}
.rpg-weather-forecast {
font-size: clamp(0.5rem, 4cqh, 0.75rem);
text-align: center;
margin: 0;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.013em;
opacity: 0.85;
line-height: 1.1;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
width: 100%;
white-space: normal;
flex-shrink: 1;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
hyphens: auto;
}
.rpg-weather-forecast.rpg-editable {
margin-top: 0;
}
/* Temperature Widget - Thermometer */
.rpg-temp-widget {
gap: 0.188em;
}
.rpg-thermometer {
position: relative;
width: 1.25rem;
height: 2.5rem;
display: flex;
flex-direction: column;
align-items: center;
}
.rpg-thermometer-tube {
position: relative;
width: 0.5rem;
height: 1.75rem;
background: rgba(255, 255, 255, 0.1);
border: 2px solid var(--rpg-border);
border-radius: 0.625em 0.625em 0 0;
overflow: hidden;
display: flex;
align-items: flex-end;
}
.rpg-thermometer-fill {
width: 100%;
background: linear-gradient(to top, #e94560, #ff6b6b);
transition: height 0.5s ease;
border-radius: 0.5em 0.5em 0 0;
}
.rpg-thermometer-bulb {
position: absolute;
bottom: 0;
width: 0.875rem;
height: 0.875rem;
background: var(--rpg-highlight);
border: 2px solid var(--rpg-border);
border-radius: 50%;
z-index: 1;
}
.rpg-temp-value {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: bold;
color: var(--rpg-text);
text-align: center;
}
/* Clock Widget */
.rpg-clock-widget {
gap: 0.188em;
}
.rpg-clock {
width: 2.625rem;
height: 2.625rem;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
border: 3px solid var(--rpg-border);
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
position: relative;
}
.rpg-clock-face {
width: 100%;
height: 100%;
position: relative;
}
.rpg-clock-hour,
.rpg-clock-minute {
position: absolute;
background: var(--rpg-highlight);
transform-origin: bottom center;
left: 50%;
bottom: 50%;
border-radius: 2px 2px 0 0;
}
.rpg-clock-hour {
width: 0.188rem;
height: 0.75rem;
margin-left: -0.094em;
opacity: 0.9;
}
.rpg-clock-minute {
width: 0.125rem;
height: 1rem;
margin-left: -0.062em;
}
.rpg-clock-center {
position: absolute;
width: 0.312rem;
height: 0.312rem;
background: var(--rpg-highlight);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
}
.rpg-time-value {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: bold;
color: var(--rpg-text);
text-align: center;
margin-top: 0.25em;
}
.rpg-time-range {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25em;
margin-top: 0.25em;
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
white-space: nowrap;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.rpg-time-range .rpg-time-value {
margin-top: 0;
min-width: 2.5em;
}
.rpg-time-separator {
font-size: clamp(0.5rem, 0.5vw, 0.625rem);
color: var(--rpg-text-muted, var(--rpg-text));
opacity: 0.7;
}
/* Minimal scrollbar styling for time range display */
.rpg-time-range::-webkit-scrollbar {
height: 3px;
}
.rpg-time-range::-webkit-scrollbar-track {
background: transparent;
}
.rpg-time-range::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.rpg-time-range::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Location Widget - Map */
.rpg-map-bg {
width: 100%;
height: auto;
max-height: 60%;
padding: 0.5em 0;
margin: 0;
background:
linear-gradient(45deg, rgba(255,255,255,0.05) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.05) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.05) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.05) 75%);
background-size: 6px 6px;
background-position: 0 0, 0 3px, 3px -3px, -3px 0px;
background-color: rgba(0, 0, 0, 0.3);
border: 2px solid var(--rpg-border);
border-radius: 0.25em;
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex: 0 0 auto;
}
.rpg-map-marker {
font-size: clamp(0.875rem, 2.5vw, 1.25rem);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8));
animation: markerPulse 2s ease-in-out infinite;
line-height: 1;
}
@keyframes markerPulse {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
.rpg-location-text {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: bold;
color: var(--rpg-text);
text-align: center;
line-height: 1.2;
padding: 0;
margin: 0;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 100%;
}
/* Minimal scrollbar styling for location text */
.rpg-location-text::-webkit-scrollbar {
width: 3px;
}
.rpg-location-text::-webkit-scrollbar-track {
background: transparent;
}
.rpg-location-text::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.rpg-location-text::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Row 3: Recent Events */
.rpg-dashboard-row-3 {
flex: 0 0 auto;
min-height: 0;
}
.rpg-dashboard-row-3 .rpg-dashboard-widget {
flex: 1;
width: 100%;
}
/* Recent Events Widget - Notebook Style */
.rpg-events-widget {
background: linear-gradient(to bottom,
rgba(255, 248, 220, 0.08) 0%,
rgba(255, 248, 220, 0.12) 50%,
rgba(255, 248, 220, 0.08) 100%);
border: 2px solid var(--rpg-border);
border-radius: 0.375em;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
min-height: 0;
overflow: hidden;
position: relative;
}
/* Notebook paper lines effect */
.rpg-events-widget::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-image: repeating-linear-gradient(
transparent,
transparent calc(1em + 0.5em),
rgba(var(--SmartThemeBorderColor), 0.08) calc(1em + 0.5em),
rgba(var(--SmartThemeBorderColor), 0.08) calc(1em + 0.6em)
);
pointer-events: none;
z-index: 0;
}
.rpg-events-widget:hover {
transform: translateY(-0.125rem);
box-shadow: 0 4px 12px var(--rpg-shadow);
}
/* Notebook ring binding at top */
.rpg-notebook-header {
display: flex;
justify-content: center;
gap: 1.5em;
padding: 0.25em 0;
background: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid var(--rpg-border);
position: relative;
z-index: 1;
}
.rpg-notebook-ring {
width: 0.5em;
height: 0.5em;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%,
rgba(80, 80, 80, 0.4),
rgba(40, 40, 40, 0.6));
border: 1px solid rgba(0, 0, 0, 0.5);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.2),
0 1px 2px rgba(0, 0, 0, 0.3);
}
.rpg-notebook-title {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: bold;
color: var(--rpg-highlight);
text-align: center;
padding: 0.25em 0.5em 0.25em 0.5em;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
position: relative;
z-index: 1;
}
.rpg-notebook-lines {
display: flex;
flex-direction: column;
gap: 0.125em;
padding: 0.25em 0.75em 0.5em 0.75em;
position: relative;
z-index: 1;
overflow-y: auto;
flex: 1 1 auto;
}
.rpg-notebook-lines::-webkit-scrollbar {
width: 0.188rem;
}
.rpg-notebook-lines::-webkit-scrollbar-track {
background: var(--rpg-bg);
border-radius: 2px;
}
.rpg-notebook-lines::-webkit-scrollbar-thumb {
background: var(--rpg-highlight);
border-radius: 2px;
}
.rpg-notebook-lines::-webkit-scrollbar-thumb:hover {
background: var(--rpg-text);
}
.rpg-notebook-line {
display: flex;
align-items: flex-start;
gap: 0.375em;
position: relative;
}
.rpg-notebook-line.rpg-event-add {
opacity: 0.5;
}
.rpg-notebook-line.rpg-event-add:hover {
opacity: 0.8;
}
.rpg-bullet {
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
color: var(--rpg-highlight);
flex-shrink: 0;
line-height: 1.4;
opacity: 0.8;
}
.rpg-event-add .rpg-bullet {
color: var(--rpg-text);
opacity: 0.6;
}
.rpg-event-text {
font-size: clamp(0.563rem, 0.55vw, 0.688rem);
color: var(--rpg-text);
line-height: 1.4;
flex: 1;
word-wrap: break-word;
cursor: text;
padding: 0.125em 0.25em;
border-radius: 0.2em;
transition: background 0.2s ease;
opacity: 0.85;
}
.rpg-event-text.rpg-event-placeholder {
opacity: 0.5;
font-style: italic;
}
.rpg-event-text:hover {
background: rgba(255, 255, 255, 0.05);
opacity: 1;
}
.rpg-event-text:focus {
background: rgba(255, 255, 255, 0.1);
outline: 1px solid var(--rpg-highlight);
opacity: 1;
}
/* Character Status Cards */
.rpg-character-status {
display: flex;
flex-direction: column;
gap: 0.25em;
margin: 0;
padding: 0;
flex-shrink: 0;
}
.rpg-character-status-card {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.25em 0.375em;
background: rgba(0, 0, 0, 0.3);
border-left: 3px solid var(--rpg-highlight);
border-radius: 0.25em;
transition: all 0.2s ease;
}
.rpg-character-status-card:hover {
background: rgba(0, 0, 0, 0.5);
transform: translateX(0.188rem);
}
.rpg-char-emoji {
font-size: clamp(14px, 2.5vw, 18px);
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.rpg-char-details {
flex: 1;
min-width: 0;
}
.rpg-char-name {
font-size: clamp(8px, 1.5vw, 10px);
font-weight: bold;
color: var(--rpg-highlight);
margin-bottom: 0.062em;
}
.rpg-char-traits {
font-size: clamp(7px, 1.3vw, 9px);
color: var(--rpg-text);
opacity: 0.8;
line-height: 1.2;
}/* Old info line styles (legacy) */
.rpg-info-line {
margin: 0.375em 0;
padding: 0.375em 0.625em;
background: rgba(0, 0, 0, 0.2);
border-left: 2px solid var(--rpg-highlight);
border-radius: 0.25em;
transition: all 0.3s ease;
}
.rpg-info-line:hover {
background: rgba(0, 0, 0, 0.4);
transform: translateX(0.312rem);
}
+679
View File
@@ -0,0 +1,679 @@
/* ============================================
CHARACTER THOUGHTS SECTION
============================================ */
.rpg-thoughts-section {
text-align: center;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.rpg-thoughts-header {
font-size: clamp(1rem, 1.3vw, 1.3rem);
font-weight: bold;
margin-bottom: 0.625em;
color: var(--rpg-highlight);
text-shadow: 0 0 8px var(--rpg-highlight);
flex-shrink: 0;
}
.rpg-thoughts-content {
position: relative;
padding: 0.5em;
border-radius: 0.5em;
border-left: 3px solid var(--rpg-highlight);
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.3);
text-align: left;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: clamp(6px, 1vh, 8px);
overflow-y: auto;
overflow-x: hidden;
/* Remove centering for multiple character cards */
}
/* Individual thought item */
.rpg-thought-item {
display: flex;
align-items: flex-start;
gap: 0.625em;
padding: 0.5em;
margin-bottom: 0.5em;
background: rgba(0, 0, 0, 0.3);
border-radius: 0.375em;
width: 100%; /* Ensure items take full width */
box-sizing: border-box; /* Include padding in width calculation */
}
.rpg-thought-item:last-child {
margin-bottom: 0;
}
/* Character avatar with thought bubbles */
.rpg-thought-avatar {
position: relative;
flex-shrink: 0;
}
.rpg-thought-avatar img {
width: clamp(30px, 5vh, 40px);
height: clamp(30px, 5vh, 40px);
border-radius: 50%;
border: 2px solid var(--rpg-highlight);
object-fit: cover;
}
/* Thought bubbles - Left side, ascending size from bottom-left to top-right */
.rpg-thought-bubbles {
position: absolute;
bottom: -0.25rem;
left: -0.5rem;
display: flex;
flex-direction: column;
gap: 0.125em;
align-items: flex-start;
}
.rpg-bubble {
background: var(--rpg-highlight);
border-radius: 50%;
opacity: 0.8;
}
.rpg-bubble-1 {
width: 0.5rem;
height: 0.5rem;
}
.rpg-bubble-2 {
width: 0.375rem;
height: 0.375rem;
margin-left: 0.125em;
}
/* Thought content */
.rpg-thought-content {
flex: 1;
min-width: 0;
}
.rpg-thought-name {
font-weight: bold;
color: var(--rpg-highlight);
font-size: clamp(8px, 1.5vw, 10px);
margin-bottom: 0.188em;
text-transform: uppercase;
letter-spacing: 0.031em;
}
.rpg-thought-name::before,
.rpg-thought-name::after {
content: none !important;
}
.rpg-thought-text {
font-size: clamp(9px, 1.6vw, 11px);
font-style: italic;
line-height: 1.3;
color: var(--rpg-text);
opacity: 0.9;
}
.rpg-thought-text::before {
content: ''; /* Explicitly ensure no emoji */
}
/* Overlay to fade the background portrait and provide contrast */
.rpg-thoughts-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.75) 50%,
rgba(0, 0, 0, 0.85) 100%
);
z-index: 1;
display: none; /* Hide background overlay for new format */
}
/* Content wrapper to sit above the background */
.rpg-thoughts-overlay {
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
gap: 0.5em;
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
font-style: italic;
line-height: 1.4;
}
/* Present Characters - Character Cards */
.rpg-character-card {
display: flex;
flex-direction: column;
gap: clamp(6px, 0.8vh, 10px);
padding: clamp(6px, 1vh, 8px);
background: rgba(0, 0, 0, 0.3);
border-radius: clamp(4px, 0.5vh, 6px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.2s ease;
width: 100%; /* Ensure cards take full width */
max-height: clamp(200px, 18vh, 250px);
box-sizing: border-box; /* Include padding and border in width calculation */
flex-shrink: 0; /* Prevent cards from shrinking */
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--rpg-border) transparent;
}
.rpg-character-card::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.rpg-character-card::-webkit-scrollbar-track {
background: transparent;
}
.rpg-character-card::-webkit-scrollbar-thumb {
background: var(--rpg-border);
border-radius: 2px;
}
.rpg-character-card::-webkit-scrollbar-thumb:hover {
background: var(--rpg-highlight);
}
.rpg-character-card:hover {
background: rgba(0, 0, 0, 0.4);
border-color: var(--rpg-highlight);
}
/* Header row with avatar and name */
.rpg-character-header-row {
display: flex;
align-items: center;
gap: clamp(8px, 1vw, 12px);
width: 100%;
}
/* Character avatar container with relationship badge */
.rpg-character-avatar {
position: relative;
flex-shrink: 0;
}
.rpg-character-avatar img {
width: clamp(30px, 5vh, 40px);
height: clamp(30px, 5vh, 40px);
border-radius: 50%;
border: 2px solid var(--rpg-highlight);
object-fit: cover;
display: block; /* Prevent inline spacing issues */
}
/* Uploadable avatar - make it clickable */
.rpg-avatar-upload {
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.rpg-avatar-upload::after {
content: '📷';
position: absolute;
bottom: -2px;
right: -2px;
font-size: clamp(10px, 1.5vh, 14px);
background: var(--rpg-bg);
border: 1px solid var(--rpg-highlight);
border-radius: 50%;
width: clamp(18px, 2.5vh, 22px);
height: clamp(18px, 2.5vh, 22px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.rpg-avatar-upload:hover::after {
opacity: 1;
}
.rpg-avatar-upload:hover {
transform: scale(1.05);
}
.rpg-avatar-upload:hover img {
opacity: 0.8;
border-color: var(--rpg-text);
}
.rpg-avatar-upload:active {
transform: scale(0.98);
}
/* Relationship badge in top-right corner */
.rpg-relationship-badge {
position: absolute;
top: -0.125rem;
right: -0.125rem;
background: var(--rpg-bg);
border: 1px solid var(--rpg-highlight);
border-radius: 50%;
width: clamp(16px, 2.5vh, 20px);
height: clamp(16px, 2.5vh, 20px);
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(8px, 1.2vw, 12px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Character info section - now takes full width below header row */
.rpg-character-content {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(3px, 0.5vh, 5px);
overflow: hidden;
}
.rpg-character-info {
display: flex;
flex-direction: column;
gap: clamp(3px, 0.5vh, 5px);
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: var(--rpg-border) transparent;
}
.rpg-character-info::-webkit-scrollbar {
width: 4px;
}
.rpg-character-info::-webkit-scrollbar-track {
background: transparent;
}
.rpg-character-info::-webkit-scrollbar-thumb {
background: var(--rpg-border);
border-radius: 2px;
}
.rpg-character-info::-webkit-scrollbar-thumb:hover {
background: var(--rpg-highlight);
}
/* Character header with emoji and name - now inside header row */
.rpg-character-header {
display: flex;
align-items: center;
gap: clamp(4px, 0.5vw, 6px);
flex-wrap: nowrap; /* Prevent wrapping */
flex: 1;
min-width: 0;
}
.rpg-character-emoji {
font-size: clamp(12px, 2vw, 16px);
flex-shrink: 0;
}
.rpg-character-name {
font-weight: bold;
color: var(--rpg-highlight);
font-size: clamp(0.75rem, 0.75vw, 0.875rem);
text-transform: uppercase;
letter-spacing: 0.031em;
white-space: nowrap; /* Prevent name from wrapping */
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* Character remove button */
.rpg-character-remove {
background: transparent;
border: none;
color: var(--rpg-text);
font-size: clamp(14px, 2vw, 18px);
font-weight: bold;
cursor: pointer;
padding: 0;
margin-left: auto;
width: clamp(16px, 2.5vh, 20px);
height: clamp(16px, 2.5vh, 20px);
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0.5;
transition: all 0.2s ease;
flex-shrink: 0;
}
.rpg-character-remove:hover {
opacity: 1;
background: rgba(255, 0, 0, 0.2);
color: #ff4444;
transform: scale(1.1);
}
.rpg-character-remove:active {
transform: scale(0.95);
}
/* Add Character Button (inside rpg-thoughts-content, after last character) */
.rpg-add-character-btn {
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
color: var(--rpg-text);
padding: 0;
margin: 0.5rem auto 0;
font-size: clamp(0.625rem, 0.6vw, 0.75rem);
font-weight: 500;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0.6;
width: auto;
}
.rpg-add-character-btn:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
color: var(--rpg-bg);
opacity: 1;
}
.rpg-add-character-btn:active {
transform: scale(0.95);
}
.rpg-add-character-btn i {
font-size: 0.875em;
}
/* Character traits/status line and custom fields */
.rpg-character-traits,
.rpg-character-field {
font-size: clamp(0.75rem, 0.7vw, 0.875rem);
color: var(--rpg-text);
opacity: 0.8;
line-height: 1.3;
overflow-wrap: break-word; /* Allow long text to wrap */
word-wrap: break-word;
}
/* Empty field placeholder using data-placeholder attribute */
.rpg-editable.rpg-empty-field:empty::before {
content: attr(data-placeholder);
color: var(--rpg-highlight);
opacity: 0.4;
font-style: italic;
pointer-events: none;
}
/* Ensure empty fields have minimum height for clickability */
.rpg-editable.rpg-empty-field {
min-height: 1.2em;
display: inline-block;
min-width: 3em;
}
/* Character stats display */
.rpg-character-stats {
width: 100%;
max-height: clamp(50px, 7vh, 70px);
margin-top: clamp(3px, 0.5vh, 5px);
padding: clamp(3px, 0.4vh, 5px) clamp(4px, 0.5vw, 6px);
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: clamp(2px, 0.3vh, 4px);
box-sizing: border-box;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--rpg-border) transparent;
}
.rpg-character-stats::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.rpg-character-stats::-webkit-scrollbar-track {
background: transparent;
}
.rpg-character-stats::-webkit-scrollbar-thumb {
background: var(--rpg-border);
border-radius: 2px;
}
.rpg-character-stats::-webkit-scrollbar-thumb:hover {
background: var(--rpg-highlight);
}
.rpg-character-stats-inner {
display: flex;
flex-wrap: wrap;
gap: clamp(6px, 1vw, 12px);
align-items: center;
}
.rpg-character-stat {
flex-shrink: 0;
}
.rpg-character-stat .rpg-stat-name {
font-size: clamp(9px, 0.6vw, 0.7vw) !important;
font-weight: 600 !important;
white-space: nowrap !important;
}
/* Placeholder styles for empty sections */
.rpg-thoughts-placeholder,
.rpg-placeholder-widget {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(12px, 2vh, 20px);
text-align: center;
opacity: 0.6;
}
.rpg-placeholder-text {
font-size: clamp(10px, 1.6vw, 14px);
color: var(--rpg-text);
font-weight: bold;
margin-bottom: clamp(4px, 0.6vh, 6px);
}
.rpg-placeholder-hint {
font-size: clamp(8px, 1.2vw, 10px);
color: var(--rpg-text);
opacity: 0.7;
font-style: italic;
}
/* Editable field styles */
.rpg-editable,
.rpg-editable-stat,
.rpg-editable-stat-name {
cursor: text;
transition: all 0.2s ease;
border-radius: 2px;
padding: 0.062em 0.125em;
}
.rpg-editable:hover,
.rpg-editable-stat:hover,
.rpg-editable-stat-name:hover {
background: var(--rpg-accent);
outline: 1px solid var(--rpg-highlight);
}
.rpg-editable:focus,
.rpg-editable-stat:focus,
.rpg-editable-stat-name:focus {
background: var(--rpg-bg);
outline: 2px solid var(--rpg-highlight);
box-shadow: 0 0 8px var(--rpg-highlight);
}
/* Edit button container and styling */
.rpg-edit-button-container {
display: flex;
justify-content: center;
padding: clamp(4px, 0.8vh, 8px);
margin-top: clamp(4px, 0.8vh, 8px);
}
.rpg-edit-button {
background: var(--rpg-accent);
border: 1px solid var(--rpg-highlight);
color: var(--rpg-text);
padding: clamp(3px, 0.6vh, 6px) clamp(6px, 1.2vh, 12px);
border-radius: clamp(3px, 0.6vh, 6px);
font-size: clamp(7px, 1.2vw, 10px);
cursor: pointer;
display: flex;
align-items: center;
gap: clamp(2px, 0.4vh, 4px);
transition: all 0.2s ease;
}
.rpg-edit-button:hover {
background: var(--rpg-highlight);
color: var(--rpg-bg);
transform: translateY(-0.062rem);
box-shadow: 0 2px 8px var(--rpg-highlight);
}
.rpg-edit-button:active {
transform: translateY(0);
}
.rpg-edit-button i {
font-size: clamp(7px, 1.2vw, 10px);
}
/* Removed emoji icon styling - no longer needed */
/* Settings Styling */
.rpg-settings {
margin-top: 0.625em;
padding-top: 0.5em;
border-top: 1px solid #444;
}
.rpg-settings summary {
cursor: pointer;
font-weight: bold;
padding: 0.375em;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.25em;
margin-bottom: 0.5em;
display: flex;
align-items: center;
gap: 0.375em;
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
}
.rpg-settings summary:hover {
background: rgba(255, 255, 255, 0.1);
}
.rpg-settings-content {
padding: 0.5em;
}
.rpg-settings-content label {
display: block;
margin: 0.375em 0;
}
.rpg-setting-row {
margin: 0.5em 0;
}
.rpg-setting-row label {
display: block;
margin-bottom: 0.188em;
font-size: clamp(0.875rem, 1vw, 1rem);
}
.rpg-setting-row input[type="number"] {
width: 100%;
padding: 0.25em;
border: 1px solid #444;
border-radius: 3px;
background: rgba(0, 0, 0, 0.3);
color: inherit;
font-size: clamp(0.875rem, 1vw, 1rem);
}
.rpg-setting-row small {
display: block;
margin-top: 0.188em;
color: #888;
font-size: clamp(0.75rem, 0.95vw, 0.95rem);
}
#rpg-manual-update {
width: 100%;
margin-top: 0.5em;
padding: 0.375em;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375em;
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.rpg-panel {
max-width: 100%;
}
.rpg-stat-label {
min-width: 5rem;
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
}
}
/* Animation for stats updates */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.rpg-stat-row.updating {
animation: pulse 0.5s ease-in-out;
}
+605
View File
@@ -0,0 +1,605 @@
/* ============================================
SETTINGS SECTION
============================================ */
.rpg-settings {
margin-top: 0.938em;
padding-top: 0.938em;
border-top: 2px solid var(--rpg-border);
}
.rpg-settings summary {
cursor: pointer;
font-weight: bold;
padding: 0.75em;
background: var(--rpg-accent);
border: 2px solid var(--rpg-border);
border-radius: 0.625em;
margin-bottom: 0.938em;
display: flex;
align-items: center;
gap: 0.625em;
transition: all 0.3s ease;
}
.rpg-settings summary:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
transform: translateX(0.312rem);
}
.rpg-settings-content {
padding: 0.938em;
background: var(--rpg-accent);
border-radius: 0.625em;
}
.rpg-settings-group {
margin-bottom: 1.25em;
padding-bottom: 0.938em;
border-bottom: 1px solid var(--rpg-border);
}
.rpg-settings-group:last-child {
border-bottom: none;
}
.rpg-settings-group h4 {
margin: 0 0 0.75em 0;
font-size: clamp(1.125rem, 1.5vw, 1.5rem);
color: var(--rpg-highlight);
display: flex;
align-items: center;
gap: 0.5em;
}
.rpg-setting-row {
margin: 0.75em 0;
}
.rpg-setting-row label {
display: block;
margin-bottom: 0.375em;
font-size: clamp(0.938rem, 1.2vw, 1.2rem);
font-weight: 600;
}
.rpg-select,
.rpg-input {
width: 100%;
padding: 0.5em;
border: 2px solid var(--rpg-border);
border-radius: 0.375em;
background: var(--rpg-bg);
color: var(--rpg-text);
font-size: clamp(1rem, 1.3vw, 1.3rem);
transition: all 0.3s ease;
}
.rpg-select:focus,
.rpg-input:focus {
outline: none;
border-color: var(--rpg-highlight);
box-shadow: 0 0 10px var(--rpg-highlight);
}
.rpg-setting-row input[type="color"] {
width: 100%;
height: 2.5rem;
padding: 0.25em;
border: 2px solid var(--rpg-border);
border-radius: 0.375em;
background: var(--rpg-bg);
cursor: pointer;
transition: all 0.3s ease;
/* Ensure color picker works on all browsers */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
/* Remove padding for better color swatch display */
.rpg-setting-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.rpg-setting-row input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 0.25em;
}
.rpg-setting-row input[type="color"]::-moz-color-swatch {
border: none;
border-radius: 0.25em;
}
.rpg-setting-row input[type="color"]:hover {
border-color: var(--rpg-highlight);
}
.rpg-setting-row small {
display: block;
margin-top: 0.25em;
color: #999;
font-size: clamp(0.875rem, 1vw, 1rem);
font-style: italic;
}
.rpg-custom-colors {
margin-top: 0.625em;
padding: 0.938em;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.5em;
border: 1px solid var(--rpg-border);
}
.checkbox_label {
display: flex;
align-items: center;
gap: 0.5em;
margin: 0.625em 0;
cursor: pointer;
transition: all 0.3s ease;
}
.checkbox_label input[type="checkbox"] {
accent-color: #666 !important;
}
.checkbox_label input[type="checkbox"]:checked {
background-color: #666 !important;
}
.rpg-panel[data-theme="sci-fi"] .checkbox_label input[type="checkbox"],
.rpg-settings-popup[data-theme="sci-fi"] .checkbox_label input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="sci-fi"] .checkbox_label input[type="checkbox"]:checked,
.rpg-settings-popup[data-theme="sci-fi"] .checkbox_label input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="fantasy"] .checkbox_label input[type="checkbox"],
.rpg-settings-popup[data-theme="fantasy"] .checkbox_label input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="fantasy"] .checkbox_label input[type="checkbox"]:checked,
.rpg-settings-popup[data-theme="fantasy"] .checkbox_label input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="cyberpunk"] .checkbox_label input[type="checkbox"],
.rpg-settings-popup[data-theme="cyberpunk"] .checkbox_label input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="cyberpunk"] .checkbox_label input[type="checkbox"]:checked,
.rpg-settings-popup[data-theme="cyberpunk"] .checkbox_label input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="custom"] .checkbox_label input[type="checkbox"],
.rpg-settings-popup[data-theme="custom"] .checkbox_label input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="custom"] .checkbox_label input[type="checkbox"]:checked,
.rpg-settings-popup[data-theme="custom"] .checkbox_label input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
/* Radio buttons - default theme */
.checkbox_label input[type="radio"] {
accent-color: #666 !important;
}
/* Radio buttons - sci-fi theme */
.rpg-panel[data-theme="sci-fi"] .checkbox_label input[type="radio"],
.rpg-settings-popup[data-theme="sci-fi"] .checkbox_label input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
}
/* Radio buttons - fantasy theme */
.rpg-panel[data-theme="fantasy"] .checkbox_label input[type="radio"],
.rpg-settings-popup[data-theme="fantasy"] .checkbox_label input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
}
/* Radio buttons - cyberpunk theme */
.rpg-panel[data-theme="cyberpunk"] .checkbox_label input[type="radio"],
.rpg-settings-popup[data-theme="cyberpunk"] .checkbox_label input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
}
/* Radio buttons - custom theme */
.rpg-panel[data-theme="custom"] .checkbox_label input[type="radio"],
.rpg-settings-popup[data-theme="custom"] .checkbox_label input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
}
/* All radio buttons (including those not in checkbox_label) - default theme */
.rpg-settings-popup input[type="radio"],
#rpg-tracker-editor-popup input[type="radio"] {
accent-color: #666 !important;
color-scheme: dark;
background-color: transparent !important;
}
.rpg-settings-popup input[type="radio"]:checked,
#rpg-tracker-editor-popup input[type="radio"]:checked {
background-color: #666 !important;
}
/* All radio buttons - sci-fi theme */
.rpg-settings-popup[data-theme="sci-fi"] input[type="radio"],
#rpg-tracker-editor-popup[data-theme="sci-fi"] input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
color-scheme: dark;
background-color: transparent !important;
}
.rpg-settings-popup[data-theme="sci-fi"] input[type="radio"]:checked,
#rpg-tracker-editor-popup[data-theme="sci-fi"] input[type="radio"]:checked {
background-color: var(--rpg-highlight) !important;
}
/* All radio buttons - fantasy theme */
.rpg-settings-popup[data-theme="fantasy"] input[type="radio"],
#rpg-tracker-editor-popup[data-theme="fantasy"] input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
color-scheme: dark;
background-color: transparent !important;
}
.rpg-settings-popup[data-theme="fantasy"] input[type="radio"]:checked,
#rpg-tracker-editor-popup[data-theme="fantasy"] input[type="radio"]:checked {
background-color: var(--rpg-highlight) !important;
}
/* All radio buttons - cyberpunk theme */
.rpg-settings-popup[data-theme="cyberpunk"] input[type="radio"],
#rpg-tracker-editor-popup[data-theme="cyberpunk"] input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
color-scheme: dark;
background-color: transparent !important;
}
.rpg-settings-popup[data-theme="cyberpunk"] input[type="radio"]:checked,
#rpg-tracker-editor-popup[data-theme="cyberpunk"] input[type="radio"]:checked {
background-color: var(--rpg-highlight) !important;
}
/* All radio buttons - custom theme */
.rpg-settings-popup[data-theme="custom"] input[type="radio"],
#rpg-tracker-editor-popup[data-theme="custom"] input[type="radio"] {
accent-color: var(--rpg-highlight) !important;
color-scheme: dark;
background-color: transparent !important;
}
.rpg-settings-popup[data-theme="custom"] input[type="radio"]:checked,
#rpg-tracker-editor-popup[data-theme="custom"] input[type="radio"]:checked {
background-color: var(--rpg-highlight) !important;
}
/* Tracker Editor checkboxes (not wrapped in checkbox_label) */
#rpg-tracker-editor-popup input[type="checkbox"] {
accent-color: #666 !important;
}
#rpg-tracker-editor-popup input[type="checkbox"]:checked {
background-color: #666 !important;
}
#rpg-tracker-editor-popup[data-theme="sci-fi"] input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="sci-fi"] input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="fantasy"] input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="fantasy"] input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="cyberpunk"] input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="cyberpunk"] input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="custom"] input[type="checkbox"] {
accent-color: var(--rpg-highlight) !important;
}
#rpg-tracker-editor-popup[data-theme="custom"] input[type="checkbox"]:checked {
background-color: var(--rpg-highlight) !important;
}
/* Panel checkboxes (not wrapped in checkbox_label) */
.rpg-panel input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]) {
accent-color: #666 !important;
}
.rpg-panel input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]):checked {
background-color: #666 !important;
}
.rpg-panel[data-theme="sci-fi"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]) {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="sci-fi"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]):checked {
background-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="fantasy"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]) {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="fantasy"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]):checked {
background-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="cyberpunk"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]) {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="cyberpunk"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]):checked {
background-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="custom"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]) {
accent-color: var(--rpg-highlight) !important;
}
.rpg-panel[data-theme="custom"] input[type="checkbox"]:not(.checkbox_label input[type="checkbox"]):checked {
background-color: var(--rpg-highlight) !important;
}
.checkbox_label:hover {
color: var(--rpg-highlight);
}
.rpg-btn-primary {
width: 100%;
padding: 0.75em;
background: var(--rpg-accent);
border: 2px solid var(--rpg-border);
border-radius: 0.625em;
color: var(--rpg-text);
font-size: clamp(1.063rem, 1.4vw, 1.4rem);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625em;
transition: all 0.3s ease;
box-shadow: 0 4px 15px var(--rpg-shadow);
}
.rpg-btn-primary:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
transform: translateY(-0.125rem);
box-shadow: 0 6px 20px var(--rpg-shadow);
}
.rpg-btn-primary:active {
transform: translateY(0);
}
/* Clear Cache Button - Danger style */
.rpg-btn-clear-cache {
width: 100%;
padding: 0.625em;
background: rgba(220, 53, 69, 0.2);
border: 2px solid rgba(220, 53, 69, 0.5);
border-radius: 0.5em;
color: #ff6b6b;
font-size: clamp(0.938rem, 1.2vw, 1.2rem);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
transition: all 0.3s ease;
}
.rpg-btn-clear-cache:hover {
background: rgba(220, 53, 69, 0.3);
border-color: rgba(220, 53, 69, 0.8);
color: #ff8787;
transform: translateY(-0.062rem);
}
.rpg-btn-clear-cache:active {
transform: translateY(0);
}
/* Reset FAB Positions Button - Similar to clear cache but different color */
.rpg-btn-reset-fab {
width: 100%;
padding: 0.625em;
background: rgba(52, 152, 219, 0.2);
border: 2px solid rgba(52, 152, 219, 0.5);
border-radius: 0.5em;
color: #5dade2;
font-size: clamp(0.938rem, 1.2vw, 1.2rem);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
transition: all 0.3s ease;
}
.rpg-btn-reset-fab:hover {
background: rgba(52, 152, 219, 0.3);
border-color: rgba(52, 152, 219, 0.8);
color: #85c1e9;
transform: translateY(-0.062rem);
}
.rpg-btn-reset-fab:active {
transform: translateY(0);
}
/* Customize Prompts Button - Neutral gray style */
.rpg-btn-customize-prompts {
width: 100%;
padding: 0.625em;
background: rgba(128, 128, 128, 0.2);
border: 2px solid rgba(128, 128, 128, 0.5);
border-radius: 0.5em;
color: #aaa;
font-size: clamp(0.938rem, 1.2vw, 1.2rem);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
transition: all 0.3s ease;
}
.rpg-btn-customize-prompts:hover {
background: rgba(128, 128, 128, 0.3);
border-color: rgba(128, 128, 128, 0.8);
color: #ccc;
transform: translateY(-0.062rem);
}
.rpg-btn-customize-prompts:active {
transform: translateY(0);
}
/* Memory Recollection styles removed - feature deprecated */
.rpg-memory-modal-header h3 {
margin: 0;
color: var(--SmartThemeBodyColor, #eaeaea);
font-size: 1.25em;
font-weight: 600;
}
/* Modal Body */
.rpg-memory-modal-body {
padding: 1.5em;
color: var(--SmartThemeBodyColor, #eaeaea);
}
.rpg-memory-modal-body p {
margin: 0.75em 0;
line-height: 1.6;
}
.rpg-memory-modal-info {
background: var(--rpg-border, rgba(102, 126, 234, 0.1));
padding: 1em;
border-radius: 8px;
border-left: 4px solid var(--rpg-border, var(--SmartThemeBorderColor, #4a7ba7));
margin-top: 1em;
}
.rpg-memory-modal-hint {
font-size: 0.85em;
color: #999;
}
/* Progress Elements */
.rpg-memory-progress-text {
text-align: center;
font-weight: 600;
margin-bottom: 1em;
}
.rpg-memory-progress-bar {
width: 100%;
height: 30px;
background: rgba(0, 0, 0, 0.3);
border-radius: 15px;
overflow: hidden;
position: relative;
}
.rpg-memory-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--rpg-border, #4a7ba7) 0%, var(--rpg-highlight, #e94560) 100%);
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.85em;
}
.rpg-memory-status {
margin-top: 1em;
padding: 0.75em;
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
font-size: 0.9em;
color: #999;
max-height: 100px;
overflow-y: auto;
}
/* Modal Footer */
.rpg-memory-modal-footer {
padding: 1em 1.25em;
border-top: 1px solid var(--rpg-border, var(--SmartThemeBorderColor, #4a7ba7));
display: flex;
gap: 0.75em;
justify-content: flex-end;
}
/* Modal Buttons */
.rpg-memory-modal-btn {
padding: 0.625em 1.25em;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
font-size: 14px;
}
.rpg-memory-cancel {
background: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
border: 2px solid rgba(220, 53, 69, 0.5);
}
.rpg-memory-cancel:hover {
background: rgba(220, 53, 69, 0.3);
border-color: rgba(220, 53, 69, 0.8);
}
.rpg-memory-proceed {
background: linear-gradient(135deg, var(--rpg-border, #4a7ba7) 0%, var(--rpg-highlight, #e94560) 100%);
color: white;
border: 2px solid var(--rpg-border-transparent, rgba(74, 123, 167, 0.5));
}
.rpg-memory-proceed:hover {
background: linear-gradient(135deg, var(--rpg-highlight, #e94560) 0%, var(--rpg-border, #4a7ba7) 100%);
border-color: var(--rpg-border-opaque, rgba(74, 123, 167, 0.8));
box-shadow: 0 4px 12px var(--rpg-border-transparent, rgba(74, 123, 167, 0.3));
}
/* Lorebook Limiter styles removed - feature deprecated */
+276
View File
@@ -0,0 +1,276 @@
/* ============================================
THEME VARIATIONS
============================================ */
/* Sci-Fi / Synthwave Theme */
.rpg-panel[data-theme="sci-fi"] {
--rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a;
--rpg-text: #00fff9;
--rpg-highlight: #ff006e;
--rpg-border: #8b00ff;
--rpg-shadow: rgba(139, 0, 255, 0.5);
--rpg-content-bg: linear-gradient(135deg, #1a1f3a 0%, #0a0e27 100%);
--rpg-content-box-shadow: 0 0 40px rgba(139, 0, 255, 0.4), inset 0 0 30px rgba(0, 0, 0, 0.5);
--rpg-content-border: 2px solid #8b00ff;
--rpg-header-text-shadow: 0 0 20px #ff006e, 0 0 40px #8b00ff;
--rpg-divider-bg: linear-gradient(to right, transparent, #8b00ff, #ff006e, #8b00ff, transparent);
--rpg-thoughts-bg: linear-gradient(135deg, rgba(10, 14, 39, 0.9) 0%, rgba(26, 31, 58, 0.8) 50%, rgba(10, 14, 39, 0.9) 100%);
}
/* Apply sci-fi theme to thought panel */
#rpg-thought-panel[data-theme="sci-fi"],
#rpg-thought-icon[data-theme="sci-fi"],
#rpg-alt-present-characters[data-theme="sci-fi"],
.rpg-mobile-toggle[data-theme="sci-fi"] {
--rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a;
--rpg-text: #00fff9;
--rpg-highlight: #ff006e;
--rpg-border: #8b00ff;
--rpg-shadow: rgba(139, 0, 255, 0.5);
}
/* Fantasy / Rustic Parchment Theme */
.rpg-panel[data-theme="fantasy"] {
--rpg-bg: #2b1810;
--rpg-accent: #3d2414;
--rpg-text: #f4e8d0;
--rpg-highlight: #d4af37;
--rpg-border: #8b6914;
--rpg-shadow: rgba(0, 0, 0, 0.7);
--rpg-bg-image: linear-gradient(rgba(43, 24, 16, 0.9), rgba(43, 24, 16, 0.9)), url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><filter id="noise"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" /></filter><rect width="100" height="100" filter="url(%23noise)" opacity="0.1"/></svg>');
--rpg-content-bg: linear-gradient(135deg, #3d2414 0%, #2b1810 100%);
--rpg-content-box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8), inset 0 0 40px rgba(139, 105, 20, 0.2);
--rpg-content-border: 3px solid #8b6914;
--rpg-content-border-style: ridge;
--rpg-header-font-family: 'Georgia', serif;
--rpg-header-text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
--rpg-divider-content: '❦';
--rpg-divider-font-size: clamp(1rem, 1.5vw, 1.5rem);
--rpg-thoughts-bg: linear-gradient(135deg, rgba(43, 24, 16, 0.92) 0%, rgba(61, 36, 20, 0.88) 50%, rgba(43, 24, 16, 0.92) 100%);
}
/* Apply fantasy theme to thought panel */
#rpg-thought-panel[data-theme="fantasy"],
#rpg-thought-icon[data-theme="fantasy"],
#rpg-alt-present-characters[data-theme="fantasy"],
.rpg-mobile-toggle[data-theme="fantasy"] {
--rpg-bg: #2b1810;
--rpg-accent: #3d2414;
--rpg-text: #f4e8d0;
--rpg-highlight: #d4af37;
--rpg-border: #8b6914;
--rpg-shadow: rgba(0, 0, 0, 0.7);
}
/* Cyberpunk / Neon Grid Theme */
.rpg-panel[data-theme="cyberpunk"] {
--rpg-bg: #000000;
--rpg-accent: #0d0d0d;
--rpg-text: #00ff41;
--rpg-highlight: #ff2a6d;
--rpg-border: #05d9e8;
--rpg-shadow: rgba(5, 217, 232, 0.5);
--rpg-bg-image: linear-gradient(rgba(0, 0, 0, 0.95), rgba(0, 0, 0, 0.95)), repeating-linear-gradient(0deg, rgba(5, 217, 232, 0.1) 0px, transparent 1px, transparent 2px, rgba(5, 217, 232, 0.1) 3px), repeating-linear-gradient(90deg, rgba(5, 217, 232, 0.1) 0px, transparent 1px, transparent 2px, rgba(5, 217, 232, 0.1) 3px);
--rpg-bg-size: 100% 100%, 30px 30px, 30px 30px;
--rpg-content-bg: linear-gradient(135deg, rgba(13, 13, 13, 0.9) 0%, rgba(0, 0, 0, 0.9) 100%);
--rpg-content-box-shadow: 0 0 40px rgba(255, 42, 109, 0.4), inset 0 0 30px rgba(5, 217, 232, 0.2);
--rpg-content-border: 2px solid #05d9e8;
--rpg-header-font-family: 'Courier New', monospace;
--rpg-header-letter-spacing: 0.125em;
--rpg-header-text-shadow: 0 0 10px #ff2a6d, 0 0 20px #05d9e8, 0 0 30px #ff2a6d;
--rpg-divider-bg: linear-gradient(to right, transparent, #05d9e8, #ff2a6d, #05d9e8, transparent);
--rpg-divider-height: 0.188rem;
--rpg-thoughts-bg: linear-gradient(135deg, rgba(0, 0, 0, 0.92) 0%, rgba(13, 13, 13, 0.85) 50%, rgba(0, 0, 0, 0.92) 100%);
}
/* Apply cyberpunk theme to thought panel */
#rpg-thought-panel[data-theme="cyberpunk"],
#rpg-thought-icon[data-theme="cyberpunk"],
#rpg-alt-present-characters[data-theme="cyberpunk"],
.rpg-mobile-toggle[data-theme="cyberpunk"] {
--rpg-bg: #000000;
--rpg-accent: #0d0d0d;
--rpg-text: #00ff41;
--rpg-highlight: #ff2a6d;
--rpg-border: #05d9e8;
--rpg-shadow: rgba(5, 217, 232, 0.5);
}
/* ============================================
THEME SUPPORT FOR ALL PANEL ELEMENTS
Uses CSS custom properties defined above — no selector duplication needed.
Any element inside .rpg-panel inherits the theme variables automatically.
============================================ */
/* Apply theme background image if defined */
.rpg-panel[data-theme] {
background-image: var(--rpg-bg-image, none);
background-size: var(--rpg-bg-size, auto);
}
/* Content box theming via custom properties */
.rpg-panel[data-theme] .rpg-content-box {
background: var(--rpg-content-bg);
box-shadow: var(--rpg-content-box-shadow);
border: var(--rpg-content-border);
border-style: var(--rpg-content-border-style, solid);
}
/* Header theming */
.rpg-panel[data-theme] .rpg-panel-header h3,
.rpg-panel[data-theme] .rpg-stats-title,
.rpg-panel[data-theme] .rpg-info-header,
.rpg-panel[data-theme] .rpg-thoughts-header {
font-family: var(--rpg-header-font-family, inherit);
text-shadow: var(--rpg-header-text-shadow, none);
letter-spacing: var(--rpg-header-letter-spacing, normal);
}
/* Divider theming */
.rpg-panel[data-theme] .rpg-divider {
background: var(--rpg-divider-bg);
height: var(--rpg-divider-height, auto);
}
.rpg-panel[data-theme] .rpg-divider::after {
content: var(--rpg-divider-content, none);
font-size: var(--rpg-divider-font-size, inherit);
}
/* Thoughts content theming */
.rpg-panel[data-theme] .rpg-thoughts-content::before {
background: var(--rpg-thoughts-bg);
}
/* Tabs navigation theming */
.rpg-panel[data-theme] .rpg-tabs-nav {
border-bottom-color: var(--rpg-border);
}
.rpg-panel[data-theme] .rpg-tab-btn {
background: var(--rpg-accent);
border-color: var(--rpg-border);
color: var(--rpg-text);
}
.rpg-panel[data-theme] .rpg-tab-btn:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
}
.rpg-panel[data-theme] .rpg-tab-btn.active {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
color: var(--rpg-bg);
}
/* Inventory subtabs theming */
.rpg-panel[data-theme] .rpg-inventory-subtabs {
border-bottom-color: var(--rpg-border);
}
.rpg-panel[data-theme] .rpg-inventory-subtab {
border-color: var(--rpg-border);
color: var(--rpg-text);
}
.rpg-panel[data-theme] .rpg-inventory-subtab:hover {
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-panel[data-theme] .rpg-inventory-subtab.active {
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
/* Quests subtabs theming */
.rpg-panel[data-theme] .rpg-quests-subtabs {
border-bottom-color: var(--rpg-border);
}
.rpg-panel[data-theme] .rpg-quests-subtab {
border-color: var(--rpg-border);
color: var(--rpg-text);
}
.rpg-panel[data-theme] .rpg-quests-subtab:hover {
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-panel[data-theme] .rpg-quests-subtab.active {
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
/* Storage locations theming */
.rpg-panel[data-theme] .rpg-storage-location {
border-color: var(--rpg-border);
}
.rpg-panel[data-theme] .rpg-storage-header {
background: var(--rpg-highlight);
}
.rpg-panel[data-theme] .rpg-storage-content {
background: var(--rpg-accent);
}
/* Button theming */
.rpg-panel[data-theme] .rpg-inventory-edit-btn,
.rpg-panel[data-theme] .rpg-inventory-add-btn,
.rpg-panel[data-theme] .rpg-inventory-remove-btn,
.rpg-panel[data-theme] .rpg-quest-edit,
.rpg-panel[data-theme] .rpg-quest-remove,
.rpg-panel[data-theme] .rpg-add-quest-btn {
background: var(--rpg-accent);
border-color: var(--rpg-border);
color: var(--rpg-text);
}
.rpg-panel[data-theme] .rpg-inventory-add-btn,
.rpg-panel[data-theme] .rpg-add-quest-btn {
background: transparent;
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
/* Inventory/quest items theming */
.rpg-panel[data-theme] .rpg-inventory-text,
.rpg-panel[data-theme] .rpg-quest-item,
.rpg-panel[data-theme] .rpg-main-quest-display {
background: var(--rpg-accent);
border-color: var(--rpg-border);
color: var(--rpg-text);
}
.rpg-panel[data-theme] .rpg-quest-item:hover {
border-color: var(--rpg-highlight);
}
/* Hints and empty states theming */
.rpg-panel[data-theme] .rpg-inventory-hint,
.rpg-panel[data-theme] .rpg-quest-hint,
.rpg-panel[data-theme] .rpg-inventory-empty,
.rpg-panel[data-theme] .rpg-quest-empty {
border-color: var(--rpg-highlight);
color: var(--rpg-text);
}
/* Input fields theming */
.rpg-panel[data-theme] .rpg-inline-input,
.rpg-panel[data-theme] .rpg-quest-edit-form input,
.rpg-panel[data-theme] .rpg-quest-edit-form textarea {
background: var(--rpg-bg);
border-color: var(--rpg-border);
color: var(--rpg-text);
}
.rpg-panel[data-theme] .rpg-inline-input:focus,
.rpg-panel[data-theme] .rpg-quest-edit-form input:focus,
.rpg-panel[data-theme] .rpg-quest-edit-form textarea:focus {
border-color: var(--rpg-highlight);
}
+425
View File
@@ -0,0 +1,425 @@
/* ============================================
RESPONSIVE DESIGN
============================================ */
@media (max-width: 768px) {
.rpg-panel {
width: 100%;
max-width: 100%;
}
body:has(.rpg-panel) #sheld {
margin-right: 0;
}
.rpg-user-portrait {
width: 3.75rem;
height: 3.75rem;
}
.rpg-stats-title {
font-size: clamp(1.125rem, 1.5vw, 1.5rem);
}
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(0.625rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.rpg-section {
animation: fadeIn 0.5s ease-out;
}
.rpg-stat-row {
animation: fadeIn 0.3s ease-out;
animation-fill-mode: both;
}
.rpg-stat-row:nth-child(1) { animation-delay: 0.1s; }
.rpg-stat-row:nth-child(2) { animation-delay: 0.15s; }
.rpg-stat-row:nth-child(3) { animation-delay: 0.2s; }
.rpg-stat-row:nth-child(4) { animation-delay: 0.25s; }
.rpg-stat-row:nth-child(5) { animation-delay: 0.3s; }
/* ============================================
DICE ROLL MODAL - MOBILE FIRST
============================================ */
/* CSS Custom Properties for Responsive Scaling */
.rpg-dice-popup {
/* Fluid spacing that scales with viewport */
--modal-padding: clamp(0.5rem, 2vw, 0.75rem);
--modal-gap: clamp(0.375rem, 1.5vw, 0.5rem);
--modal-border-width: 2px;
/* Fluid typography */
--modal-font-base: clamp(0.8rem, 3vw, 0.9rem);
--modal-font-small: clamp(0.7rem, 2.5vw, 0.8rem);
--modal-font-large: clamp(1.25rem, 6vw, 1.75rem);
--modal-font-huge: clamp(1.5rem, 8vw, 2.5rem);
/* Touch-friendly sizing */
--modal-button-height: 44px;
--modal-input-height: 44px;
/* Content constraints - MUCH more conservative */
--modal-max-width: min(90vw, 360px);
--modal-max-height: 70vh;
}
/* Modal Container - Hidden by default */
.rpg-dice-popup {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
padding: 0.5rem;
}
/* Open state - managed by JavaScript classList */
.rpg-dice-popup.is-open {
display: flex;
animation: fadeIn 0.2s ease-out;
}
/* Closing state - allows exit animation */
.rpg-dice-popup.is-closing {
display: flex;
animation: fadeOut 0.2s ease-in;
}
/* Backdrop overlay - using ::before pseudo-element */
.rpg-dice-popup::before {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
/* Modal Content Box */
.rpg-dice-popup-content {
position: relative;
width: 100%;
max-width: var(--modal-max-width);
height: auto;
max-height: var(--modal-max-height);
min-height: 0;
background: rgba(30, 30, 30, 0.8);
border: var(--modal-border-width) solid var(--rpg-border);
border-radius: 0.5rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9);
color: var(--rpg-text);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
margin: auto 0;
}
/* Header */
.rpg-dice-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--modal-padding);
background: var(--rpg-accent);
border-bottom: var(--modal-border-width) solid var(--rpg-border);
flex-shrink: 0;
}
.rpg-dice-popup-header h3 {
margin: 0;
font-size: var(--modal-font-base);
color: var(--rpg-highlight);
display: flex;
align-items: center;
gap: var(--modal-gap);
}
/* Close button - touch-friendly */
#rpg-dice-popup-close {
min-width: 44px;
min-height: 44px;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Scrollable Body */
.rpg-dice-popup-body {
padding: var(--modal-padding);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
flex: 1 1 auto;
min-height: 0;
}
/* Input Container */
.rpg-dice-selector-container {
padding: var(--modal-padding);
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5rem;
border: var(--modal-border-width) solid var(--rpg-border);
margin-bottom: var(--modal-gap);
}
/* Input Grid - Stacked on mobile */
.rpg-dice-selector {
display: grid;
grid-template-columns: 1fr;
gap: var(--modal-gap);
margin-bottom: var(--modal-gap);
}
.rpg-dice-input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rpg-dice-input-group label {
font-size: var(--modal-font-small);
font-weight: 600;
color: var(--rpg-text);
}
.rpg-dice-input-group input,
.rpg-dice-input-group select {
width: 100%;
min-height: var(--modal-input-height);
padding: 0.5rem;
border: var(--modal-border-width) solid var(--rpg-border);
border-radius: 0.375rem;
background: var(--rpg-accent);
color: var(--rpg-text);
font-size: var(--modal-font-base);
font-weight: 600;
text-align: center;
transition: all 0.2s ease;
}
.rpg-dice-input-group input:focus,
.rpg-dice-input-group select:focus {
outline: none;
border-color: var(--rpg-highlight);
box-shadow: 0 0 0 3px rgba(var(--rpg-highlight-rgb, 255, 0, 100), 0.2);
background: rgba(0, 0, 0, 0.5);
}
/* Roll Button - touch-friendly */
#rpg-dice-roll-btn {
width: 100%;
min-height: var(--modal-button-height);
padding: 0.75rem 1rem;
background: linear-gradient(135deg, var(--rpg-highlight), var(--rpg-accent));
border: var(--modal-border-width) solid var(--rpg-highlight);
border-radius: 0.5rem;
color: var(--rpg-text);
font-size: var(--modal-font-base);
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
#rpg-dice-roll-btn:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* Animation Section */
.rpg-dice-animation {
text-align: center;
padding: var(--modal-padding);
}
.rpg-dice-rolling i {
font-size: var(--modal-font-large);
color: var(--rpg-highlight);
animation: diceRoll 0.8s ease-in-out infinite;
}
.rpg-dice-rolling-text {
margin-top: var(--modal-gap);
font-size: var(--modal-font-base);
font-weight: 600;
color: var(--rpg-highlight);
animation: pulseGlow 1s ease-in-out infinite;
}
/* Result Section */
.rpg-dice-result {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--modal-padding);
background: rgba(0, 0, 0, 0.3);
border-radius: 0.5rem;
border: var(--modal-border-width) solid var(--rpg-border);
}
.rpg-dice-result-label {
font-size: var(--modal-font-small);
color: var(--rpg-text);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.rpg-dice-result-value {
font-size: var(--modal-font-huge);
font-weight: 700;
color: var(--rpg-highlight);
text-shadow: 0 0 20px var(--rpg-highlight);
line-height: 1;
}
.rpg-dice-result-value.is-animating {
animation: resultPop 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.rpg-dice-result-details {
margin-top: var(--modal-gap);
font-size: var(--modal-font-small);
color: var(--rpg-text);
opacity: 0.7;
}
/* Save Button */
.rpg-dice-save-btn {
margin-top: var(--modal-gap);
width: 100%;
min-height: var(--modal-button-height);
padding: 0.75rem 1rem;
background: linear-gradient(135deg, #28a745, #20c997);
border: var(--modal-border-width) solid #28a745;
border-radius: 0.5rem;
color: white;
font-size: var(--modal-font-base);
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.rpg-dice-save-btn:active {
transform: scale(0.98);
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.4);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes diceRoll {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(90deg) scale(1.1); }
50% { transform: rotate(180deg); }
75% { transform: rotate(270deg) scale(1.1); }
}
@keyframes resultPop {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.1); }
100% { transform: scale(1); opacity: 1; }
}
/* Theme Support - CSS Custom Properties */
.rpg-dice-popup[data-theme="sci-fi"] .rpg-dice-popup-content {
--rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a;
--rpg-text: #00fff9;
--rpg-highlight: #ff006e;
--rpg-border: #00fff9;
}
.rpg-dice-popup[data-theme="fantasy"] .rpg-dice-popup-content {
--rpg-bg: #2c1810;
--rpg-accent: #3d2817;
--rpg-text: #f4e8d0;
--rpg-highlight: #d4af37;
--rpg-border: #8b7355;
}
.rpg-dice-popup[data-theme="cyberpunk"] .rpg-dice-popup-content {
--rpg-bg: #0d0221;
--rpg-accent: #1a0b2e;
--rpg-text: #00ff9f;
--rpg-highlight: #ff00ff;
--rpg-border: #ff00ff;
}
/* Desktop Enhancement (1001px+) */
@media (min-width: 1001px) {
.rpg-dice-popup {
--modal-padding: 1.5rem;
--modal-gap: 1rem;
--modal-font-base: 1rem;
--modal-font-small: 0.875rem;
--modal-font-large: 3rem;
--modal-font-huge: 3.75rem;
--modal-max-width: 500px;
}
/* Side-by-side inputs on desktop */
.rpg-dice-selector {
grid-template-columns: 1fr 1fr;
}
/* Hover effects on desktop */
#rpg-dice-roll-btn:hover,
.rpg-dice-save-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px currentColor;
}
.rpg-dice-save-btn {
max-width: 200px;
margin-left: auto;
margin-right: auto;
}
}
+739
View File
@@ -0,0 +1,739 @@
/* ============================================
HTML PROMPT TOGGLE
============================================ */
.rpg-toggle-container {
padding: 0.5em;
background: rgba(0, 0, 0, 0.2);
border-radius: 0.312em;
margin: 0;
}
.rpg-toggle-label {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
.rpg-toggle-label input[type="checkbox"] {
margin: 0 0.5em 0 0;
cursor: pointer;
}
.rpg-toggle-label i {
margin-right: 0.375em;
}
/* ============================================
MANUAL UPDATE BUTTON
============================================ */
.rpg-manual-update-btn {
width: 100%;
height: 2.5rem;
margin: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
padding: 0;
background: linear-gradient(135deg, var(--rpg-highlight), var(--rpg-accent));
border: 2px solid var(--rpg-highlight);
border-radius: 0.5em;
color: var(--rpg-text);
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375em;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
flex-shrink: 0;
box-sizing: border-box;
}
.rpg-manual-update-btn:hover {
transform: translateY(-0.125rem);
box-shadow: 0 6px 20px var(--rpg-highlight);
background: var(--rpg-highlight);
}
.rpg-manual-update-btn:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
/* ============================================
SETTINGS BUTTON
============================================ */
.rpg-btn-settings {
width: 100%;
height: 2.5rem;
margin: 0;
padding: 0;
background: var(--rpg-accent);
border: 2px solid var(--rpg-border);
border-radius: 0.5em;
color: var(--rpg-text);
font-size: clamp(0.875rem, 1.1vw, 1.1rem);
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375em;
flex-shrink: 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
box-sizing: border-box;
}
.rpg-btn-settings:hover {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
transform: translateY(-0.125rem);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.rpg-btn-settings:active {
transform: translateY(0);
}
/* Settings buttons row (Edit Trackers + Settings side by side) */
.rpg-settings-buttons-row {
display: flex;
gap: 0.5em;
width: 100%;
}
.rpg-btn-half {
flex: 1;
min-width: 0;
}
/* ============================================
TRACKER EDITOR MODAL
============================================ */
/* Preset Management Section */
.rpg-preset-management {
padding: 0.75em 1em;
background: var(--rpg-accent);
border-bottom: 2px solid var(--rpg-border);
margin-bottom: 0;
}
.rpg-preset-row {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: nowrap;
}
.rpg-preset-row label {
font-weight: 600;
font-size: 0.9em;
white-space: nowrap;
}
.rpg-preset-row .rpg-select {
flex: 1;
min-width: 120px;
max-width: 250px;
height: 2.2em;
padding: 0 0.6em;
font-size: 0.9em;
}
.rpg-preset-row .rpg-btn-icon {
height: 2.2em;
width: 2.2em;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9em;
flex-shrink: 0;
}
.rpg-preset-row .rpg-btn-icon.rpg-btn-active {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
color: white;
}
.rpg-preset-association-row {
margin-top: 0.5em;
padding-top: 0.5em;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.rpg-preset-association-row .checkbox_label {
margin: 0;
font-size: 0.85em;
opacity: 0.9;
}
.rpg-preset-association-row .checkbox_label strong {
color: var(--rpg-highlight);
font-weight: 600;
}
/* Editor tabs */
.rpg-editor-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--rpg-border);
}
.rpg-editor-tab {
flex: 1;
padding: 0.75em 1em;
background: var(--rpg-accent);
border: none;
border-bottom: 3px solid transparent;
color: var(--rpg-text);
font-size: 0.9em;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.rpg-editor-tab:hover {
background: var(--rpg-bg);
}
.rpg-editor-tab.active {
background: var(--rpg-bg);
border-bottom-color: var(--rpg-highlight);
}
.rpg-editor-tab-content {
max-height: none;
overflow-y: visible;
}
.rpg-editor-section {
padding: 1em 0;
}
.rpg-editor-section h4 {
color: var(--rpg-highlight);
margin: 1em 0 0.5em 0;
display: flex;
align-items: center;
gap: 0.5em;
}
.rpg-editor-section h4:first-child {
margin-top: 0;
}
.rpg-editor-hint {
font-size: 0.9em;
color: var(--rpg-text);
opacity: 0.7;
margin-bottom: 1em;
}
/* Stats list */
.rpg-editor-stats-list {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-bottom: 1em;
}
.rpg-editor-stat-item {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.5em;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 0.375em;
}
.rpg-stat-toggle {
flex-shrink: 0;
}
.rpg-stat-name {
flex: 1;
padding: 0.375em 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
}
.rpg-stat-max {
width: 5em;
padding: 0.375em 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
text-align: center;
}
.rpg-hidden {
display: none !important;
}
.rpg-radio-group {
display: flex;
gap: 1em;
align-items: center;
}
.rpg-radio-group label {
display: flex;
align-items: center;
gap: 0.375em;
cursor: pointer;
}
.rpg-stat-remove,
.rpg-attr-remove,
.rpg-remove-relationship {
flex-shrink: 0;
padding: 0.375em 0.625em;
background: var(--rpg-highlight);
border: none;
border-radius: 0.25em;
color: white;
cursor: pointer;
transition: opacity 0.2s;
}
.rpg-stat-remove:hover,
.rpg-attr-remove:hover,
.rpg-remove-relationship:hover {
opacity: 0.8;
}
/* RPG Attributes editor styles (same as custom stats) */
.rpg-attr-toggle {
flex-shrink: 0;
}
.rpg-attr-name {
flex: 1;
padding: 0.375em 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
}
.rpg-attr-remove {
flex-shrink: 0;
padding: 0.375em 0.625em;
background: var(--rpg-highlight);
border: none;
border-radius: 0.25em;
color: white;
cursor: pointer;
transition: opacity 0.2s;
}
.rpg-attr-remove:hover {
opacity: 0.8;
}
/* Toggle rows */
.rpg-editor-toggle-row {
display: flex;
align-items: center;
gap: 0.5em;
margin: 0.75em 0;
}
.rpg-editor-toggle-row input[type="checkbox"] {
flex-shrink: 0;
}
.rpg-editor-toggle-row label {
flex: 1;
color: var(--rpg-text);
}
/* Text inputs */
.rpg-text-input {
width: 100%;
padding: 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.375em;
color: var(--rpg-text);
font-size: 0.95em;
margin-top: 0.25em;
}
/* Widget rows */
.rpg-editor-widget-row {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.625em;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 0.375em;
margin-bottom: 0.5em;
}
.rpg-editor-widget-row input[type="checkbox"] {
flex-shrink: 0;
}
.rpg-editor-widget-row label {
flex: 1;
color: var(--rpg-text);
}
.rpg-select-mini {
padding: 0.375em 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.9em;
}
.rpg-radio-group {
display: flex;
gap: 1em;
}
.rpg-radio-group label {
display: flex;
align-items: center;
gap: 0.375em;
color: var(--rpg-text);
}
/* Character fields list */
.rpg-editor-fields-list {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-bottom: 1em;
}
/* Relationship Mapping Styles */
.rpg-relationship-mapping-list {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-bottom: 1em;
}
.rpg-relationship-item {
display: grid;
grid-template-columns: 1fr auto 80px auto;
align-items: center;
gap: 0.75em;
padding: 0.5em;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 0.375em;
}
.rpg-relationship-name,
.rpg-relationship-emoji {
padding: 0.375em 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
}
.rpg-relationship-emoji {
text-align: center;
font-size: 1.2em;
}
.rpg-arrow {
color: var(--rpg-highlight);
font-weight: bold;
font-size: 1.2em;
}
/* Thoughts Configuration Input Groups */
.rpg-thoughts-config {
display: flex;
flex-direction: column;
gap: 1em;
margin-top: 0.75em;
}
.rpg-editor-input-group {
display: flex;
flex-direction: column;
gap: 0.375em;
}
.rpg-editor-input-group label {
font-size: 0.9em;
color: var(--rpg-text);
opacity: 0.9;
}
.rpg-editor-input-group input[type="text"] {
width: 100%;
padding: 0.5em;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
}
/* History Persistence inputs/selects - match tracker editor styling */
#rpg-tracker-editor-popup .rpg-input,
#rpg-tracker-editor-popup .rpg-select {
padding: 0.5em;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
text-align: left;
margin-left: 0 !important;
display: block;
}
#rpg-tracker-editor-popup .rpg-input:focus,
#rpg-tracker-editor-popup .rpg-select:focus {
outline: none;
border-color: var(--rpg-highlight);
box-shadow: 0 0 5px rgba(var(--rpg-highlight-rgb), 0.3);
}
.rpg-editor-field-item {
display: grid;
grid-template-columns: auto auto 1fr 2fr auto;
align-items: center;
gap: 0.5em;
padding: 0.5em;
background: var(--rpg-accent);
border: 1px solid var(--rpg-border);
border-radius: 0.375em;
min-width: 0; /* Allow grid items to shrink below content size */
}
.rpg-field-controls {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.rpg-field-move-up,
.rpg-field-move-down {
padding: 0.125em 0.375em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
cursor: pointer;
font-size: 0.75em;
}
.rpg-field-move-up:disabled,
.rpg-field-move-down:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.rpg-field-toggle {
flex-shrink: 0;
}
.rpg-field-label,
.rpg-char-stat-label,
.rpg-field-placeholder {
padding: 0.375em 0.5em;
background: var(--rpg-bg);
border: 1px solid var(--rpg-border);
border-radius: 0.25em;
color: var(--rpg-text);
font-size: 0.95em;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0; /* Allow input to shrink */
width: 100%; /* Fill available grid space */
box-sizing: border-box;
}
.rpg-field-remove,
.rpg-remove-relationship {
flex-shrink: 0;
padding: 0.375em 0.625em;
background: var(--rpg-highlight);
border: none;
border-radius: 0.25em;
color: white;
cursor: pointer;
transition: opacity 0.2s;
}
.rpg-field-remove:hover,
.rpg-remove-relationship:hover {
opacity: 0.8;
}
/* Character stats checkboxes */
.rpg-char-stats-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 1em;
margin-top: 0.5em;
}
.rpg-char-stats-checkboxes label {
display: flex;
align-items: center;
gap: 0.375em;
color: var(--rpg-text);
}
/* Footer buttons */
.rpg-settings-popup-footer {
padding: 1em;
border-top: 2px solid var(--rpg-border);
background: var(--rpg-accent);
}
.rpg-editor-footer-buttons {
display: flex;
flex-direction: column;
gap: 0.75em;
}
.rpg-editor-footer-row {
display: flex;
gap: 0.5em;
justify-content: center;
}
.rpg-editor-footer-row:last-child button {
flex: 1;
max-width: 400px;
}
.rpg-footer-left {
display: flex;
gap: 0.5em;
}
.rpg-footer-right {
display: flex;
gap: 0.5em;
}
/* Prompts Editor Styles */
.rpg-prompt-editor-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--rpg-border);
}
.rpg-prompt-editor-section:last-child {
border-bottom: none;
}
.rpg-prompt-textarea {
width: 100%;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--SmartThemeBorderColor);
background: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeBodyColor);
font-family: 'Courier New', monospace;
font-size: 12px;
resize: vertical;
line-height: 1.5;
box-sizing: border-box;
}
.rpg-prompt-textarea:focus {
outline: none;
border-color: var(--rpg-highlight);
box-shadow: 0 0 0 2px rgba(233, 69, 96, 0.2);
}
.rpg-restore-prompt-btn {
font-size: 0.9em;
white-space: nowrap;
min-width: 120px;
padding: 0.5em 1em;
}
/* Editor buttons */
.rpg-btn-primary,
.rpg-btn-secondary,
.rpg-btn-cancel,
.rpg-btn-reset {
padding: 0.625em 1.25em;
border: 1px solid var(--rpg-border);
border-radius: 0.375em;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
transition: all 0.2s;
}
.rpg-btn-primary {
background: var(--rpg-highlight);
color: white;
border-color: var(--rpg-highlight);
}
.rpg-btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.rpg-btn-secondary {
background: var(--rpg-bg);
color: var(--rpg-text);
border-color: var(--rpg-border);
}
.rpg-btn-secondary:hover {
background: var(--rpg-accent);
transform: translateY(-1px);
}
.rpg-btn-cancel {
background: var(--rpg-accent);
color: var(--rpg-text);
border-color: var(--rpg-border);
}
.rpg-btn-cancel:hover {
background: var(--rpg-bg);
}
.rpg-btn-reset {
background: transparent;
color: var(--rpg-text);
border-color: var(--rpg-border);
opacity: 0.8;
}
.rpg-btn-reset:hover {
opacity: 1;
background: var(--rpg-accent);
}
+246
View File
@@ -0,0 +1,246 @@
/* ============================================
SETTINGS MODAL - MOBILE FIRST
============================================ */
/* CSS Custom Properties for Responsive Scaling */
.rpg-settings-popup {
/* Fluid spacing */
--modal-padding: clamp(0.5rem, 2vw, 0.75rem);
--modal-gap: clamp(0.375rem, 1.5vw, 0.5rem);
--modal-border-width: 2px;
/* Fluid typography */
--modal-font-base: clamp(0.8rem, 3vw, 0.9rem);
--modal-font-small: clamp(0.7rem, 2.5vw, 0.8rem);
--modal-font-heading: clamp(0.9rem, 3.5vw, 1rem);
/* Content constraints - more height than dice roller */
--modal-max-width: min(90vw, 500px);
--modal-max-height: 75vh;
}
/* Modal Container - Hidden by default */
.rpg-settings-popup {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
padding: 0.5rem;
}
/* Open state */
.rpg-settings-popup.is-open {
display: flex;
animation: fadeIn 0.2s ease-out;
}
/* Closing state */
.rpg-settings-popup.is-closing {
display: flex;
animation: fadeOut 0.2s ease-in;
}
/* Backdrop overlay - using ::before pseudo-element */
.rpg-settings-popup::before {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
/* Modal Content Box */
.rpg-settings-popup-content {
position: relative;
width: 100%;
max-width: var(--modal-max-width);
height: auto;
max-height: var(--modal-max-height);
min-height: 0;
background: rgba(30, 30, 30, 0.8);
border: var(--modal-border-width) solid var(--rpg-border);
border-radius: 0.5rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9);
color: var(--rpg-text);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
margin: auto 0;
}
/* Header */
.rpg-settings-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--modal-padding);
background: var(--rpg-accent);
border-bottom: var(--modal-border-width) solid var(--rpg-border);
flex-shrink: 0;
}
.rpg-settings-popup-header h3 {
margin: 0;
font-size: var(--modal-font-heading);
color: var(--rpg-highlight);
display: flex;
align-items: center;
gap: var(--modal-gap);
border-left: none !important;
padding-left: 0 !important;
}
.rpg-settings-popup-header h3 i {
border-left: none !important;
padding-left: 0 !important;
color: var(--rpg-highlight);
}
/* Close button - touch-friendly */
.rpg-popup-close {
background: transparent;
border: none;
color: var(--rpg-text);
font-size: clamp(1.5rem, 2.2vw, 2.2rem);
cursor: pointer;
padding: 0.5rem;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s ease;
}
.rpg-popup-close:active {
background: rgba(255, 255, 255, 0.1);
color: var(--rpg-highlight);
}
/* Scrollable Body */
.rpg-settings-popup-body {
padding: var(--modal-padding);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
flex: 1 1 auto;
min-height: 0;
background: transparent;
color: inherit;
}
/* Settings Groups */
.rpg-settings-group {
margin-bottom: var(--modal-padding);
padding-bottom: var(--modal-padding);
border-bottom: 1px solid var(--rpg-border);
}
.rpg-settings-group:last-child {
border-bottom: none;
}
.rpg-settings-group h4 {
margin: 0 0 var(--modal-gap) 0;
font-size: var(--modal-font-base);
color: var(--rpg-highlight);
display: flex;
align-items: center;
gap: var(--modal-gap);
font-weight: 600;
}
/* Theme Support - Default Theme (no data-theme attribute) */
.rpg-settings-popup-content {
--rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9));
--rpg-accent: var(--black30a, rgba(22, 33, 62, 0.9));
--rpg-text: var(--SmartThemeBodyColor, #eaeaea);
--rpg-highlight: var(--SmartThemeQuoteColor, #e94560);
--rpg-border: #6b9fd4;
}
/* Theme Support - Settings Modal */
#rpg-settings-popup[data-theme="sci-fi"] .rpg-settings-popup-content {
--rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a;
--rpg-text: #00ffff;
--rpg-highlight: #ff00ff;
--rpg-border: #00ffff;
background: rgba(10, 14, 39, 0.95);
color: #00ffff;
}
#rpg-settings-popup[data-theme="fantasy"] .rpg-settings-popup-content {
--rpg-bg: #2b1810;
--rpg-accent: #3d2516;
--rpg-text: #f4e4c1;
--rpg-highlight: #d4af37;
--rpg-border: #8b6914;
background: rgba(43, 24, 16, 0.95);
color: #f4e4c1;
}
#rpg-settings-popup[data-theme="cyberpunk"] .rpg-settings-popup-content {
--rpg-bg: #0d0221;
--rpg-accent: #1a0b2e;
--rpg-text: #00ff9f;
--rpg-highlight: #ff00ff;
--rpg-border: #ff00ff;
background: rgba(13, 2, 33, 0.95);
color: #00ff9f;
}
/* Theme Support - Tracker Editor Modal */
#rpg-tracker-editor-popup[data-theme="sci-fi"] .rpg-settings-popup-content {
--rpg-bg: #0a0e27;
--rpg-accent: #1a1f3a;
--rpg-text: #00ffff;
--rpg-highlight: #ff00ff;
--rpg-border: #00ffff;
background: rgba(10, 14, 39, 0.95);
color: #00ffff;
}
#rpg-tracker-editor-popup[data-theme="fantasy"] .rpg-settings-popup-content {
--rpg-bg: #2b1810;
--rpg-accent: #3d2516;
--rpg-text: #f4e4c1;
--rpg-highlight: #d4af37;
--rpg-border: #8b6914;
background: rgba(43, 24, 16, 0.95);
color: #f4e4c1;
}
#rpg-tracker-editor-popup[data-theme="cyberpunk"] .rpg-settings-popup-content {
--rpg-bg: #0d0221;
--rpg-accent: #1a0b2e;
--rpg-text: #00ff9f;
--rpg-highlight: #ff00ff;
--rpg-border: #ff00ff;
background: rgba(13, 2, 33, 0.95);
color: #00ff9f;
}
/* Desktop Enhancement (1001px+) */
@media (min-width: 1001px) {
.rpg-settings-popup {
--modal-padding: 1rem;
--modal-gap: 0.75rem;
--modal-font-base: 0.9rem;
--modal-font-small: 0.8rem;
--modal-font-heading: 1.125rem;
--modal-max-width: 600px;
}
/* Hover effects on desktop */
.rpg-popup-close:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--rpg-highlight);
}
}
+498
View File
@@ -0,0 +1,498 @@
/* ============================================
BELOW-CHAT PRESENT CHARACTERS
============================================ */
#rpg-alt-present-characters {
margin: 0 0 10px;
padding: 8px 10px 8px;
border-radius: 14px;
border: 1px solid var(--rpg-border, rgba(255, 255, 255, 0.14));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0) 100%),
linear-gradient(135deg, var(--rpg-accent, rgba(34, 40, 60, 0.94)) 0%, var(--rpg-bg, rgba(18, 21, 34, 0.96)) 100%);
box-shadow: 0 12px 28px var(--rpg-shadow, rgba(0, 0, 0, 0.24));
color: var(--rpg-text, var(--SmartThemeBodyColor, #ecf0f1));
backdrop-filter: blur(12px);
}
.rpg-alt-present-characters__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.rpg-alt-present-characters__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.rpg-alt-present-characters__title i {
color: var(--rpg-highlight, #e94560);
}
.rpg-alt-present-characters__count {
min-width: 24px;
height: 24px;
padding: 0 7px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--rpg-border, rgba(255, 255, 255, 0.14));
color: var(--rpg-text, var(--SmartThemeBodyColor, #ecf0f1));
font-size: 0.74rem;
font-weight: 700;
}
.rpg-alt-present-characters__scroll {
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
transform: scaleY(-1);
}
.rpg-alt-present-characters__scroll::-webkit-scrollbar {
height: 6px;
}
.rpg-alt-present-characters__scroll::-webkit-scrollbar-track {
background: transparent;
}
.rpg-alt-present-characters__scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.rpg-alt-present-characters__scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.34);
}
.rpg-alt-present-characters__track {
display: flex;
gap: 10px;
width: max-content;
min-width: 100%;
transform: scaleY(-1);
padding-top: 8px;
padding-bottom: 2px;
}
.rpg-alt-present-character {
flex: 0 0 98px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 98px;
}
.rpg-alt-present-character__portrait {
position: relative;
aspect-ratio: 11 / 15;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
background: var(--rpg-bg, rgba(18, 21, 34, 0.96));
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.rpg-alt-present-character__portrait:hover {
transform: translateY(-2px);
border-color: var(--rpg-highlight, #e94560);
box-shadow: 0 12px 20px rgba(0, 0, 0, 0.22);
}
.rpg-alt-present-character__portrait img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.rpg-alt-present-character__meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.rpg-alt-present-character__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-alt-present-character__name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.1;
text-align: center;
}
@media (max-width: 768px) {
#rpg-alt-present-characters {
margin-bottom: 8px;
padding: 7px 9px 7px;
border-radius: 12px;
}
.rpg-alt-present-character {
flex-basis: 84px;
min-width: 84px;
}
}
/* ============================================
CHAT THOUGHT OVERLAYS
============================================ */
/* Container for thought overlay on chat messages */
/* Floating thought panel - positioned next to character avatar */
#rpg-thought-panel {
position: fixed;
z-index: 1000; /* Lower z-index to stay below dropdown menus */
pointer-events: auto;
max-width: 350px; /* Fixed max like panels, will be constrained by viewport */
width: auto;
transform: translateY(0); /* No vertical centering - align with avatar top */
animation: thoughtPanelFadeIn 0.4s ease-out;
}
/* Collapsed thought icon */
#rpg-thought-icon {
position: fixed;
z-index: 1000; /* Lower z-index to stay below dropdown menus */
display: none; /* Hidden by default in desktop */
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--rpg-bg, rgba(30, 30, 50, 0.95));
border: 2px solid var(--rpg-highlight, #e94560);
color: var(--rpg-text, #ecf0f1);
font-size: clamp(1.25rem, 1.85vw, 1.875rem);
cursor: move;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
user-select: none;
-webkit-user-select: none;
}
/* Show icon only in mobile view */
@media (max-width: 1000px) {
#rpg-thought-icon {
display: flex;
}
}
/* Force hide class for desktop mode - overrides media query (unless collapsed) */
#rpg-thought-icon.rpg-force-hide:not(.rpg-collapsed-desktop) {
display: none !important;
}
/* When collapsed in desktop, show icon */
#rpg-thought-icon.rpg-collapsed-desktop {
display: flex !important;
}
/* Hidden state that allows transitions */
#rpg-thought-icon.rpg-hidden {
opacity: 0;
pointer-events: none;
}
#rpg-thought-icon.dragging {
transition: none !important;
cursor: grabbing;
}
#rpg-thought-icon:not(.dragging):hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(233, 69, 96, 0.4);
}
/* Close button */
.rpg-thought-close {
position: absolute;
top: -0.5rem;
right: -0.5rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--rpg-highlight, #e94560);
color: white;
border: 2px solid var(--rpg-bg, rgba(30, 30, 50, 0.95));
font-size: clamp(0.9rem, 1rem, 1.1rem);
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
transition: all 0.2s ease;
}
.rpg-thought-close:hover {
transform: scale(1.1);
background: var(--rpg-text, #eaeaea);
color: var(--rpg-highlight, #e94560);
}
/* Show close button in desktop view for collapsing */
@media (min-width: 1001px) {
.rpg-thought-close {
display: flex !important;
}
}
@keyframes thoughtIconPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
50% {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(233, 69, 96, 0.3);
}
}
/* Thought circles floating from avatar */
.rpg-thought-circles {
position: absolute;
display: flex;
flex-direction: row-reverse; /* Horizontal layout, flipped */
align-items: center; /* Center vertically */
gap: 0.5em;
inset: 0px -50px auto;
z-index: 1;
}
/* Flip thought circles to left side when panel is on left */
.rpg-thought-panel-right .rpg-thought-circles {
inset: 0px auto auto -50px;
flex-direction: row; /* Normal order for left side */
}
.rpg-thought-circle {
background: var(--rpg-bg, rgba(30, 30, 50, 0.8));
border: 2px solid var(--rpg-highlight, #e94560);
border-radius: 50%;
animation: thoughtCirclePulse 1.5s ease-in-out infinite;
}
.rpg-circle-1 {
width: 0.5rem;
height: 0.5rem;
animation-delay: 0s;
}
.rpg-circle-2 {
width: 0.75rem;
height: 0.75rem;
animation-delay: 0.2s;
}
.rpg-circle-3 {
width: 1rem;
height: 1rem;
animation-delay: 0.4s;
}
/* Thought bubble main container */
.rpg-thought-bubble {
background: var(--rpg-bg, rgba(30, 30, 50, 0.95));
border: 2px solid var(--rpg-highlight, #e94560);
border-radius: clamp(10px, 1.5vh, 14px);
padding: clamp(10px, 1.5vh, 14px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
position: relative;
backdrop-filter: blur(15px);
max-height: calc(100vh - 120px); /* Scale with viewport height */
overflow-y: auto;
overflow-x: hidden;
}
/* Custom scrollbar for thought bubble */
.rpg-thought-bubble::-webkit-scrollbar {
width: 0.375rem;
}
.rpg-thought-bubble::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.rpg-thought-bubble::-webkit-scrollbar-thumb {
background: var(--rpg-highlight, #e94560);
border-radius: 3px;
opacity: 0.5;
}
.rpg-thought-bubble::-webkit-scrollbar-thumb:hover {
background: var(--rpg-highlight, #e94560);
opacity: 0.8;
}
/* Individual thought item (for multiple characters) */
.rpg-thought-item {
display: flex;
gap: clamp(10px, 1.5vh, 12px);
align-items: flex-start;
}
/* Emoji box on the left */
.rpg-thought-emoji-box {
flex-shrink: 0;
width: clamp(32px, 4vh, 40px);
height: clamp(32px, 4vh, 40px);
display: flex;
align-items: center;
justify-content: center;
background: var(--rpg-accent, rgba(50, 50, 70, 0.8));
border: 1px solid var(--rpg-highlight, #e94560);
border-radius: clamp(6px, 1vh, 8px);
font-size: clamp(16px, 2vw, 20px);
}
/* Thought content on the right */
.rpg-thought-content {
flex: 1;
font-size: clamp(0.85rem, 0.9rem, 0.95rem);
line-height: 1.5;
color: var(--rpg-text, #eaeaea);
font-style: italic;
opacity: 0.95;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Divider between multiple thoughts */
.rpg-thought-divider {
height: 0.062rem;
background: linear-gradient(to right, transparent, var(--rpg-highlight, #e94560), transparent);
margin: clamp(8px, 1vh, 10px) 0;
opacity: 0.5;
}
/* Arrow pointing right (when panel is on left) */
#rpg-thought-panel.rpg-thought-panel-left .rpg-thought-bubble::after {
content: '';
position: absolute;
top: 50%;
right: -0.75rem;
transform: translateY(-50%);
width: 0;
height: 0;
border-style: solid;
border-width: 10px 0 10px 12px;
border-color: transparent transparent transparent var(--rpg-highlight, #e94560);
}
#rpg-thought-panel.rpg-thought-panel-left .rpg-thought-bubble::before {
content: '';
position: absolute;
top: 50%;
right: -0.562rem;
transform: translateY(-50%);
width: 0;
height: 0;
border-style: solid;
border-width: 0.5rem 0 0.5rem 0.625rem;
border-color: transparent transparent transparent var(--rpg-bg, rgba(30, 30, 50, 0.95));
z-index: 1;
}
/* Arrow pointing left (when panel is on right) */
#rpg-thought-panel.rpg-thought-panel-right .rpg-thought-bubble::after {
content: '';
position: absolute;
top: 50%;
left: -0.75rem;
transform: translateY(-50%);
width: 0;
height: 0;
border-style: solid;
border-width: 10px 12px 10px 0;
border-color: transparent var(--rpg-highlight, #e94560) transparent transparent;
}
#rpg-thought-panel.rpg-thought-panel-right .rpg-thought-bubble::before {
content: '';
position: absolute;
top: 50%;
left: -0.562rem;
transform: translateY(-50%);
width: 0;
height: 0;
border-style: solid;
border-width: 0.5rem 0.625rem 0.5rem 0;
border-color: transparent var(--rpg-bg, rgba(30, 30, 50, 0.95)) transparent transparent;
z-index: 1;
}
/* Animations */
@keyframes thoughtPanelFadeIn {
from {
opacity: 0;
transform: translateY(-50%) scale(0.9);
}
to {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
@keyframes thoughtCirclePulse {
0%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
/* Responsive positioning for mobile/narrow screens */
@media (max-width: 1000px) {
#rpg-thought-panel {
position: fixed !important;
max-width: 90vw !important;
width: 90vw !important;
left: 50% !important;
transform: translateX(-50%) !important;
max-height: calc(100dvh - 80px) !important;
}
#rpg-thought-panel .rpg-thought-bubble {
max-height: calc(100dvh - 100px) !important;
overflow-y: auto !important;
}
.rpg-thought-circles {
display: none !important;
}
#rpg-thought-panel .rpg-thought-bubble::after,
#rpg-thought-panel .rpg-thought-bubble::before {
display: none !important;
}
.rpg-thought-content {
font-size: clamp(12px, 3.5vw, 16px);
}
}
File diff suppressed because it is too large. Load diff
+389
View File
@@ -0,0 +1,389 @@
/* ========================================
Inventory System v2 Styles
======================================== */
/* Inventory Container */
#rpg-inventory {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem;
font-size: 0.9rem;
}
.rpg-inventory-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem;
font-size: 0.9rem;
}
/* Sub-tabs Navigation */
.rpg-inventory-subtabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--SmartThemeBorderColor);
padding-bottom: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
scrollbar-width: thin;
scrollbar-color: var(--SmartThemeBorderColor) transparent;
}
.rpg-inventory-subtabs::-webkit-scrollbar {
height: 6px;
}
.rpg-inventory-subtabs::-webkit-scrollbar-track {
background: transparent;
}
.rpg-inventory-subtabs::-webkit-scrollbar-thumb {
background: var(--SmartThemeBorderColor);
border-radius: 3px;
}
.rpg-inventory-subtabs::-webkit-scrollbar-thumb:hover {
background: var(--rpg-accent);
}
.rpg-inventory-subtab {
flex: 1;
min-width: fit-content;
white-space: nowrap;
padding: 0.5rem 1rem;
background: transparent;
border: 2px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
text-align: center;
font-size: clamp(0.75rem, 1vw, 0.9rem);
}
.rpg-inventory-subtab:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-inventory-subtab.active {
background: transparent;
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
font-weight: 600;
}
/* Inventory Sections */
.rpg-inventory-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rpg-inventory-header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--SmartThemeBorderColor);
gap: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
scrollbar-width: thin;
scrollbar-color: var(--SmartThemeBorderColor) transparent;
}
.rpg-inventory-header::-webkit-scrollbar {
height: 6px;
}
.rpg-inventory-header::-webkit-scrollbar-track {
background: transparent;
}
.rpg-inventory-header::-webkit-scrollbar-thumb {
background: var(--SmartThemeBorderColor);
border-radius: 3px;
}
.rpg-inventory-header::-webkit-scrollbar-thumb:hover {
background: var(--rpg-accent);
}
.rpg-inventory-header h4 {
margin: 0;
font-size: 1.1rem;
color: var(--SmartThemeBodyColor);
white-space: nowrap;
min-width: fit-content;
}
.rpg-inventory-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rpg-inventory-text {
padding: 0.75rem;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
.rpg-inventory-empty {
padding: 2rem;
text-align: center;
color: var(--SmartThemeFastUISliderColColor);
font-style: italic;
}
.rpg-inventory-hint {
padding: 0.5rem;
background: transparent;
border: 2px solid var(--rpg-highlight);
border-radius: 0.25rem;
font-size: 0.85rem;
color: var(--SmartThemeFastUISliderColColor);
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.rpg-inventory-hint i {
margin-top: 0.1rem;
}
/* Storage Locations */
.rpg-storage-location {
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
overflow: hidden;
}
.rpg-storage-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--SmartThemeQuoteColor);
cursor: pointer;
}
.rpg-storage-toggle {
background: none;
border: none;
color: var(--SmartThemeBodyColor);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.rpg-storage-toggle:hover {
color: var(--ac-style-color-matchedText);
}
.rpg-storage-name {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #000000;
}
.rpg-storage-actions {
display: flex;
gap: 0.5rem;
}
.rpg-storage-content {
padding: 0.5rem;
background: var(--SmartThemeBlurTintColor);
}
.rpg-storage-location.collapsed .rpg-storage-content {
display: none;
}
.rpg-storage-add-item-container {
display: flex;
justify-content: center;
margin-top: 0.75rem;
}
/* Buttons */
.rpg-inventory-edit-btn,
.rpg-inventory-add-btn,
.rpg-inventory-remove-btn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
background: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeBodyColor);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.rpg-inventory-edit-btn:hover {
background: var(--ac-style-color-matchedText);
border-color: var(--ac-style-color-matchedText);
color: white;
}
.rpg-inventory-add-btn {
background: transparent;
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
white-space: nowrap;
min-width: fit-content;
}
.rpg-inventory-add-btn:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
border-color: var(--rpg-highlight);
}
.rpg-inventory-remove-btn {
background: transparent;
color: var(--SmartThemeFastUISliderColColor);
}
.rpg-inventory-remove-btn:hover {
background: #dc3545;
border-color: #dc3545;
color: white;
}
/* Inline Editing Styles */
.rpg-inventory-text.rpg-editable {
cursor: text;
transition: all 0.2s ease;
min-height: 2rem;
}
.rpg-inventory-text.rpg-editable:hover {
background: var(--SmartThemeQuoteColor);
border-color: var(--ac-style-color-matchedText);
}
.rpg-inventory-text.rpg-editable:focus {
outline: none;
border-color: var(--ac-style-color-matchedText);
background: var(--SmartThemeEmColor);
box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2);
}
.rpg-inventory-text.rpg-editable:empty::before {
content: 'Click to edit...';
color: var(--SmartThemeFastUISliderColColor);
font-style: italic;
}
/* Inline Forms */
.rpg-inline-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--SmartThemeQuoteColor);
border: 1px solid var(--ac-style-color-matchedText);
border-radius: 0.25rem;
margin-bottom: 0.75rem;
}
.rpg-inline-input {
padding: 0.5rem 0.75rem;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
font-size: 0.9rem;
font-family: inherit;
}
.rpg-inline-input:focus {
outline: none;
border-color: var(--ac-style-color-matchedText);
box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2);
}
.rpg-inline-buttons {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.rpg-inline-btn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
background: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeBodyColor);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.rpg-inline-btn:hover {
opacity: 0.85;
}
.rpg-inline-cancel {
background: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeFastUISliderColColor);
}
.rpg-inline-cancel:hover {
background: #6c757d;
border-color: #6c757d;
color: white;
}
.rpg-inline-save,
.rpg-inline-confirm {
background: var(--ac-style-color-matchedText);
border-color: var(--ac-style-color-matchedText);
color: white;
}
.rpg-inline-save:hover,
.rpg-inline-confirm:hover {
opacity: 0.85;
}
/* Inline Confirmation */
.rpg-inline-confirmation {
padding: 0.75rem;
background: var(--SmartThemeQuoteColor);
border: 1px solid #dc3545;
border-radius: 0.25rem;
margin-top: 0.5rem;
}
.rpg-inline-confirmation p {
margin: 0 0 0.75rem 0;
color: var(--SmartThemeBodyColor);
font-size: 0.9rem;
}
+173
View File
@@ -0,0 +1,173 @@
/* ============================================
ITEM LIST AND GRID VIEW STYLES
============================================ */
/* Item list container base styles */
.rpg-item-list {
min-height: 2rem;
padding: 0.5rem;
}
/* LIST VIEW - Full-width rows */
.rpg-item-list-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rpg-item-row {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: transparent;
border: 2px solid var(--rpg-highlight);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
font-size: 0.95rem;
transition: all 0.2s ease;
}
.rpg-item-row:hover {
border-color: var(--rpg-highlight);
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
}
.rpg-item-row .rpg-item-name {
flex: 1;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
.rpg-item-row .rpg-item-remove {
flex-shrink: 0;
padding: 0.3rem 0.6rem;
background: transparent;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeFastUISliderColColor);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
}
.rpg-item-row .rpg-item-remove:hover {
background: #dc3545;
border-color: #dc3545;
color: white;
}
/* GRID VIEW - Responsive card grid */
.rpg-item-grid-view {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
.rpg-item-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 0.75rem;
background: transparent;
border: 2px solid var(--rpg-highlight);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
font-size: 0.9rem;
transition: all 0.2s ease;
min-height: 80px;
}
.rpg-item-card:hover {
border-color: var(--rpg-highlight);
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
}
.rpg-item-card .rpg-item-name {
text-align: center;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
max-width: 100%;
}
.rpg-item-card .rpg-item-remove {
position: absolute;
top: 0.25rem;
right: 0.25rem;
padding: 0;
width: 1.5rem;
height: 1.5rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: var(--SmartThemeFastUISliderColColor);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: center;
}
.rpg-item-card .rpg-item-remove:hover {
background: #dc3545;
color: white;
}
/* Empty state message */
.rpg-inventory-empty {
padding: 1rem;
text-align: center;
color: var(--SmartThemeFastUISliderColColor);
font-style: italic;
font-size: 0.9rem;
}
/* View Toggle Buttons */
.rpg-inventory-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: nowrap;
min-width: fit-content;
}
.rpg-view-toggle {
display: flex;
gap: 0.25rem;
flex-wrap: nowrap;
}
.rpg-view-btn {
padding: 0.35rem 0.6rem;
background: var(--SmartThemeBlurTintColor);
border: 2px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeFastUISliderColColor);
cursor: pointer;
white-space: nowrap;
min-width: fit-content;
transition: all 0.2s ease;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
}
.rpg-view-btn:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-view-btn.active {
background: var(--SmartThemeBlurTintColor);
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
+138
View File
@@ -0,0 +1,138 @@
/* ============================================
DESKTOP TABS SYSTEM
============================================ */
/* Desktop tabs container */
.rpg-tabs-container {
display: flex;
flex-direction: column;
gap: 0;
height: 100%;
width: 100%;
}
/* Desktop tab navigation */
.rpg-tabs-nav {
display: flex;
gap: 0;
background: var(--SmartThemeBlurTintColor);
border-bottom: 2px solid var(--SmartThemeBorderColor);
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
scrollbar-width: thin;
scrollbar-color: var(--SmartThemeBorderColor) transparent;
}
.rpg-tabs-nav::-webkit-scrollbar {
height: 6px;
}
.rpg-tabs-nav::-webkit-scrollbar-track {
background: transparent;
}
.rpg-tabs-nav::-webkit-scrollbar-thumb {
background: var(--SmartThemeBorderColor);
border-radius: 3px;
}
.rpg-tabs-nav::-webkit-scrollbar-thumb:hover {
background: var(--rpg-highlight);
}
/* Desktop tab button */
.rpg-tab-btn {
flex: 1;
min-width: fit-content;
white-space: nowrap;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--SmartThemeBodyColor);
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.rpg-tab-btn:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
color: var(--rpg-highlight);
}
.rpg-tab-btn.active {
background: transparent;
border-bottom-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-tab-btn i {
font-size: 1.1rem;
}
/* Desktop tab content */
.rpg-tab-content {
display: none;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0.5rem;
flex: 1;
}
.rpg-tab-content.active {
display: flex;
}
/* Hide dividers when tabs are active (tabs separate content) */
.rpg-tabs-container .rpg-divider {
display: none;
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
.rpg-inventory-subtabs {
flex-direction: column;
gap: 0.35rem;
}
.rpg-inventory-subtab {
font-size: 1rem;
padding: 0.75rem;
}
.rpg-inventory-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.rpg-inventory-header h4 {
font-size: 1rem;
}
.rpg-inventory-edit-btn,
.rpg-inventory-add-btn,
.rpg-inventory-remove-btn {
padding: 0.6rem 1rem;
font-size: 0.95rem;
min-height: 2.5rem;
}
.rpg-storage-header {
padding: 0.75rem;
}
.rpg-storage-toggle {
min-width: 2rem;
min-height: 2rem;
}
}
+391
View File
@@ -0,0 +1,391 @@
/* ============================================
QUESTS SYSTEM STYLING
============================================ */
/* Quest Container */
#rpg-quests {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem;
font-size: 0.9rem;
}
/* Quest Sub-tabs Navigation (Main / Optional) */
.rpg-quests-subtabs {
display: flex;
gap: 0.5rem;
border-bottom: 2px solid var(--SmartThemeBorderColor);
padding-bottom: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
scrollbar-width: thin;
scrollbar-color: var(--SmartThemeBorderColor) transparent;
}
.rpg-quests-subtabs::-webkit-scrollbar {
height: 6px;
}
.rpg-quests-subtabs::-webkit-scrollbar-track {
background: transparent;
}
.rpg-quests-subtabs::-webkit-scrollbar-thumb {
background: var(--SmartThemeBorderColor);
border-radius: 3px;
}
.rpg-quests-subtabs::-webkit-scrollbar-thumb:hover {
background: var(--rpg-accent);
}
.rpg-quests-subtab {
flex: 1;
padding: 0.5rem 1rem;
background: transparent;
border: 2px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
text-align: center;
font-size: clamp(0.75rem, 1vw, 0.9rem);
min-width: fit-content;
white-space: nowrap;
}
.rpg-quests-subtab:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-quests-subtab.active {
background: transparent;
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
font-weight: 600;
}
/* Quest Sections */
.rpg-quest-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rpg-quest-header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-quest-header h4 {
margin: 0;
font-size: 1.1rem;
color: var(--SmartThemeBodyColor);
}
.rpg-quest-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Main Quest Display */
.rpg-main-quest-display {
padding: 0.75rem;
background: var(--SmartThemeBlurTintColor);
border: 2px solid var(--rpg-highlight);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 1rem;
font-weight: 500;
}
.rpg-main-quest-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Optional Quests List */
.rpg-quest-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rpg-quest-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
transition: all 0.2s ease;
}
.rpg-quest-item:hover {
border-color: var(--rpg-highlight);
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.05);
}
.rpg-quest-title {
flex: 1;
color: var(--SmartThemeBodyColor);
line-height: 1.5;
word-wrap: break-word;
}
.rpg-quest-actions {
display: flex;
gap: 0.5rem;
}
/* Quest Buttons */
.rpg-quest-edit,
.rpg-quest-remove,
.rpg-add-quest-btn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
background: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeBodyColor);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.rpg-quest-edit:hover {
background: var(--ac-style-color-matchedText);
border-color: var(--ac-style-color-matchedText);
color: white;
}
.rpg-quest-remove:hover {
background: #e74c3c;
border-color: #e74c3c;
color: white;
}
.rpg-add-quest-btn {
background: transparent;
border-color: var(--rpg-highlight);
color: var(--rpg-highlight);
}
.rpg-add-quest-btn:hover {
background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1);
border-color: var(--rpg-highlight);
}
/* Quest Empty State */
.rpg-quest-empty {
padding: 2rem;
text-align: center;
color: var(--SmartThemeFastUISliderColColor);
font-style: italic;
}
/* Quest Hint */
.rpg-quest-hint {
padding: 0.5rem;
background: transparent;
border: 2px solid var(--rpg-highlight);
border-radius: 0.25rem;
font-size: 0.85rem;
color: var(--SmartThemeFastUISliderColColor);
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.rpg-quest-hint i {
margin-top: 0.1rem;
}
/* Quest Inline Edit Form */
.rpg-quest-edit-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background: var(--SmartThemeBlurTintColor);
border: 2px solid var(--rpg-highlight);
border-radius: 0.25rem;
}
.rpg-quest-edit-form input,
.rpg-quest-edit-form textarea {
width: 100%;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
color: var(--SmartThemeBodyColor);
font-family: inherit;
font-size: 0.9rem;
}
.rpg-quest-edit-form input:focus,
.rpg-quest-edit-form textarea:focus {
outline: none;
border-color: var(--rpg-highlight);
}
.rpg-quest-edit-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.rpg-quest-save,
.rpg-quest-cancel {
padding: 0.5rem 1rem;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
}
.rpg-quest-save {
background: var(--rpg-highlight);
border-color: var(--rpg-highlight);
color: white;
}
.rpg-quest-save:hover {
background: #ff6b81;
border-color: #ff6b81;
}
.rpg-quest-cancel {
background: transparent;
color: var(--SmartThemeBodyColor);
}
.rpg-quest-cancel:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
.rpg-quests-subtabs {
flex-direction: column;
gap: 0.5rem;
}
.rpg-quests-subtab {
padding: 0.75rem;
font-size: 1rem;
}
.rpg-quest-header h4 {
font-size: 1rem;
}
.rpg-quest-edit,
.rpg-quest-remove,
.rpg-add-quest-btn {
padding: 0.6rem 1rem;
font-size: 0.95rem;
min-height: 2.5rem;
}
.rpg-quest-item {
flex-direction: column;
align-items: flex-start;
}
.rpg-quest-actions {
width: 100%;
justify-content: space-between;
}
.rpg-main-quest-actions {
flex-direction: column;
}
.rpg-main-quest-actions button {
width: 100%;
}
/* ========================================
MOBILE FONT SIZE OVERRIDES
Fix all vw-based font sizes for mobile readability
======================================== */
/* Collapse toggle button */
.rpg-collapse-toggle {
font-size: clamp(16px, 3vw, 20px) !important;
}
/* Top position panel titles */
.rpg-panel.rpg-position-top .rpg-stats-title {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
.rpg-panel.rpg-position-top .rpg-mood {
font-size: clamp(10px, 2vw, 13px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stats-title {
font-size: clamp(10px, 2vw, 13px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-label {
font-size: clamp(8px, 1.7vw, 11px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-value {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-btn {
font-size: clamp(10px, 2.2vw, 14px) !important;
}
.rpg-panel.rpg-position-top .rpg-info-content,
.rpg-panel.rpg-position-top .rpg-thoughts-content {
font-size: clamp(10px, 2.2vw, 14px) !important;
}
/* Panel header */
.rpg-panel-header h3 {
font-size: clamp(14px, 3.4vw, 18px) !important;
}
/* Loading indicator */
.rpg-loading {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
/* Dice display */
.rpg-dice-display {
font-size: clamp(10px, 2vw, 13px) !important;
}
.rpg-dice-display i {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
.rpg-clear-dice-btn {
font-size: clamp(14px, 3vw, 18px) !important;
}
}
+149
View File
@@ -0,0 +1,149 @@
/* ========================================
MOBILE FONT SIZE OVERRIDES (ALL SCREENS)
Apply to screens up to 1000px
======================================== */
@media (max-width: 1000px) {
/* Collapse toggle button */
.rpg-collapse-toggle {
font-size: clamp(16px, 3vw, 20px) !important;
}
/* Top position panel titles */
.rpg-panel.rpg-position-top .rpg-stats-title {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
.rpg-panel.rpg-position-top .rpg-mood {
font-size: clamp(10px, 2vw, 13px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stats-title {
font-size: clamp(10px, 2vw, 13px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-label {
font-size: clamp(8px, 1.7vw, 11px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-value {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
.rpg-panel.rpg-position-top .rpg-classic-stat-btn {
font-size: clamp(10px, 2.2vw, 14px) !important;
}
.rpg-panel.rpg-position-top .rpg-info-content,
.rpg-panel.rpg-position-top .rpg-thoughts-content {
font-size: clamp(10px, 2.2vw, 14px) !important;
}
/* Panel header */
.rpg-panel-header h3 {
font-size: clamp(14px, 3.4vw, 18px) !important;
}
/* Loading indicator */
.rpg-loading {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
/* Dice display */
.rpg-dice-display {
font-size: clamp(10px, 2vw, 13px) !important;
}
.rpg-dice-display i {
font-size: clamp(12px, 2.6vw, 16px) !important;
}
.rpg-clear-dice-btn {
font-size: clamp(14px, 3vw, 18px) !important;
}
}
/* ============================================
CHAPTER CHECKPOINT STYLES
============================================ */
/* Checkpoint indicator in messages */
.rpg-checkpoint-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 12px;
background: linear-gradient(135deg, var(--rpg-border-transparent, rgba(74, 158, 255, 0.15)) 0%, var(--rpg-border-transparent-fade, rgba(74, 158, 255, 0.05)) 100%);
border-left: 4px solid var(--rpg-border, #4a7ba7);
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: var(--rpg-highlight, #4a9eff);
animation: checkpoint-fade-in 0.3s ease-out;
}
.rpg-checkpoint-indicator i {
font-size: 16px;
}
@keyframes checkpoint-fade-in {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Optional: Add a subtle glow effect for checkpoint messages */
.mes:has(.rpg-checkpoint-indicator) {
position: relative;
}
.mes:has(.rpg-checkpoint-indicator)::before {
content: '';
position: absolute;
left: -4px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--rpg-border, #4a7ba7) 0%, transparent 100%);
opacity: 0.5;
}
/* Avatar generation loading overlay */
.rpg-avatar-generating {
position: relative;
}
.rpg-generating-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
border-radius: 8px;
}
/* Textarea style for LLM instruction input */
.rpg-textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--rpg-border);
background: var(--SmartThemeBlurTintColor);
color: var(--SmartThemeBodyColor);
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
File diff suppressed because it is too large. Load diff
+132
View File
@@ -0,0 +1,132 @@
/* ============================================
STATUS EFFECTS
============================================ */
/* Status Effects Container */
.rpg-encounter-statuses {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
/* Individual Status Effect */
.rpg-encounter-status {
display: inline-block;
font-size: 20px;
line-height: 1;
padding: 4px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
cursor: help;
transition: transform 0.2s ease;
}
.rpg-encounter-status:hover {
transform: scale(1.2);
}
/* ============================================
PLOT BUTTONS - RESPONSIVE LAYOUT
============================================ */
/* Base styles for plot buttons container */
#rpg-plot-buttons {
display: flex !important;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
align-items: center;
}
/* Base button styles - ensure consistent sizing */
#rpg-plot-random,
#rpg-plot-natural,
#rpg-encounter-button {
flex: 0 1 auto;
white-space: nowrap;
min-width: 0;
}
/* Tablet and smaller screens - adjust button sizing */
@media (max-width: 768px) {
#rpg-plot-buttons {
gap: 3px;
}
#rpg-plot-random,
#rpg-plot-natural,
#rpg-encounter-button {
padding: 6px 10px !important;
font-size: 12px !important;
}
}
/* Small mobile screens - more compact buttons */
@media (max-width: 600px) {
#rpg-plot-buttons {
gap: 2px;
}
#rpg-plot-random,
#rpg-plot-natural,
#rpg-encounter-button {
padding: 5px 8px !important;
font-size: 11px !important;
}
}
/* Very small screens - stack buttons vertically or use icons only */
@media (max-width: 480px) {
#rpg-plot-buttons {
gap: 4px;
}
/* Option 1: Stack vertically (uncomment if preferred)
#rpg-plot-buttons {
flex-direction: column;
width: 100%;
}
#rpg-plot-random,
#rpg-plot-natural,
#rpg-encounter-button {
width: 100%;
padding: 8px !important;
font-size: 13px !important;
}
*/
/* Option 2: Keep horizontal but more compact (default) */
#rpg-plot-random,
#rpg-plot-natural,
#rpg-encounter-button {
padding: 6px 8px !important;
font-size: 11px !important;
}
}
/* Extra small screens - icon-only mode */
@media (max-width: 400px) {
#rpg-plot-random,
#rpg-plot-natural,
#rpg-encounter-button {
padding: 6px !important;
}
/* Hide button text, keep icons */
#rpg-plot-random .rpg-btn-text,
#rpg-plot-natural .rpg-btn-text,
#rpg-encounter-button .rpg-btn-text {
display: none;
}
/* Ensure icons are visible */
#rpg-plot-random i,
#rpg-plot-natural i,
#rpg-encounter-button i {
margin: 0 !important;
font-size: 14px;
}
}
+259
View File
@@ -0,0 +1,259 @@
/* ============================================
SPOTIFY MUSIC WIDGET STYLES
============================================ */
/* Music Widget Container - Positioned above chat input */
.rpg-music-widget {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, calc(-100% - 8px));
width: calc(100% - 40px);
max-width: 650px;
z-index: 1000;
animation: rpg-music-slide-in 0.3s ease-out;
}
@keyframes rpg-music-slide-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Widget Content - The actual player UI */
.rpg-music-widget-content {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--rpg-accent);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 2px solid var(--rpg-border);
}
.rpg-music-widget-content:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3), 0 4px 8px var(--rpg-highlight);
}
/* Spotify Icon */
.rpg-music-widget-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
border-radius: 50%;
color: #1DB954;
font-size: 20px;
}
/* Song Info Container */
.rpg-music-widget-info {
flex: 1;
min-width: 0; /* Allows text truncation */
display: flex;
flex-direction: column;
gap: 2px;
}
/* Song Title */
.rpg-music-widget-title {
color: var(--rpg-text);
font-size: 14px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* Artist Name */
.rpg-music-widget-artist {
color: var(--rpg-text);
opacity: 0.7;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Play Button */
.rpg-music-widget-play {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--rpg-highlight);
border: none;
border-radius: 50%;
color: var(--rpg-text);
font-size: 14px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.rpg-music-widget-play:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.rpg-music-widget-play:active {
transform: scale(0.95);
}
.rpg-music-widget-play i {
margin-left: 2px; /* Visual centering for play icon */
}
/* Close/Dismiss Button */
.rpg-music-widget-close {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
border: none;
border-radius: 50%;
color: var(--rpg-text);
opacity: 0.6;
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.rpg-music-widget-close:hover {
background: rgba(0, 0, 0, 0.5);
opacity: 1;
}
/* Mobile Responsive Styles */
@media (max-width: 1000px) {
.rpg-music-widget {
width: calc(100% - 20px);
max-width: none;
left: 50%;
transform: translate(-50%, calc(-100% - 8px));
}
.rpg-music-widget-content {
padding: 8px 10px;
gap: 8px;
}
.rpg-music-widget-icon {
width: 32px;
height: 32px;
font-size: 16px;
}
.rpg-music-widget-info {
gap: 1px;
}
.rpg-music-widget-title {
font-size: 12px;
}
.rpg-music-widget-artist {
font-size: 10px;
}
.rpg-music-widget-play {
width: 30px;
height: 30px;
font-size: 11px;
}
.rpg-music-widget-close {
width: 20px;
height: 20px;
font-size: 10px;
}
}
@media (max-width: 600px) {
.rpg-music-widget {
width: calc(100% - 12px);
}
.rpg-music-widget-content {
padding: 6px 8px;
gap: 6px;
}
.rpg-music-widget-icon {
width: 28px;
height: 28px;
font-size: 14px;
}
.rpg-music-widget-title {
font-size: 11px;
}
.rpg-music-widget-artist {
font-size: 9px;
}
.rpg-music-widget-play {
width: 28px;
height: 28px;
font-size: 10px;
}
.rpg-music-widget-close {
width: 18px;
height: 18px;
font-size: 9px;
}
}
/* Theme Support - Apply theme colors */
.rpg-panel[data-theme="sci-fi"] ~ .rpg-music-widget .rpg-music-widget-content,
body[data-theme="sci-fi"] .rpg-music-widget-content {
background: linear-gradient(135deg, #8b00ff 0%, #6a00cc 50%, #5000aa 100%);
}
.rpg-panel[data-theme="sci-fi"] ~ .rpg-music-widget .rpg-music-widget-play,
body[data-theme="sci-fi"] .rpg-music-widget-play {
color: #8b00ff;
}
.rpg-panel[data-theme="fantasy"] ~ .rpg-music-widget .rpg-music-widget-content,
body[data-theme="fantasy"] .rpg-music-widget-content {
background: linear-gradient(135deg, #d4af37 0%, #b8962e 50%, #9c7d25 100%);
}
.rpg-panel[data-theme="fantasy"] ~ .rpg-music-widget .rpg-music-widget-play,
body[data-theme="fantasy"] .rpg-music-widget-play {
color: #d4af37;
}
.rpg-panel[data-theme="cyberpunk"] ~ .rpg-music-widget .rpg-music-widget-content,
body[data-theme="cyberpunk"] .rpg-music-widget-content {
background: linear-gradient(135deg, #ff2a6d 0%, #d91e58 50%, #b31848 100%);
}
.rpg-panel[data-theme="cyberpunk"] ~ .rpg-music-widget .rpg-music-widget-play,
body[data-theme="cyberpunk"] .rpg-music-widget-play {
color: #ff2a6d;
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
/* ============================================
HOLIDAY PROMOTION BANNER
============================================ */
/* Mobile-friendly holiday promo */
.rpg-holiday-promo {
max-width: 100%;
word-wrap: break-word;
}
.rpg-holiday-promo a {
display: inline-block;
max-width: 100%;
}
/* Dismiss button hover effect */
#rpg-dismiss-promo:hover {
opacity: 1 !important;
transform: scale(1.1);
}
/* Mobile responsiveness */
@media (max-width: 600px) {
.rpg-holiday-promo {
font-size: 10px !important;
padding: 10px 8px !important;
}
#rpg-dismiss-promo {
font-size: 12px !important;
top: 2px !important;
right: 2px !important;
}
}
+93
View File
@@ -0,0 +1,93 @@
/* ============================================
THREE-COLUMN FEATURE TOGGLES LAYOUT
============================================ */
/* Features row container */
.rpg-features-row {
display: grid;
grid-template-rows: repeat(2, 1fr);
grid-auto-flow: column;
grid-auto-columns: min-content;
gap: 8px;
margin-bottom: 12px;
overflow-x: auto;
overflow-y: hidden;
flex-wrap: nowrap;
padding: 0 4px; /* Prevent first/last items from being cut off */
-webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
}
/* Center items when they fit, allow scrolling when they don't */
/*.rpg-features-row::before,*/
/*.rpg-features-row::after {*/
/* content: '';*/
/* margin: auto;*/
/*}*/
/* Hide scrollbar for cleaner look while maintaining functionality */
.rpg-features-row::-webkit-scrollbar {
height: 4px;
}
.rpg-features-row::-webkit-scrollbar-track {
background: transparent;
}
.rpg-features-row::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.rpg-features-row::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.5);
}
/* Each feature column */
.rpg-feature-col {
flex: 0 0 auto;
min-width: 60px;
}
.rpg-feature-col .rpg-toggle-label {
justify-content: center;
align-items: center;
}
/* Always hide text, show only checkbox + icon */
.rpg-feature-col .rpg-toggle-text {
display: none;
}
.rpg-feature-col .rpg-toggle-label i {
font-size: 0.95rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* Tablet and below: Hide text, show only checkbox + icon */
@media (max-width: 768px) {
.rpg-feature-col .rpg-toggle-text {
display: none;
}
.rpg-feature-col .rpg-toggle-label i {
font-size: 1rem;
}
}
/* Very small screens: Remove column layout since we have horizontal scroll */
@media (max-width: 400px) {
.rpg-feature-col .rpg-toggle-label i {
font-size: 1rem;
}
}
/* Ensure send_form has relative positioning for music widget placement */
#send_form {
position: relative !important;
}
+100
View File
@@ -0,0 +1,100 @@
/* ============================================
IMPORT MODE DIALOG STYLES
============================================ */
/* Import Mode Dialog Overlay */
.rpg-import-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100000;
}
.rpg-import-dialog {
background: var(--SmartThemeBlurTintColor, #2a2a3e);
border: 1px solid var(--SmartThemeBorderColor, #4a4a4a);
border-radius: 8px;
padding: 20px;
min-width: 300px;
max-width: 400px;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.rpg-import-dialog h4 {
margin: 0 0 15px 0;
color: var(--SmartThemeBodyColor, #eee);
}
.rpg-import-dialog h4 i {
margin-right: 8px;
color: var(--SmartThemeQuoteColor, #e94560);
}
.rpg-import-dialog p {
margin: 0 0 20px 0;
color: var(--SmartThemeBodyColor, #ccc);
font-size: 0.95em;
}
.rpg-import-dialog-buttons {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 15px;
}
.rpg-import-dialog-buttons button {
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 0.95em;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.rpg-import-dialog .rpg-btn-secondary {
background: var(--black30a, rgba(0, 0, 0, 0.3));
border: 1px solid var(--SmartThemeBorderColor, #4a4a4a);
color: var(--SmartThemeBodyColor, #ccc);
}
.rpg-import-dialog .rpg-btn-secondary:hover {
background: var(--SmartThemeBorderColor, #4a4a4a);
color: white;
}
.rpg-import-dialog .rpg-btn-primary {
background: var(--SmartThemeQuoteColor, #e94560);
border: 1px solid var(--SmartThemeQuoteColor, #e94560);
color: white;
}
.rpg-import-dialog .rpg-btn-primary:hover {
background: var(--SmartThemeQuoteColor, #ff5a75);
border-color: var(--SmartThemeQuoteColor, #ff5a75);
}
.rpg-import-dialog .rpg-btn-cancel {
background: transparent;
border: none;
color: var(--SmartThemeBodyColor, #888);
cursor: pointer;
font-size: 0.85em;
padding: 5px;
}
.rpg-import-dialog .rpg-btn-cancel:hover {
color: var(--SmartThemeBodyColor, #ccc);
text-decoration: underline;
}
+332
View File
@@ -0,0 +1,332 @@
/* ============================================
FAB Widget System - Improved Layout
============================================ */
/* Widget container - positioned relative to FAB */
.rpg-fab-widget-container {
position: fixed;
pointer-events: none;
z-index: 9998;
}
/* Hide FAB widgets on desktop viewport */
@media (min-width: 1001px) {
.rpg-fab-widget-container {
display: none !important;
}
}
/* Individual widget base styling */
.rpg-fab-widget {
position: absolute;
pointer-events: auto;
background: rgba(20, 20, 35, 0.95);
border: 1px solid rgba(100, 150, 255, 0.3);
border-radius: 8px;
padding: 6px 10px;
font-size: 11px;
color: #fff;
white-space: nowrap;
backdrop-filter: blur(10px);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5), 0 0 1px rgba(100, 150, 255, 0.3);
transition: opacity 0.2s ease, transform 0.15s ease;
}
.rpg-fab-widget:hover {
border-color: rgba(100, 150, 255, 0.5);
}
/* Expanded state for truncated widgets - desktop hover and mobile tap */
.rpg-fab-widget[data-full-text]:hover,
.rpg-fab-widget[data-full-text].expanded {
z-index: 9999 !important;
max-width: none !important;
white-space: nowrap;
}
.rpg-fab-widget[data-full-text]:hover .rpg-fab-widget-text,
.rpg-fab-widget[data-full-text].expanded .rpg-fab-widget-text {
/* Show full text on hover/tap */
}
/* Hide truncated text and show full text on expand */
.rpg-fab-widget[data-full-text]:hover .rpg-truncated,
.rpg-fab-widget[data-full-text].expanded .rpg-truncated {
display: none;
}
.rpg-fab-widget[data-full-text]:hover .rpg-full-text,
.rpg-fab-widget[data-full-text].expanded .rpg-full-text {
display: inline;
}
/* Default: show truncated, hide full */
.rpg-fab-widget .rpg-full-text {
display: none;
}
.rpg-fab-widget .rpg-truncated {
display: inline;
}
/* 8-Position system - spread out more to avoid overlap
Positions: 0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW */
/* Position 0: North (top center) */
.rpg-fab-widget-pos-0 {
bottom: calc(100% + 15px);
left: 50%;
transform: translateX(-50%);
}
.rpg-fab-widget-pos-0:hover { transform: translateX(-50%) scale(1.05); }
/* Position 1: Northeast */
.rpg-fab-widget-pos-1 {
bottom: calc(100% + 10px);
left: calc(100% + 15px);
}
/* Position 2: East (right center) */
.rpg-fab-widget-pos-2 {
top: 50%;
left: calc(100% + 15px);
transform: translateY(-50%);
}
.rpg-fab-widget-pos-2:hover { transform: translateY(-50%) scale(1.05); }
/* Position 3: Southeast */
.rpg-fab-widget-pos-3 {
top: calc(100% + 10px);
left: calc(100% + 15px);
}
/* Position 4: South (bottom center) */
.rpg-fab-widget-pos-4 {
top: calc(100% + 15px);
left: 50%;
transform: translateX(-50%);
}
.rpg-fab-widget-pos-4:hover { transform: translateX(-50%) scale(1.05); }
/* Position 5: Southwest */
.rpg-fab-widget-pos-5 {
top: calc(100% + 10px);
right: calc(100% + 15px);
left: auto;
}
/* Position 6: West - Stats (top edge at FAB center + gap, grows DOWN) */
.rpg-fab-widget-pos-6 {
top: calc(50% + 8px);
right: calc(100% + 15px);
left: auto;
}
/* Position 7: Northwest - Attributes (bottom edge at FAB center - gap, grows UP) */
.rpg-fab-widget-pos-7 {
bottom: calc(50% + 8px);
right: calc(100% + 15px);
left: auto;
}
/* Centered large widget (when only one is visible) - vertically centered */
.rpg-fab-widget-centered.rpg-fab-widget-pos-6,
.rpg-fab-widget-centered.rpg-fab-widget-pos-7 {
top: 50%;
bottom: auto;
transform: translateY(-50%);
}
/* Weather icon widget - larger emoji display */
.rpg-fab-widget-weather-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
padding: 6px;
min-width: 32px;
min-height: 32px;
width: 32px;
height: 32px;
border-radius: 50%;
}
/* Weather description widget */
.rpg-fab-widget-weather-desc {
max-width: 100px;
font-size: 10px;
}
/* Clock/Time widget - bottom position with animated clock */
.rpg-fab-widget-clock {
display: flex;
flex-direction: column;
align-items: center;
font-family: 'Roboto Mono', 'Consolas', monospace;
font-size: 11px;
letter-spacing: 0.5px;
padding: 4px 8px;
gap: 2px;
}
/* Mini animated clock face */
.rpg-fab-clock-face {
position: relative;
width: 24px;
height: 24px;
border: 2px solid var(--rpg-border, #4a7ba7);
border-radius: 50%;
background: var(--rpg-accent, rgba(22, 33, 62, 0.9));
}
.rpg-fab-clock-hour {
position: absolute;
width: 2px;
height: 7px;
background: var(--rpg-text, #eaeaea);
left: 50%;
bottom: 50%;
margin-left: -1px;
transform-origin: bottom center;
border-radius: 1px;
}
.rpg-fab-clock-minute {
position: absolute;
width: 1.5px;
height: 9px;
background: var(--rpg-highlight, #4a90e2);
left: 50%;
bottom: 50%;
margin-left: -0.75px;
transform-origin: bottom center;
border-radius: 1px;
}
.rpg-fab-clock-center {
position: absolute;
width: 4px;
height: 4px;
background: var(--rpg-highlight, #4a90e2);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.rpg-fab-clock-time {
font-size: 11px;
white-space: nowrap;
}
/* Date widget */
.rpg-fab-widget-date {
font-size: 10px;
}
/* Location widget - two lines */
.rpg-fab-widget-location {
max-width: 90px;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Attributes widget - compact grid */
.rpg-fab-widget-attributes {
padding: 6px 10px;
}
.rpg-fab-widget-attr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.rpg-fab-widget-attr-item {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.2;
}
.rpg-fab-widget-attr-name {
font-size: 8px;
opacity: 0.7;
text-transform: uppercase;
}
.rpg-fab-widget-attr-value {
font-size: 12px;
font-weight: bold;
color: var(--rpg-highlight, #4a90e2);
white-space: nowrap;
}
/* Stats widget - vertical compact list */
.rpg-fab-widget-stats {
padding: 6px 10px;
min-width: 70px;
text-align: center;
}
.rpg-fab-widget-stats-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.rpg-fab-widget-stat-item {
font-size: 11px;
font-family: 'Roboto Mono', 'Consolas', monospace;
font-weight: 600;
white-space: nowrap;
display: block;
}
/* RPG Attributes widget - 2x3 grid */
.rpg-fab-widget-attributes {
padding: 6px 8px;
}
.rpg-fab-widget-attr-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px 10px;
}
.rpg-fab-widget-attr-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.rpg-fab-widget-attr-name {
font-size: 8px;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 0.5px;
}
.rpg-fab-widget-attr-value {
font-size: 12px;
font-weight: 700;
color: #6af;
font-family: 'Roboto Mono', 'Consolas', monospace;
}
/* FAB Loading State */
#rpg-mobile-toggle.rpg-fab-loading {
animation: fabSpin 1s linear infinite;
}
#rpg-mobile-toggle.rpg-fab-loading i {
opacity: 0.7;
}
@keyframes fabSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
+497
View File
@@ -0,0 +1,497 @@
/* ============================================
FAB WIDGET THEME VARIATIONS
============================================ */
/* Sci-Fi / Synthwave Theme for FAB Widgets */
body:has(.rpg-panel[data-theme="sci-fi"]) .rpg-fab-widget {
background: rgba(10, 14, 39, 0.95);
border: 1px solid rgba(139, 0, 255, 0.5);
color: #00fff9;
box-shadow: 0 3px 12px rgba(139, 0, 255, 0.3), 0 0 8px rgba(255, 0, 110, 0.2);
}
body:has(.rpg-panel[data-theme="sci-fi"]) .rpg-fab-widget:hover {
border-color: rgba(255, 0, 110, 0.7);
box-shadow: 0 4px 16px rgba(139, 0, 255, 0.5), 0 0 12px rgba(255, 0, 110, 0.4);
}
/* Fantasy / Rustic Theme for FAB Widgets */
body:has(.rpg-panel[data-theme="fantasy"]) .rpg-fab-widget {
background: rgba(43, 24, 16, 0.95);
border: 1px solid rgba(139, 105, 20, 0.6);
color: #f4e8d0;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.6), 0 0 1px rgba(212, 175, 55, 0.3);
}
body:has(.rpg-panel[data-theme="fantasy"]) .rpg-fab-widget:hover {
border-color: rgba(212, 175, 55, 0.8);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.7), 0 0 8px rgba(212, 175, 55, 0.4);
}
/* Cyberpunk Theme for FAB Widgets */
body:has(.rpg-panel[data-theme="cyberpunk"]) .rpg-fab-widget {
background: rgba(15, 5, 25, 0.95);
border: 1px solid rgba(255, 0, 255, 0.4);
color: #00ffff;
box-shadow: 0 3px 12px rgba(255, 0, 255, 0.3), 0 0 8px rgba(0, 255, 255, 0.2);
}
body:has(.rpg-panel[data-theme="cyberpunk"]) .rpg-fab-widget:hover {
border-color: rgba(0, 255, 255, 0.7);
box-shadow: 0 4px 16px rgba(255, 0, 255, 0.5), 0 0 12px rgba(0, 255, 255, 0.4);
}
/* Minimal Theme for FAB Widgets */
body:has(.rpg-panel[data-theme="minimal"]) .rpg-fab-widget {
background: rgba(245, 245, 250, 0.98);
border: 1px solid rgba(200, 200, 210, 0.8);
color: #2c3e50;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
body:has(.rpg-panel[data-theme="minimal"]) .rpg-fab-widget:hover {
border-color: rgba(52, 152, 219, 0.6);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
}
/* Dark Theme for FAB Widgets */
body:has(.rpg-panel[data-theme="dark"]) .rpg-fab-widget {
background: rgba(25, 25, 35, 0.95);
border: 1px solid rgba(70, 70, 90, 0.6);
color: #e0e0e0;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5);
}
body:has(.rpg-panel[data-theme="dark"]) .rpg-fab-widget:hover {
border-color: rgba(100, 100, 130, 0.8);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
/* Light Theme for FAB Widgets */
body:has(.rpg-panel[data-theme="light"]) .rpg-fab-widget {
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(220, 220, 230, 0.8);
color: #2c3e50;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
body:has(.rpg-panel[data-theme="light"]) .rpg-fab-widget:hover {
border-color: rgba(52, 152, 219, 0.6);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
}
/* ============================================
DESKTOP STRIP WIDGETS (Collapsed Panel)
Shows compact widgets when panel is collapsed
============================================ */
/* Strip Widget Container - hidden by default, shown when collapsed with strip widgets enabled */
.rpg-strip-widget-container {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 4px;
padding-top: 40px;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--rpg-border) transparent;
z-index: 10;
}
/* Show strip widgets when panel is collapsed and strip widgets are enabled */
.rpg-panel.rpg-collapsed.rpg-strip-widgets-enabled .rpg-strip-widget-container {
display: flex;
}
/* Wider collapsed state when strip widgets are enabled */
.rpg-panel.rpg-collapsed.rpg-strip-widgets-enabled {
max-width: 5rem !important;
min-width: 5rem !important;
}
/* Adjust collapse button position for wider strip */
.rpg-panel.rpg-collapsed.rpg-strip-widgets-enabled .rpg-collapse-toggle {
left: -2rem;
}
.rpg-panel.rpg-position-left.rpg-collapsed.rpg-strip-widgets-enabled .rpg-collapse-toggle {
left: auto;
right: -2rem;
}
/* Fix positioning for wider strip */
.rpg-panel.rpg-position-right.rpg-collapsed.rpg-strip-widgets-enabled {
left: calc(100vw - 5rem);
}
.rpg-panel.rpg-position-left.rpg-collapsed.rpg-strip-widgets-enabled {
right: calc(100vw - 5rem);
}
/* Base Strip Widget Styles */
.rpg-strip-widget {
display: none; /* Hidden by default, shown via JS when enabled */
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(20, 20, 35, 0.85);
border: 1px solid var(--rpg-border, rgba(100, 150, 255, 0.3));
border-radius: 6px;
padding: 4px;
font-size: 10px;
color: var(--rpg-text, #eaeaea);
backdrop-filter: blur(8px);
width: calc(100% - 8px);
min-width: 0;
transition: all 0.2s ease;
}
.rpg-strip-widget.rpg-strip-widget-visible {
display: flex;
}
.rpg-strip-widget:hover {
border-color: var(--rpg-highlight, #4a90e2);
box-shadow: 0 0 8px rgba(100, 150, 255, 0.3);
}
/* Weather Icon Widget */
.rpg-strip-widget-weather {
font-size: 18px;
padding: 6px 4px;
gap: 2px;
}
.rpg-strip-widget-weather .rpg-strip-widget-icon {
line-height: 1;
}
.rpg-strip-widget-weather .rpg-strip-widget-desc {
font-size: 8px;
opacity: 0.8;
text-align: center;
word-break: break-word;
line-height: 1.2;
}
/* Clock Widget with animated face */
.rpg-strip-widget-clock {
font-family: 'Roboto Mono', 'Consolas', monospace;
font-size: 10px;
gap: 4px;
padding: 6px 4px;
}
/* Mini animated clock face for strip */
.rpg-strip-clock-face {
position: relative;
width: 28px;
height: 28px;
border: 2px solid var(--rpg-border, #4a7ba7);
border-radius: 50%;
background: var(--rpg-accent, rgba(22, 33, 62, 0.9));
}
.rpg-strip-clock-hour {
position: absolute;
width: 2px;
height: 8px;
background: var(--rpg-text, #eaeaea);
left: 50%;
bottom: 50%;
margin-left: -1px;
transform-origin: bottom center;
border-radius: 1px;
}
.rpg-strip-clock-minute {
position: absolute;
width: 1.5px;
height: 10px;
background: var(--rpg-highlight, #4a90e2);
left: 50%;
bottom: 50%;
margin-left: -0.75px;
transform-origin: bottom center;
border-radius: 1px;
}
.rpg-strip-clock-center {
position: absolute;
width: 4px;
height: 4px;
background: var(--rpg-highlight, #4a90e2);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.rpg-strip-widget-clock .rpg-strip-widget-value {
font-weight: 600;
letter-spacing: 0.5px;
font-size: 10px;
}
/* Date Widget */
.rpg-strip-widget-date {
font-size: 9px;
text-align: center;
gap: 2px;
}
.rpg-strip-widget-date i {
font-size: 11px;
opacity: 0.7;
}
.rpg-strip-widget-date .rpg-strip-widget-value {
word-break: break-word;
line-height: 1.2;
}
/* Location Widget */
.rpg-strip-widget-location {
font-size: 9px;
text-align: center;
gap: 2px;
}
.rpg-strip-widget-location i {
font-size: 11px;
color: var(--rpg-highlight, #4a90e2);
}
.rpg-strip-widget-location .rpg-strip-widget-value {
word-break: break-word;
line-height: 1.2;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
}
/* Stats Widget - Vertical list */
.rpg-strip-widget-stats {
padding: 4px 2px;
}
.rpg-strip-stats-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
}
.rpg-strip-stat-item {
display: flex;
flex-direction: column;
align-items: center;
font-family: 'Roboto Mono', 'Consolas', monospace;
font-size: 9px;
width: 100%;
}
.rpg-strip-stat-name {
font-size: 7px;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rpg-strip-stat-value {
font-size: 11px;
font-weight: 700;
}
/* Attributes Widget - 2-column grid */
.rpg-strip-widget-attributes {
padding: 4px 2px;
}
.rpg-strip-attributes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2px 4px;
width: 100%;
}
.rpg-strip-attr-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.rpg-strip-attr-name {
font-size: 7px;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.rpg-strip-attr-value {
font-size: 10px;
font-weight: 700;
color: var(--rpg-highlight, #6af);
font-family: 'Roboto Mono', 'Consolas', monospace;
}
/* Strip Refresh Button */
.rpg-strip-refresh-btn {
margin-top: auto;
padding: 8px;
background: var(--rpg-accent, rgba(22, 33, 62, 0.9));
border: 1px solid var(--rpg-border, rgba(100, 150, 255, 0.3));
border-radius: 6px;
color: var(--rpg-highlight, #4a90e2);
cursor: pointer;
font-size: 14px;
width: calc(100% - 8px);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.rpg-strip-refresh-btn:hover {
background: var(--rpg-highlight, #4a90e2);
color: var(--rpg-bg, #1a1a2e);
box-shadow: 0 0 10px rgba(100, 150, 255, 0.4);
}
.rpg-strip-refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.rpg-strip-refresh-btn i.fa-spin {
animation: fa-spin 1s linear infinite;
}
/* ============================================
STRIP WIDGET THEME VARIATIONS
============================================ */
/* Sci-Fi Theme */
body:has(.rpg-panel[data-theme="sci-fi"]) .rpg-strip-widget {
background: rgba(10, 14, 39, 0.9);
border-color: rgba(139, 0, 255, 0.5);
color: #00fff9;
}
/* Fantasy Theme */
body:has(.rpg-panel[data-theme="fantasy"]) .rpg-strip-widget {
background: rgba(43, 24, 16, 0.9);
border-color: rgba(139, 105, 20, 0.6);
color: #f4e8d0;
}
/* Cyberpunk Theme */
body:has(.rpg-panel[data-theme="cyberpunk"]) .rpg-strip-widget {
background: rgba(15, 5, 25, 0.9);
border-color: rgba(255, 0, 255, 0.4);
color: #00ffff;
}
/* Dark Theme */
body:has(.rpg-panel[data-theme="dark"]) .rpg-strip-widget {
background: rgba(25, 25, 35, 0.9);
border-color: rgba(70, 70, 90, 0.6);
color: #e0e0e0;
}
/* Light Theme */
body:has(.rpg-panel[data-theme="light"]) .rpg-strip-widget {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(220, 220, 230, 0.8);
color: #2c3e50;
}
/* Hide strip widgets on mobile */
@media (max-width: 768px) {
.rpg-strip-widget-container {
display: none !important;
}
.rpg-panel.rpg-collapsed.rpg-strip-widgets-enabled {
max-width: 2.5rem !important;
min-width: 2.5rem !important;
}
}
.rpg-inline-thoughts {
display: flex;
flex-direction: column;
gap: 0.5em;
margin: 0.2em var(--mes-right-spacing, 30px) 0.35em 0;
}
body.documentstyle .rpg-inline-thoughts {
margin-left: 20px;
}
.rpg-inline-thought {
margin: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-left: 2px solid rgba(255, 120, 140, 0.6);
border-radius: 0.5em;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.rpg-inline-thought[open] {
background: rgba(255, 255, 255, 0.045);
}
.rpg-inline-thought-summary {
display: flex;
align-items: center;
gap: 0.45em;
padding: 0.45em 0.7em;
cursor: pointer;
list-style: none;
font-weight: 600;
color: var(--SmartThemeBodyColor);
}
.rpg-inline-thought-summary:hover {
background: rgba(255, 255, 255, 0.035);
}
.rpg-inline-thought-summary::-webkit-details-marker {
display: none;
}
.rpg-inline-thought-icon {
opacity: 0.9;
flex: 0 0 auto;
}
.rpg-inline-thought-name {
line-height: 1.2;
}
.rpg-inline-thought-content {
padding: 0 0.7em 0.65em;
}
.rpg-inline-thought-text {
margin: 0;
padding: 0.6em 0.75em;
border-radius: 0.4em;
background: rgba(0, 0, 0, 0.28);
color: var(--SmartThemeBodyColor);
font-style: italic;
line-height: 1.45;
}
+379
View File
@@ -0,0 +1,379 @@
/* ============================================
Equipment Section Styles
============================================ */
.rpg-equipment-section {
display: none;
}
.rpg-equipment-container {
padding: 8px 0;
}
.rpg-equipment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-equipment-header h3 {
margin: 0;
font-size: 14px;
color: var(--SmartThemeTitleColor);
display: flex;
align-items: center;
gap: 6px;
}
.rpg-equipment-add-btn {
background: var(--SmartThemeAccentColor);
color: var(--SmartThemeBodyColor);
border: none;
border-radius: 4px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s;
}
.rpg-equipment-add-btn:hover {
background: var(--SmartThemeButtonColor);
}
.rpg-equipment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.rpg-equipment-slot {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 6px;
padding: 8px;
min-height: 70px;
transition: border-color 0.2s, background-color 0.2s;
}
.rpg-equipment-slot.equipped {
border-color: var(--SmartThemeAccentColor);
background: rgba(0, 0, 0, 0.3);
}
.rpg-equipment-slot-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
font-size: 10px;
color: var(--SmartThemeTitleColor);
opacity: 0.7;
}
.rpg-equipment-slot-header i {
font-size: 9px;
}
.rpg-equipment-slot-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-unequip-btn {
background: none;
border: none;
color: var(--SmartThemeBodyColor);
opacity: 0.5;
cursor: pointer;
padding: 2px;
font-size: 9px;
transition: opacity 0.2s;
}
.rpg-equipment-unequip-btn:hover {
opacity: 1;
}
.rpg-equipment-item-name {
font-size: 12px;
font-weight: 600;
color: var(--SmartThemeTitleColor);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-empty {
font-size: 11px;
color: var(--SmartThemeBodyColor);
opacity: 0.4;
font-style: italic;
padding: 4px 0;
}
.rpg-equipment-stats {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin: 4px 0;
}
.rpg-eq-stat {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 9px;
background: rgba(0, 0, 0, 0.3);
padding: 1px 4px;
border-radius: 3px;
}
.rpg-eq-stat-label {
color: var(--SmartThemeBodyColor);
opacity: 0.7;
}
.rpg-eq-stat-value {
color: #4caf50;
font-weight: 600;
}
.rpg-equipment-description {
font-size: 10px;
color: var(--SmartThemeBodyColor);
opacity: 0.6;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-item-actions {
display: flex;
gap: 4px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.rpg-equipment-edit-btn,
.rpg-equipment-delete-btn,
.rpg-equipment-equip-btn {
background: none;
border: none;
color: var(--SmartThemeBodyColor);
opacity: 0.5;
cursor: pointer;
padding: 2px 4px;
font-size: 10px;
transition: opacity 0.2s, color 0.2s;
}
.rpg-equipment-edit-btn:hover {
opacity: 1;
color: #4fc3f7;
}
.rpg-equipment-delete-btn:hover {
opacity: 1;
color: #f44336;
}
.rpg-equipment-equip-btn:hover {
opacity: 1;
color: #4caf50;
}
/* Equipment Inventory (unequipped items) */
.rpg-equipment-inventory {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--SmartThemeBorderColor);
}
.rpg-equipment-inventory h4 {
margin: 0 0 8px 0;
font-size: 12px;
color: var(--SmartThemeTitleColor);
opacity: 0.8;
}
.rpg-equipment-inventory-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.rpg-equipment-inventory-item {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 6px;
padding: 8px;
transition: border-color 0.2s;
}
.rpg-equipment-inventory-item:hover {
border-color: var(--SmartThemeAccentColor);
}
.rpg-equipment-inventory-item-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.rpg-equipment-inventory-item-header i {
font-size: 10px;
opacity: 0.6;
}
.rpg-equipment-inventory-item-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--SmartThemeTitleColor);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-inventory-item-type {
font-size: 9px;
color: var(--SmartThemeBodyColor);
opacity: 0.5;
background: rgba(0, 0, 0, 0.2);
padding: 1px 5px;
border-radius: 3px;
}
/* Equipment Modal */
.rpg-equipment-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.rpg-equipment-modal-content {
background: var(--SmartThemeDialogBgColor, #1e1e2e);
border-radius: 8px;
width: 90%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.rpg-equipment-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-equipment-modal-header h3 {
margin: 0;
font-size: 14px;
color: var(--SmartThemeTitleColor);
display: flex;
align-items: center;
gap: 6px;
}
.rpg-equipment-modal-body {
padding: 16px;
}
.rpg-equipment-form-group {
margin-bottom: 12px;
}
.rpg-equipment-form-group label {
display: block;
font-size: 11px;
color: var(--SmartThemeTitleColor);
margin-bottom: 4px;
opacity: 0.8;
}
.rpg-equipment-form-group .rpg-input,
.rpg-equipment-form-group .rpg-select,
.rpg-equipment-form-group .rpg-textarea {
width: 100%;
padding: 6px 8px;
background: var(--SmartThemeInputBg);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 4px;
color: var(--SmartThemeBodyColor);
font-size: 12px;
}
.rpg-equipment-form-group .rpg-textarea {
resize: vertical;
}
.rpg-eq-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.rpg-eq-stat-checkbox {
display: flex;
align-items: center;
gap: 3px;
font-size: 10px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
padding: 3px 6px;
border-radius: 3px;
}
.rpg-eq-stat-checkbox input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.rpg-eq-stat-check-label {
color: var(--SmartThemeBodyColor);
opacity: 0.8;
}
.rpg-eq-stat-value-input {
width: 30px;
padding: 1px 2px;
background: var(--SmartThemeInputBg);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 2px;
color: var(--SmartThemeBodyColor);
font-size: 10px;
text-align: center;
}
.rpg-equipment-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--SmartThemeBorderColor);
}
/* Equipment bonus display on RPG attributes */
+26
View File
@@ -0,0 +1,26 @@
/* ============================================
RPG COMPANION - CSS VARIABLES
CSS Custom Properties for Theming
============================================ */
/* CSS Variables for Theming - Default uses SillyTavern's variables */
.rpg-panel,
#rpg-thought-panel,
#rpg-thought-icon,
#rpg-alt-present-characters,
.rpg-mobile-toggle {
--rpg-bg: var(--SmartThemeBlurTintColor, rgba(26, 26, 46, 0.9));
--rpg-accent: var(--black30a, rgba(22, 33, 62, 0.9));
--rpg-text: var(--SmartThemeBodyColor, #eaeaea);
--rpg-highlight: var(--SmartThemeQuoteColor, #e94560);
--rpg-border: var(--SmartThemeBorderColor, #4a7ba7);
--rpg-shadow: rgba(0, 0, 0, 0.5);
}
/* CSS Custom Properties for Responsive Scaling */
:root {
--rpg-panel-min-width: 300px;
--rpg-panel-max-width: 500px;
--rpg-modal-min-width: 320px;
--rpg-modal-max-width: 600px;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Equipment Constants
* Shared definitions for the equipment system
*/
/**
* Equipment category definitions: maps type to allowed slots, max equipped, and icon
*/
export const EQUIPMENT_CATEGORIES = {
helmet: { slots: ['helmet'], maxEquipped: 1, icon: 'fa-hat-cowboy-side' },
necklace: { slots: ['necklace'], maxEquipped: 1, icon: 'fa-circle-nodes' },
bodyArmor: { slots: ['bodyArmor'], maxEquipped: 1, icon: 'fa-vest' },
gloves: { slots: ['gloves'], maxEquipped: 1, icon: 'fa-hand' },
pants: { slots: ['pants'], maxEquipped: 1, icon: 'fa-socks' },
shoes: { slots: ['shoes'], maxEquipped: 1, icon: 'fa-shoe-prints' },
ring: { slots: ['ring1', 'ring2', 'ring3', 'ring4', 'ring5', 'ring6', 'ring7', 'ring8', 'ring9', 'ring10'], maxEquipped: 10, icon: 'fa-ring' },
accessory: { slots: ['accessory1', 'accessory2', 'accessory3'], maxEquipped: 3, icon: 'fa-gem' }
};
/**
* Flat list of all slots with their category info
*/
export const SLOTS_LIST = Object.entries(EQUIPMENT_CATEGORIES).flatMap(([type, def]) =>
def.slots.map(slotId => ({
id: slotId,
type,
icon: def.icon
}))
);
/**
* Escapes HTML special characters to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
export function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+3 -2
View File
@@ -9,7 +9,8 @@
* - Manual regeneration support
*/
import { generateRaw, characters, this_chid } from '../../../../../../../script.js';
import { characters, this_chid } from '../../../../../../../script.js';
import { safeGenerateRaw } from '../../utils/responseExtractor.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { selected_group, getGroupMembers } from '../../../../../../group-chats.js';
import { extensionSettings, sessionAvatarPrompts, setSessionAvatarPrompt } from '../../core/state.js';
@@ -254,7 +255,7 @@ async function generateAvatarPrompt(characterName) {
// console.log('[RPG Avatar] Using external API for avatar prompt generation');
response = await generateWithExternalAPI(promptMessages);
} else {
response = await generateRaw({
response = await safeGenerateRaw({
prompt: promptMessages,
quietToLoud: false
});
+8
View File
@@ -20,6 +20,10 @@ export function setupClassicStatsButtons() {
// Delegated event listener for increase buttons
$userStatsContainer.on('click', '.rpg-stat-increase', function() {
const stat = $(this).data('stat');
// Initialize custom attributes if they don't exist
if (extensionSettings.classicStats[stat] === undefined) {
extensionSettings.classicStats[stat] = 10;
}
if (extensionSettings.classicStats[stat] < 999) {
extensionSettings.classicStats[stat]++;
saveSettings();
@@ -33,6 +37,10 @@ export function setupClassicStatsButtons() {
// Delegated event listener for decrease buttons
$userStatsContainer.on('click', '.rpg-stat-decrease', function() {
const stat = $(this).data('stat');
// Initialize custom attributes if they don't exist
if (extensionSettings.classicStats[stat] === undefined) {
extensionSettings.classicStats[stat] = 10;
}
if (extensionSettings.classicStats[stat] > 1) {
extensionSettings.classicStats[stat]--;
saveSettings();
+1 -1
View File
@@ -76,7 +76,7 @@ export async function ensureJsonCleaningRegex(st_extension_settings, saveSetting
}
// Small delay to ensure save completes
await new Promise(resolve => setTimeout(resolve, 100));
console.log('[RPG Companion] ✅ Updated JSON cleaning regex to v3.2.6 settings.');
console.log('[RPG Companion] ✅ Updated JSON cleaning regex to v3.2.3 settings.');
} else {
console.log('[RPG Companion] JSON Cleaning Regex is up to date.');
}
+7 -6
View File
@@ -7,6 +7,7 @@ import { togglePlotButtons } from '../ui/layout.js';
import { extensionSettings, setIsPlotProgression } from '../../core/state.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT } from '../generation/promptBuilder.js';
import { Generate } from '../../../../../../../script.js';
import { i18n } from '../../core/i18n.js';
/**
* Sets up the plot progression buttons inside the send form area.
@@ -34,8 +35,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button" title="Generate a random plot twist or event">
<i class="fa-solid fa-dice"></i>&nbsp;<span class="rpg-btn-text">Randomized Plot</span>
" tabindex="0" role="button" title="${i18n.getTranslation('plotProgression.tooltips.randomizedPlot') || 'Generate a random plot twist or event'}">
<i class="fa-solid fa-dice"></i>&nbsp;<span class="rpg-btn-text">${i18n.getTranslation('plotProgression.buttons.randomizedPlot') || 'Randomized Plot'}</span>
</button>
<button id="rpg-plot-natural" class="menu_button interactable" style="
background-color: #4a90e2;
@@ -46,8 +47,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button" title="Continue the story naturally without twists">
<i class="fa-solid fa-forward"></i>&nbsp;<span class="rpg-btn-text">Natural Plot</span>
" tabindex="0" role="button" title="${i18n.getTranslation('plotProgression.tooltips.naturalPlot') || 'Continue the story naturally without twists'}">
<i class="fa-solid fa-forward"></i>&nbsp;<span class="rpg-btn-text">${i18n.getTranslation('plotProgression.buttons.naturalPlot') || 'Natural Plot'}</span>
</button>
<button id="rpg-encounter-button" class="menu_button interactable" style="
background-color: #cc3333;
@@ -58,8 +59,8 @@ export function setupPlotButtons(handlePlotClick, handleEncounterClick) {
font-size: 13px;
cursor: pointer;
margin: 0 2px;
" tabindex="0" role="button" title="Enter combat encounter">
<i class="fa-solid fa-fire"></i>&nbsp;<span class="rpg-btn-text">Enter Encounter</span>
" tabindex="0" role="button" title="${i18n.getTranslation('plotProgression.tooltips.enterEncounter') || 'Enter combat encounter'}">
<i class="fa-solid fa-fire"></i>&nbsp;<span class="rpg-btn-text">${i18n.getTranslation('plotProgression.buttons.enterEncounter') || 'Enter Encounter'}</span>
</button>
</span>
`;
+24 -13
View File
@@ -3,8 +3,9 @@
* Handles API calls for RPG tracker generation
*/
import { generateRaw, chat, eventSource } from '../../../../../../../script.js';
import { chat, eventSource } from '../../../../../../../script.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { safeGenerateRaw, extractTextFromResponse } from '../../utils/responseExtractor.js';
// Custom event name for when RPG Companion finishes updating tracker data
// Other extensions can listen for this event to know when RPG Companion is done
@@ -17,9 +18,10 @@ import {
lastActionWasSwipe,
setIsGenerating,
setLastActionWasSwipe,
$musicPlayerContainer
$musicPlayerContainer,
getSeparateGenerationId
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { saveChatData, setMessageSwipeTrackerData } from '../../core/persistence.js';
import {
generateSeparateUpdatePrompt
} from './promptBuilder.js';
@@ -30,6 +32,7 @@ import { renderInfoBox } from '../rendering/infoBox.js';
import { removeLocks } from './lockManager.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { i18n } from '../../core/i18n.js';
@@ -107,11 +110,10 @@ export async function generateWithExternalAPI(messages) {
const data = await response.json();
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('Invalid response format from external API');
const content = extractTextFromResponse(data);
if (!content || !content.trim()) {
throw new Error('Invalid response format from external API — no text content found');
}
const content = data.choices[0].message.content;
// console.log('[RPG Companion] External API response received successfully');
return content;
@@ -218,7 +220,7 @@ export async function switchToPreset(presetName) {
* @param {Function} renderThoughts - UI function to render character thoughts
* @param {Function} renderInventory - UI function to render inventory
*/
export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory) {
export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, generationId = null) {
if (isGenerating) {
// console.log('[RPG Companion] Already generating, skipping...');
return;
@@ -255,20 +257,28 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
// console.log('[RPG Companion] Using external API for tracker generation');
response = await generateWithExternalAPI(prompt);
} else {
// Separate mode: Use SillyTavern's generateRaw
response = await generateRaw({
// Separate mode: Use SillyTavern's generateRaw (with extended thinking fallback)
response = await safeGenerateRaw({
prompt: prompt,
quietToLoud: false
});
}
// If a generationId was provided and the counter has since been incremented
// (by a deletion or a newer generation), discard this result entirely.
// The finally block still runs to restore button state.
if (generationId !== null && getSeparateGenerationId() !== generationId) {
// console.log('[RPG Companion] ⚠️ Separate generation result discarded — superseded (genId', generationId, '!= current', getSeparateGenerationId(), ')');
return;
}
if (response) {
// console.log('[RPG Companion] Raw AI response:', response);
const parsedData = parseResponse(response);
// Check if parsing completely failed (no tracker data found)
if (parsedData.parsingFailed) {
toastr.error(i18n.getTranslation('errors.parsingError'), '', { timeOut: 5000 });
toastr.error(i18n.getTranslation('errors.parsingError') || 'RPG Companion Trackers parsing error! The model returned incorrect format. Consider switching generation model if this persists.', '', { timeOut: 5000 });
}
// Remove locks from parsed data (JSON format only, text format is unaffected)
@@ -317,11 +327,11 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
});
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
}
@@ -347,6 +357,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
+2 -1
View File
@@ -9,6 +9,7 @@ import { selected_group, getGroupMembers, groups } from '../../../../../../group
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { currentEncounter } from '../features/encounterState.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js';
import { applyLocks } from './lockManager.js';
@@ -709,7 +710,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) {
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled())) {
summaryMessage += `\n--- TRACKER UPDATE ---\n\n`;
summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `;
summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`;
+74 -118
View File
@@ -4,7 +4,8 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles, eventSource, event_types } from '../../../../../../../script.js';
import { extension_prompt_types, extension_prompt_roles, setExtensionPrompt, event_types } from '../../../../../../../script.js';
import { on as onEvent } from '../../core/events.js';
import {
extensionSettings,
committedTrackerData,
@@ -22,11 +23,15 @@ import {
DEFAULT_HTML_PROMPT,
DEFAULT_DIALOGUE_COLORING_PROMPT,
DEFAULT_DECEPTION_PROMPT,
DEFAULT_OMNISCIENCE_FILTER_PROMPT,
DEFAULT_CYOA_PROMPT,
DEFAULT_SPOTIFY_PROMPT,
DEFAULT_NARRATOR_PROMPT,
DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT,
SPOTIFY_FORMAT_INSTRUCTION
} from './promptBuilder.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
import { commitTrackerDataFromPriorMessage } from '../../core/persistence.js';
// Track suppression state for event handler
let currentSuppressionState = false;
@@ -34,8 +39,9 @@ let currentSuppressionState = false;
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
// Track last chat length we committed at to prevent duplicate commits from streaming
let lastCommittedChatLength = -1;
// Track the latest user message we committed for to prevent duplicate commits
// when GENERATION_STARTED can fire multiple times for the same turn.
let lastCommittedUserMessageSignature = null;
// Store context map for prompt injection (used by event handlers)
let pendingContextMap = new Map();
@@ -478,6 +484,7 @@ function onGenerateBeforeCombinePrompts(eventData) {
/**
* Event handler for GENERATE_AFTER_COMBINE_PROMPTS (text completion).
* This is now a backup/fallback - primary injection happens in BEFORE_COMBINE.
* Also fixes newline spacing after </context> tag.
*
* @param {Object} eventData - Event data with prompt property
*/
@@ -490,24 +497,25 @@ function onGenerateAfterCombinePrompts(eventData) {
return;
}
// Skip if injection already happened in BEFORE_COMBINE
if (historyInjectionDone) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
let didInjectHistory = false;
// Inject historical context if available and not already done
if (!historyInjectionDone && pendingContextMap.size > 0) {
// Fallback injection for edge cases where BEFORE_COMBINE didn't work
console.log('[RPG Companion] Using fallback string-based injection (AFTER_COMBINE)');
eventData.prompt = injectContextIntoTextPrompt(eventData.prompt);
didInjectHistory = true;
}
// Always fix newlines around context tags (whether we just injected or not)
eventData.prompt = eventData.prompt.replace(/<context>/g, '\n<context>');
eventData.prompt = eventData.prompt.replace(/<\/context>/g, '</context>\n');
}
/**
* Event handler for CHAT_COMPLETION_PROMPT_READY.
* Injects historical context into the chat message array.
* Also fixes newline spacing around <context> tags.
*
* @param {Object} eventData - Event data with chat property
*/
@@ -520,16 +528,22 @@ function onChatCompletionPromptReady(eventData) {
return;
}
// Only inject if we have pending context
if (pendingContextMap.size === 0) {
return;
}
// Inject historical context if we have pending context
if (pendingContextMap.size > 0) {
eventData.chat = injectContextIntoChatPrompt(eventData.chat);
// DON'T clear pendingContextMap here - let it persist for other generations
// (e.g., prewarm extensions). It will be cleared on GENERATION_ENDED.
}
// Fix newlines around context tags for all messages
for (const message of eventData.chat) {
if (message.content && typeof message.content === 'string') {
message.content = message.content.replace(/<context>/g, '\n<context>');
message.content = message.content.replace(/<\/context>/g, '</context>\n');
}
}
}
/**
* Event handler for generation start.
* Manages tracker data commitment and prompt injection based on generation mode.
@@ -595,100 +609,12 @@ export async function onGenerationStarted(type, data, dryRun) {
// Ensure checkpoint is applied before generation
await restoreCheckpointOnLoad();
const currentChatLength = chat ? chat.length : 0;
// For TOGETHER mode: Commit when user sends message (before first generation)
if (extensionSettings.generationMode === 'together') {
// By the time onGenerationStarted fires, ST has already added the placeholder AI message
// So we check the second-to-last message to see if user just sent a message
const secondToLastMessage = chat && chat.length > 1 ? chat[chat.length - 2] : null;
const isUserMessage = secondToLastMessage && secondToLastMessage.is_user;
// Commit if:
// 1. Second-to-last message is from USER (user just sent message)
// 2. Not a swipe (lastActionWasSwipe = false)
// 3. Haven't already committed for this chat length (prevent streaming duplicates)
const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength;
if (shouldCommit) {
// console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing data from BEFORE user message');
// console.log('[RPG Companion] Chat length:', currentChatLength, 'Last committed:', lastCommittedChatLength);
// console.log('[RPG Companion] BEFORE: committedTrackerData =', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
// // });
// console.log('[RPG Companion] BEFORE: lastGeneratedData =', {
// userStats: lastGeneratedData.userStats ? `${lastGeneratedData.userStats.substring(0, 50)}...` : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? `${lastGeneratedData.characterThoughts.substring(0, 100)}...` : 'null'
// });
// Commit displayed data (from before user sent message)
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// Track chat length to prevent duplicate commits
lastCommittedChatLength = currentChatLength;
// console.log('[RPG Companion] AFTER: committedTrackerData =', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
// });
} else if (lastActionWasSwipe) {
// console.log('[RPG Companion] ⏭️ Skipping commit: swipe (using previous committed data)');
} else if (!isUserMessage) {
// console.log('[RPG Companion] ⏭️ Skipping commit: second-to-last message is not user message (likely swipe or continuation)');
}
// console.log('[RPG Companion] 📦 TOGETHER MODE: Injecting committed tracker data into prompt');
// console.log('[RPG Companion] committedTrackerData =', {
// userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null'
// });
}
// For SEPARATE and EXTERNAL modes: Check if we need to commit extension data
// BUT: Only do this for the MAIN generation, not the tracker update generation
// If isGenerating is true, this is the tracker update generation (second call), so skip flag logic
// console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts);
if ((extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') && !isGenerating) {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData');
// console.log('[RPG Companion] BEFORE commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// // });
// console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] AFTER commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after committing (ready for next cycle)
} else {
// console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)');
// console.log('[RPG Companion] committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after using it (swipe generation complete, ready for next action)
}
// If this is a new generation (not a swipe and not the tracker update pass),
// commit the tracker data from the last assistant message (N-1 rule).
// Passing chat.length ensures we start searching backwards from the end of the chat,
// correctly finding the latest valid assistant state regardless of where the user message is.
if (!lastActionWasSwipe && !isGenerating) {
commitTrackerDataFromPriorMessage(chat ? chat.length : 0);
}
// Use the committed tracker data as source for generation
@@ -792,6 +718,19 @@ export async function onGenerationStarted(type, data, dryRun) {
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Omniscience Filter prompt separately at depth 0 if enabled
if (extensionSettings.enableOmniscienceFilter && !shouldSuppress) {
// Use custom Omniscience Filter prompt if set, otherwise use default
const omnisciencePromptText = extensionSettings.customOmnisciencePrompt || DEFAULT_OMNISCIENCE_FILTER_PROMPT;
const omnisciencePrompt = `\n${omnisciencePromptText}\n`;
setExtensionPrompt('rpg-companion-omniscience', omnisciencePrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Omniscience Filter prompt at depth 0 for together mode');
} else {
// Clear Omniscience Filter prompt if disabled
setExtensionPrompt('rpg-companion-omniscience', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
@@ -823,12 +762,14 @@ export async function onGenerationStarted(type, data, dryRun) {
const contextSummary = generateContextualSummary();
if (contextSummary) {
const wrappedContext = `\nHere is context information about the current scene, and what follows is the last message in the chat history:
// Use custom context instructions prompt if set, otherwise use default
const contextInstructionsText = extensionSettings.customContextInstructionsPrompt || DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT;
const wrappedContext = `
<context>
${contextSummary}
Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses.
</context>\n\n`;
${contextInstructionsText}
</context>`;
// Inject context at depth 1 (before last user message) as SYSTEM
// Skip when a guided generation injection is present to avoid conflicting instructions
@@ -880,6 +821,19 @@ Ensure these details naturally reflect and influence the narrative. Character be
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Omniscience Filter prompt separately at depth 0 if enabled
if (extensionSettings.enableOmniscienceFilter && !shouldSuppress) {
// Use custom Omniscience Filter prompt if set, otherwise use default
const omnisciencePromptText = extensionSettings.customOmnisciencePrompt || DEFAULT_OMNISCIENCE_FILTER_PROMPT;
const omnisciencePrompt = `\n${omnisciencePromptText}\n`;
setExtensionPrompt('rpg-companion-omniscience', omnisciencePrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected Omniscience Filter prompt at depth 0 for separate/external mode');
} else {
// Clear Omniscience Filter prompt if disabled
setExtensionPrompt('rpg-companion-omniscience', '', extension_prompt_types.IN_CHAT, 0, false);
}
// Inject Spotify prompt separately at depth 0 if enabled
if (extensionSettings.enableSpotifyMusic && !shouldSuppress) {
// Use custom Spotify prompt if set, otherwise use default
@@ -917,6 +871,7 @@ Ensure these details naturally reflect and influence the narrative. Character be
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-deception', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-omniscience', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-zzz-cyoa', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false);
}
@@ -938,15 +893,16 @@ Ensure these details naturally reflect and influence the narrative. Character be
export function initHistoryInjectionListeners() {
// Register persistent listeners for prompt injection
// These check pendingContextMap and only inject if there's data
// Use tracked registration (onEvent) so unregisterAllEvents() cleans them up
// Primary: BEFORE_COMBINE for text completion (more reliable - modifies message objects)
eventSource.on(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, onGenerateBeforeCombinePrompts);
onEvent(event_types.GENERATE_BEFORE_COMBINE_PROMPTS, onGenerateBeforeCombinePrompts);
// Fallback: AFTER_COMBINE for text completion (string-based injection)
eventSource.on(event_types.GENERATE_AFTER_COMBINE_PROMPTS, onGenerateAfterCombinePrompts);
onEvent(event_types.GENERATE_AFTER_COMBINE_PROMPTS, onGenerateAfterCombinePrompts);
// Chat completion (OpenAI, etc.)
eventSource.on(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
onEvent(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
console.log('[RPG Companion] History injection listeners initialized');
}
+22 -4
View File
@@ -5,6 +5,8 @@
import { extensionSettings, committedTrackerData } from '../../core/state.js';
import { getContext } from '../../../../../../extensions.js';
import { getWeatherKeywordsAsPromptString } from '../ui/weatherEffects.js';
import { i18n } from '../../core/i18n.js';
/**
* Converts a field name to snake_case for use as JSON key
@@ -15,10 +17,23 @@ import { getContext } from '../../../../../../extensions.js';
function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Parenthetical content is treated as a description/hint, not part of the key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* Example: "Status Effects" -> "status_effects"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return toSnakeCase(baseName);
}
/**
* Builds User Stats JSON format instruction
* @returns {string} JSON format instruction for user stats
@@ -58,12 +73,12 @@ export function buildUserStatsJSONInstruction() {
if (customFields.length > 0) {
for (let i = 0; i < customFields.length; i++) {
const fieldName = customFields[i].toLowerCase();
const fieldKey = toSnakeCase(fieldName);
const fieldKey = toFieldKey(fieldName);
const comma = (i === customFields.length - 1 && !userStatsConfig.statusSection.showMoodEmoji) ? '' : (userStatsConfig.statusSection.showMoodEmoji || i < customFields.length - 1 ? ',\n' : '\n');
if (i === 0 && userStatsConfig.statusSection.showMoodEmoji) {
instruction += ',\n';
}
instruction += ` "${fieldKey}": "[${fieldName}1, ${fieldName}2]"${comma}`;
instruction += ` "${fieldKey}": "[${fieldName}]"${comma}`;
}
}
if (!userStatsConfig.statusSection.showMoodEmoji && customFields.length > 0) {
@@ -132,7 +147,10 @@ export function buildInfoBoxJSONInstruction() {
}
if (widgets.weather?.enabled) {
instruction += (hasFields ? ',\n' : '') + ' "weather": {"emoji": "Weather Emoji", "forecast": "Forecast"}';
// Get valid weather keywords for the current language to guide LLM generation
const currentLang = i18n.currentLanguage || 'en';
const weatherHint = getWeatherKeywordsAsPromptString(currentLang);
instruction += (hasFields ? ',\n' : '') + ` "weather": {"emoji": "Weather Emoji", "forecast": "Forecast"} // ${weatherHint}`;
hasFields = true;
}
+15 -12
View File
@@ -98,16 +98,19 @@ function applyUserStatsLocks(data, lockedItems) {
}
}
// Lock inventory items - handle bracket notation paths like "inventory.onPerson[0]"
// Lock inventory items - match by item name instead of index
if (data.inventory && lockedItems.inventory) {
// Helper function to parse bracket notation and apply lock
// Helper function to apply locks based on item name
const applyInventoryLocks = (items, category) => {
if (!Array.isArray(items)) return items;
if (!lockedItems.inventory[category]) return items;
return items.map((item, index) => {
// Check if this specific item is locked using bracket notation with inventory prefix
const bracketPath = `${category}[${index}]`;
if (lockedItems.inventory[bracketPath]) {
return items.map((item) => {
// Get item name (handle both string and object formats)
const itemName = typeof item === 'string' ? item : (item.item || item.name || '');
// Check if this specific item name is locked
if (lockedItems.inventory[category][itemName]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
@@ -131,13 +134,13 @@ function applyUserStatsLocks(data, lockedItems) {
data.inventory.assets = applyInventoryLocks(data.inventory.assets, 'assets');
}
// Apply locks to stored items (nested structure with inventory.stored.location[index])
// Apply locks to stored items - match by item name
if (data.inventory.stored && lockedItems.inventory.stored) {
for (const location in data.inventory.stored) {
if (Array.isArray(data.inventory.stored[location])) {
data.inventory.stored[location] = data.inventory.stored[location].map((item, index) => {
const bracketPath = `${location}[${index}]`;
if (lockedItems.inventory.stored[bracketPath]) {
if (Array.isArray(data.inventory.stored[location]) && lockedItems.inventory.stored[location]) {
data.inventory.stored[location] = data.inventory.stored[location].map((item) => {
const itemName = typeof item === 'string' ? item : (item.item || item.name || '');
if (lockedItems.inventory.stored[location][itemName]) {
return typeof item === 'string'
? { item, locked: true }
: { ...item, locked: true };
@@ -248,7 +251,7 @@ function applyCharactersLocks(data, lockedItems) {
// Use the same conversion as toSnakeCase in thoughts.js
const snakeCaseFieldName = fieldName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
let locked = false;
+342 -96
View File
@@ -7,7 +7,151 @@
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { repairJSON, extractJSONFromText } from '../../utils/jsonRepair.js';
/* ===== Pre-compiled regex patterns (module-level constants) ===== */
// Thinking tag removal
const THINKING_TAG_RE = /<think>[\s\S]*?<\/think>/gi;
const THINKING_TAG_ALT_RE = /<thinking>[\s\S]*?<\/thinking>/gi;
// FORMAT: marker removal
const FORMAT_MARKER_RE = /FORMAT:\s*/gi;
// Emoji extraction (separateEmojiFromText)
const EMOJI_RE = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
// Bracket stripping
const PLACEHOLDER_PATTERN_RE = /\[([A-Za-z\s\/]+)\]/g;
// JSON code block extraction
const JSON_BLOCK_RE = /```json\s*\n([\s\S]*?)```/g;
// XML trackers
const XML_TRACKERS_RE = /<trackers>([\s\S]*?)<\/trackers>/i;
// Code block extraction
const CODE_BLOCK_RE = /```([^`]+)```/g;
// XML text fallback patterns
const XML_STATS_MATCH_RE = /(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i;
const XML_INFOBOX_MATCH_RE = /Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i;
const XML_CHARACTERS_MATCH_RE = /Present Characters\s*\n\s*---[\s\S]*$/i;
// Combined code block section patterns
const COMBINED_STATS_RE = /(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i;
const COMBINED_INFOBOX_RE = /Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i;
const COMBINED_CHARACTERS_RE = /Present Characters\s*\n\s*---[\s\S]*$/i;
// Section detection patterns
const IS_STATS_HEADER_RE = /Stats\s*\n\s*---/i;
const IS_USER_STATS_HEADER_RE = /User Stats\s*\n\s*---/i;
const IS_PLAYER_STATS_HEADER_RE = /Player Stats\s*\n\s*---/i;
const IS_STATS_HEALTH_RE = /Health:\s*\d+%/i;
const IS_STATS_ENERGY_RE = /Energy:\s*\d+%/i;
const IS_INFOBOX_HEADER_RE = /Info Box\s*\n\s*---/i;
const IS_SCENE_INFO_HEADER_RE = /Scene Info\s*\n\s*---/i;
const IS_INFORMATION_HEADER_RE = /Information\s*\n\s*---/i;
const IS_INFOBOX_DATE_RE = /Date:/i;
const IS_INFOBOX_LOCATION_RE = /Location:/i;
const IS_INFOBOX_TIME_RE = /Time:/i;
const IS_CHARACTERS_HEADER_RE = /Present Characters\s*\n\s*---/i;
const IS_CHARACTERS_ALT_RE = /Characters\s*\n\s*---/i;
const IS_CHARACTERS_THOUGHTS_RE = /Character Thoughts\s*\n\s*---/i;
const IS_CHARACTERS_BULLET_RE = /^-\s+\w+/m;
const IS_CHARACTERS_DETAILS_RE = /Details:/i;
// Debug pattern checks
const DEBUG_STATS_RE = /Stats\s*\n\s*---/i;
const DEBUG_INFOBOX_RE = /Info Box\s*\n\s*---/i;
const DEBUG_CHARACTERS_RE = /Present Characters\s*\n\s*---/i;
// Final fallback fenced regex
const FENCED_FALLBACK_RE = /```(?:json)?\s*\n?([\s\S]*?)```/gi;
// parseUserStats patterns
const RPG_STR_RE = /STR:\s*(\d+)/i;
const RPG_DEX_RE = /DEX:\s*(\d+)/i;
const RPG_CON_RE = /CON:\s*(\d+)/i;
const RPG_INT_RE = /INT:\s*(\d+)/i;
const RPG_WIS_RE = /WIS:\s*(\d+)/i;
const RPG_CHA_RE = /CHA:\s*(\d+)/i;
const RPG_LVL_RE = /LVL:\s*(\d+)/i;
const STATUS_MATCH_RE = /Status:\s*(.+)/i;
const SKILLS_MATCH_RE = /Skills:\s*(.+)/i;
const INVENTORY_MATCH_RE = /Inventory:\s*(.+)/i;
const MAIN_QUEST_MATCH_RE = /Main Quests?:\s*(.+)/i;
const OPTIONAL_QUESTS_MATCH_RE = /Optional Quests:\s*(.+)/i;
// Section detection helpers
const STATS_SECTION_RE = /Stats\s*\n\s*---/i;
const INFOBOX_SECTION_RE = /Info Box\s*\n\s*---/i;
const CHARACTERS_SECTION_RE = /Present Characters\s*\n\s*---/i;
/* ===== Format detection cache ===== */
// Cache the last detected format to skip unnecessary checks on subsequent calls.
// The cache is invalidated when the page reloads (module re-initializes).
let lastDetectedFormat = null; // 'json', 'json_block', 'xml', 'text', null
const FORMAT_CACHE_HIT_THRESHOLD = 3; // Minimum consecutive hits before trusting cache
let formatCacheHits = 0;
/**
* Clear the format detection cache. Call this when the AI model or prompt changes.
*/
export function clearFormatCache() {
lastDetectedFormat = null;
formatCacheHits = 0;
}
/**
* Unwraps common envelope keys models may use around tracker payloads.
* Keeps extraction resilient when output is nested under wrappers like "trackers".
*
* @param {object} payload - Parsed JSON payload
* @returns {object} Unwrapped payload (or original when no wrapper exists)
*/
function unwrapTrackerEnvelope(payload) {
let current = payload;
for (let depth = 0; depth < 4; depth++) {
if (!current || typeof current !== 'object' || Array.isArray(current)) {
return payload;
}
if (
current.userStats ||
current.infoBox ||
current.characters ||
current.characterThoughts ||
current.presentCharacters
) {
return current;
}
const next = current.trackers || current.tracker || current.context || current.state || null;
if (!next || typeof next !== 'object') {
break;
}
current = next;
}
return payload;
}
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\([^)]*\)\s*$/, '').trim();
return baseName
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Helper to separate emoji from text in a string
@@ -20,10 +164,7 @@ function separateEmojiFromText(str) {
str = str.trim();
// Regex to match emoji at the start (handles most emoji including compound ones)
// This matches emoji sequences including skin tones, gender modifiers, etc.
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
const emojiMatch = str.match(EMOJI_RE);
if (emojiMatch) {
const emoji = emojiMatch[0];
@@ -71,13 +212,8 @@ function stripBrackets(text) {
text = text.substring(1, text.length - 1).trim();
}
// Remove placeholder text patterns like [Location], [Mood Emoji], [Name], etc.
// Pattern matches: [anything with letters/spaces inside]
// This preserves actual content while removing template placeholders
const placeholderPattern = /\[([A-Za-z\s\/]+)\]/g;
// Check if a bracketed text looks like a placeholder vs real content
const isPlaceholder = (match, content) => {
// Replace placeholders with empty string, keep real content
text = text.replace(PLACEHOLDER_PATTERN_RE, (match, content) => {
// Common placeholder words to detect
const placeholderKeywords = [
'location', 'mood', 'emoji', 'name', 'description', 'placeholder',
@@ -91,23 +227,15 @@ function stripBrackets(text) {
// If it contains common placeholder keywords, it's likely a placeholder
if (placeholderKeywords.some(keyword => lowerContent.includes(keyword))) {
return true;
return ''; // Remove placeholder
}
// If it's a short generic phrase (1-3 words) with only letters/spaces, might be placeholder
const wordCount = content.trim().split(/\s+/).length;
if (wordCount <= 3 && /^[A-Za-z\s\/]+$/.test(content)) {
return true;
return '';
}
return false;
};
// Replace placeholders with empty string, keep real content
text = text.replace(placeholderPattern, (match, content) => {
if (isPlaceholder(match, content)) {
return ''; // Remove placeholder
}
return match; // Keep real bracketed content
});
@@ -141,9 +269,13 @@ function debugLog(message, data = null) {
* Handles both separate code blocks and combined code blocks gracefully.
*
* @param {string} responseText - The raw AI response text
* @param {Object} [options] - Parser behavior options
* @param {boolean} [options.suppressNoDataError=false] - Avoid console error when no tracker data is found
* @param {boolean} [options.forceFormat=null] - Force a specific format check (bypasses cache)
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
export function parseResponse(responseText, options = {}) {
const { suppressNoDataError = false, forceFormat = null } = options;
const result = {
userStats: null,
infoBox: null,
@@ -157,14 +289,22 @@ export function parseResponse(responseText) {
// Remove content inside thinking tags first (model's internal reasoning)
// This prevents parsing code blocks from the model's thinking process
let cleanedResponse = responseText.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
let cleanedResponse = responseText.replace(THINKING_TAG_RE, '');
cleanedResponse = cleanedResponse.replace(THINKING_TAG_ALT_RE, '');
debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars');
// Remove "FORMAT:" markers that the model might accidentally output
cleanedResponse = cleanedResponse.replace(/FORMAT:\s*/gi, '');
cleanedResponse = cleanedResponse.replace(FORMAT_MARKER_RE, '');
debugLog('[RPG Parser] Removed FORMAT: markers, new length:', cleanedResponse.length + ' chars');
// Format cache: if we've seen the same format multiple times, try that path first
const cachedFormat = forceFormat ?? lastDetectedFormat;
let detectedFormat = null;
if (cachedFormat && formatCacheHits >= FORMAT_CACHE_HIT_THRESHOLD) {
debugLog('[RPG Parser] Using cached format:', cachedFormat);
}
// First, try to extract raw JSON objects (v3 format)
// Note: Prompts now instruct models to use ```json``` code blocks, but we extract
// from any JSON found using brace-matching for maximum compatibility
@@ -214,32 +354,49 @@ export function parseResponse(responseText) {
// console.log(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
debugLog(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
// Update format cache
detectedFormat = 'json';
if (cachedFormat === 'json') {
formatCacheHits++;
} else {
lastDetectedFormat = 'json';
formatCacheHits = 1;
}
// First, try to parse as unified JSON structure (new v3.1 format)
if (extractedObjects.length === 1) {
const parsed = repairJSON(extractedObjects[0]);
if (parsed && (parsed.userStats || parsed.infoBox || parsed.characters)) {
// Look through all extracted objects for unified structure
let foundUnified = false;
for (let idx = 0; idx < extractedObjects.length; idx++) {
const parsed = repairJSON(extractedObjects[idx]);
const unwrapped = parsed ? unwrapTrackerEnvelope(parsed) : null;
if (unwrapped && (unwrapped.userStats || unwrapped.infoBox || unwrapped.characters || unwrapped.characterThoughts || unwrapped.presentCharacters)) {
// console.log('[RPG Parser] ✓ Detected unified JSON structure (v3.1 format)');
if (parsed.userStats) {
result.userStats = JSON.stringify(parsed.userStats);
if (unwrapped.userStats) {
result.userStats = JSON.stringify(unwrapped.userStats);
// console.log('[RPG Parser] ✓ Extracted userStats from unified structure');
}
if (parsed.infoBox) {
result.infoBox = JSON.stringify(parsed.infoBox);
if (unwrapped.infoBox) {
result.infoBox = JSON.stringify(unwrapped.infoBox);
// console.log('[RPG Parser] ✓ Extracted infoBox from unified structure');
}
if (parsed.characters) {
result.characterThoughts = JSON.stringify(parsed.characters);
const unifiedCharacters = unwrapped.characters || unwrapped.presentCharacters || unwrapped.characterThoughts;
if (unifiedCharacters) {
result.characterThoughts = JSON.stringify(unifiedCharacters);
// console.log('[RPG Parser] ✓ Extracted characters from unified structure');
}
if (result.userStats || result.infoBox || result.characterThoughts) {
foundUnified = true;
break; // Found unified structure, stop searching
}
}
if (foundUnified) {
// console.log('[RPG Parser] ✓ Returning unified JSON parse results');
debugLog('[RPG Parser] Returning unified JSON parse results');
return result;
}
}
}
// If no unified structure found, proceed to multi-object classification
// Fall back to parsing multiple separate JSON objects (legacy v3.0 format)
for (let idx = 0; idx < extractedObjects.length; idx++) {
@@ -250,11 +407,12 @@ export function parseResponse(responseText) {
const parsed = repairJSON(jsonContent);
if (parsed) {
const normalizedParsed = unwrapTrackerEnvelope(parsed);
// console.log(`[RPG Parser] Object ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Check if object is wrapped (e.g., {"userStats": {...}})
// Unwrap single-key objects that match our tracker types
let unwrapped = parsed;
let unwrapped = normalizedParsed;
if (Object.keys(parsed).length === 1) {
const key = Object.keys(parsed)[0];
if (key === 'userStats' || key === 'infoBox' || key === 'characters') {
@@ -263,6 +421,22 @@ export function parseResponse(responseText) {
}
}
// Check for unified structure format (even if previous detection missed it)
// This handles the prompt-requested format: {"userStats": {...}, "infoBox": {...}, "characters": [...]}
if (normalizedParsed.userStats || normalizedParsed.infoBox || normalizedParsed.characters || normalizedParsed.characterThoughts || normalizedParsed.presentCharacters) {
if (normalizedParsed.userStats) {
result.userStats = JSON.stringify(normalizedParsed.userStats);
}
if (normalizedParsed.infoBox) {
result.infoBox = JSON.stringify(normalizedParsed.infoBox);
}
const normalizedCharacters = normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts;
if (normalizedCharacters) {
result.characterThoughts = JSON.stringify(normalizedCharacters);
}
continue; // Skip further classification
}
// Detect tracker type by checking for top-level fields
if (unwrapped.stats || unwrapped.status || unwrapped.skills || unwrapped.inventory || unwrapped.quests) {
result.userStats = jsonContent;
@@ -299,13 +473,21 @@ export function parseResponse(responseText) {
// Check for JSON code blocks (legacy v3 format with ```json fences)
// Look for ```json code blocks which indicate JSON format
const jsonBlockRegex = /```json\s*\n([\s\S]*?)```/g;
const jsonMatches = [...cleanedResponse.matchAll(jsonBlockRegex)];
const jsonMatches = [...cleanedResponse.matchAll(JSON_BLOCK_RE)];
if (jsonMatches.length > 0) {
// console.log('[RPG Parser] ✓ Found', jsonMatches.length, 'JSON code blocks (v3 format with fences)');
debugLog('[RPG Parser] ✓ Found JSON code blocks (v3 format), parsing as JSON');
// Update format cache
detectedFormat = 'json_block';
if (cachedFormat === 'json_block') {
formatCacheHits++;
} else if (!detectedFormat) {
lastDetectedFormat = 'json_block';
formatCacheHits = 1;
}
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
@@ -317,18 +499,30 @@ export function parseResponse(responseText) {
const parsed = repairJSON(jsonContent);
if (parsed) {
const normalizedParsed = unwrapTrackerEnvelope(parsed);
// console.log(`[RPG Parser] JSON block ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Detect tracker type by checking for top-level fields
if (parsed.stats || parsed.status || parsed.skills || parsed.inventory || parsed.quests) {
if (normalizedParsed.userStats || normalizedParsed.infoBox || normalizedParsed.characters || normalizedParsed.characterThoughts || normalizedParsed.presentCharacters) {
if (normalizedParsed.userStats) {
result.userStats = JSON.stringify(normalizedParsed.userStats);
}
if (normalizedParsed.infoBox) {
result.infoBox = JSON.stringify(normalizedParsed.infoBox);
}
const normalizedCharacters = normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts;
if (normalizedCharacters) {
result.characterThoughts = JSON.stringify(normalizedCharacters);
}
} else if (normalizedParsed.stats || normalizedParsed.status || normalizedParsed.skills || normalizedParsed.inventory || normalizedParsed.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted JSON User Stats');
} else if (parsed.date || parsed.location || parsed.weather || parsed.temperature || parsed.time) {
} else if (normalizedParsed.date || normalizedParsed.location || normalizedParsed.weather || normalizedParsed.temperature || normalizedParsed.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted JSON Info Box');
} else if (parsed.characters || Array.isArray(parsed)) {
} else if (normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts || Array.isArray(normalizedParsed)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted JSON Characters');
@@ -357,13 +551,22 @@ export function parseResponse(responseText) {
}
// Check if response uses XML <trackers> tags (hybrid format)
const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i);
const xmlMatch = cleanedResponse.match(XML_TRACKERS_RE);
if (xmlMatch) {
debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser');
const trackersContent = xmlMatch[1].trim();
// Update format cache
detectedFormat = 'xml';
if (cachedFormat === 'xml') {
formatCacheHits++;
} else if (!lastDetectedFormat) {
lastDetectedFormat = 'xml';
formatCacheHits = 1;
}
// Try to parse JSON blocks within XML first
const xmlJsonMatches = [...trackersContent.matchAll(jsonBlockRegex)];
const xmlJsonMatches = [...trackersContent.matchAll(JSON_BLOCK_RE)];
if (xmlJsonMatches.length > 0) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
@@ -385,19 +588,19 @@ export function parseResponse(responseText) {
}
} else {
// Fallback to text extraction from XML content (legacy v2 text format)
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
const statsMatch = trackersContent.match(XML_STATS_MATCH_RE);
if (statsMatch) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from XML (text format)');
}
const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
const infoBoxMatch = trackersContent.match(XML_INFOBOX_MATCH_RE);
if (infoBoxMatch) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
const charactersMatch = trackersContent.match(XML_CHARACTERS_MATCH_RE);
if (charactersMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML (text format)');
@@ -412,8 +615,7 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] No XML tags found, using code block parser');
// Extract code blocks
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...cleanedResponse.matchAll(codeBlockRegex)];
const matches = [...cleanedResponse.matchAll(CODE_BLOCK_RE)];
debugLog('[RPG Parser] Found', matches.length + ' code blocks');
@@ -426,8 +628,8 @@ export function parseResponse(responseText) {
// Check if this is a combined code block with multiple sections
const hasMultipleSections = (
content.match(/Stats\s*\n\s*---/i) &&
(content.match(/Info Box\s*\n\s*---/i) || content.match(/Present Characters\s*\n\s*---/i))
content.match(IS_STATS_HEADER_RE) &&
(content.match(IS_INFOBOX_HEADER_RE) || content.match(IS_CHARACTERS_HEADER_RE))
);
if (hasMultipleSections) {
@@ -435,21 +637,21 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] ✓ Found combined code block with multiple sections');
// Extract User Stats section
const statsMatch = content.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
const statsMatch = content.match(COMBINED_STATS_RE);
if (statsMatch && !result.userStats) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from combined block');
}
// Extract Info Box section
const infoBoxMatch = content.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
const infoBoxMatch = content.match(COMBINED_INFOBOX_RE);
if (infoBoxMatch && !result.infoBox) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from combined block');
}
// Extract Present Characters section
const charactersMatch = content.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
const charactersMatch = content.match(COMBINED_CHARACTERS_RE);
if (charactersMatch && !result.characterThoughts) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from combined block');
@@ -458,27 +660,27 @@ export function parseResponse(responseText) {
// Handle separate code blocks with flexible pattern matching
// Match Stats section - flexible patterns
const isStats =
content.match(/Stats\s*\n\s*---/i) ||
content.match(/User Stats\s*\n\s*---/i) ||
content.match(/Player Stats\s*\n\s*---/i) ||
content.match(IS_STATS_HEADER_RE) ||
content.match(IS_USER_STATS_HEADER_RE) ||
content.match(IS_PLAYER_STATS_HEADER_RE) ||
// Fallback: look for stat keywords without strict header
(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i));
(content.match(IS_STATS_HEALTH_RE) && content.match(IS_STATS_ENERGY_RE));
// Match Info Box section - flexible patterns
const isInfoBox =
content.match(/Info Box\s*\n\s*---/i) ||
content.match(/Scene Info\s*\n\s*---/i) ||
content.match(/Information\s*\n\s*---/i) ||
content.match(IS_INFOBOX_HEADER_RE) ||
content.match(IS_SCENE_INFO_HEADER_RE) ||
content.match(IS_INFORMATION_HEADER_RE) ||
// Fallback: look for info box keywords
(content.match(/Date:/i) && content.match(/Location:/i) && content.match(/Time:/i));
(content.match(IS_INFOBOX_DATE_RE) && content.match(IS_INFOBOX_LOCATION_RE) && content.match(IS_INFOBOX_TIME_RE));
// Match Present Characters section - flexible patterns
const isCharacters =
content.match(/Present Characters\s*\n\s*---/i) ||
content.match(/Characters\s*\n\s*---/i) ||
content.match(/Character Thoughts\s*\n\s*---/i) ||
content.match(IS_CHARACTERS_HEADER_RE) ||
content.match(IS_CHARACTERS_ALT_RE) ||
content.match(IS_CHARACTERS_THOUGHTS_RE) ||
// Fallback: look for new multi-line format patterns
(content.match(/^-\s+\w+/m) && content.match(/Details:/i));
(content.match(IS_CHARACTERS_BULLET_RE) && content.match(IS_CHARACTERS_DETAILS_RE));
if (isStats && !result.userStats) {
result.userStats = stripBrackets(content);
@@ -492,12 +694,12 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Full content:', content);
} else {
debugLog('[RPG Parser] ✗ No match - checking patterns:');
debugLog('[RPG Parser] - Has "Stats\\n---"?', !!content.match(/Stats\s*\n\s*---/i));
debugLog('[RPG Parser] - Has stat keywords?', !!(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i)));
debugLog('[RPG Parser] - Has "Info Box\\n---"?', !!content.match(/Info Box\s*\n\s*---/i));
debugLog('[RPG Parser] - Has info keywords?', !!(content.match(/Date:/i) && content.match(/Location:/i)));
debugLog('[RPG Parser] - Has "Present Characters\\n---"?', !!content.match(/Present Characters\s*\n\s*---/i));
debugLog('[RPG Parser] - Has new format ("- Name" + "Details:")?', !!(content.match(/^-\s+\w+/m) && content.match(/Details:/i)));
debugLog('[RPG Parser] - Has "Stats\\n---"?', !!content.match(DEBUG_STATS_RE));
debugLog('[RPG Parser] - Has stat keywords?', !!(content.match(IS_STATS_HEALTH_RE) && content.match(IS_STATS_ENERGY_RE)));
debugLog('[RPG Parser] - Has "Info Box\\n---"?', !!content.match(DEBUG_INFOBOX_RE));
debugLog('[RPG Parser] - Has info keywords?', !!(content.match(IS_INFOBOX_DATE_RE) && content.match(IS_INFOBOX_LOCATION_RE)));
debugLog('[RPG Parser] - Has "Present Characters\\n---"?', !!content.match(DEBUG_CHARACTERS_RE));
debugLog('[RPG Parser] - Has new format ("- Name" + "Details:")?', !!(content.match(IS_CHARACTERS_BULLET_RE) && content.match(IS_CHARACTERS_DETAILS_RE)));
}
}
}
@@ -508,10 +710,46 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
// Final fallback: try to extract tracker JSON from any fenced block content
// This catches responses where JSON is embedded in non-standard markdown structure.
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
const fencedMatches = [...cleanedResponse.matchAll(FENCED_FALLBACK_RE)];
for (const match of fencedMatches) {
const fencedContent = (match[1] || '').trim();
if (!fencedContent) continue;
const extracted = extractJSONFromText(fencedContent) || fencedContent;
const parsed = repairJSON(extracted);
const normalizedParsed = parsed ? unwrapTrackerEnvelope(parsed) : null;
if (!normalizedParsed) continue;
if (normalizedParsed.userStats && !result.userStats) {
result.userStats = JSON.stringify(normalizedParsed.userStats);
}
if (normalizedParsed.infoBox && !result.infoBox) {
result.infoBox = JSON.stringify(normalizedParsed.infoBox);
}
const normalizedCharacters = normalizedParsed.characters || normalizedParsed.presentCharacters || normalizedParsed.characterThoughts;
if (normalizedCharacters && !result.characterThoughts) {
result.characterThoughts = JSON.stringify(normalizedCharacters);
}
if (result.userStats || result.infoBox || result.characterThoughts) {
debugLog('[RPG Parser] ✓ Extracted trackers from final fenced-block fallback');
break;
}
}
}
// Check if we found at least one section - if not, mark as parsing failure
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
result.parsingFailed = true;
if (!suppressNoDataError) {
console.error('[RPG Parser] ❌ No tracker data found in response - parsing failed');
} else {
debugLog('[RPG Parser] No tracker data found (suppressed no-data error)');
}
}
return result;
@@ -559,10 +797,12 @@ export function parseUserStats(statsText) {
const trackerConfig = extensionSettings.trackerConfig;
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
if (statsData.status[fieldKey]) {
extensionSettings.userStats[fieldKey] = statsData.status[fieldKey];
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, statsData.status[fieldKey]);
const fieldKey = toFieldKey(fieldName);
// Try the base key first (e.g., "conditions"), then fall back to full lowercase name
const value = statsData.status[fieldKey] || statsData.status[fieldName.toLowerCase()];
if (value) {
extensionSettings.userStats[fieldKey] = value;
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, value);
}
}
}
@@ -617,6 +857,13 @@ export function parseUserStats(statsText) {
if (!quest) return '';
if (typeof quest === 'string') return quest;
if (typeof quest === 'object') {
// Check for locked format: {value, locked}
// Recursively extract value if it's nested
let extracted = quest;
while (typeof extracted === 'object' && extracted.value !== undefined) {
extracted = extracted.value;
}
if (typeof extracted === 'string') return extracted;
// v3 format: {title, description, status}
return quest.title || quest.description || JSON.stringify(quest);
}
@@ -670,13 +917,13 @@ export function parseUserStats(statsText) {
// Parse RPG attributes if enabled
if (trackerConfig?.userStats?.showRPGAttributes) {
const strMatch = statsText.match(/STR:\s*(\d+)/i);
const dexMatch = statsText.match(/DEX:\s*(\d+)/i);
const conMatch = statsText.match(/CON:\s*(\d+)/i);
const intMatch = statsText.match(/INT:\s*(\d+)/i);
const wisMatch = statsText.match(/WIS:\s*(\d+)/i);
const chaMatch = statsText.match(/CHA:\s*(\d+)/i);
const lvlMatch = statsText.match(/LVL:\s*(\d+)/i);
const strMatch = statsText.match(RPG_STR_RE);
const dexMatch = statsText.match(RPG_DEX_RE);
const conMatch = statsText.match(RPG_CON_RE);
const intMatch = statsText.match(RPG_INT_RE);
const wisMatch = statsText.match(RPG_WIS_RE);
const chaMatch = statsText.match(RPG_CHA_RE);
const lvlMatch = statsText.match(RPG_LVL_RE);
if (strMatch) extensionSettings.classicStats.str = parseInt(strMatch[1]);
if (dexMatch) extensionSettings.classicStats.dex = parseInt(dexMatch[1]);
@@ -696,7 +943,7 @@ export function parseUserStats(statsText) {
const customFields = statusConfig.customFields || [];
// Try Status: format
const statusMatch = statsText.match(/Status:\s*(.+)/i);
const statusMatch = statsText.match(STATUS_MATCH_RE);
if (statusMatch) {
const statusContent = statusMatch[1].trim();
@@ -747,7 +994,7 @@ export function parseUserStats(statsText) {
// Parse skills section if enabled
const skillsConfig = trackerConfig?.userStats?.skillsSection;
if (skillsConfig?.enabled) {
const skillsMatch = statsText.match(/Skills:\s*(.+)/i);
const skillsMatch = statsText.match(SKILLS_MATCH_RE);
if (skillsMatch) {
extensionSettings.userStats.skills = skillsMatch[1].trim();
debugLog('[RPG Parser] Skills extracted:', skillsMatch[1].trim());
@@ -765,7 +1012,7 @@ export function parseUserStats(statsText) {
}
} else {
// Legacy v1 parsing for backward compatibility
const inventoryMatch = statsText.match(/Inventory:\s*(.+)/i);
const inventoryMatch = statsText.match(INVENTORY_MATCH_RE);
if (inventoryMatch) {
extensionSettings.userStats.inventory = inventoryMatch[1].trim();
debugLog('[RPG Parser] Inventory v1 extracted:', inventoryMatch[1].trim());
@@ -775,13 +1022,13 @@ export function parseUserStats(statsText) {
}
// Extract quests
const mainQuestMatch = statsText.match(/Main Quests?:\s*(.+)/i);
const mainQuestMatch = statsText.match(MAIN_QUEST_MATCH_RE);
if (mainQuestMatch) {
extensionSettings.quests.main = mainQuestMatch[1].trim();
debugLog('[RPG Parser] Main quests extracted:', mainQuestMatch[1].trim());
}
const optionalQuestsMatch = statsText.match(/Optional Quests:\s*(.+)/i);
const optionalQuestsMatch = statsText.match(OPTIONAL_QUESTS_MATCH_RE);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText !== 'None') {
@@ -824,8 +1071,7 @@ export function parseUserStats(statsText) {
* @returns {Array<string>} Array of code block contents
*/
export function extractCodeBlocks(text) {
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...text.matchAll(codeBlockRegex)];
const matches = [...text.matchAll(CODE_BLOCK_RE)];
return matches.map(match => match[1].trim());
}
@@ -835,7 +1081,7 @@ export function extractCodeBlocks(text) {
* @returns {boolean} True if this is a stats section
*/
export function isStatsSection(content) {
return content.match(/Stats\s*\n\s*---/i) !== null;
return content.match(STATS_SECTION_RE) !== null;
}
/**
@@ -844,7 +1090,7 @@ export function isStatsSection(content) {
* @returns {boolean} True if this is an info box section
*/
export function isInfoBoxSection(content) {
return content.match(/Info Box\s*\n\s*---/i) !== null;
return content.match(INFOBOX_SECTION_RE) !== null;
}
/**
@@ -853,5 +1099,5 @@ export function isInfoBoxSection(content) {
* @returns {boolean} True if this is a character thoughts section
*/
export function isCharacterThoughtsSection(content) {
return content.match(/Present Characters\s*\n\s*---/i) !== null || content.includes(" | ");
return content.match(CHARACTERS_SECTION_RE) !== null || content.includes(" | ");
}
+113 -62
View File
@@ -14,6 +14,7 @@ import {
addLockInstruction
} from './jsonPromptHelpers.js';
import { applyLocks } from './lockManager.js';
import { isPresentCharactersEnabled } from '../../utils/presentCharacters.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -33,6 +34,15 @@ export const DEFAULT_DIALOGUE_COLORING_PROMPT = `Wrap all character/NPC "dialogu
*/
export const DEFAULT_DECEPTION_PROMPT = `When a character is lying or deceiving, you should follow up that line with the <lie> tag, containing a brief description of the truth and the lie's reason, using the template below (replace placeholders in quotation marks). This will be hidden from the user's view, but not to you, making it useful for future consequences: <lie character="name" type="lying/deceiving/omitting" truth="truth" reason="reason"/>.`;
/**
* Default Omniscience Filter prompt text
* This instructs the AI to separate information the player character cannot perceive
*/
export const DEFAULT_OMNISCIENCE_FILTER_PROMPT = `You must strictly separate what the player can directly perceive from what they cannot. They should only read limited narrative content that their persona can actually see, hear, smell, touch, or otherwise directly sense. Before writing any narrative content that involves events, actions, or details the player directly cannot perceive (because they're not looking, too far away, behind them, in another room, happening silently, include NPCs' internal thoughts, etc.), you absolutely must output that hidden information inside a <ofilter> tag using this exact format:
<ofilter event="[Brief description of what is happening that the player cannot perceive]" reason="[Why the player character cannot perceive this - e.g., 'behind them', 'in another room', 'too quiet to hear', 'focused elsewhere']"/>
Example: <ofilter event="Zandik quietly takes the key from the table and slips out the back door" reason="Zandik is behind Mari, who is absorbed in reading, and he moves silently"/> You hear a faint click from somewhere behind you, but when you glance up from your newspaper, the room seems unchanged.`;
/**
* Default CYOA prompt text
*/
@@ -53,6 +63,11 @@ export const SPOTIFY_FORMAT_INSTRUCTION = `Include it in this exact format: <spo
*/
export const DEFAULT_NARRATOR_PROMPT = `Infer the identity and details of characters present in each scene from the story context below. Do not use fixed character references; instead, identify characters naturally based on their actions, dialogue, and descriptions in the narrative.`;
/**
* Default Context Instructions prompt text (customizable by users)
*/
export const DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT = `The context above is information about the current scene, and what follows is the last message in the chat history. Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses.`;
/**
* Gets character card information for current chat (handles both single and group chats)
* @returns {string} Formatted character information
@@ -63,83 +78,97 @@ async function getCharacterCardsInfo() {
// Narrator mode: use character card as narrator context, infer characters from story context
if (extensionSettings.narratorMode) {
if (this_chid !== undefined && characters && characters[this_chid]) {
const character = characters[this_chid];
characterInfo += 'You are acting as the narrator for this story. The narrator card provides context for the story tone and style:\n\n';
characterInfo += `<narrator>\n`;
if (character.description) {
characterInfo += `${character.description}\n`;
}
if (character.personality) {
characterInfo += `${character.personality}\n`;
}
characterInfo += `</narrator>\n\n`;
// Use custom narrator prompt if available, otherwise use default
const narratorPrompt = extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT;
characterInfo += narratorPrompt + '\n\n';
characterInfo = buildNarratorCardInfo(characters[this_chid]);
}
return characterInfo;
}
// Check if in group chat
if (selected_group) {
// Find the current group directly from the groups array
const group = groups.find(g => g.id === selected_group);
const groupMembers = getGroupMembers(selected_group);
if (groupMembers && groupMembers.length > 0) {
characterInfo += 'Characters in this roleplay:\n\n';
const disabledMembers = groups.find(g => g.id === selected_group)?.disabled_members || [];
characterInfo = buildGroupCardInfo(groupMembers, disabledMembers);
}
} else if (this_chid !== undefined && characters && characters[this_chid]) {
// Single character chat
characterInfo = buildSingleCardInfo(characters[this_chid]);
}
// Filter out disabled (muted) members
const disabledMembers = group?.disabled_members || [];
// console.log('[RPG Companion] 🔍 Group ID:', selected_group, '| Disabled members:', disabledMembers);
return characterInfo;
}
/**
* Builds narrator mode character card information.
* @param {Object} character - Character card data
* @returns {string} Formatted narrator info
*/
function buildNarratorCardInfo(character) {
let info = '';
info += 'You are acting as the narrator for this story. The narrator card provides context for the story tone and style:\n\n';
info += `<narrator>\n`;
info += appendCharacterFields(character);
info += `</narrator>\n\n`;
info += (extensionSettings.customNarratorPrompt || DEFAULT_NARRATOR_PROMPT) + '\n\n';
return info;
}
/**
* Builds character card information for a group chat.
* @param {Array} groupMembers - Array of group member objects
* @param {Array} disabledMembers - Array of disabled (muted) member avatar filenames
* @returns {string} Formatted group character info
*/
function buildGroupCardInfo(groupMembers, disabledMembers) {
let info = 'Characters in this roleplay:\n\n';
let characterIndex = 0;
groupMembers.forEach((member) => {
if (!member || !member.name) return;
// Skip muted characters - check against avatar filename
// Skip muted characters
if (member.avatar && disabledMembers.includes(member.avatar)) {
// console.log(`[RPG Companion] ❌ Skipping muted: ${member.name} (${member.avatar})`);
return;
}
characterIndex++;
characterInfo += `<character${characterIndex}="${member.name}">\n`;
if (member.description) {
characterInfo += `${member.description}\n`;
}
if (member.personality) {
characterInfo += `${member.personality}\n`;
}
characterInfo += `</character${characterIndex}>\n\n`;
info += `<character${characterIndex}="${member.name}">\n`;
info += appendCharacterFields(member);
info += `</character${characterIndex}>\n\n`;
});
return info;
}
} else if (this_chid !== undefined && characters && characters[this_chid]) {
// Single character chat
const character = characters[this_chid];
characterInfo += 'Character in this roleplay:\n\n';
characterInfo += `<character="${character.name}">\n`;
/**
* Builds character card information for a single character chat.
* @param {Object} character - Character card data
* @returns {string} Formatted single character info
*/
function buildSingleCardInfo(character) {
let info = 'Character in this roleplay:\n\n';
info += `<character="${character.name}">\n`;
info += appendCharacterFields(character);
info += `</character>\n\n`;
return info;
}
/**
* Appends description and personality fields from a character object.
* Shared helper used by narrator, group, and single character info builders.
* @param {Object} character - Character object with optional description/personality fields
* @returns {string} Appended fields (empty string if neither present)
*/
function appendCharacterFields(character) {
let fields = '';
if (character.description) {
characterInfo += `${character.description}\n`;
fields += `${character.description}\n`;
}
if (character.personality) {
characterInfo += `${character.personality}\n`;
fields += `${character.personality}\n`;
}
characterInfo += `</character>\n\n`;
}
return characterInfo;
return fields;
}
/**
@@ -279,7 +308,7 @@ export function generateTrackerExample() {
}
}
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try {
JSON.parse(committedTrackerData.characterThoughts);
const lockedData = applyLocks(committedTrackerData.characterThoughts, 'characters');
@@ -315,7 +344,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
let instructions = '';
// Check if any trackers are enabled
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts;
const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || isPresentCharactersEnabled();
// Only add tracker instructions if at least one tracker is enabled
if (hasAnyTrackers) {
@@ -346,7 +375,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
if (extensionSettings.showInfoBox) {
enabledTrackers.push('infoBox');
}
if (extensionSettings.showCharacterThoughts) {
if (isPresentCharactersEnabled()) {
enabledTrackers.push('characters');
}
@@ -369,7 +398,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon
instructions += enabledTrackers.indexOf('infoBox') < enabledTrackers.length - 1 ? ',\n' : '\n';
}
if (extensionSettings.showCharacterThoughts) {
if (isPresentCharactersEnabled()) {
instructions += ' "characters": ';
const charactersJSON = buildCharactersJSONInstruction();
// Add 2 spaces to all lines after the first to properly nest within root object
@@ -546,12 +575,33 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
if (trackerType === 'userStats') {
formatted += `${userName}'s Stats:\n`;
// Get display mode and custom stats config for maxValue lookup
const userStatsConfig = extensionSettings.trackerConfig?.userStats;
const displayMode = userStatsConfig?.statsDisplayMode || 'percentage';
const customStats = userStatsConfig?.customStats || [];
// Helper to get maxValue for a stat by id
const getMaxValue = (statId) => {
const statConfig = customStats.find(s => s.id === statId);
return statConfig?.maxValue || 100;
};
// Helper to format stat value based on display mode
const formatStatValue = (value, statId) => {
if (displayMode === 'number') {
const maxValue = getMaxValue(statId);
return `${value}/${maxValue}`;
}
return value;
};
// Handle stats array format: [{id, name, value}, ...]
if (data.stats && Array.isArray(data.stats)) {
for (const stat of data.stats) {
if (stat && stat.value !== undefined) {
const statName = stat.name || (stat.id ? stat.id.charAt(0).toUpperCase() + stat.id.slice(1) : 'Unknown');
formatted += `${statName}: ${stat.value}\n`;
const statId = stat.id || statName.toLowerCase();
formatted += `${statName}: ${formatStatValue(stat.value, statId)}\n`;
}
}
} else {
@@ -564,7 +614,7 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
const value = getValue(data[statName]);
if (value) {
const displayName = statName.charAt(0).toUpperCase() + statName.slice(1);
formatted += `${displayName}: ${value}\n`;
formatted += `${displayName}: ${formatStatValue(value, statName)}\n`;
}
}
}
@@ -573,7 +623,7 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
for (const [key, value] of Object.entries(data)) {
if (!statFieldOrder.includes(key) && !specialFields.includes(key) && typeof value === 'number') {
const displayName = key.charAt(0).toUpperCase() + key.slice(1);
formatted += `${displayName}: ${getValue(value)}\n`;
formatted += `${displayName}: ${formatStatValue(getValue(value), key)}\n`;
}
}
}
@@ -704,13 +754,14 @@ function formatTrackerDataForContext(jsonData, trackerType, userName) {
}
}
// Relationship
if (char.relationship) {
// Relationship - check both Relationship (new format) and relationship (old format)
const relationshipValue = char.Relationship || char.relationship;
if (relationshipValue) {
let relValue;
if (typeof char.relationship === 'object' && !Array.isArray(char.relationship) && 'status' in char.relationship) {
relValue = getValue(char.relationship.status);
if (typeof relationshipValue === 'object' && !Array.isArray(relationshipValue) && 'status' in relationshipValue) {
relValue = getValue(relationshipValue.status);
} else {
relValue = getValue(char.relationship);
relValue = getValue(relationshipValue);
}
if (relValue) formatted += ` Relationship: ${relValue}\n`;
}
@@ -1025,7 +1076,7 @@ export function generateContextualSummary() {
}
// Add Present Characters tracker data if enabled
if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) {
if (isPresentCharactersEnabled() && committedTrackerData.characterThoughts) {
try {
const formatted = formatTrackerDataForContext(committedTrackerData.characterThoughts, 'characters', userName);
if (formatted) {
+460 -231
View File
@@ -4,7 +4,7 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } from '../../../../../../../script.js';
import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js';
// Core modules
import {
@@ -20,9 +20,22 @@ import {
setIsAwaitingNewMessage,
updateLastGeneratedData,
updateCommittedTrackerData,
$musicPlayerContainer
$musicPlayerContainer,
incrementSeparateGenerationId
} from '../../core/state.js';
import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js';
import {
saveChatData,
loadChatData,
autoSwitchPresetForEntity,
getMessageSwipeTrackerData,
getCurrentMessageSwipeTrackerData,
restoreLatestTrackerStateFromChat,
setMessageSwipeTrackerData,
getSwipeData,
commitTrackerDataFromPriorMessage,
inheritSwipeDataFromPriorMessage,
flushDeferredChatDataSave
} from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
// Generation & Parsing
@@ -37,8 +50,10 @@ import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { scheduleRender, clearAllCaches } from '../rendering/renderUtils.js';
// Utils
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
@@ -51,6 +66,47 @@ import { updateStripWidgets } from '../ui/desktop.js';
import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js';
import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js';
let chatStateRehydrateRunId = 0;
/**
* Reads the swipe store of the last assistant message in `currentChat` and
* writes its data into `lastGeneratedData`, including syncing stat bars via
* `parseUserStats`. If no assistant message exists, or none has stored swipe
* data, `lastGeneratedData` is left unchanged.
*
* Use this wherever the displayed tracker state must be re-derived from the
* authoritative swipe store rather than from chat_metadata (e.g. after a
* CHAT_CHANGED caused by branching, or after a message deletion).
*
* @param {Array} currentChat - Live chat array from getContext().chat
* @returns {boolean} True if swipe data was found and applied
*/
function syncLastGeneratedDataFromSwipeStore(currentChat) {
for (let i = currentChat.length - 1; i >= 0; i--) {
const msg = currentChat[i];
if (!msg.is_user && !msg.is_system) {
const swipeId = msg.swipe_id || 0;
const swipeData = getSwipeData(msg, swipeId);
if (swipeData) {
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
// Normalize characterThoughts to string (backward compat with old object format).
if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') {
lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2);
} else {
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
}
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
return true;
}
return false; // Last assistant message exists but has no swipe data yet
}
}
return false; // No assistant messages in chat
}
/**
* Commits the tracker data from the last assistant message to be used as source for next generation.
* This should be called when the user has replied to a message, ensuring all swipes of the next
@@ -65,28 +121,320 @@ export function commitTrackerData() {
// Find the last assistant message
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
if (!message.is_user && !message.is_system) {
// Found last assistant message - commit its tracker data
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
const swipeData = message.extra.rpg_companion_swipes[swipeId];
const swipeData = getSwipeData(message, swipeId);
if (swipeData) {
// console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId);
committedTrackerData.userStats = swipeData.userStats || null;
committedTrackerData.infoBox = swipeData.infoBox || null;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
const rawCharacterThoughts = swipeData.characterThoughts;
if (rawCharacterThoughts == null) {
committedTrackerData.characterThoughts = null;
} else if (typeof rawCharacterThoughts === 'object') {
committedTrackerData.characterThoughts = JSON.stringify(rawCharacterThoughts);
} else {
// console.log('[RPG Companion] No swipe data found for swipe', swipeId);
committedTrackerData.characterThoughts = String(rawCharacterThoughts);
}
} else {
// console.log('[RPG Companion] No RPG data found in last assistant message');
// No saved swipe data — treat as empty (e.g. first message, no prior generation)
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
break;
}
}
}
function getSwipeTrackerData(message) {
return getMessageSwipeTrackerData(message);
}
function getCurrentSwipeTrackerData(message) {
return getCurrentMessageSwipeTrackerData(message);
}
function hasAssistantMessageBody() {
const $messages = $('#chat .mes');
for (let i = $messages.length - 1; i >= 0; i--) {
const $message = $messages.eq(i);
if ($message.attr('is_user') === 'true') continue;
if ($message.find('.mes_text').length > 0) {
return true;
}
}
return false;
}
function hasAnyTrackerStateInChat() {
const chatMessages = getContext()?.chat || [];
for (let i = chatMessages.length - 1; i >= 0; i--) {
const swipeData = getSwipeTrackerData(chatMessages[i]);
if (swipeData?.userStats || swipeData?.infoBox || swipeData?.characterThoughts) {
return true;
}
}
return false;
}
function hasAssistantMessagesInChat() {
const chatMessages = getContext()?.chat || [];
return chatMessages.some(message => message && !message.is_user && !message.is_system);
}
function hasPotentialTrackerSourceInChat() {
const chatMessages = getContext()?.chat || [];
for (const message of chatMessages) {
if (!message || message.is_user || message.is_system) {
continue;
}
if (message.extra?.rpg_companion_swipes) {
return true;
}
if (Array.isArray(message.swipe_info) && message.swipe_info.some(info => info?.extra?.rpg_companion_swipes)) {
return true;
}
if (Array.isArray(message.swipes) && message.swipes.length > 1) {
return true;
}
}
return false;
}
function maybeRehydrateUserStatsFromDisplayData() {
const hasSavedUserStats = !!chat_metadata?.rpg_companion?.userStats;
if (!hasSavedUserStats && lastGeneratedData.userStats) {
try {
parseUserStats(lastGeneratedData.userStats);
} catch (error) {
console.warn('[RPG Companion] Failed to rebuild user stats from display data:', error);
}
}
}
function getCurrentSwipeText(message) {
const swipeId = Number(message?.swipe_id ?? 0);
if (Array.isArray(message?.swipes) && typeof message.swipes[swipeId] === 'string' && message.swipes[swipeId].trim()) {
return message.swipes[swipeId];
}
return typeof message?.mes === 'string' ? message.mes : '';
}
/**
* Resolves the currently active swipe index for a message.
* Some ST flows can briefly expose a stale message.swipe_id during swipe transitions,
* so we also match against message.mes in the swipes array when possible.
*
* @param {Object} message - Assistant message object
* @returns {number} Active swipe index
*/
function resolveActiveSwipeId(message) {
const fallbackSwipeId = Number(message?.swipe_id ?? 0);
const swipes = Array.isArray(message?.swipes) ? message.swipes : null;
if (!swipes || swipes.length === 0) {
return Math.max(0, fallbackSwipeId);
}
const currentText = typeof message?.mes === 'string' ? message.mes : '';
if (currentText) {
for (let i = swipes.length - 1; i >= 0; i--) {
if (typeof swipes[i] === 'string' && swipes[i] === currentText) {
return i;
}
}
}
if (fallbackSwipeId < 0) {
return 0;
}
return Math.min(fallbackSwipeId, swipes.length - 1);
}
function repairLatestTrackerStateFromCurrentSwipeContent(chatMessages = getContext()?.chat || []) {
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (!message || message.is_user || message.is_system) {
continue;
}
const swipeId = Number(message.swipe_id ?? 0);
if (getCurrentSwipeTrackerData(message)) {
continue;
}
const currentSwipeText = getCurrentSwipeText(message);
if (!currentSwipeText) {
continue;
}
const parsedData = parseResponse(currentSwipeText, { suppressNoDataError: true });
if (parsedData.userStats) {
parsedData.userStats = removeLocks(parsedData.userStats);
}
if (parsedData.infoBox) {
parsedData.infoBox = removeLocks(parsedData.infoBox);
}
if (parsedData.characterThoughts) {
parsedData.characterThoughts = removeLocks(parsedData.characterThoughts);
}
if (!parsedData.userStats && !parsedData.infoBox && !parsedData.characterThoughts) {
continue;
}
setMessageSwipeTrackerData(message, swipeId, {
userStats: parsedData.userStats || null,
infoBox: parsedData.infoBox || null,
characterThoughts: parsedData.characterThoughts || null
});
return true;
}
return false;
}
function restoreOrRepairLatestTrackerState() {
const chatMessages = getContext()?.chat || [];
let restored = restoreLatestTrackerStateFromChat(chatMessages);
if (!restored) {
const repaired = repairLatestTrackerStateFromCurrentSwipeContent(chatMessages);
if (repaired) {
restored = restoreLatestTrackerStateFromChat(chatMessages);
}
}
return restored;
}
/**
* Renders all RPG state panels.
* Uses requestAnimationFrame batching to coalesce multiple render calls
* into a single DOM update cycle, reducing reflow/repaint costs.
*/
function rerenderRpgState() {
scheduleRender(renderUserStats);
scheduleRender(renderInfoBox);
scheduleRender(renderThoughts);
scheduleRender(renderInventory);
scheduleRender(renderEquipment);
scheduleRender(renderQuests);
scheduleRender(() => renderMusicPlayer($musicPlayerContainer[0]));
scheduleRender(updateFabWidgets);
scheduleRender(updateStripWidgets);
}
/**
* Immediately renders all RPG state panels (synchronous).
* Use this when you need the DOM to be updated before the next line of code.
*/
function rerenderRpgStateSync() {
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
updateFabWidgets();
updateStripWidgets();
}
export function scheduleChatStateRehydration() {
chatStateRehydrateRunId++;
const runId = chatStateRehydrateRunId;
let attempts = 0;
const maxAttempts = 15;
const eagerRetryAttempts = 4;
const tryRestoreState = () => {
if (runId !== chatStateRehydrateRunId) {
return;
}
attempts++;
loadChatData();
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
const hasRestoredTrackerState = !!(
lastGeneratedData.userStats
|| lastGeneratedData.infoBox
|| lastGeneratedData.characterThoughts
|| committedTrackerData.userStats
|| committedTrackerData.infoBox
|| committedTrackerData.characterThoughts
);
const hasStoredTrackerState = !!chat_metadata?.rpg_companion || hasAnyTrackerStateInChat();
const hasAssistantMessages = hasAssistantMessagesInChat();
const hasPotentialTrackerSource = hasPotentialTrackerSourceInChat();
const chatBodyReady = hasAssistantMessageBody();
if (chatBodyReady) {
updateChatThoughts();
}
const shouldRetryForRestore = !hasRestoredTrackerState && (
hasStoredTrackerState
|| (hasAssistantMessages && attempts < eagerRetryAttempts)
|| (hasPotentialTrackerSource && attempts < maxAttempts)
);
const shouldRetryForDom = !chatBodyReady && hasAssistantMessages;
if ((shouldRetryForRestore || shouldRetryForDom) && attempts < maxAttempts) {
setTimeout(tryRestoreState, 200);
}
};
setTimeout(tryRestoreState, 200);
}
export function onChatLoaded() {
loadChatData();
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
flushDeferredChatDataSave();
scheduleChatStateRehydration();
updateAllCheckpointIndicators();
}
function syncDisplayedTrackerStateFromChat() {
const restored = restoreOrRepairLatestTrackerState();
if (!restored) {
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
rerenderRpgState();
updateChatThoughts();
}
/**
* Event handler for when the user sends a message.
* Sets the flag to indicate this is NOT a swipe.
@@ -103,6 +451,7 @@ export function onMessageSent() {
const chat = context.chat;
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
if (lastMessage && lastMessage.mes === '...') {
// console.log('[RPG Companion] 🟢 Ignoring onMessageSent: streaming placeholder message');
return;
@@ -115,20 +464,12 @@ export function onMessageSent() {
// This allows auto-update to distinguish between new generations and loading chat history
setIsAwaitingNewMessage(true);
// Note: FAB spinning is NOT shown for together mode since no extra API request is made
// The RPG data comes embedded in the main response
// FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called
// For separate mode with auto-update disabled, commit displayed tracker
if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) {
if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) {
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] 💾 SEPARATE MODE: Committed displayed tracker (auto-update disabled)');
}
}
}
/**
@@ -151,8 +492,9 @@ export async function onMessageReceived(data) {
// Commit happens in onMessageSent (when user sends message, before generation)
const lastMessage = chat[chat.length - 1];
if (lastMessage && !lastMessage.is_user) {
const rawSwipeId = Number(lastMessage.swipe_id ?? 0);
const responseText = lastMessage.mes;
const parsedData = parseResponse(responseText);
const parsedData = parseResponse(responseText, { suppressNoDataError: true });
// Note: Don't show parsing error here - this event fires when loading chat history too
// Error notification is handled in apiClient.js for fresh generations only
@@ -192,19 +534,21 @@ export async function onMessageReceived(data) {
lastMessage.extra.rpg_companion_swipes = {};
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
const currentSwipeId = resolveActiveSwipeId(lastMessage);
setMessageSwipeTrackerData(lastMessage, currentSwipeId, {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
});
// console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId);
// Remove the tracker code blocks from the visible message
let cleanedMessage = responseText;
// Note: JSON code blocks are hidden from display by regex script (but preserved in message data)
// Remove JSON code blocks (v3 format) — primary defense, works regardless of regex script
cleanedMessage = cleanedMessage.replace(/```(?:json|markdown)?\s*[\s\S]*?```/gim, '');
// Remove old text format code blocks (legacy support)
cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, '');
@@ -230,6 +574,7 @@ export async function onMessageReceived(data) {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
@@ -244,6 +589,11 @@ export async function onMessageReceived(data) {
// console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM');
// Re-insert chat thoughts after SillyTavern finishes rerendering the cleaned message DOM.
if (parsedData.characterThoughts) {
setTimeout(() => updateChatThoughts(), 100);
}
// Save to chat metadata
saveChatData();
}
@@ -261,13 +611,26 @@ export async function onMessageReceived(data) {
// Just render the music player
renderMusicPlayer($musicPlayerContainer[0]);
}
// When auto-update is disabled, no tracker API call will run for this message.
// Inherit the prior assistant message's tracker data into this swipe slot so that
// commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything.
// Inheritance does not overwrite existing data, so it's safe to call even if the condition misses an edge case.
if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) {
inheritSwipeDataFromPriorMessage(lastMessage, chat.length - 1);
}
}
// Trigger auto-update if enabled (for both separate and external modes)
// Only trigger if this is a newly generated message, not loading chat history
if (extensionSettings.autoUpdate && isAwaitingNewMessage) {
// Capture the current generation ID before the async gap so that any
// message deletion (or a newer generation) that increments the counter
// while the 500ms timer or the API call is in-flight will cause
// updateRPGData to discard its result rather than stomping the UI.
const genId = incrementSeparateGenerationId();
setTimeout(async () => {
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, genId);
// Update FAB widgets and strip widgets after separate/external mode update completes
setFabLoadingState(false);
updateFabWidgets();
@@ -310,6 +673,7 @@ export function onCharacterChanged() {
// Remove thought panel and icon when changing characters
$('#rpg-thought-panel').remove();
$('#rpg-thought-icon').remove();
$('.rpg-inline-thoughts, .rpg-inline-thought').remove();
$('#chat').off('scroll.thoughtPanel');
$(window).off('resize.thoughtPanel');
$(document).off('click.thoughtPanel');
@@ -322,26 +686,32 @@ export function onCharacterChanged() {
// Load chat-specific data when switching chats
loadChatData();
flushDeferredChatDataSave();
// chat_metadata may not reflect the actual chat tail for branches, so
// loadChatData() may have just restored stale data from the parent chat.
// Override lastGeneratedData from the swipe store of the last assistant message.
// The message objects in the branch already carry their full swipe stores, making this authoritative.
// If no swipe data exists (e.g. branching at message 0, or a chat with no generations yet),
// null out lastGeneratedData and committedTrackerData so we don't display stale values from the parent chat.
const hadSwipeData = syncLastGeneratedDataFromSwipeStore(getContext().chat);
if (!hadSwipeData) {
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
// Don't call commitTrackerData() here - it would overwrite the loaded committedTrackerData
// with data from the last message, which may be null/empty. The loaded committedTrackerData
// already contains the committed state from when we last left this chat.
// commitTrackerData() will be called naturally when new messages arrive.
// Re-render with the loaded data
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Re-render with the loaded data and retry once SillyTavern finishes restoring chat state.
rerenderRpgState();
scheduleChatStateRehydration();
// Update checkpoint indicators for the loaded chat
updateAllCheckpointIndicators();
@@ -359,14 +729,14 @@ export function onMessageSwiped(messageIndex) {
// console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped at index:', messageIndex);
// Get the message that was swiped
const currentChat = getContext().chat;
const message = currentChat[messageIndex];
const message = chat[messageIndex];
if (!message || message.is_user) {
// console.log('[RPG Companion] 🔵 Ignoring swipe - message is user or undefined');
return;
}
const currentSwipeId = message.swipe_id || 0;
const currentSwipeId = resolveActiveSwipeId(message);
const swipeCount = Array.isArray(message.swipes) ? message.swipes.length : 0;
// Only set flag to true if this swipe will trigger a NEW generation
// Check if the swipe already exists (has content in the swipes array)
@@ -374,238 +744,97 @@ export function onMessageSwiped(messageIndex) {
message.swipes[currentSwipeId] !== undefined &&
message.swipes[currentSwipeId] !== null &&
message.swipes[currentSwipeId].length > 0;
const swipeData = getSwipeData(message, currentSwipeId);
const isPendingNewSwipe = currentSwipeId >= swipeCount;
if (!isExistingSwipe) {
// This is a NEW swipe that will trigger generation
setLastActionWasSwipe(true);
setIsAwaitingNewMessage(true);
// Immediately commit context from the prior assistant message (N-1) so generation
// uses the world state before this message, not the last-viewed sibling swipe.
commitTrackerDataFromPriorMessage(messageIndex);
// console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true');
// CRITICAL: For new swipes, commit data from the PREVIOUS assistant message
// This ensures the LLM gets context from BEFORE the message being regenerated,
// not the message itself (which would cause time/story to advance incorrectly)
for (let i = messageIndex - 1; i >= 0; i--) {
const prevMessage = currentChat[i];
if (!prevMessage.is_user && prevMessage.extra?.rpg_companion_swipes) {
const prevSwipeId = prevMessage.swipe_id || 0;
const prevSwipeData = prevMessage.extra.rpg_companion_swipes[prevSwipeId];
if (prevSwipeData) {
// console.log('[RPG Companion] 🔵 Committing tracker data from PREVIOUS message at index', i);
committedTrackerData.userStats = prevSwipeData.userStats || null;
committedTrackerData.infoBox = prevSwipeData.infoBox || null;
committedTrackerData.characterThoughts = prevSwipeData.characterThoughts || null;
} else {
// Previous message has no swipe data - clear committed data
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
break;
}
// If we hit index 0 without finding a previous assistant message, clear committed data
if (i === 0) {
// console.log('[RPG Companion] 🔵 No previous assistant message found - clearing committed data');
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
}
// Edge case: if messageIndex is 0 (first message being swiped), clear committed data
if (messageIndex === 0) {
// console.log('[RPG Companion] 🔵 Swiping first message - clearing committed data');
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
}
// For new swipes, also update lastGeneratedData to match committed data
// This ensures the UI shows the "before" state while waiting for the new response
lastGeneratedData.userStats = committedTrackerData.userStats;
lastGeneratedData.infoBox = committedTrackerData.infoBox;
lastGeneratedData.characterThoughts = committedTrackerData.characterThoughts;
// Parse user stats for display if available
if (committedTrackerData.userStats) {
parseUserStats(committedTrackerData.userStats);
}
} else {
// This is navigating to an EXISTING swipe - don't change the flag
// console.log('[RPG Companion] 🔵 EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe);
}
// Load RPG data for this existing swipe for DISPLAY purposes
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
if (isPendingNewSwipe) {
lastGeneratedData.characterThoughts = null;
}
// Load swipe data into lastGeneratedData for display
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load saved swipe data for the active swipe only.
// Using the current-swipe helper here avoids falling back to another
// stored swipe payload and showing stale tracker state.
if (swipeData) {
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
// Normalize characterThoughts to string format (for backward compatibility with old object format)
if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') {
lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2);
} else {
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
}
// Sync extensionSettings.userStats so stat bars reflect this swipe
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
// console.log('[RPG Companion] 🔄 Loaded swipe data for swipe:', currentSwipeId);
} else {
// console.log('[RPG Companion] ℹ️ No stored data for swipe:', currentSwipeId);
}
}
// Re-render the panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update widget strips with the newly loaded swipe data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
}
/**
* Event handler for when a message is deleted.
* Restores RPG state from the last assistant message with RPG data,
* or clears state if no messages remain.
*/
export function onMessageDeleted(messageIndex) {
export function onMessageDeleted() {
if (!extensionSettings.enabled) {
return;
}
// console.log('[RPG Companion] 🗑️ EVENT: onMessageDeleted at index:', messageIndex);
// Invalidate any pending or in-flight separate-mode generation so
// its result is not applied to the (now-changed) chat tail.
incrementSeparateGenerationId();
const context = getContext();
const currentChat = context.chat;
// If chat is empty, clear all RPG state
if (!currentChat || currentChat.length === 0) {
// console.log('[RPG Companion] 🗑️ Chat is empty - clearing RPG state');
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
// Clear parsed stats from extensionSettings
if (extensionSettings.userStats) {
extensionSettings.userStats = null;
}
// Re-render empty panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays (removes any remaining)
updateChatThoughts();
// Save the cleared state
saveChatData();
return;
}
// Find the last assistant message with RPG data
const currentChat = getContext().chat || [];
let lastAssistantIndex = -1;
for (let i = currentChat.length - 1; i >= 0; i--) {
const message = currentChat[i];
if (!message.is_user && message.extra?.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
const swipeData = message.extra.rpg_companion_swipes[swipeId];
if (swipeData) {
// Check if this is the same data we already have displayed
const sameUserStats = lastGeneratedData.userStats === swipeData.userStats;
const sameInfoBox = lastGeneratedData.infoBox === swipeData.infoBox;
const sameThoughts = lastGeneratedData.characterThoughts === swipeData.characterThoughts;
if (sameUserStats && sameInfoBox && sameThoughts) {
// console.log('[RPG Companion] 🗑️ RPG state already matches last message - no restore needed');
return;
}
// console.log('[RPG Companion] 🗑️ Restoring RPG state from message index', i, 'swipe', swipeId);
// Restore state from this message
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// Also update committed data so next generation uses correct context
committedTrackerData.userStats = swipeData.userStats || null;
committedTrackerData.infoBox = swipeData.infoBox || null;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// Re-render panels with restored data
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Save the restored state
saveChatData();
return;
if (!currentChat[i].is_user && !currentChat[i].is_system) {
lastAssistantIndex = i;
break;
}
}
syncDisplayedTrackerStateFromChat();
// After the display state has been rebuilt, restore generation context from
// the assistant message immediately before the new tail message so the next
// generation uses the correct N-1 tracker state.
if (lastAssistantIndex !== -1) {
commitTrackerDataFromPriorMessage(lastAssistantIndex);
}
// No assistant message with RPG data found - clear state
// console.log('[RPG Companion] 🗑️ No assistant message with RPG data found - clearing state');
lastGeneratedData.userStats = null;
lastGeneratedData.infoBox = null;
lastGeneratedData.characterThoughts = null;
committedTrackerData.userStats = null;
committedTrackerData.infoBox = null;
committedTrackerData.characterThoughts = null;
// Clear parsed stats
if (extensionSettings.userStats) {
extensionSettings.userStats = null;
}
// Re-render empty panels
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// Save the cleared state
saveChatData();
}
@@ -0,0 +1,550 @@
/**
* Thought-based Character Expressions for the below-chat Present Characters panel.
*
* Derives portrait expressions from the current Present Characters thoughts
* payload, while keeping SillyTavern's native Character Expressions widget
* independent from the below-chat panel.
*/
import { getContext } from '../../../../../../extensions.js';
import {
extensionSettings,
thoughtBasedExpressionPortraits,
setThoughtBasedExpressionPortraits
} from '../../core/state.js';
import {
getCurrentMessageSwipeTrackerData,
saveChatData,
setMessageSwipeTrackerField
} from '../../core/persistence.js';
import { isUsableThoughtBasedExpressionSrc } from '../../utils/thoughtBasedExpressionPortraits.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters
} from '../../utils/presentCharacters.js';
import {
classifyExpressionText,
clearExpressionsCompatibilityCache,
getExpressionClassificationSettingsSignature,
getExpressionPortraitSettingsSignature,
getExpressionsSettingsSignature,
isExpressionsExtensionEnabled,
resolveSpriteFolderNameForCharacter,
resolveExpressionPortraitForCharacter
} from '../../utils/sillyTavernExpressions.js';
const OFF_SCENE_THOUGHT_PATTERN = /\b(not\s+(currently\s+)?(in|at|present|in\s+the)\s+(the\s+)?(scene|area|room|location|vicinity))\b|\b(off[\s-]?scene)\b|\b(not\s+present)\b|\b(absent)\b|\b(away\s+from\s+(the\s+)?scene)\b/i;
const CHAT_CHANGE_RETRY_DELAYS = [0, 80, 220, 500];
const REFRESH_DEBOUNCE_DELAY = 80;
const THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION = 1;
const THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD = 'thoughtBasedExpressions';
let hiddenExpressionStyleElement = null;
let thoughtBasedExpressionsRefreshHandler = null;
let scheduledRefreshTimer = null;
let activeRefreshRunId = 0;
let lastCompletedRefreshSignature = null;
let lastExpressionSettingsSignature = null;
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
}
function shouldHideNativeExpressionDisplay() {
return extensionSettings.enabled === true && extensionSettings.hideDefaultExpressionDisplay === true;
}
function shouldUseThoughtBasedExpressions() {
return extensionSettings.enabled === true
&& extensionSettings.enableThoughtBasedExpressions === true
&& extensionSettings.showAlternatePresentCharactersPanel === true;
}
function notifyThoughtBasedExpressionsConsumers() {
thoughtBasedExpressionsRefreshHandler?.();
}
function getHideStyleCss() {
return `
#expression-image,
#expression-holder,
.expression-holder,
[data-expression-container],
#expression-image img,
#expression-holder img,
.expression-holder img,
[data-expression-container] img {
position: absolute !important;
left: -10000px !important;
top: 0 !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
`;
}
function hideNativeExpressionDisplay() {
if (hiddenExpressionStyleElement?.isConnected) {
return;
}
const styleElement = document.createElement('style');
styleElement.id = 'rpg-hidden-native-expression-display-style';
styleElement.textContent = getHideStyleCss();
document.head.appendChild(styleElement);
hiddenExpressionStyleElement = styleElement;
}
function showNativeExpressionDisplay() {
if (hiddenExpressionStyleElement?.isConnected) {
hiddenExpressionStyleElement.remove();
} else {
document.getElementById('rpg-hidden-native-expression-display-style')?.remove();
}
hiddenExpressionStyleElement = null;
}
function updateNativeExpressionDisplayVisibility() {
if (shouldHideNativeExpressionDisplay()) {
hideNativeExpressionDisplay();
} else {
showNativeExpressionDisplay();
}
}
function clearScheduledRefresh() {
if (scheduledRefreshTimer !== null) {
clearTimeout(scheduledRefreshTimer);
scheduledRefreshTimer = null;
}
}
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(item => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
function normalizeThoughtPayload(payload) {
if (!payload) {
return null;
}
if (typeof payload === 'object') {
return stableStringify(payload);
}
if (typeof payload !== 'string') {
return String(payload);
}
const trimmed = payload.trim();
if (!trimmed) {
return null;
}
try {
return stableStringify(JSON.parse(trimmed));
} catch {
return trimmed.replace(/\r\n/g, '\n');
}
}
function normalizeExpressionLabel(label) {
return String(label || '').trim().toLowerCase();
}
function arePortraitMapsEqual(left, right) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every(key => left[key] === right[key]);
}
function applyThoughtBasedExpressionPortraits(nextPortraits) {
if (arePortraitMapsEqual(thoughtBasedExpressionPortraits, nextPortraits)) {
return false;
}
setThoughtBasedExpressionPortraits(nextPortraits);
return true;
}
function purgeInvalidThoughtBasedExpressionPortraits() {
const nextPortraits = {};
for (const [characterName, src] of Object.entries(thoughtBasedExpressionPortraits)) {
if (isUsableThoughtBasedExpressionSrc(src)) {
nextPortraits[characterName] = src;
}
}
return applyThoughtBasedExpressionPortraits(nextPortraits);
}
function getMessageThoughtPayload(message) {
if (!message || message.is_user) {
return null;
}
const swipeData = getCurrentMessageSwipeTrackerData(message);
return normalizeThoughtPayload(swipeData?.characterThoughts ?? null);
}
function findThoughtSourceMessageInfo(characterThoughtsData) {
const chatMessages = getContext()?.chat || [];
const currentThoughts = normalizeThoughtPayload(characterThoughtsData);
let fallback = null;
for (let i = chatMessages.length - 1; i >= 0; i--) {
const message = chatMessages[i];
if (!message || message.is_user || message.is_system) {
continue;
}
const swipeData = getCurrentMessageSwipeTrackerData(message);
if (!swipeData) {
continue;
}
const sourceInfo = {
message,
messageIndex: i,
swipeId: Number(message.swipe_id ?? 0),
swipeData
};
if (!fallback) {
fallback = sourceInfo;
}
const messageThoughts = getMessageThoughtPayload(message);
if (currentThoughts && messageThoughts === currentThoughts) {
return sourceInfo;
}
}
return currentThoughts ? null : fallback;
}
function isThoughtBasedExpressionsCache(candidate) {
return !!(
candidate
&& typeof candidate === 'object'
&& !Array.isArray(candidate)
&& candidate.version === THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION
&& candidate.entries
&& typeof candidate.entries === 'object'
&& !Array.isArray(candidate.entries)
);
}
function getSwipeThoughtBasedExpressionsCache(sourceInfo) {
const directCache = sourceInfo?.swipeData?.[THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD];
return isThoughtBasedExpressionsCache(directCache) ? directCache : null;
}
function areThoughtBasedExpressionsCachesEqual(left, right) {
return stableStringify(left) === stableStringify(right);
}
function getThoughtBasedExpressionEntries(characterThoughtsData) {
const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts;
if (thoughtsConfig?.enabled === false) {
return [];
}
if (!characterThoughtsData) {
return [];
}
const presentCharacters = parsePresentCharacters(characterThoughtsData);
return presentCharacters
.map(character => ({
name: String(character?.name || '').trim(),
thought: String(character?.ThoughtsContent || '').trim()
}))
.filter(character => character.name && character.thought && !OFF_SCENE_THOUGHT_PATTERN.test(character.thought));
}
function buildRefreshSignature(thoughtEntries, expressionsSettingsSignature) {
return JSON.stringify({
expressionsSettingsSignature,
thoughtEntries: thoughtEntries.map(entry => ({
name: normalizeName(entry.name),
thought: entry.thought,
spriteFolderName: resolveSpriteFolderNameForCharacter(entry.name)
}))
});
}
async function refreshThoughtBasedExpressions({ force = false } = {}) {
updateNativeExpressionDisplayVisibility();
if (!extensionSettings.enabled) {
showNativeExpressionDisplay();
return;
}
if (!shouldUseThoughtBasedExpressions()) {
return;
}
if (!isExpressionsExtensionEnabled()) {
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
const portraitsChanged = applyThoughtBasedExpressionPortraits({});
if (portraitsChanged) {
saveChatData();
}
notifyThoughtBasedExpressionsConsumers();
return;
}
const expressionsSettingsSignature = getExpressionsSettingsSignature();
if (expressionsSettingsSignature !== lastExpressionSettingsSignature) {
clearExpressionsCompatibilityCache();
lastExpressionSettingsSignature = expressionsSettingsSignature;
lastCompletedRefreshSignature = null;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback: true });
const thoughtEntries = getThoughtBasedExpressionEntries(characterThoughtsData);
const refreshSignature = buildRefreshSignature(thoughtEntries, expressionsSettingsSignature);
if (!force && refreshSignature === lastCompletedRefreshSignature) {
return;
}
const sourceInfo = findThoughtSourceMessageInfo(characterThoughtsData);
const cachedThoughtBasedExpressions = getSwipeThoughtBasedExpressionsCache(sourceInfo);
const cachedEntries = cachedThoughtBasedExpressions?.entries && typeof cachedThoughtBasedExpressions.entries === 'object' && !Array.isArray(cachedThoughtBasedExpressions.entries)
? cachedThoughtBasedExpressions.entries
: {};
const currentThoughtsSignature = normalizeThoughtPayload(characterThoughtsData);
const classificationSettingsSignature = getExpressionClassificationSettingsSignature();
const portraitSettingsSignature = getExpressionPortraitSettingsSignature();
const runId = ++activeRefreshRunId;
const nextPortraits = {};
const nextCacheEntries = {};
for (const entry of thoughtEntries) {
const portraitKey = normalizeName(entry.name);
if (!portraitKey) {
continue;
}
const spriteFolderName = resolveSpriteFolderNameForCharacter(entry.name);
const cachedEntry = cachedEntries[portraitKey] && typeof cachedEntries[portraitKey] === 'object'
? cachedEntries[portraitKey]
: null;
const previousSrc = nextPortraits[portraitKey] || thoughtBasedExpressionPortraits[portraitKey] || null;
const canReuseExpression = cachedEntry
&& cachedEntry.thought === entry.thought
&& cachedEntry.classificationSettingsSignature === classificationSettingsSignature
&& cachedEntry.spriteFolderName === spriteFolderName
&& typeof cachedEntry.expression === 'string';
const expression = canReuseExpression
? normalizeExpressionLabel(cachedEntry.expression)
: normalizeExpressionLabel(await classifyExpressionText(entry.thought, { characterName: entry.name }));
if (runId !== activeRefreshRunId) {
return;
}
const canReusePortrait = cachedEntry
&& cachedEntry.thought === entry.thought
&& cachedEntry.expression === expression
&& cachedEntry.portraitSettingsSignature === portraitSettingsSignature
&& cachedEntry.spriteFolderName === spriteFolderName
&& cachedEntry.portraitResolved === true;
const portraitSrc = canReusePortrait
? (isUsableThoughtBasedExpressionSrc(cachedEntry.portraitSrc) ? cachedEntry.portraitSrc : null)
: await resolveExpressionPortraitForCharacter(entry.name, expression, { previousSrc });
if (runId !== activeRefreshRunId) {
return;
}
if (isUsableThoughtBasedExpressionSrc(portraitSrc)) {
nextPortraits[portraitKey] = portraitSrc;
}
nextCacheEntries[portraitKey] = {
name: entry.name,
thought: entry.thought,
spriteFolderName,
classificationSettingsSignature,
portraitSettingsSignature,
expression,
portraitSrc: isUsableThoughtBasedExpressionSrc(portraitSrc) ? portraitSrc : null,
portraitResolved: true
};
}
if (runId !== activeRefreshRunId) {
return;
}
let cacheChanged = false;
if (sourceInfo) {
const nextCache = {
version: THOUGHT_BASED_EXPRESSIONS_CACHE_VERSION,
thoughtsSignature: currentThoughtsSignature,
entries: nextCacheEntries
};
if (!areThoughtBasedExpressionsCachesEqual(cachedThoughtBasedExpressions, nextCache)) {
setMessageSwipeTrackerField(sourceInfo.message, sourceInfo.swipeId, THOUGHT_BASED_EXPRESSIONS_CACHE_FIELD, nextCache);
cacheChanged = true;
}
}
lastCompletedRefreshSignature = refreshSignature;
const portraitsChanged = applyThoughtBasedExpressionPortraits(nextPortraits);
if (portraitsChanged || cacheChanged) {
saveChatData();
}
if (portraitsChanged) {
notifyThoughtBasedExpressionsConsumers();
}
}
export function setThoughtBasedExpressionsRefreshHandler(handler) {
thoughtBasedExpressionsRefreshHandler = typeof handler === 'function' ? handler : null;
}
export function queueThoughtBasedExpressionsUpdate({ immediate = false, force = false } = {}) {
clearScheduledRefresh();
const runRefresh = () => {
refreshThoughtBasedExpressions({ force }).catch(error => {
console.warn('[RPG Companion] Thought-based expressions update failed:', error);
});
};
if (immediate) {
runRefresh();
return;
}
scheduledRefreshTimer = setTimeout(() => {
scheduledRefreshTimer = null;
runRefresh();
}, REFRESH_DEBOUNCE_DELAY);
}
export function initThoughtBasedExpressions() {
const purged = purgeInvalidThoughtBasedExpressionPortraits();
updateNativeExpressionDisplayVisibility();
if (purged) {
saveChatData();
notifyThoughtBasedExpressionsConsumers();
}
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
}
}
export function onThoughtBasedExpressionsChatChanged() {
if (!extensionSettings.enabled) {
showNativeExpressionDisplay();
return;
}
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
const purged = purgeInvalidThoughtBasedExpressionPortraits();
if (purged) {
saveChatData();
notifyThoughtBasedExpressionsConsumers();
}
for (const delay of CHAT_CHANGE_RETRY_DELAYS) {
setTimeout(() => {
updateNativeExpressionDisplayVisibility();
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
} else {
notifyThoughtBasedExpressionsConsumers();
}
}, delay);
}
}
export function onThoughtBasedExpressionsSettingChanged(enabled) {
updateNativeExpressionDisplayVisibility();
if (enabled) {
const purged = purgeInvalidThoughtBasedExpressionPortraits();
if (purged) {
saveChatData();
notifyThoughtBasedExpressionsConsumers();
}
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
} else {
notifyThoughtBasedExpressionsConsumers();
}
return;
}
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
notifyThoughtBasedExpressionsConsumers();
}
export function onAlternatePresentCharactersVisibilityChanged() {
updateNativeExpressionDisplayVisibility();
if (shouldUseThoughtBasedExpressions()) {
queueThoughtBasedExpressionsUpdate({ immediate: true, force: true });
return;
}
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
}
export function onHideDefaultExpressionDisplaySettingChanged(enabled) {
extensionSettings.hideDefaultExpressionDisplay = enabled === true;
updateNativeExpressionDisplayVisibility();
setTimeout(() => updateNativeExpressionDisplayVisibility(), 0);
setTimeout(() => updateNativeExpressionDisplayVisibility(), 120);
}
export function clearThoughtBasedExpressionsCache() {
clearScheduledRefresh();
activeRefreshRunId += 1;
lastCompletedRefreshSignature = null;
lastExpressionSettingsSignature = null;
clearExpressionsCompatibilityCache();
showNativeExpressionDisplay();
}
+578
View File
@@ -0,0 +1,578 @@
/**
* Equipment Actions Module
* Handles all user interactions with the equipment system
*/
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderUserStats } from '../rendering/userStats.js';
import { EQUIPMENT_CATEGORIES, escapeHtml } from '../equipment/constants.js';
import { i18n } from '../../core/i18n.js';
/**
* Check if a given slot is currently occupied by any item
* @param {string} slotId - Slot to check
* @param {Array} items - Equipment items array
* @returns {boolean}
*/
function isSlotOccupied(slotId, items) {
return items.some(item => item.slot === slotId);
}
/**
* Find the first available slot for a given equipment type
* @param {string} type - Equipment type (helmet, ring, accessory, etc.)
* @param {Array} items - Equipment items array
* @returns {string|null} Available slot ID or null if all full
*/
function findAvailableSlot(type, items) {
const category = EQUIPMENT_CATEGORIES[type];
if (!category) return null;
return category.slots.find(slot => !isSlotOccupied(slot, items)) || null;
}
/**
* Get the slot ID currently assigned to an item
* @param {Object} item - Equipment item
* @returns {string|null}
*/
function getItemSlot(item) {
return item.slot || null;
}
/**
* Convert old specific slot type (e.g. 'ring3') to generic category (e.g. 'ring')
* @param {string} type - Equipment type
* @returns {string} Generic type
*/
function normalizeType(type) {
if (!type) return type;
if (type.startsWith('ring') && type !== 'ring') return 'ring';
if (type.startsWith('accessory') && type !== 'accessory') return 'accessory';
return type;
}
/**
* Migrate old item types in the entire items array (for v6 → v7 migration)
* @param {Array} items - Equipment items array
*/
function migrateItemTypes(items) {
for (const item of items) {
if (item.type) {
item.type = normalizeType(item.type);
}
}
}
/**
* Generate a unique ID for equipment items
* @returns {string} Unique ID
*/
function generateItemId() {
return 'eq_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
}
/**
* Updates lastGeneratedData and committedTrackerData to include current equipment state
*/
function updateEquipmentData() {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
jsonData.equipment = JSON.parse(JSON.stringify(equipment));
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Equipment] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fallback: rebuild text format
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
let text = '';
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
text += `${stat.name}: ${value}%\n`;
}
if (config.statusSection?.enabled) {
if (config.statusSection.showMoodEmoji) {
text += `${stats.mood}: `;
}
text += `${stats.conditions || 'None'}\n`;
}
// Include equipment data in fallback
const equipped = equipment.items.filter(item => item.slot);
if (equipped.length > 0) {
text += `\nEquipment:\n`;
for (const item of equipped) {
const bonuses = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `${key.toUpperCase()}+${val}`)
.join(' ');
text += `- ${item.slot}: ${item.name}${bonuses ? ' (' + bonuses + ')' : ''}\n`;
}
}
lastGeneratedData.userStats = text.trim();
committedTrackerData.userStats = text.trim();
}
/**
* Shows the equipment creation modal
*/
export function showCreateModal() {
const modalHtml = generateCreateModalHTML();
$('body').append(modalHtml);
const $modal = $('#rpg-equipment-modal');
$modal.hide().fadeIn(200);
$('#rpg-eq-name').focus();
initStatCheckboxes();
}
/**
* Shows the equipment edit modal
* @param {string} itemId - ID of the item to edit
*/
export function showEditModal(itemId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const item = equipment.items.find(i => i.id === itemId);
if (!item) return;
const modalHtml = generateEditModalHTML(item);
$('body').append(modalHtml);
const $modal = $('#rpg-equipment-modal');
$modal.hide().fadeIn(200);
initStatCheckboxes();
}
/**
* Closes the equipment modal
*/
export function closeModal() {
$('#rpg-equipment-modal').fadeOut(150, function() {
$(this).remove();
});
}
/**
* Generates HTML for the create modal
* @returns {string} Modal HTML
*/
function generateCreateModalHTML() {
const attributes = getAvailableAttributes();
const typeOptions = generateTypeOptions(null);
const statCheckboxes = generateStatCheckboxes(attributes, null);
return `
<div id="rpg-equipment-modal" class="rpg-equipment-modal" role="dialog" aria-modal="true">
<div class="rpg-equipment-modal-content">
<header class="rpg-equipment-modal-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span>${i18n.getTranslation('equipment.createItemTitle') || 'Create Equipment'}</span>
</h3>
<button id="rpg-equipment-modal-close" class="rpg-popup-close" type="button">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-equipment-modal-body">
<div class="rpg-equipment-form-group">
<label for="rpg-eq-name">${i18n.getTranslation('equipment.name') || 'Name'}:</label>
<input type="text" id="rpg-eq-name" class="rpg-input" maxlength="50" placeholder="${i18n.getTranslation('equipment.namePlaceholder') || 'Enter equipment name...'}" />
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-type">${i18n.getTranslation('equipment.type') || 'Type'}:</label>
<select id="rpg-eq-type" class="rpg-select">${typeOptions}</select>
</div>
<div class="rpg-equipment-form-group">
<label>${i18n.getTranslation('equipment.stats') || 'Stats'}:</label>
<div class="rpg-eq-stats-grid">${statCheckboxes}</div>
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-description">${i18n.getTranslation('equipment.description') || 'Description'}:</label>
<textarea id="rpg-eq-description" class="rpg-textarea" maxlength="200" rows="2" placeholder="${i18n.getTranslation('equipment.descriptionPlaceholder') || 'Enter description (optional)...'}"></textarea>
</div>
</div>
<footer class="rpg-equipment-modal-footer">
<button id="rpg-equipment-modal-cancel" class="rpg-btn-secondary" type="button">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="rpg-equipment-modal-save" class="rpg-btn-primary" type="button">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</footer>
</div>
</div>
`;
}
/**
* Generates HTML for the edit modal
* @param {Object} item - The item to edit
* @returns {string} Modal HTML
*/
function generateEditModalHTML(item) {
const attributes = getAvailableAttributes();
const typeOptions = generateTypeOptions(item.type);
const statCheckboxes = generateStatCheckboxes(attributes, item);
return `
<div id="rpg-equipment-modal" class="rpg-equipment-modal" role="dialog" aria-modal="true">
<div class="rpg-equipment-modal-content">
<header class="rpg-equipment-modal-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span>${i18n.getTranslation('equipment.editItemTitle') || 'Edit Equipment'}</span>
</h3>
<button id="rpg-equipment-modal-close" class="rpg-popup-close" type="button">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-equipment-modal-body">
<div class="rpg-equipment-form-group">
<label for="rpg-eq-name">${i18n.getTranslation('equipment.name') || 'Name'}:</label>
<input type="text" id="rpg-eq-name" class="rpg-input" maxlength="50" value="${escapeHtml(item.name)}" placeholder="${i18n.getTranslation('equipment.namePlaceholder') || 'Enter equipment name...'}" />
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-type">${i18n.getTranslation('equipment.type') || 'Type'}:</label>
<select id="rpg-eq-type" class="rpg-select">${typeOptions}</select>
</div>
<div class="rpg-equipment-form-group">
<label>${i18n.getTranslation('equipment.stats') || 'Stats'}:</label>
<div class="rpg-eq-stats-grid">${statCheckboxes}</div>
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-description">${i18n.getTranslation('equipment.description') || 'Description'}:</label>
<textarea id="rpg-eq-description" class="rpg-textarea" maxlength="200" rows="2" placeholder="${i18n.getTranslation('equipment.descriptionPlaceholder') || 'Enter description (optional)...'}">${escapeHtml(item.description || '')}</textarea>
</div>
</div>
<footer class="rpg-equipment-modal-footer">
<button id="rpg-equipment-modal-cancel" class="rpg-btn-secondary" type="button">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="rpg-equipment-modal-save" class="rpg-btn-primary" type="button" data-edit-id="${item.id}">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</footer>
</div>
</div>
`;
}
/**
* Generates type dropdown options
* @param {string|null} selectedType - Currently selected type or null
* @returns {string} HTML options
*/
function generateTypeOptions(selectedType) {
const types = Object.entries(EQUIPMENT_CATEGORIES).map(([value, def]) => ({
value,
label: def.slots[0] === value
? def.slots[0].charAt(0).toUpperCase() + def.slots[0].slice(1)
: value.charAt(0).toUpperCase() + value.slice(1) + (def.slots.length > 1 ? ` (max ${def.maxEquipped})` : '')
}));
return types.map(type => {
const selected = selectedType === type.value ? 'selected' : '';
return `<option value="${type.value}" ${selected}>${type.label}</option>`;
}).join('');
}
/**
* Generates stat checkboxes HTML
* @param {string[]} attributes - Available attribute IDs
* @param {Object|null} item - Current item for editing or null
* @returns {string} HTML for stat checkboxes
*/
function generateStatCheckboxes(attributes, item) {
return attributes.map(attr => {
const checked = item && item.stats && item.stats[attr] > 0;
const val = checked ? item.stats[attr] : 1;
return `
<label class="rpg-eq-stat-checkbox">
<input type="checkbox" class="rpg-eq-stat-check" data-attr="${attr}" ${checked ? 'checked' : ''} />
<span class="rpg-eq-stat-check-label">${attr.toUpperCase()}</span>
<input type="number" class="rpg-eq-stat-value-input" data-attr="${attr}" value="${val}" min="1" max="99" ${!checked ? 'disabled' : ''} />
</label>
`;
}).join('');
}
/**
* Gets available RPG attribute IDs from config
* @returns {string[]} Array of attribute IDs
*/
function getAvailableAttributes() {
const config = extensionSettings.trackerConfig?.userStats || {};
const rpgAttributes = config.rpgAttributes || [];
return rpgAttributes
.filter(attr => attr && attr.enabled && attr.id)
.map(attr => attr.id);
}
/**
* Initializes stat checkbox change handlers
*/
function initStatCheckboxes() {
$('.rpg-eq-stat-check').on('change', function() {
const $input = $(this).siblings('.rpg-eq-stat-value-input');
$input.prop('disabled', !$(this).prop('checked'));
});
}
/**
* Saves the equipment item from the modal form
*/
export function saveEquipmentItem() {
const name = $('#rpg-eq-name').val().trim();
const type = $('#rpg-eq-type').val();
const description = $('#rpg-eq-description').val().trim();
const editId = $('#rpg-equipment-modal-save').data('edit-id');
if (!name) {
$('#rpg-eq-name').focus();
return;
}
// Collect stats
const stats = {};
$('.rpg-eq-stat-check:checked').each(function() {
const attr = $(this).data('attr');
const val = parseInt($(this).siblings('.rpg-eq-stat-value-input').val()) || 1;
stats[attr] = Math.max(1, Math.min(99, val));
});
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
// Migrate old types (ring1-ring10 -> ring, accessory1-3 -> accessory)
migrateItemTypes(equipment.items);
if (editId) {
// Edit existing item
const item = equipment.items.find(i => i.id === editId);
if (item) {
const wasEquipped = !!item.slot;
item.name = name;
item.type = normalizeType(type);
item.stats = stats;
item.description = description;
// If type changed and item was equipped, re-equip to valid slot for new type
if (wasEquipped) {
const newCategory = EQUIPMENT_CATEGORIES[item.type];
if (newCategory && !newCategory.slots.includes(item.slot)) {
// Old slot is invalid for new type, find a new one
const newSlot = findAvailableSlot(item.type, equipment.items.filter(i => i.id !== editId));
item.slot = newSlot || null;
}
}
}
} else {
// Create new item
const newItem = {
id: generateItemId(),
name: name,
type: normalizeType(type),
stats: stats,
description: description,
slot: null
};
equipment.items.push(newItem);
// Auto-equip to first available slot
const availableSlot = findAvailableSlot(newItem.type, equipment.items);
if (availableSlot) {
newItem.slot = availableSlot;
} else {
console.warn(`[RPG Equipment] Created "${name}" but no available slot for type "${newItem.type}". Item added to inventory unequipped.`);
}
}
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
closeModal();
renderEquipment();
renderUserStats();
}
/**
* Equips an item to its designated slot
* @param {string} itemId - ID of the item to equip
*/
export function equipItem(itemId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const item = equipment.items.find(i => i.id === itemId);
if (!item) return;
const type = normalizeType(item.type);
item.type = type;
const category = EQUIPMENT_CATEGORIES[type];
if (!category) return;
const availableSlot = findAvailableSlot(type, equipment.items);
if (!availableSlot) {
console.warn(`[RPG Equipment] No available slot for type "${type}". All ${EQUIPMENT_CATEGORIES[type].maxEquipped} slots are full.`);
return;
}
item.slot = availableSlot;
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Unequips an item from a slot
* @param {string} slotId - The slot to unequip from
*/
export function unequipItem(slotId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const item = equipment.items.find(i => i.slot === slotId);
if (item) {
item.slot = null;
}
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Deletes an equipment item
* @param {string} itemId - ID of the item to delete
*/
export function deleteItem(itemId) {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
// Unequip if currently equipped
const item = equipment.items.find(i => i.id === itemId);
if (item && item.slot) {
item.slot = null;
}
// Remove from items array
equipment.items = equipment.items.filter(i => i.id !== itemId);
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Calculates total equipment bonuses per attribute from currently equipped items
* @returns {Object} Map of attribute ID to total bonus value
*/
export function getEquipmentBonuses() {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const bonuses = {};
const items = equipment.items || [];
for (const item of items) {
if (!item.slot || !item.stats) continue;
for (const [attr, val] of Object.entries(item.stats)) {
if (val > 0) {
bonuses[attr] = (bonuses[attr] || 0) + val;
}
}
}
return bonuses;
}
/**
* Initializes all event listeners for equipment interactions
*/
export function initEquipmentEventListeners() {
// Show create modal
$(document).on('click', '[data-action="show-create-modal"]', function(e) {
e.preventDefault();
showCreateModal();
});
// Equip item from inventory
$(document).on('click', '[data-action="equip"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
equipItem(itemId);
});
// Unequip item from slot
$(document).on('click', '[data-action="unequip"]', function(e) {
e.preventDefault();
const slot = $(this).closest('.rpg-equipment-slot').data('slot');
unequipItem(slot);
});
// Edit item
$(document).on('click', '[data-action="edit-item"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
showEditModal(itemId);
});
// Delete item
$(document).on('click', '[data-action="delete-item"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
deleteItem(itemId);
});
// Modal close button
$(document).on('click', '#rpg-equipment-modal-close', closeModal);
// Modal cancel button
$(document).on('click', '#rpg-equipment-modal-cancel', closeModal);
// Modal save button
$(document).on('click', '#rpg-equipment-modal-save', saveEquipmentItem);
// Close modal on backdrop click
$(document).on('click', '#rpg-equipment-modal', function(e) {
if (e.target === this) {
closeModal();
}
});
// Enter key to save in modal inputs
$(document).on('keypress', '#rpg-eq-name', function(e) {
if (e.which === 13) {
e.preventDefault();
saveEquipmentItem();
}
});
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Equipment Rendering Module
* Handles UI rendering for the equipment grid and item creation
*/
import { extensionSettings, $equipmentContainer } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js';
import { updateIfChanged } from './renderUtils.js';
/**
* Renders a single equipment slot
* @param {Object} slotDef - Slot definition from SLOTS_LIST
* @param {Object|null} item - The equipped item or null
* @returns {string} HTML for the slot
*/
function renderSlot(slotDef, item) {
const slotId = slotDef.id;
const slotName = i18n.getTranslation(`equipment.slots.${slotId}`) || slotId.replace(/(\d+)/, ' $1');
const equippedClass = item ? 'equipped' : '';
if (item) {
const statsText = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
.join('');
return `
<div class="rpg-equipment-slot ${equippedClass}" data-slot="${slotId}" data-item-id="${item.id}">
<div class="rpg-equipment-slot-header">
<i class="fa-solid ${slotDef.icon}"></i>
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
<button class="rpg-equipment-unequip-btn" data-action="unequip" title="${i18n.getTranslation('equipment.unequip') || 'Unequip'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="rpg-equipment-item-name">${escapeHtml(item.name)}</div>
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
${item.description ? `<div class="rpg-equipment-description">${escapeHtml(item.description)}</div>` : ''}
<div class="rpg-equipment-item-actions">
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
<i class="fa-solid fa-pen"></i>
</button>
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
}
return `
<div class="rpg-equipment-slot" data-slot="${slotId}">
<div class="rpg-equipment-slot-header">
<i class="fa-solid ${slotDef.icon}"></i>
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
</div>
<div class="rpg-equipment-empty">${i18n.getTranslation('equipment.emptySlot') || 'Empty'}</div>
</div>
`;
}
/**
* Generates the full equipment section HTML
* @returns {string} Complete HTML for the equipment section
*/
function generateEquipmentHTML() {
const equipment = extensionSettings.userStats?.equipment || { items: [], slots: {} };
const slots = equipment.slots || {};
const items = equipment.items || [];
let html = '<div class="rpg-equipment-container">';
// Header with add button
html += `
<div class="rpg-equipment-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span data-i18n-key="equipment.title">${i18n.getTranslation('equipment.title') || 'Equipment'}</span>
</h3>
<button class="rpg-equipment-add-btn" data-action="show-create-modal" title="${i18n.getTranslation('equipment.createItem') || 'Create new equipment'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('equipment.createItem') || 'Create Equipment'}
</button>
</div>
`;
// Equipment grid
html += '<div class="rpg-equipment-grid">';
// Render each slot
for (const slotDef of SLOTS_LIST) {
const item = items.find(i => i.slot === slotDef.id);
html += renderSlot(slotDef, item);
}
html += '</div>';
// Inventory list (items not currently equipped)
const unequipped = items.filter(item => !item.slot);
if (unequipped.length > 0) {
html += '<div class="rpg-equipment-inventory">';
html += `<h4>${i18n.getTranslation('equipment.inventoryTitle') || 'Inventory'}</h4>`;
html += '<div class="rpg-equipment-inventory-list">';
for (const item of unequipped) {
const category = EQUIPMENT_CATEGORIES[item.type];
const statsText = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
.join('');
html += `
<div class="rpg-equipment-inventory-item" data-item-id="${item.id}">
<div class="rpg-equipment-inventory-item-header">
<i class="fa-solid ${category ? category.icon : 'fa-circle'}"></i>
<span class="rpg-equipment-inventory-item-name">${escapeHtml(item.name)}</span>
<span class="rpg-equipment-inventory-item-type">${category ? (i18n.getTranslation(`equipment.types.${item.type}`) || item.type) : escapeHtml(item.type)}</span>
</div>
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
<div class="rpg-equipment-item-actions">
<button class="rpg-equipment-equip-btn" data-action="equip" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.equip') || 'Equip'}">
<i class="fa-solid fa-hand"></i>
</button>
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
<i class="fa-solid fa-pen"></i>
</button>
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
}
html += '</div>';
html += '</div>';
}
html += '</div>';
return html;
}
/**
* Main equipment rendering function
* Gets data from state/settings and updates DOM directly.
*/
export function renderEquipment() {
if (!$equipmentContainer || !extensionSettings.showEquipment) {
return;
}
const html = generateEquipmentHTML();
updateIfChanged($equipmentContainer, html, 'rpg-equipment');
// Re-apply translations
i18n.applyTranslations($equipmentContainer[0]);
}
+23 -19
View File
@@ -10,11 +10,12 @@ import {
committedTrackerData,
$infoBoxContainer
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { saveChatData, setMessageSwipeTrackerField } from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
import { isItemLocked } from '../generation/lockManager.js';
import { repairJSON } from '../../utils/jsonRepair.js';
import { updateFabWidgets } from '../ui/mobile.js';
import { updateIfChanged } from './renderUtils.js';
/**
* Helper to generate lock icon HTML if setting is enabled
@@ -363,9 +364,9 @@ export function renderInfoBox() {
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-calendar-widget">
${dateLockIconHtml}
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="Click to edit">${monthDisplay}</div>
<div class="rpg-calendar-day" title="Click to edit"><span class="rpg-calendar-day-text rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}">${weekdayDisplay}</span></div>
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="Click to edit">${yearDisplay}</div>
<div class="rpg-calendar-top rpg-editable" contenteditable="true" data-field="month" data-full-value="${data.month || ''}" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${monthDisplay}</div>
<div class="rpg-calendar-day" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}"><span class="rpg-calendar-day-text rpg-editable" contenteditable="true" data-field="weekday" data-full-value="${data.weekday || ''}">${weekdayDisplay}</span></div>
<div class="rpg-calendar-year rpg-editable" contenteditable="true" data-field="year" data-full-value="${data.year || ''}" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${yearDisplay}</div>
</div>
`);
}
@@ -373,14 +374,14 @@ export function renderInfoBox() {
// Weather widget - show if enabled
if (config?.widgets?.weather?.enabled) {
const weatherEmoji = data.weatherEmoji || '🌤️';
const weatherForecast = data.weatherForecast || 'Weather';
const weatherForecast = data.weatherForecast || i18n.getTranslation('infoBox.weatherFallback') || 'Weather unknown';
const weatherLockIconHtml = getLockIconHtml('infoBox', 'weather');
row1Widgets.push(`
<div class="rpg-dashboard-widget rpg-weather-widget">
${weatherLockIconHtml}
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="Click to edit emoji">${weatherEmoji}</div>
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="Click to edit">${weatherForecast}</div>
<div class="rpg-weather-icon rpg-editable" contenteditable="true" data-field="weatherEmoji" title="${i18n.getTranslation('userStats.clickToEditEmoji') || 'Click to edit emoji'}">${weatherEmoji}</div>
<div class="rpg-weather-forecast rpg-editable" contenteditable="true" data-field="weatherForecast" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${weatherForecast}</div>
</div>
`);
}
@@ -429,7 +430,7 @@ export function renderInfoBox() {
<div class="rpg-thermometer-fill" style="height: ${tempPercent}%; background: ${tempColor}"></div>
</div>
</div>
<div class="rpg-temp-value rpg-editable" contenteditable="true" data-field="temperature" title="Click to edit">${tempDisplay}</div>
<div class="rpg-temp-value rpg-editable" contenteditable="true" data-field="temperature" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${tempDisplay}</div>
</div>
`);
}
@@ -464,9 +465,9 @@ export function renderInfoBox() {
</div>
</div>
<div class="rpg-time-range">
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="Click to edit start time">${timeStartDisplay}</div>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeStart" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${timeStartDisplay}</div>
<span class="rpg-time-separator">→</span>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeEnd" title="Click to edit end time">${timeEndDisplay}</div>
<div class="rpg-time-value rpg-editable" contenteditable="true" data-field="timeEnd" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${timeEndDisplay}</div>
</div>
</div>
`);
@@ -481,7 +482,7 @@ export function renderInfoBox() {
// Row 2: Location widget (full width) - show if enabled
if (config?.widgets?.location?.enabled) {
const locationDisplay = data.location || 'Location';
const locationDisplay = data.location || i18n.getTranslation('infoBox.locationFallback') || 'Unknown location';
const locationLockIconHtml = getLockIconHtml('infoBox', 'location');
html += `
@@ -491,7 +492,7 @@ export function renderInfoBox() {
<div class="rpg-map-bg">
<div class="rpg-map-marker">📍</div>
</div>
<div class="rpg-location-text rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${locationDisplay}</div>
<div class="rpg-location-text rpg-editable" contenteditable="true" data-field="location" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${locationDisplay}</div>
</div>
</div>
`;
@@ -541,7 +542,7 @@ export function renderInfoBox() {
<div class="rpg-notebook-ring"></div>
<div class="rpg-notebook-ring"></div>
</div>
<div class="rpg-notebook-title" data-i18n-key="infobox.recentEvents.title">${i18n.getTranslation('infobox.recentEvents.title')}</div>
<div class="rpg-notebook-title" data-i18n-key="infobox.recentEvents.title">${i18n.getTranslation('infobox.recentEvents.title') || 'Recent Events'}</div>
<div class="rpg-notebook-lines">
`;
@@ -550,7 +551,7 @@ export function renderInfoBox() {
html += `
<div class="rpg-notebook-line">
<span class="rpg-bullet">•</span>
<span class="rpg-event-text rpg-editable" contenteditable="true" data-field="event${i + 1}" title="Click to edit">${validEvents[i]}</span>
<span class="rpg-event-text rpg-editable" contenteditable="true" data-field="event${i + 1}" title="${i18n.getTranslation('infoBox.clickToEdit') || 'Click to edit'}">${validEvents[i]}</span>
</div>
`;
}
@@ -560,7 +561,7 @@ export function renderInfoBox() {
html += `
<div class="rpg-notebook-line rpg-event-add">
<span class="rpg-bullet">+</span>
<span class="rpg-event-text rpg-editable rpg-event-placeholder" contenteditable="true" data-field="event${i + 1}" title="Click to add event" data-i18n-key="infobox.recentEvents.addEventPlaceholder">${i18n.getTranslation('infobox.recentEvents.addEventPlaceholder')}</span>
<span class="rpg-event-text rpg-editable rpg-event-placeholder" contenteditable="true" data-field="event${i + 1}" title="Click to add event" data-i18n-key="infobox.recentEvents.addEventPlaceholder">${i18n.getTranslation('infobox.recentEvents.addEventPlaceholder') || 'Click to add event'}</span>
</div>
`;
}
@@ -575,8 +576,10 @@ export function renderInfoBox() {
// Close the scrollable content wrapper
html += '</div>';
$infoBoxContainer.html(html);
const domUpdated = updateIfChanged($infoBoxContainer, html, 'rpg-info-box');
// Only re-bind event handlers and apply dynamic styling if DOM was updated
if (domUpdated) {
// Add dynamic text scaling for location field
const updateLocationTextSize = ($element) => {
const text = $element.text();
@@ -652,13 +655,14 @@ export function renderInfoBox() {
// Update icon
$lockIcon.text(newLockState ? '🔒' : '🔓');
$lockIcon.attr('title', newLockState ? 'Locked - AI cannot change this' : 'Unlocked - AI can change this');
$lockIcon.attr('title', newLockState ? (i18n.getTranslation('infoBox.locked') || 'Locked') : (i18n.getTranslation('infoBox.unlocked') || 'Unlocked'));
$lockIcon.toggleClass('locked', newLockState);
// Save settings to persist lock state
saveSettings();
});
});
} // end if (domUpdated)
// Remove updating class after animation
if (extensionSettings.enableAnimations) {
@@ -989,7 +993,7 @@ export function updateInfoBoxField(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
// console.log('[RPG Companion] Updated infoBox in message swipe data');
}
}
@@ -1074,7 +1078,7 @@ function updateRecentEvent(field, value) {
if (message.extra && message.extra.rpg_companion_swipes) {
const swipeId = message.swipe_id || 0;
if (message.extra.rpg_companion_swipes[swipeId]) {
message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n');
setMessageSwipeTrackerField(message, swipeId, 'infoBox', updatedLines.join('\n'));
}
}
break;
+87 -78
View File
@@ -9,6 +9,8 @@ import { getInventoryRenderOptions, restoreFormStates } from '../interaction/inv
import { updateInventoryItem } from '../interaction/inventoryEdit.js';
import { parseItems } from '../../utils/itemParser.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { i18n } from '../../core/i18n.js';
import { updateIfChanged } from './renderUtils.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -25,7 +27,7 @@ function getLockIconHtml(tracker, path) {
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockTitle = isLocked ? i18n.getTranslation('global.locked') || 'Locked' : i18n.getTranslation('global.unlocked') || 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
@@ -47,19 +49,24 @@ export function getLocationId(locationName) {
* @returns {string} HTML for sub-tab navigation
*/
export function renderInventorySubTabs(activeTab = 'onPerson') {
const onPersonText = i18n.getTranslation('inventory.section.onPerson') || 'On Person';
const clothingText = i18n.getTranslation('inventory.section.clothing') || 'Clothing';
const storedText = i18n.getTranslation('inventory.section.stored') || 'Stored';
const assetsText = i18n.getTranslation('inventory.section.assets') || 'Assets';
return `
<div class="rpg-inventory-subtabs">
<button class="rpg-inventory-subtab ${activeTab === 'onPerson' ? 'active' : ''}" data-tab="onPerson">
On Person
${onPersonText}
</button>
<button class="rpg-inventory-subtab ${activeTab === 'clothing' ? 'active' : ''}" data-tab="clothing">
Clothing
${clothingText}
</button>
<button class="rpg-inventory-subtab ${activeTab === 'stored' ? 'active' : ''}" data-tab="stored">
Stored
${storedText}
</button>
<button class="rpg-inventory-subtab ${activeTab === 'assets' ? 'active' : ''}" data-tab="assets">
Assets
${assetsText}
</button>
</div>
`;
@@ -76,30 +83,30 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No items carried</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.onPerson.empty') || 'No items carried') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
return `
<div class="rpg-item-card" data-field="onPerson" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.onPerson.${item}`);
return `
<div class="rpg-item-row" data-field="onPerson" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="Remove item">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="onPerson" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
@@ -112,30 +119,30 @@ export function renderOnPersonView(onPersonItems, viewMode = 'list') {
return `
<div class="rpg-inventory-section" data-section="onPerson">
<div class="rpg-inventory-header">
<h4>Items Currently Carried</h4>
<h4>${i18n.getTranslation('inventory.onPerson.title') || 'Items Currently Carried'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="onPerson" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="Add new item">
<i class="fa-solid fa-plus"></i> Add Item
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="onPerson" title="${i18n.getTranslation('inventory.onPerson.addItemTitle') || 'Add new item'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.onPerson.addItemButton') || 'Add Item'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-onPerson" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-onPerson" placeholder="Enter item name..." />
<input type="text" class="rpg-inline-input" id="rpg-new-item-onPerson" placeholder="${i18n.getTranslation('inventory.onPerson.addItemPlaceholder') || 'Enter item name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="onPerson">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="onPerson">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -158,30 +165,30 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No clothing worn</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.clothing.empty') || 'No clothing worn') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
return `
<div class="rpg-item-card" data-field="clothing" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item">
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.clothing.${item}`);
return `
<div class="rpg-item-row" data-field="clothing" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="Remove item">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="clothing" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
@@ -194,30 +201,30 @@ export function renderClothingView(clothingItems, viewMode = 'list') {
return `
<div class="rpg-inventory-section" data-section="clothing">
<div class="rpg-inventory-header">
<h4>Clothing Worn</h4>
<h4>${i18n.getTranslation('inventory.clothing.title') || 'Clothing & Armor'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="clothing" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="clothing" title="Add new clothing item">
<i class="fa-solid fa-plus"></i> Add Clothing
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="clothing" title="${i18n.getTranslation('inventory.clothing.addItemTitle') || 'Add new clothing item'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.clothing.addItemButton') || 'Add Clothing'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-clothing" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-clothing" placeholder="Enter clothing item..." />
<input type="text" class="rpg-inline-input" id="rpg-new-item-clothing" placeholder="${i18n.getTranslation('inventory.clothing.addItemPlaceholder') || 'Enter clothing item...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="clothing">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="clothing">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -242,30 +249,30 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
let html = `
<div class="rpg-inventory-section" data-section="stored">
<div class="rpg-inventory-header">
<h4>Storage Locations</h4>
<h4>${i18n.getTranslation('inventory.stored.title') || 'Storage Locations'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="stored" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-location" title="Add new storage location">
<i class="fa-solid fa-plus"></i> Add Location
<button class="rpg-inventory-add-btn" data-action="add-location" title="${i18n.getTranslation('inventory.stored.addLocationTitle') || 'Add new storage location'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.stored.addLocationButton') || 'Add Location'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-location-form" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-location-name" placeholder="Enter location name..." />
<input type="text" class="rpg-inline-input" id="rpg-new-location-name" placeholder="${i18n.getTranslation('inventory.stored.addLocationPlaceholder') || 'Enter location name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-location">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-location">
<i class="fa-solid fa-check"></i> Save
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</div>
</div>
@@ -274,7 +281,7 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
if (locations.length === 0) {
html += `
<div class="rpg-inventory-empty">
No storage locations yet. Click "Add Location" to create one.
${i18n.getTranslation('inventory.stored.empty') || 'No storage locations yet. Click "Add Location" to create one.'}
</div>
`;
} else {
@@ -286,30 +293,30 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No items stored here</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.stored.noItems') || 'No items stored here') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}.${item}`);
return `
<div class="rpg-item-card" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.stored.${location}.${item}`);
return `
<div class="rpg-item-row" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="Remove item">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="stored" data-location="${escapeHtml(location)}" data-index="${index}" title="${i18n.getTranslation('global.removeItem') || 'Remove item'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
@@ -327,20 +334,20 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
</button>
<h5 class="rpg-storage-name">${escapeHtml(location)}</h5>
<div class="rpg-storage-actions">
<button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}" title="Remove this storage location">
<button class="rpg-inventory-remove-btn" data-action="remove-location" data-location="${escapeHtml(location)}" title="${i18n.getTranslation('inventory.stored.removeLocationTitle') || 'Remove this storage location'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
<div class="rpg-storage-content" ${isCollapsed ? 'style="display:none;"' : ''}>
<div class="rpg-inline-form" id="rpg-add-item-form-stored-${locationId}" style="display: none;">
<input type="text" class="rpg-inline-input rpg-location-item-input" data-location="${escapeHtml(location)}" placeholder="Enter item name..." />
<input type="text" class="rpg-inline-input rpg-location-item-input" data-location="${escapeHtml(location)}" placeholder="${i18n.getTranslation('inventory.addItemPlaceholder') || 'Enter item name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="stored" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="stored" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -348,19 +355,19 @@ export function renderStoredView(stored, collapsedLocations = [], viewMode = 'li
${itemsHtml}
</div>
<div class="rpg-storage-add-item-container">
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="Add item to this location">
<i class="fa-solid fa-plus"></i> Add Item
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="stored" data-location="${escapeHtml(location)}" title="${i18n.getTranslation('inventory.stored.addItemToLocationTitle') || 'Add item to this location'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.stored.addItemButton') || 'Add Item'}
</button>
</div>
</div>
<div class="rpg-inline-confirmation" id="rpg-remove-confirm-${locationId}" style="display: none;">
<p>Remove "${escapeHtml(location)}"? This will delete all items stored there.</p>
<p>${(i18n.getTranslation('inventory.stored.removeLocationConfirm') || 'Remove "{location}"? This will delete all items stored there.').replace('{location}', escapeHtml(location))}</p>
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-remove-location" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-confirm" data-action="confirm-remove-location" data-location="${escapeHtml(location)}">
<i class="fa-solid fa-check"></i> Confirm
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.confirm') || 'Confirm'}
</button>
</div>
</div>
@@ -388,30 +395,30 @@ export function renderAssetsView(assets, viewMode = 'list') {
let itemsHtml = '';
if (items.length === 0) {
itemsHtml = '<div class="rpg-inventory-empty">No assets owned</div>';
itemsHtml = '<div class="rpg-inventory-empty">' + (i18n.getTranslation('inventory.assets.empty') || 'No assets owned') + '</div>';
} else {
if (viewMode === 'grid') {
// Grid view: card-style items
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
return `
<div class="rpg-item-card" data-field="assets" data-index="${index}">
${lockIconHtml}
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="${i18n.getTranslation('inventory.assets.removeAssetTitle') || 'Remove asset'}">
<i class="fa-solid fa-times"></i>
</button>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
</div>
`}).join('');
} else {
// List view: full-width rows
itemsHtml = items.map((item, index) => {
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets[${index}]`);
const lockIconHtml = getLockIconHtml('userStats', `inventory.assets.${item}`);
return `
<div class="rpg-item-row" data-field="assets" data-index="${index}">
${lockIconHtml}
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="Click to edit">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="Remove asset">
<span class="rpg-item-name rpg-editable" contenteditable="true" data-field="assets" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(item)}</span>
<button class="rpg-item-remove" data-action="remove-item" data-field="assets" data-index="${index}" title="${i18n.getTranslation('inventory.assets.removeAssetTitle') || 'Remove asset'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
@@ -424,30 +431,30 @@ export function renderAssetsView(assets, viewMode = 'list') {
return `
<div class="rpg-inventory-section" data-section="assets">
<div class="rpg-inventory-header">
<h4>Vehicles, Property & Major Possessions</h4>
<h4>${i18n.getTranslation('inventory.assets.title') || 'Vehicles, Property & Major Possessions'}</h4>
<div class="rpg-inventory-header-actions">
<div class="rpg-view-toggle">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="List view">
<button class="rpg-view-btn ${viewMode === 'list' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="list" title="${i18n.getTranslation('global.listView') || 'List view'}">
<i class="fa-solid fa-list"></i>
</button>
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="Grid view">
<button class="rpg-view-btn ${viewMode === 'grid' ? 'active' : ''}" data-action="switch-view" data-field="assets" data-view="grid" title="${i18n.getTranslation('global.gridView') || 'Grid view'}">
<i class="fa-solid fa-th"></i>
</button>
</div>
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="Add new asset">
<i class="fa-solid fa-plus"></i> Add Asset
<button class="rpg-inventory-add-btn" data-action="add-item" data-field="assets" title="${i18n.getTranslation('inventory.assets.addItemTitle') || 'Add new asset'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('inventory.assets.addAssetButton') || 'Add Asset'}
</button>
</div>
</div>
<div class="rpg-inventory-content">
<div class="rpg-inline-form" id="rpg-add-item-form-assets" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-item-assets" placeholder="Enter asset name..." />
<input type="text class="rpg-inline-input" id="rpg-new-item-assets" placeholder="${i18n.getTranslation('inventory.assets.addAssetPlaceholder') || 'Enter asset name...'}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-item" data-field="assets">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-item" data-field="assets">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -456,8 +463,7 @@ export function renderAssetsView(assets, viewMode = 'list') {
</div>
<div class="rpg-inventory-hint">
<i class="fa-solid fa-info-circle"></i>
Assets include vehicles (cars, motorcycles), property (homes, apartments),
and major equipment (workshop tools, special items).
${i18n.getTranslation('inventory.assets.description') || 'Assets include vehicles (cars, motorcycles), property (homes, apartments), and major equipment (workshop tools, special items).'}
</div>
</div>
</div>
@@ -589,11 +595,13 @@ export function renderInventory() {
// Generate HTML and update DOM
const html = generateInventoryHTML(inventory, options);
$inventoryContainer.html(html);
const domUpdated = updateIfChanged($inventoryContainer, html, 'rpg-inventory');
// Restore form states after re-rendering (fixes Bug #1)
restoreFormStates();
// Only re-bind event handlers if DOM was actually updated
if (domUpdated) {
// Event listener for editing item names (mobile-friendly contenteditable)
$inventoryContainer.find('.rpg-item-name.rpg-editable').on('blur', function() {
const field = $(this).data('field');
@@ -627,6 +635,7 @@ export function renderInventory() {
// Save settings
saveSettings();
});
} // end if (domUpdated)
}
/**
+51 -30
View File
@@ -6,6 +6,8 @@
import { extensionSettings, $questsContainer, committedTrackerData, lastGeneratedData } from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { i18n } from '../../core/i18n.js';
import { updateIfChanged } from './renderUtils.js';
/**
* Syncs the current extensionSettings.quests to committedTrackerData.userStats
@@ -44,7 +46,7 @@ function getLockIconHtml(tracker, path) {
const isLocked = isItemLocked(tracker, path);
const lockIcon = isLocked ? '🔒' : '🔓';
const lockTitle = isLocked ? 'Locked' : 'Unlocked';
const lockTitle = isLocked ? i18n.getTranslation('global.locked') || 'Locked' : i18n.getTranslation('global.unlocked') || 'Unlocked';
const lockedClass = isLocked ? ' locked' : '';
return `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="${tracker}" data-path="${path}" title="${lockTitle}">${lockIcon}</span>`;
}
@@ -66,13 +68,16 @@ function escapeHtml(text) {
* @returns {string} HTML for sub-tab navigation
*/
export function renderQuestsSubTabs(activeTab = 'main') {
const mainText = i18n.getTranslation('quests.section.main') || 'Main Quest';
const optionalText = i18n.getTranslation('quests.section.optional') || 'Optional Quests';
return `
<div class="rpg-quests-subtabs">
<button class="rpg-quests-subtab ${activeTab === 'main' ? 'active' : ''}" data-tab="main">
Main Quest
${mainText}
</button>
<button class="rpg-quests-subtab ${activeTab === 'optional' ? 'active' : ''}" data-tab="optional">
Optional Quests
${optionalText}
</button>
</div>
`;
@@ -86,13 +91,18 @@ export function renderQuestsSubTabs(activeTab = 'main') {
export function renderMainQuestView(mainQuest) {
const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : '';
const hasQuest = questDisplay.length > 0;
const mainTitle = i18n.getTranslation('quests.main.title') || 'Main Quests';
const mainHint = i18n.getTranslation('quests.main.hint') || 'The main quest represents your primary objective in the story.';
const mainEmptyText = i18n.getTranslation('quests.main.empty') || 'No active main quests';
const addQuestButtonText = i18n.getTranslation('quests.main.addQuestButton') || 'Add Quest';
const addQuestPlaceholderText = i18n.getTranslation('quests.main.addQuestPlaceholder') || 'Enter main quest title...';
return `
<div class="rpg-quest-section">
<div class="rpg-quest-header">
<h3 class="rpg-quest-section-title">Main Quests</h3>
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="Add main quests">
<i class="fa-solid fa-plus"></i> Add Quest
<h3 class="rpg-quest-section-title">${mainTitle}</h3>
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="${i18n.getTranslation('quests.main.addQuestTitle') || 'Add main quests'}">
<i class="fa-solid fa-plus"></i> ${addQuestButtonText}
</button>` : ''}
</div>
<div class="rpg-quest-content">
@@ -101,10 +111,10 @@ export function renderMainQuestView(mainQuest) {
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-edit-quest" data-field="main">
<i class="fa-solid fa-check"></i> Save
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</div>
</div>
@@ -112,32 +122,32 @@ export function renderMainQuestView(mainQuest) {
${getLockIconHtml('userStats', 'quests.main')}
<div class="rpg-quest-title">${escapeHtml(questDisplay)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="${i18n.getTranslation('quests.editQuestTitle') || 'Edit quest'}">
<i class="fa-solid fa-edit"></i>
</button>
<button class="rpg-quest-remove" data-action="remove-quest" data-field="main" title="Complete/Remove quest">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="main" title="${i18n.getTranslation('quests.removeQuestTitle') || 'Complete/Remove quest'}">
<i class="fa-solid fa-check"></i>
</button>
</div>
</div>
` : `
<div class="rpg-inline-form" id="rpg-add-quest-form-main" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="Enter main quests title..." />
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="${addQuestPlaceholderText}" />
<div class="rpg-inline-actions">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="main">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="main">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
<div class="rpg-quest-empty">No active main quests</div>
<div class="rpg-quest-empty">${mainEmptyText}</div>
`}
</div>
<div class="rpg-quest-hint">
<i class="fa-solid fa-lightbulb"></i>
The main quests represent your primary objective in the story.
${mainHint}
</div>
</div>
`;
@@ -150,18 +160,23 @@ export function renderMainQuestView(mainQuest) {
*/
export function renderOptionalQuestsView(optionalQuests) {
const quests = optionalQuests.filter(q => q && q !== 'None');
const optionalTitle = i18n.getTranslation('quests.optional.title') || 'Optional Quests';
const optionalHint = i18n.getTranslation('quests.optional.hint') || 'Optional quests are side objectives that complement your main story.';
const optionalEmptyText = i18n.getTranslation('quests.optional.empty') || 'No active optional quests';
const addQuestButtonText = i18n.getTranslation('quests.optional.addQuestButton') || 'Add Quest';
const addQuestPlaceholderText = i18n.getTranslation('quests.optional.addQuestPlaceholder') || 'Enter optional quest title...';
let questsHtml = '';
if (quests.length === 0) {
questsHtml = '<div class="rpg-quest-empty">No active optional quests</div>';
questsHtml = `<div class="rpg-quest-empty">${optionalEmptyText}</div>`;
} else {
questsHtml = quests.map((quest, index) => {
return `
<div class="rpg-quest-item" data-field="optional" data-index="${index}">
${getLockIconHtml('userStats', `quests.optional[${index}]`)}
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div>
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="${i18n.getTranslation('global.clickToEdit') || 'Click to edit'}">${escapeHtml(quest)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="${i18n.getTranslation('quests.removeQuestTitle') || 'Complete/Remove quest'}">
<i class="fa-solid fa-check"></i>
</button>
</div>
@@ -172,20 +187,20 @@ export function renderOptionalQuestsView(optionalQuests) {
return `
<div class="rpg-quest-section">
<div class="rpg-quest-header">
<h3 class="rpg-quest-section-title">Optional Quests</h3>
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="Add optional quest">
<i class="fa-solid fa-plus"></i> Add Quest
<h3 class="rpg-quest-section-title">${optionalTitle}</h3>
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="${i18n.getTranslation('quests.optional.addQuestTitle') || 'Add optional quest'}">
<i class="fa-solid fa-plus"></i> ${addQuestButtonText}
</button>
</div>
<div class="rpg-quest-content">
<div class="rpg-inline-form" id="rpg-add-quest-form-optional" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="Enter optional quest title..." />
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="${addQuestPlaceholderText}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="optional">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="optional">
<i class="fa-solid fa-check"></i> Add
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.add') || 'Add'}
</button>
</div>
</div>
@@ -194,7 +209,7 @@ export function renderOptionalQuestsView(optionalQuests) {
</div>
<div class="rpg-quest-hint">
<i class="fa-solid fa-info-circle"></i>
Optional quests are side objectives that complement your main story.
${optionalHint}
</div>
</div>
</div>
@@ -205,15 +220,19 @@ export function renderOptionalQuestsView(optionalQuests) {
* Main render function for quests
*/
export function renderQuests() {
if (!extensionSettings.showInventory || !$questsContainer) {
if (!extensionSettings.showQuests || !$questsContainer) {
return;
}
// Get current sub-tab from container or default to 'main'
const activeSubTab = $questsContainer.data('active-subtab') || 'main';
// Get quests data
const mainQuest = extensionSettings.quests.main || 'None';
// Get quests data - extract value if it's a locked object
let mainQuest = extensionSettings.quests.main || 'None';
// Recursively extract value if it's nested objects
while (typeof mainQuest === 'object' && mainQuest.value !== undefined) {
mainQuest = mainQuest.value;
}
const optionalQuests = extensionSettings.quests.optional || [];
// Build HTML
@@ -229,11 +248,13 @@ export function renderQuests() {
}
html += '</div></div>';
$questsContainer.html(html);
const domUpdated = updateIfChanged($questsContainer, html, 'rpg-quests');
// Attach event handlers
// Attach event handlers only if DOM was updated
if (domUpdated) {
attachQuestEventHandlers();
}
}
/**
* Attach event handlers for quest interactions
+163
View File
@@ -0,0 +1,163 @@
/**
* Rendering Utilities Module
* Provides performance optimization utilities for the rendering pipeline:
* - requestAnimationFrame batching for render calls
* - Lightweight change detection to skip unnecessary DOM updates
*/
/**
* Cached HTML content per container for change detection.
* @type {Map<string, string>}
*/
const htmlCache = new Map();
/**
* Pending render functions queued for the next animation frame.
* @type {Array<Function>}
*/
let pendingRenders = [];
/**
* Whether a requestAnimationFrame is already scheduled.
* @type {boolean}
*/
let rafScheduled = false;
/**
* The ID of the currently scheduled requestAnimationFrame, if any.
* @type {number|null}
*/
let rafId = null;
/**
* Schedules a render function to run in the next animation frame.
* Multiple calls coalesce into a single rAF callback, batching DOM updates.
*
* @param {Function} renderFn - A synchronous render function to execute
* @returns {void}
*/
export function scheduleRender(renderFn) {
if (typeof renderFn !== 'function') return;
pendingRenders.push(renderFn);
if (!rafScheduled) {
rafScheduled = true;
rafId = requestAnimationFrame(flushRenders);
}
}
/**
* Flushes all pending render functions in a single animation frame.
* @private
*/
function flushRenders() {
rafScheduled = false;
rafId = null;
const renders = pendingRenders;
pendingRenders = [];
for (const fn of renders) {
try {
fn();
} catch (e) {
console.error('[RPG Companion] Render error:', e);
}
}
}
/**
* Cancels any pending scheduled renders.
* Useful when a newer render call makes older ones stale.
*
* @returns {void}
*/
export function cancelScheduledRenders() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
pendingRenders = [];
rafScheduled = false;
}
/**
* Updates a jQuery container's HTML only if the new content differs from the cached version.
* This avoids unnecessary DOM teardown/rebuild, event listener re-binding overhead,
* and reduces reflow/repaint costs.
*
* @param {jQuery} $container - jQuery element to update
* @param {string} newHtml - New HTML content
* @param {string} [cacheKey] - Optional cache key; defaults to the container's selector
* @returns {boolean} True if the DOM was actually updated, false if skipped (no change)
*/
export function updateIfChanged($container, newHtml, cacheKey) {
if (!$container || !$container.length) return false;
if (!cacheKey) {
cacheKey = $container.selector || $container.attr('id') || String($container[0]);
}
const cached = htmlCache.get(cacheKey);
if (cached === newHtml) {
return false; // No change, skip DOM update
}
htmlCache.set(cacheKey, newHtml);
$container.html(newHtml);
return true;
}
/**
* Clears the HTML cache for a specific key, forcing the next render to update.
* Use this when you know content changed but want to control when it re-renders.
*
* @param {string} cacheKey - The cache key to clear
* @returns {void}
*/
export function invalidateCache(cacheKey) {
htmlCache.delete(cacheKey);
}
/**
* Clears the entire HTML cache.
* Use sparingly — e.g., on chat change or full reset.
*
* @returns {void}
*/
export function clearAllCaches() {
htmlCache.clear();
}
/**
* Wraps an existing render function with change detection.
* The wrapped function generates HTML, compares it to the cache,
* and only updates the DOM if content changed.
*
* @param {Function} renderFn - Function that returns { $container, html } or calls updateIfChanged internally
* @param {string} cacheKey - Cache key for this render target
* @returns {Function} Wrapped render function
*/
export function withChangeDetection(renderFn, cacheKey) {
return function () {
return renderFn();
};
}
/**
* Batches multiple DOM updates into a single reflow by using
* document fragment pattern. Reduces layout thrashing.
*
* @param {Function} callback - Function that receives a document fragment and populates it
* @param {jQuery} $container - Container to append the fragment to
* @returns {void}
*/
export function batchDomUpdates(callback, $container) {
if (!$container || !$container.length) return;
const fragment = document.createDocumentFragment();
callback(fragment);
$container[0].appendChild(fragment);
}
File diff suppressed because it is too large. Load diff
+60 -74
View File
@@ -5,22 +5,30 @@
import { getContext } from '../../../../../../extensions.js';
import { user_avatar } from '../../../../../../../script.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
$userStatsContainer,
FALLBACK_AVATAR_DATA_URI
} from '../../core/state.js';
import {
saveSettings,
saveChatData,
updateMessageSwipeData
} from '../../core/persistence.js';
import { extensionSettings, lastGeneratedData, committedTrackerData, $userStatsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
import { getStatBarColors } from '../ui/theme.js';
import { getEquipmentBonuses } from '../interaction/equipmentActions.js';
import { updateIfChanged } from './renderUtils.js';
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return baseName
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Builds the user stats text string using custom stat names
@@ -106,7 +114,7 @@ function updateUserStatsData() {
// Then, add any other numeric stats from extensionSettings that aren't in config
// (these could be custom stats the AI added or disabled stats)
const customFields = config.statusSection?.customFields || [];
const excludeFields = new Set(['mood', ...customFields.map(f => f.toLowerCase()), 'inventory', 'skills', 'level']);
const excludeFields = new Set(['mood', ...customFields.map(f => toFieldKey(f)), 'inventory', 'skills', 'level']);
Object.entries(stats).forEach(([key, value]) => {
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
statsArray.push({
@@ -126,7 +134,7 @@ function updateUserStatsData() {
// Add all custom status fields
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldKey = toFieldKey(fieldName);
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
}
@@ -197,7 +205,7 @@ export function renderUserStats() {
if (!lastGeneratedData.userStats && !committedTrackerData.userStats) {
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html('<div class="rpg-inventory-empty">No statuses generated yet</div>');
$userStatsContainer.html('<div class="rpg-inventory-empty">' + (i18n.getTranslation('userStats.empty') || 'No statuses generated yet') + '</div>');
return;
}
@@ -251,13 +259,14 @@ export function renderUserStats() {
}
}
// Create gradient from low to high color
const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`;
// Create gradient from low to high color with opacity
const colors = getStatBarColors();
const gradient = `linear-gradient(to right, ${colors.low}, ${colors.high})`;
// Check if stats bars section is locked
const isStatsLocked = isItemLocked('userStats', 'stats');
const lockIcon = isStatsLocked ? '🔒' : '🔓';
const lockTitle = isStatsLocked ? 'Locked - AI cannot change stats' : 'Unlocked - AI can change stats';
const lockTitle = isStatsLocked ? (i18n.getTranslation('userStats.statsLocked') || 'Stats locked') : (i18n.getTranslation('userStats.statsUnlocked') || 'Stats unlocked');
const lockedClass = isStatsLocked ? ' locked' : '';
let html = '<div class="rpg-stats-content">';
@@ -270,8 +279,8 @@ export function renderUserStats() {
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
<span class="rpg-user-name">${userName}</span>
${showLevel ? `<span style="opacity: 0.5;">|</span>
<span class="rpg-level-label">LVL</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${extensionSettings.level}</span>` : ''}
<span class="rpg-level-label">${i18n.getTranslation('userStats.level') || 'Level'}</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="${i18n.getTranslation('userStats.clickToEditLevel') || 'Click to edit level'}">${extensionSettings.level}</span>` : ''}
</div>
`;
@@ -304,11 +313,11 @@ export function renderUserStats() {
html += `
<div class="rpg-stat-row">
<span class="rpg-stat-label rpg-editable-stat-name" contenteditable="true" data-field="${stat.id}" title="Click to edit stat name">${stat.name}:</span>
<span class="rpg-stat-label rpg-editable-stat-name" contenteditable="true" data-field="${stat.id}" title="${i18n.getTranslation('userStats.clickToEditStatName') || 'Click to edit stat name'}">${stat.name}:</span>
<div class="rpg-stat-bar" style="background: ${gradient}">
<div class="rpg-stat-fill" style="width: ${100 - percentage}%"></div>
</div>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" data-max="${maxValue}" data-mode="${displayMode}" title="Click to edit">${displayValue}</span>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" data-max="${maxValue}" data-mode="${displayMode}" title="${i18n.getTranslation('userStats.clickToEditStatValue') || 'Click to edit stat value'}">${displayValue}</span>
</div>
`;
}
@@ -318,7 +327,7 @@ export function renderUserStats() {
if (config.statusSection.enabled) {
const isMoodLocked = isItemLocked('userStats', 'status');
const moodLockIcon = isMoodLocked ? '🔒' : '🔓';
const moodLockTitle = isMoodLocked ? 'Locked - AI cannot change mood' : 'Unlocked - AI can change mood';
const moodLockTitle = isMoodLocked ? (i18n.getTranslation('userStats.moodLocked') || 'Mood locked') : (i18n.getTranslation('userStats.moodUnlocked') || 'Mood unlocked');
const moodLockedClass = isMoodLocked ? ' locked' : '';
html += '<div class="rpg-mood">';
if (showLockIcons) {
@@ -326,19 +335,22 @@ export function renderUserStats() {
}
if (config.statusSection.showMoodEmoji) {
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>`;
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="${i18n.getTranslation('userStats.clickToEditEmoji') || 'Click to edit emoji'}">${stats.mood}</div>`;
}
// Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
for (const fieldName of config.statusSection.customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldKey = toFieldKey(fieldName);
let fieldValue = stats[fieldKey] || 'None';
// Handle array format (from JSON)
if (Array.isArray(fieldValue)) {
fieldValue = fieldValue.join(', ') || 'None';
} else if (typeof fieldValue === 'string') {
// Strip brackets if present (from JSON array format)
if (typeof fieldValue === 'string') {
fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="Click to edit ${fieldName}">${fieldValue}</div>`;
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="${i18n.getTranslation('userStats.clickToEdit') || 'Click to edit'} ${fieldName}">${fieldValue}</div>`;
}
}
@@ -349,7 +361,7 @@ export function renderUserStats() {
if (config.skillsSection.enabled) {
const isSkillsLocked = isItemLocked('userStats', 'skills');
const skillsLockIcon = isSkillsLocked ? '🔒' : '🔓';
const skillsLockTitle = isSkillsLocked ? 'Locked - AI cannot change skills' : 'Unlocked - AI can change skills';
const skillsLockTitle = isSkillsLocked ? (i18n.getTranslation('userStats.skillsLocked') || 'Skills locked') : (i18n.getTranslation('userStats.skillsUnlocked') || 'Skills unlocked');
const skillsLockedClass = isSkillsLocked ? ' locked' : '';
let skillsValue = 'None';
// Handle JSON array format: [{name: "Art"}, {name: "Coding"}]
@@ -366,7 +378,7 @@ export function renderUserStats() {
}
html += `
<span class="rpg-skills-label">${config.skillsSection.label}:</span>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="Click to edit skills">${skillsValue}</div>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="${i18n.getTranslation('userStats.clickToEditSkills') || 'Click to edit skills'}">${skillsValue}</div>
</div>
`;
}
@@ -390,6 +402,7 @@ export function renderUserStats() {
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
if (enabledAttributes.length > 0) {
const equipmentBonuses = getEquipmentBonuses();
html += `
<div class="rpg-stats-right">
<div class="rpg-classic-stats">
@@ -398,12 +411,14 @@ export function renderUserStats() {
enabledAttributes.forEach(attr => {
const value = extensionSettings.classicStats[attr.id] !== undefined ? extensionSettings.classicStats[attr.id] : 10;
const bonus = equipmentBonuses[attr.id] || 0;
const bonusHtml = bonus > 0 ? `<span class="rpg-classic-stat-bonus" title="Equipment bonus: +${bonus}"> +${bonus}</span>` : '';
html += `
<div class="rpg-classic-stat" data-stat="${attr.id}">
<span class="rpg-classic-stat-label">${attr.name}</span>
<div class="rpg-classic-stat-buttons">
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="${attr.id}">−</button>
<span class="rpg-classic-stat-value">${value}</span>
<span class="rpg-classic-stat-value">${value}${bonusHtml}</span>
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="${attr.id}">+</button>
</div>
</div>
@@ -425,11 +440,13 @@ export function renderUserStats() {
// console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length);
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html(html);
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container');
// Use change detection to skip unnecessary DOM updates
const domUpdated = updateIfChanged($userStatsContainer, html, 'rpg-user-stats');
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container, updated:', domUpdated);
// Add event listeners for editable stat values
$('.rpg-editable-stat').on('blur', function() {
// Only re-bind event listeners if DOM was actually updated
if (domUpdated) {
$userStatsContainer.find('.rpg-editable-stat').on('blur', function () {
const field = $(this).data('field');
const mode = $(this).data('mode');
const maxValue = parseInt($(this).data('max')) || 100;
@@ -437,82 +454,61 @@ export function renderUserStats() {
let value;
if (mode === 'number') {
// In number mode, parse "X/MAX" or just "X"
const parts = textValue.split('/');
value = parseInt(parts[0]);
// Validate and clamp value between 0 and maxValue
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(maxValue, value));
} else {
// In percentage mode, parse "X%" or just "X"
value = parseInt(textValue.replace('%', ''));
// Validate and clamp value between 0 and 100
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(100, value));
}
// Update the setting
extensionSettings.userStats[field] = value;
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
// Re-render to update the bar and FAB widgets
renderUserStats();
updateFabWidgets();
});
// Add event listeners for mood/conditions editing
$('.rpg-mood-emoji.rpg-editable').on('blur', function() {
$userStatsContainer.find('.rpg-mood-emoji.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.mood = value || '😐';
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
$('.rpg-mood-conditions.rpg-editable').on('blur', function() {
$userStatsContainer.find('.rpg-mood-conditions.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
const fieldKey = $(this).data('field');
extensionSettings.userStats[fieldKey] = value || 'None';
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
// Add event listener for skills editing
$('.rpg-skills-value.rpg-editable').on('blur', function() {
$userStatsContainer.find('.rpg-skills-value.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.skills = value || 'None';
// Update userStats data (maintains JSON or text format)
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
// Add event listeners for stat name editing
$('.rpg-editable-stat-name').on('blur', function() {
$userStatsContainer.find('.rpg-editable-stat-name').on('blur', function () {
const field = $(this).data('field');
const value = $(this).text().trim().replace(':', '');
@@ -527,34 +523,28 @@ export function renderUserStats() {
}
extensionSettings.statNames[field] = value || extensionSettings.statNames[field];
saveSettings();
saveChatData();
// Re-render to update the display
renderUserStats();
});
// Add event listener for level editing
$('.rpg-level-value.rpg-editable').on('blur', function() {
$userStatsContainer.find('.rpg-level-value.rpg-editable').on('blur', function () {
let value = parseInt($(this).text().trim());
if (isNaN(value) || value < 1) {
value = 1;
}
// Set reasonable max level
value = Math.min(100, value);
extensionSettings.level = value;
saveSettings();
saveChatData();
updateMessageSwipeData();
// Re-render to update the display
renderUserStats();
});
// Prevent line breaks in level field
$('.rpg-level-value.rpg-editable').on('keydown', function(e) {
$userStatsContainer.find('.rpg-level-value.rpg-editable').on('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
$(this).blur();
@@ -562,7 +552,7 @@ export function renderUserStats() {
});
// Add event listener for section lock icon clicks (support both click and touch)
$('.rpg-section-lock-icon').on('click touchend', function(e) {
$userStatsContainer.find('.rpg-section-lock-icon').on('click touchend', function (e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
@@ -570,19 +560,15 @@ export function renderUserStats() {
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
// Toggle lock state
setItemLock(trackerType, itemPath, !currentlyLocked);
// Update icon
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? 'Locked - AI cannot change this section' : 'Unlocked - AI can change this section';
const newTitle = !currentlyLocked ? (i18n.getTranslation('infoBox.locked') || 'Locked') : (i18n.getTranslation('infoBox.unlocked') || 'Unlocked');
$icon.text(newIcon);
$icon.attr('title', newTitle);
// Toggle 'locked' class for persistent visibility
$icon.toggleClass('locked', !currentlyLocked);
// Save settings
saveSettings();
});
} // end if (domUpdated)
}
@@ -0,0 +1,177 @@
import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { getThoughtBasedExpressionPortraitForCharacter } from '../../utils/thoughtBasedExpressionPortraits.js';
import { getSafeImageSrc } from '../../utils/imageUrls.js';
import {
getPresentCharactersTrackerData,
parsePresentCharacters,
resolvePresentCharacterPortrait
} from '../../utils/presentCharacters.js';
const PANEL_ID = 'rpg-alt-present-characters';
function ensureAlternatePresentCharactersPanel() {
let $panel = $(`#${PANEL_ID}`);
if ($panel.length) {
return $panel;
}
$panel = $(`<div id="${PANEL_ID}" class="rpg-alt-present-characters" style="display:none;"></div>`);
const $sendForm = $('#send_form');
const $sheld = $('#sheld');
const $chat = $sheld.find('#chat');
if ($sendForm.length) {
$sendForm.before($panel);
} else if ($chat.length) {
$chat.after($panel);
} else if ($sheld.length) {
$sheld.append($panel);
} else {
$('body').append($panel);
}
return $panel;
}
function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const a = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function handlePortraitLoadError() {
this.style.opacity = '0.5';
$(this).off('error', handlePortraitLoadError);
}
function createAlternatePresentCharacterCard(character) {
const rawPortrait = (extensionSettings.enableThoughtBasedExpressions
? getThoughtBasedExpressionPortraitForCharacter(character.name)
: null) || resolvePresentCharacterPortrait(character.name);
const portrait = getSafeImageSrc(rawPortrait);
const name = String(character.name || '');
const $card = $('<div class="rpg-alt-present-character"></div>')
.attr('data-character-name', name)
.attr('title', name);
const $portrait = $('<div class="rpg-alt-present-character__portrait"></div>');
const $image = $('<img />')
.attr({
alt: name,
loading: 'lazy'
})
.on('error', handlePortraitLoadError);
if (portrait) {
$image.attr('src', portrait);
}
const $meta = $('<div class="rpg-alt-present-character__meta"></div>');
const $name = $('<div class="rpg-alt-present-character__name"></div>').text(name);
$portrait.append($image);
$meta.append($name);
$card.append($portrait, $meta);
return $card;
}
export function removeAlternatePresentCharactersPanel() {
$(`#${PANEL_ID}`).remove();
}
export function syncAlternatePresentCharactersTheme() {
const $panel = $(`#${PANEL_ID}`);
if (!$panel.length) {
return;
}
const theme = extensionSettings.theme || 'default';
$panel.css({
'--rpg-bg': '',
'--rpg-accent': '',
'--rpg-text': '',
'--rpg-highlight': '',
'--rpg-border': '',
'--rpg-shadow': ''
});
if (theme === 'default') {
$panel.removeAttr('data-theme');
return;
}
$panel.attr('data-theme', theme);
if (theme === 'custom') {
const colors = extensionSettings.customColors || {};
const bgColor = hexToRgba(colors.bg || '#1a1a2e', colors.bgOpacity ?? 100);
const accentColor = hexToRgba(colors.accent || '#16213e', colors.accentOpacity ?? 100);
const textColor = hexToRgba(colors.text || '#eaeaea', colors.textOpacity ?? 100);
const highlightColor = hexToRgba(colors.highlight || '#e94560', colors.highlightOpacity ?? 100);
const shadowColor = hexToRgba(colors.highlight || '#e94560', (colors.highlightOpacity ?? 100) * 0.5);
$panel.css({
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
});
}
}
export function renderAlternatePresentCharacters({ useCommittedFallback = true } = {}) {
if (!extensionSettings.enabled || !extensionSettings.showAlternatePresentCharactersPanel) {
removeAlternatePresentCharactersPanel();
return;
}
const characterThoughtsData = getPresentCharactersTrackerData({ useCommittedFallback });
if (!characterThoughtsData) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const presentCharacters = parsePresentCharacters(characterThoughtsData);
if (presentCharacters.length === 0) {
const $panel = ensureAlternatePresentCharactersPanel();
$panel.empty().hide();
return;
}
const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters';
const $panel = ensureAlternatePresentCharactersPanel();
const $header = $('<div class="rpg-alt-present-characters__header"></div>');
const $headerTitle = $('<div class="rpg-alt-present-characters__title"></div>');
const $scroll = $('<div class="rpg-alt-present-characters__scroll"></div>');
const $track = $('<div class="rpg-alt-present-characters__track"></div>');
$headerTitle.append(
$('<i class="fa-solid fa-users" aria-hidden="true"></i>'),
$('<span></span>').text(title)
);
$header.append(
$headerTitle,
$('<div class="rpg-alt-present-characters__count"></div>').text(String(presentCharacters.length))
);
for (const character of presentCharacters) {
$track.append(createAlternatePresentCharacterCard(character));
}
$scroll.append($track);
$panel.empty().append($header, $scroll).show();
syncAlternatePresentCharactersTheme();
}
+34 -5
View File
@@ -5,6 +5,7 @@
import { i18n } from '../../core/i18n.js';
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { hexToRgba } from './theme.js';
/**
* Helper to parse time string and calculate clock hand angles
@@ -237,7 +238,9 @@ export function updateStripWidgets() {
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const lowOpacity = extensionSettings.statBarColorLowOpacity ?? 100;
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
const highOpacity = extensionSettings.statBarColorHighOpacity ?? 100;
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
@@ -246,13 +249,14 @@ function getStatColor(value) {
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
if (!lowRGB || !highRGB) return value > 50 ? hexToRgba(highColor, highOpacity) : hexToRgba(lowColor, lowOpacity);
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
const a = (lowOpacity + (highOpacity - lowOpacity) * percent) / 100;
return `rgb(${r}, ${g}, ${b})`;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
@@ -288,16 +292,18 @@ export function setupDesktopTabs() {
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $inventory = $('#rpg-inventory');
const $equipment = $('#rpg-equipment');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $equipment.length === 0 && $quests.length === 0) {
return;
}
// Build tab navigation dynamically based on enabled settings
const tabButtons = [];
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasEquipment = $equipment.length > 0 && extensionSettings.showEquipment;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Status tab (always present if any status content exists)
@@ -318,6 +324,16 @@ export function setupDesktopTabs() {
`);
}
// Equipment tab (only if enabled in settings)
if (hasEquipment) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="equipment">
<i class="fa-solid fa-shield-halved"></i>
<span data-i18n-key="equipment.title">Equipment</span>
</button>
`);
}
// Quests tab (only if enabled in settings)
if (hasQuests) {
tabButtons.push(`
@@ -333,6 +349,7 @@ export function setupDesktopTabs() {
// Create tab content containers
const $statusTab = $('<div class="rpg-tab-content active" data-tab-content="status"></div>');
const $inventoryTab = $('<div class="rpg-tab-content" data-tab-content="inventory"></div>');
const $equipmentTab = $('<div class="rpg-tab-content" data-tab-content="equipment"></div>');
const $questsTab = $('<div class="rpg-tab-content" data-tab-content="quests"></div>');
// Move sections into their respective tabs (detach to preserve event handlers)
@@ -357,6 +374,11 @@ export function setupDesktopTabs() {
// Only show if enabled (will be part of tab structure)
if (hasInventory) $inventory.show();
}
if ($equipment.length > 0) {
$equipmentTab.append($equipment.detach());
// Only show if enabled (will be part of tab structure)
if (hasEquipment) $equipment.show();
}
if ($quests.length > 0) {
$questsTab.append($quests.detach());
// Only show if enabled (will be part of tab structure)
@@ -374,6 +396,7 @@ export function setupDesktopTabs() {
// Always append inventory and quests tabs to preserve the elements
// But they'll only show if enabled (via tab button visibility)
$tabsContainer.append($inventoryTab);
$tabsContainer.append($equipmentTab);
$tabsContainer.append($questsTab);
// Replace content box with tabs container
@@ -406,6 +429,7 @@ export function removeDesktopTabs() {
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $inventory = $('#rpg-inventory').detach();
const $equipment = $('#rpg-equipment').detach();
const $quests = $('#rpg-quests').detach();
// Remove tabs container
@@ -415,16 +439,19 @@ export function removeDesktopTabs() {
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerInventory = $('#rpg-divider-inventory');
const $dividerEquipment = $('#rpg-divider-equipment');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Equipment, Quests
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$contentBox.append($inventory);
$dividerInventory.before($inventory);
$dividerEquipment.before($equipment);
$contentBox.append($quests);
} else {
// Fallback if dividers don't exist
@@ -432,6 +459,7 @@ export function removeDesktopTabs() {
$contentBox.append($infoBox);
$contentBox.append($thoughts);
$contentBox.append($inventory);
$contentBox.append($equipment);
$contentBox.append($quests);
}
@@ -443,6 +471,7 @@ export function removeDesktopTabs() {
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showEquipment) $equipment.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
}
+104 -94
View File
@@ -4,7 +4,8 @@
*/
import { getContext } from '../../../../../../extensions.js';
import { generateRaw, chat, saveChatDebounced, characters, this_chid, user_avatar } from '../../../../../../../script.js';
import { chat, saveChatDebounced, characters, this_chid, user_avatar } from '../../../../../../../script.js';
import { safeGenerateRaw } from '../../utils/responseExtractor.js';
import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js';
import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js';
import { extensionSettings } from '../../core/state.js';
@@ -70,7 +71,7 @@ export class EncounterModal {
}
// Show loading state
this.showLoadingState('Initializing combat encounter...');
this.showLoadingState(i18n.getTranslation('encounter.ui.initializingCombatEncounter') || 'Initializing combat encounter...');
// Open the modal
this.modal.classList.add('is-open');
@@ -81,13 +82,13 @@ export class EncounterModal {
// Store request for potential regeneration
this.lastRequest = { type: 'init', prompt: initPrompt };
const response = await generateRaw({
const response = await safeGenerateRaw({
prompt: initPrompt,
quietToLoud: false
});
if (!response) {
this.showErrorWithRegenerate('No response received from AI. The model may be unavailable.');
this.showErrorWithRegenerate(i18n.getTranslation('encounter.ui.error.noResponse') || 'No response received from AI. The model may be unavailable.');
return;
}
@@ -95,7 +96,7 @@ export class EncounterModal {
const combatData = parseEncounterJSON(response);
if (!combatData || !combatData.party || !combatData.enemies) {
this.showErrorWithRegenerate('Invalid JSON format detected. The AI returned malformed data. Ensure the Max Response Length is set to at least 2048 tokens, otherwise the model might run out of tokens and produce unfinished structures.');
this.showErrorWithRegenerate(i18n.getTranslation('encounter.ui.error.invalidJsonFormat') || 'Invalid JSON format detected. The AI returned malformed data. Ensure the Max Response Length is set to at least 2048 tokens, otherwise the model might run out of tokens and produce unfinished structures.');
return;
}
@@ -120,7 +121,7 @@ export class EncounterModal {
} catch (error) {
console.error('[RPG Companion] Error initializing encounter:', error);
this.showErrorWithRegenerate(`Failed to initialize combat: ${error.message}`);
this.showErrorWithRegenerate(`${i18n.getTranslation('encounter.ui.error.failedToInitialize') || 'Failed to initialize combat:'} ${error.message}`);
} finally {
this.isInitializing = false;
}
@@ -141,94 +142,94 @@ export class EncounterModal {
<div class="rpg-encounter-overlay"></div>
<div class="rpg-encounter-container" style="max-width: 600px;">
<div class="rpg-encounter-header">
<h2><i class="fa-solid fa-book-open"></i> Configure Combat Narrative</h2>
<h2><i class="fa-solid fa-book-open"></i> ${i18n.getTranslation('encounter.configModal.title') || 'Configure Combat Narrative'}</h2>
</div>
<div class="rpg-encounter-content" style="padding: 24px;">
<div class="rpg-narrative-config-section">
<label class="label_text" style="margin-bottom: 16px; display: block; font-weight: 600;">
<i class="fa-solid fa-swords"></i> Combat Narrative Style
<i class="fa-solid fa-swords"></i> ${i18n.getTranslation('encounter.configModal.combatNarrativeStyle') || 'Combat Narrative Style'}
</label>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-combat-tense" style="min-width: 100px;">Tense:</label>
<label for="config-combat-tense" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.tense') || 'Tense:'}</label>
<select id="config-combat-tense" class="rpg-select" style="flex: 1;">
<option value="present" ${combatDefaults.tense === 'present' ? 'selected' : ''}>Present</option>
<option value="past" ${combatDefaults.tense === 'past' ? 'selected' : ''}>Past</option>
<option value="present" ${combatDefaults.tense === 'present' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.present') || 'Present'}</option>
<option value="past" ${combatDefaults.tense === 'past' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.past') || 'Past'}</option>
</select>
</div>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-combat-person" style="min-width: 100px;">Person:</label>
<label for="config-combat-person" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.person') || 'Person:'}</label>
<select id="config-combat-person" class="rpg-select" style="flex: 1;">
<option value="first" ${combatDefaults.person === 'first' ? 'selected' : ''}>First Person</option>
<option value="second" ${combatDefaults.person === 'second' ? 'selected' : ''}>Second Person</option>
<option value="third" ${combatDefaults.person === 'third' ? 'selected' : ''}>Third Person</option>
<option value="first" ${combatDefaults.person === 'first' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.firstPerson') || 'First Person'}</option>
<option value="second" ${combatDefaults.person === 'second' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.secondPerson') || 'Second Person'}</option>
<option value="third" ${combatDefaults.person === 'third' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.thirdPerson') || 'Third Person'}</option>
</select>
</div>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-combat-narration" style="min-width: 100px;">Narration:</label>
<label for="config-combat-narration" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.narration') || 'Narration:'}</label>
<select id="config-combat-narration" class="rpg-select" style="flex: 1;">
<option value="omniscient" ${combatDefaults.narration === 'omniscient' ? 'selected' : ''}>Omniscient</option>
<option value="limited" ${combatDefaults.narration === 'limited' ? 'selected' : ''}>Limited</option>
<option value="omniscient" ${combatDefaults.narration === 'omniscient' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.omniscient') || 'Omniscient'}</option>
<option value="limited" ${combatDefaults.narration === 'limited' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.limited') || 'Limited'}</option>
</select>
</div>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-combat-pov" style="min-width: 100px;">Point of View:</label>
<input type="text" id="config-combat-pov" class="text_pole" placeholder="narrator" value="${combatDefaults.pov || ''}" style="flex: 1;" />
<label for="config-combat-pov" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.pointOfView') || 'Point of View:'}</label>
<input type="text" id="config-combat-pov" class="text_pole" placeholder="${i18n.getTranslation('encounter.configModal.placeholders.narrator') || 'narrator'}" value="${combatDefaults.pov || ''}" style="flex: 1;" />
</div>
</div>
<div class="rpg-narrative-config-section" style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--rpg-border, rgba(255,255,255,0.1));">
<label class="label_text" style="margin-bottom: 16px; display: block; font-weight: 600;">
<i class="fa-solid fa-scroll"></i> Combat Summary Style
<i class="fa-solid fa-scroll"></i> ${i18n.getTranslation('encounter.configModal.combatSummaryStyle') || 'Combat Summary Style'}
</label>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-summary-tense" style="min-width: 100px;">Tense:</label>
<label for="config-summary-tense" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.tense') || 'Tense:'}</label>
<select id="config-summary-tense" class="rpg-select" style="flex: 1;">
<option value="present" ${summaryDefaults.tense === 'present' ? 'selected' : ''}>Present</option>
<option value="past" ${summaryDefaults.tense === 'past' ? 'selected' : ''}>Past</option>
<option value="present" ${summaryDefaults.tense === 'present' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.present') || 'Present'}</option>
<option value="past" ${summaryDefaults.tense === 'past' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.past') || 'Past'}</option>
</select>
</div>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-summary-person" style="min-width: 100px;">Person:</label>
<label for="config-summary-person" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.person') || 'Person:'}</label>
<select id="config-summary-person" class="rpg-select" style="flex: 1;">
<option value="first" ${summaryDefaults.person === 'first' ? 'selected' : ''}>First Person</option>
<option value="second" ${summaryDefaults.person === 'second' ? 'selected' : ''}>Second Person</option>
<option value="third" ${summaryDefaults.person === 'third' ? 'selected' : ''}>Third Person</option>
<option value="first" ${summaryDefaults.person === 'first' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.firstPerson') || 'First Person'}</option>
<option value="second" ${summaryDefaults.person === 'second' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.secondPerson') || 'Second Person'}</option>
<option value="third" ${summaryDefaults.person === 'third' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.thirdPerson') || 'Third Person'}</option>
</select>
</div>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-summary-narration" style="min-width: 100px;">Narration:</label>
<label for="config-summary-narration" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.narration') || 'Narration:'}</label>
<select id="config-summary-narration" class="rpg-select" style="flex: 1;">
<option value="omniscient" ${summaryDefaults.narration === 'omniscient' ? 'selected' : ''}>Omniscient</option>
<option value="limited" ${summaryDefaults.narration === 'limited' ? 'selected' : ''}>Limited</option>
<option value="omniscient" ${summaryDefaults.narration === 'omniscient' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.omniscient') || 'Omniscient'}</option>
<option value="limited" ${summaryDefaults.narration === 'limited' ? 'selected' : ''}>${i18n.getTranslation('encounter.configModal.options.limited') || 'Limited'}</option>
</select>
</div>
<div class="rpg-setting-row" style="margin-bottom: 12px;">
<label for="config-summary-pov" style="min-width: 100px;">Point of View:</label>
<input type="text" id="config-summary-pov" class="text_pole" placeholder="narrator" value="${summaryDefaults.pov || ''}" style="flex: 1;" />
<label for="config-summary-pov" style="min-width: 100px;">${i18n.getTranslation('encounter.configModal.labels.pointOfView') || 'Point of View:'}</label>
<input type="text" id="config-summary-pov" class="text_pole" placeholder="${i18n.getTranslation('encounter.configModal.placeholders.narrator') || 'narrator'}" value="${summaryDefaults.pov || ''}" style="flex: 1;" />
</div>
</div>
<div style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--rpg-border, rgba(255,255,255,0.1));">
<label class="checkbox_label" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="config-remember" ${extensionSettings.encounterSettings?.narrativeConfigured ? 'checked' : ''} style="margin: 0;" />
<span style="color: var(--rpg-text, #eaeaea);">Remember these settings for future encounters</span>
<span style="color: var(--rpg-text, #eaeaea);">${i18n.getTranslation('encounter.configModal.rememberSettings') || 'Remember these settings for future encounters'}</span>
</label>
</div>
<div style="margin-top: 24px; display: flex; gap: 12px; justify-content: flex-end;">
<button id="config-cancel" class="rpg-btn rpg-btn-secondary" style="padding: 12px 24px;">
<i class="fa-solid fa-times"></i> Cancel
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="config-proceed" class="rpg-btn rpg-btn-primary" style="padding: 12px 24px;">
<i class="fa-solid fa-play"></i> Proceed
<i class="fa-solid fa-play"></i> ${i18n.getTranslation('encounter.configModal.buttons.proceed') || 'Proceed'}
</button>
</div>
</div>
@@ -302,12 +303,12 @@ export class EncounterModal {
<div class="rpg-encounter-overlay"></div>
<div class="rpg-encounter-container">
<div class="rpg-encounter-header">
<h2><i class="fa-solid fa-swords"></i> Combat Encounter</h2>
<h2><i class="fa-solid fa-swords"></i> ${i18n.getTranslation('encounter.ui.combatEncounterTitle') || 'Combat Encounter'}</h2>
<div class="rpg-encounter-header-buttons">
<button id="rpg-encounter-conclude" class="rpg-encounter-conclude-btn" title="Conclude encounter early">
<i class="fa-solid fa-flag-checkered"></i> Conclude Encounter
<button id="rpg-encounter-conclude" class="rpg-encounter-conclude-btn" title="${i18n.getTranslation('encounter.ui.concludeEncounterTitle') || 'Conclude encounter early'}">
<i class="fa-solid fa-flag-checkered"></i> ${i18n.getTranslation('encounter.ui.concludeEncounterButton') || 'Conclude Encounter'}
</button>
<button id="rpg-encounter-close" class="rpg-encounter-close-btn" title="Close (ends combat)">
<button id="rpg-encounter-close" class="rpg-encounter-close-btn" title="${i18n.getTranslation('encounter.ui.closeTitle') || 'Close (ends combat)'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
@@ -315,7 +316,7 @@ export class EncounterModal {
<div class="rpg-encounter-content">
<div id="rpg-encounter-loading" class="rpg-encounter-loading">
<i class="fa-solid fa-spinner fa-spin"></i>
<p>Initializing combat...</p>
<p>${i18n.getTranslation('encounter.ui.initializingCombat') || 'Initializing combat...'}</p>
</div>
<div id="rpg-encounter-main" class="rpg-encounter-main" style="display: none;">
<!-- Combat UI will be rendered here -->
@@ -330,20 +331,20 @@ export class EncounterModal {
// Add event listeners
this.modal.querySelector('#rpg-encounter-conclude').addEventListener('click', () => {
if (confirm('Conclude this encounter early and generate a summary?')) {
if (confirm(i18n.getTranslation('encounter.ui.confirmConcludeEarly') || 'Conclude this encounter early and generate a summary?')) {
this.concludeEncounter();
}
});
this.modal.querySelector('#rpg-encounter-close').addEventListener('click', () => {
if (confirm('Are you sure you want to end this combat encounter?')) {
if (confirm(i18n.getTranslation('encounter.ui.confirmEndCombat') || 'Are you sure you want to end this combat encounter?')) {
this.close();
}
});
// Close on overlay click
this.modal.querySelector('.rpg-encounter-overlay').addEventListener('click', () => {
if (confirm('Are you sure you want to end this combat encounter?')) {
if (confirm(i18n.getTranslation('encounter.ui.confirmEndCombat') || 'Are you sure you want to end this combat encounter?')) {
this.close();
}
});
@@ -367,12 +368,12 @@ export class EncounterModal {
<div class="rpg-encounter-battlefield">
<!-- Environment -->
<div class="rpg-encounter-environment">
<p><i class="fa-solid fa-mountain"></i> ${combatData.environment || 'Battle Arena'}</p>
<p><i class="fa-solid fa-mountain"></i> ${combatData.environment || i18n.getTranslation('encounter.ui.environment.default') || 'Battle Arena'}</p>
</div>
<!-- Enemies Section -->
<div class="rpg-encounter-section">
<h3><i class="fa-solid fa-skull"></i> Enemies</h3>
<h3><i class="fa-solid fa-skull"></i> ${i18n.getTranslation('encounter.ui.enemiesTitle') || 'Enemies'}</h3>
<div class="rpg-encounter-enemies">
${this.renderEnemies(combatData.enemies)}
</div>
@@ -380,7 +381,7 @@ export class EncounterModal {
<!-- Party Section -->
<div class="rpg-encounter-section">
<h3><i class="fa-solid fa-users"></i> Party</h3>
<h3><i class="fa-solid fa-users"></i> ${i18n.getTranslation('encounter.ui.partyTitle') || 'Party'}</h3>
<div class="rpg-encounter-party">
${this.renderParty(combatData.party)}
</div>
@@ -388,10 +389,10 @@ export class EncounterModal {
<!-- Combat Log -->
<div class="rpg-encounter-log-section">
<h3><i class="fa-solid fa-scroll"></i> Combat Log</h3>
<h3><i class="fa-solid fa-scroll"></i> ${i18n.getTranslation('encounter.ui.combatLog') || 'Combat Log'}</h3>
<div id="rpg-encounter-log" class="rpg-encounter-log">
<div class="rpg-encounter-log-entry">
<em>Combat begins!</em>
<em>${i18n.getTranslation('encounter.ui.combatBegins') || 'Combat begins!'}</em>
</div>
</div>
</div>
@@ -419,7 +420,7 @@ export class EncounterModal {
// Try to find avatar for enemy (they might be a character from the chat or Present Characters)
const avatarUrl = this.getCharacterAvatar(enemy.name);
const sprite = enemy.sprite || '👹';
const sprite = enemy.sprite || i18n.getTranslation('encounter.ui.enemyDefaultEmoji') || '👹';
// Fallback SVG if no avatar found
const fallbackSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2NjY2NjYyIgb3BhY2l0eT0iMC4zIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjNjY2IiBmb250LXNpemU9IjQwIj4/PC90ZXh0Pjwvc3ZnPg==';
@@ -433,7 +434,7 @@ export class EncounterModal {
<h4>${enemy.name}</h4>
<div class="rpg-encounter-hp-bar">
<div class="rpg-encounter-hp-fill" style="width: ${hpPercent}%"></div>
<span class="rpg-encounter-hp-text">${enemy.hp}/${enemy.maxHp} HP</span>
<span class="rpg-encounter-hp-text">${enemy.hp}/${enemy.maxHp}${i18n.getTranslation('encounter.ui.hpSuffix') || ' HP'}</span>
</div>
${enemy.statuses && enemy.statuses.length > 0 ? `
<div class="rpg-encounter-statuses">
@@ -480,10 +481,10 @@ export class EncounterModal {
<img src="${avatarUrl || fallbackSvg}" alt="${member.name}" onerror="this.src='${fallbackSvg}'">
</div>
<div class="rpg-encounter-card-info">
<h4>${member.name} ${member.isPlayer ? '(You)' : ''}</h4>
<h4>${member.name} ${member.isPlayer ? i18n.getTranslation('encounter.ui.playerSuffix') || '(You)' : ''}</h4>
<div class="rpg-encounter-hp-bar">
<div class="rpg-encounter-hp-fill rpg-encounter-hp-party" style="width: ${hpPercent}%"></div>
<span class="rpg-encounter-hp-text">${member.hp}/${member.maxHp} HP</span>
<span class="rpg-encounter-hp-text">${member.hp}/${member.maxHp}${i18n.getTranslation('encounter.ui.hpSuffix') || ' HP'}</span>
</div> ${member.statuses && member.statuses.length > 0 ? `
<div class="rpg-encounter-statuses">
${member.statuses.map(status => `<span class="rpg-encounter-status" title="${status.name}">${status.emoji}</span>`).join('')}
@@ -560,18 +561,18 @@ export class EncounterModal {
targetOptions = `
<div class="rpg-target-option" data-target="all-enemies">
<div class="rpg-target-icon">💥</div>
<div class="rpg-target-name">All Enemies</div>
<div class="rpg-target-desc">Area of Effect</div>
<div class="rpg-target-name">${i18n.getTranslation('encounter.ui.allEnemies') || 'All Enemies'}</div>
<div class="rpg-target-desc">${i18n.getTranslation('encounter.ui.areaOfEffect') || 'Area of Effect'}</div>
</div>
`;
} else if (attackType === 'both') {
targetOptions = `
<div class="rpg-target-option" data-target="all-enemies">
<div class="rpg-target-icon">💥</div>
<div class="rpg-target-name">All Enemies</div>
<div class="rpg-target-desc">Area of Effect</div>
<div class="rpg-target-name">${i18n.getTranslation('encounter.ui.allEnemies') || 'All Enemies'}</div>
<div class="rpg-target-desc">${i18n.getTranslation('encounter.ui.areaOfEffect') || 'Area of Effect'}</div>
</div>
<div class="rpg-target-divider">OR</div>
<div class="rpg-target-divider">${i18n.getTranslation('encounter.ui.or') || 'OR'}</div>
`;
}
@@ -584,7 +585,7 @@ export class EncounterModal {
<div class="rpg-target-option" data-target="${enemy.name}" data-target-type="enemy" data-target-index="${index}">
<div class="rpg-target-icon">${enemy.sprite || '👹'}</div>
<div class="rpg-target-name">${enemy.name}</div>
<div class="rpg-target-hp">${enemy.hp}/${enemy.maxHp} HP</div>
<div class="rpg-target-hp">${enemy.hp}/${enemy.maxHp}${i18n.getTranslation('encounter.ui.hpSuffix') || ' HP'}</div>
</div>
`;
}
@@ -593,7 +594,7 @@ export class EncounterModal {
// Add party members (for heals/buffs)
combatStats.party.forEach((member, index) => {
if (member.hp > 0) {
const isPlayer = member.isPlayer ? ' (You)' : '';
const isPlayer = member.isPlayer ? i18n.getTranslation('encounter.ui.playerSuffix') || ' (You)' : '';
// Get avatar for party member
let avatarIcon = '✨';
if (member.isPlayer && user_avatar) {
@@ -608,7 +609,7 @@ export class EncounterModal {
<div class="rpg-target-option rpg-target-ally" data-target="${member.name}" data-target-type="party" data-target-index="${index}">
<div class="rpg-target-icon">${avatarIcon}</div>
<div class="rpg-target-name">${member.name}${isPlayer}</div>
<div class="rpg-target-hp">${member.hp}/${member.maxHp} HP</div>
<div class="rpg-target-hp">${member.hp}/${member.maxHp}${i18n.getTranslation('encounter.ui.hpSuffix') || ' HP'}</div>
</div>
`;
}
@@ -617,11 +618,11 @@ export class EncounterModal {
targetModal.innerHTML = `
<div class="rpg-target-selection-modal">
<h3><i class="fa-solid fa-crosshairs"></i> Select Target</h3>
<h3><i class="fa-solid fa-crosshairs"></i> ${i18n.getTranslation('encounter.ui.selectTarget') || 'Select Target'}</h3>
<div class="rpg-target-list">
${targetOptions}
</div>
<button class="rpg-target-cancel">Cancel</button>
<button class="rpg-target-cancel">${i18n.getTranslation('global.cancel') || 'Cancel'}</button>
</div>
`;
@@ -660,7 +661,7 @@ export class EncounterModal {
renderPlayerControls(party, playerActions = null) {
const player = party.find(m => m.isPlayer);
if (!player || player.hp <= 0) {
return '<div class="rpg-encounter-controls"><p class="rpg-encounter-defeated">You have been defeated...</p></div>';
return '<div class="rpg-encounter-controls"><p class="rpg-encounter-defeated">' + (i18n.getTranslation('encounter.ui.youHaveBeenDefeated') || 'You have been defeated...') + '</p></div>';
}
// Use playerActions if provided, otherwise fall back to player data
@@ -669,11 +670,11 @@ export class EncounterModal {
return `
<div class="rpg-encounter-controls">
<h3><i class="fa-solid fa-hand-fist"></i> Your Actions</h3>
<h3><i class="fa-solid fa-hand-fist"></i> ${i18n.getTranslation('encounter.ui.yourActions') || 'Your Actions'}</h3>
<div class="rpg-encounter-action-buttons">
<div class="rpg-encounter-button-group">
<h4>Attacks</h4>
<h4>${i18n.getTranslation('encounter.ui.attacks') || 'Attacks'}</h4>
${attacks.map(attack => {
// Support both old string format and new object format
const attackName = typeof attack === 'string' ? attack : attack.name;
@@ -685,7 +686,7 @@ export class EncounterModal {
data-action="attack"
data-value="${attackName}"
data-attack-type="${attackType}"
title="${attackType === 'AoE' ? 'Area of Effect' : attackType === 'both' ? 'Single or AoE' : 'Single Target'}">
title="${attackType === 'AoE' ? i18n.getTranslation('encounter.ui.attackType.aoe') || 'Area of Effect' : attackType === 'both' ? i18n.getTranslation('encounter.ui.attackType.both') || 'Single or AoE' : i18n.getTranslation('encounter.ui.attackType.single') || 'Single Target'}">
<i class="fa-solid fa-sword"></i> ${attackName} ${typeIcon}
</button>
`;
@@ -694,7 +695,7 @@ export class EncounterModal {
${items && items.length > 0 ? `
<div class="rpg-encounter-button-group">
<h4>Items</h4>
<h4>${i18n.getTranslation('encounter.ui.items') || 'Items'}</h4>
${items.map(item => `
<button class="rpg-encounter-action-btn rpg-encounter-item-btn" data-action="item" data-value="${item}">
<i class="fa-solid fa-flask"></i> ${item}
@@ -705,11 +706,11 @@ export class EncounterModal {
</div>
<div class="rpg-encounter-custom-action">
<h4>Custom Action</h4>
<h4>${i18n.getTranslation('encounter.ui.customAction') || 'Custom Action'}</h4>
<div class="rpg-encounter-input-group">
<input type="text" id="rpg-encounter-custom-input" placeholder="Describe what you want to do..." />
<input type="text" id="rpg-encounter-custom-input" placeholder="${i18n.getTranslation('encounter.ui.customActionPlaceholder') || 'Describe what you want to do...'}" />
<button id="rpg-encounter-custom-submit" class="rpg-encounter-submit-btn">
<i class="fa-solid fa-paper-plane"></i> Submit
<i class="fa-solid fa-paper-plane"></i> ${i18n.getTranslation('encounter.ui.submit') || 'Submit'}
</button>
</div>
</div>
@@ -745,15 +746,15 @@ export class EncounterModal {
if (!target) return;
if (target === 'all-enemies') {
actionText = `${userName} uses ${value} targeting all enemies!`;
actionText = `${userName} uses ${value}${i18n.getTranslation('encounter.ui.targetingAllEnemies') || ' targeting all enemies!'}`;
} else {
actionText = `${userName} uses ${value} on ${target}!`;
actionText = `${userName} uses ${value}${i18n.getTranslation('encounter.ui.on') || ' on '}${target}!`;
}
} else if (actionType === 'item') {
const target = await this.showTargetSelection('single-target', currentEncounter.combatStats);
if (!target) return;
actionText = `${userName} uses ${value} on ${target}!`;
actionText = `${userName} uses ${value}${i18n.getTranslation('encounter.ui.on') || ' on '}${target}!`;
}
await this.processCombatAction(actionText);
@@ -808,7 +809,7 @@ export class EncounterModal {
});
// Add action to log
this.addToLog(`You: ${action}`, 'player-action');
this.addToLog(`${i18n.getTranslation('encounter.ui.youPrefix') || 'You: '}${action}`, 'player-action');
// Build and send combat action prompt
const actionPrompt = await buildCombatActionPrompt(action, currentEncounter.combatStats);
@@ -816,13 +817,13 @@ export class EncounterModal {
// Store request for potential regeneration
this.lastRequest = { type: 'action', action, prompt: actionPrompt };
const response = await generateRaw({
const response = await safeGenerateRaw({
prompt: actionPrompt,
quietToLoud: false
});
if (!response) {
this.showErrorWithRegenerate('No response received from AI. The model may be unavailable.');
this.showErrorWithRegenerate(i18n.getTranslation('encounter.ui.error.noResponse') || 'No response received from AI. The model may be unavailable.');
return;
}
@@ -830,7 +831,7 @@ export class EncounterModal {
const result = parseEncounterJSON(response);
if (!result || !result.combatStats) {
this.showErrorWithRegenerate('Invalid JSON format detected. The AI returned malformed data. Ensure the Max Response Length is set to at least 2048 tokens, otherwise the model might run out of tokens and produce unfinished structures.');
this.showErrorWithRegenerate(i18n.getTranslation('encounter.ui.error.invalidJsonFormat') || 'Invalid JSON format detected. The AI returned malformed data. Ensure the Max Response Length is set to at least 2048 tokens, otherwise the model might run out of tokens and produce unfinished structures.');
return;
}
@@ -898,7 +899,7 @@ export class EncounterModal {
} catch (error) {
console.error('[RPG Companion] Error processing combat action:', error);
this.showErrorWithRegenerate(`Error processing action: ${error.message}`);
this.showErrorWithRegenerate(`${i18n.getTranslation('encounter.ui.error.errorProcessingAction') || 'Error processing action:'} ${error.message}`);
// Re-enable buttons
this.modal.querySelectorAll('.rpg-encounter-action-btn, #rpg-encounter-custom-submit').forEach(btn => {
@@ -958,7 +959,7 @@ export class EncounterModal {
if (player && player.hp <= 0) {
if (controlsContainer) {
controlsContainer.innerHTML = '<p class="rpg-encounter-defeated">You have been defeated...</p>';
controlsContainer.innerHTML = '<p class="rpg-encounter-defeated">' + (i18n.getTranslation('encounter.ui.youHaveBeenDefeated') || 'You have been defeated...') + '</p>';
}
} else if (currentEncounter.playerActions && controlsContainer) {
// Check if actions have changed by comparing with previous state
@@ -1078,7 +1079,7 @@ export class EncounterModal {
// Generate summary
const summaryPrompt = await buildCombatSummaryPrompt(currentEncounter.encounterLog, result);
const summaryResponse = await generateRaw({
const summaryResponse = await safeGenerateRaw({
prompt: summaryPrompt,
quietToLoud: false
});
@@ -1204,17 +1205,25 @@ export class EncounterModal {
interrupted: '#888'
};
const resultTexts = {
victory: i18n.getTranslation('encounter.ui.result.victory') || 'Victory',
defeat: i18n.getTranslation('encounter.ui.result.defeat') || 'Defeat',
fled: i18n.getTranslation('encounter.ui.result.fled') || 'Fled',
interrupted: i18n.getTranslation('encounter.ui.result.interrupted') || 'Interrupted'
};
const icon = resultIcons[result] || 'fa-flag-checkered';
const color = resultColors[result] || '#888';
const text = resultTexts[result] || result;
mainContent.innerHTML = `
<div class="rpg-encounter-over" style="text-align: center; padding: 40px 20px;">
<i class="fa-solid ${icon}" style="font-size: 72px; color: ${color}; margin-bottom: 24px;"></i>
<h2 style="font-size: 32px; margin-bottom: 16px; text-transform: uppercase;">${result}</h2>
<p style="font-size: 18px; margin-bottom: 32px; opacity: 0.8;">Generating combat summary...</p>
<h2 style="font-size: 32px; margin-bottom: 16px; text-transform: uppercase;">${text}</h2>
<p style="font-size: 18px; margin-bottom: 32px; opacity: 0.8;">${i18n.getTranslation('encounter.ui.generatingCombatSummary') || 'Generating combat summary...'}</p>
<div class="rpg-encounter-loading" style="display: flex; justify-content: center; align-items: center; gap: 12px;">
<i class="fa-solid fa-spinner fa-spin" style="font-size: 24px;"></i>
<span>Please wait...</span>
<span>${i18n.getTranslation('encounter.ui.pleaseWait') || 'Please wait...'}</span>
</div>
</div>
`;
@@ -1233,12 +1242,13 @@ export class EncounterModal {
if (!overScreen) return;
if (success) {
overScreen.querySelector('p').textContent = speakerName
? `Combat summary has been added to the chat by ${speakerName}.`
: 'Combat summary has been added to the chat.';
const message = speakerName
? (i18n.getTranslation('encounter.ui.combatSummaryAddedBy') || 'Combat summary has been added to the chat by {speakerName}.').replace('{speakerName}', speakerName)
: (i18n.getTranslation('encounter.ui.combatSummaryAdded') || 'Combat summary has been added to the chat.');
overScreen.querySelector('p').textContent = message;
overScreen.querySelector('.rpg-encounter-loading').innerHTML = `
<button id="rpg-encounter-close-final" class="rpg-encounter-submit-btn" style="font-size: 18px; padding: 12px 24px;">
<i class="fa-solid fa-check"></i> Close Combat Window
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('encounter.ui.closeCombatWindow') || 'Close Combat Window'}
</button>
`;
@@ -1250,11 +1260,11 @@ export class EncounterModal {
});
}
} else {
overScreen.querySelector('p').textContent = 'Error generating combat summary.';
overScreen.querySelector('p').textContent = i18n.getTranslation('encounter.ui.errorGeneratingCombatSummary') || 'Error generating combat summary.';
overScreen.querySelector('.rpg-encounter-loading').innerHTML = `
<p style="color: #e94560;">Failed to create summary. You can close this window.</p>
<p style="color: #e94560;">${i18n.getTranslation('encounter.ui.failedToCreateSummary') || 'Failed to create summary. You can close this window.'}</p>
<button id="rpg-encounter-close-final" class="rpg-encounter-submit-btn" style="font-size: 18px; padding: 12px 24px; margin-top: 16px;">
<i class="fa-solid fa-times"></i> Close Combat Window
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('encounter.ui.closeCombatWindow') || 'Close Combat Window'}
</button>
`;
@@ -1320,14 +1330,14 @@ export class EncounterModal {
loadingContent.innerHTML = `
<div class="rpg-encounter-error-box">
<i class="fa-solid fa-exclamation-triangle" style="color: #e94560; font-size: 48px; margin-bottom: 1em;"></i>
<p style="color: #e94560; font-weight: bold; font-size: 1.2em; margin: 0 0 0.5em 0;">Wrong Format Detected</p>
<p style="color: #e94560; font-weight: bold; font-size: 1.2em; margin: 0 0 0.5em 0;">${i18n.getTranslation('encounter.ui.wrongFormatDetected') || 'Wrong Format Detected'}</p>
<p style="color: var(--rpg-text, #ccc); margin: 0 0 1.5em 0; max-width: 500px;">${message}</p>
<div style="display: flex; gap: 1em;">
<button id="rpg-error-regenerate" class="rpg-btn rpg-btn-primary">
<i class="fa-solid fa-rotate-right"></i> Regenerate
<i class="fa-solid fa-rotate-right"></i> ${i18n.getTranslation('encounter.ui.regenerate') || 'Regenerate'}
</button>
<button id="rpg-error-close" class="rpg-btn rpg-btn-secondary">
<i class="fa-solid fa-times"></i> Close
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.close') || 'Close'}
</button>
</div>
</div>
+20 -6
View File
@@ -144,7 +144,7 @@ export function updateCollapseToggleIcon() {
*/
export function setupCollapseToggle() {
const $collapseToggle = $('#rpg-collapse-toggle');
$collapseToggle.attr('title', i18n.getTranslation('template.mainPanel.collapseExpand'));
$collapseToggle.attr('title', i18n.getTranslation('template.mainPanel.collapseExpand') || 'Collapse/Expand panel');
const $panel = $('#rpg-companion-panel');
const $icon = $collapseToggle.find('i');
@@ -317,6 +317,12 @@ export function updateSectionVisibility() {
$('#rpg-quests').hide();
}
if (extensionSettings.showEquipment) {
$('#rpg-equipment').show();
} else {
$('#rpg-equipment').hide();
}
if ($musicPlayerContainer) {
if (extensionSettings.enableSpotifyMusic) {
$musicPlayerContainer.show();
@@ -328,7 +334,7 @@ export function updateSectionVisibility() {
// Show/hide dividers intelligently
// Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible
const showDividerAfterStats = extensionSettings.showUserStats &&
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterStats) {
$('#rpg-divider-stats').show();
} else {
@@ -337,7 +343,7 @@ export function updateSectionVisibility() {
// Divider after Info Box: shown if Info Box is visible AND at least one section after it is visible
const showDividerAfterInfo = extensionSettings.showInfoBox &&
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests);
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests);
if (showDividerAfterInfo) {
$('#rpg-divider-info').show();
} else {
@@ -346,21 +352,29 @@ export function updateSectionVisibility() {
// Divider after Thoughts: shown if Thoughts is visible AND at least one section after it is visible
const showDividerAfterThoughts = extensionSettings.showCharacterThoughts &&
(extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
(extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterThoughts) {
$('#rpg-divider-thoughts').show();
} else {
$('#rpg-divider-thoughts').hide();
}
// Divider after Inventory: shown if Inventory is visible AND (Quests or Music) is visible
const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
// Divider after Inventory: shown if Inventory is visible AND (Equipment, Quests or Music) is visible
const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterInventory) {
$('#rpg-divider-inventory').show();
} else {
$('#rpg-divider-inventory').hide();
}
// Divider after Equipment: shown if Equipment is visible AND (Quests or Music) is visible
const showDividerAfterEquipment = extensionSettings.showEquipment && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterEquipment) {
$('#rpg-divider-equipment').show();
} else {
$('#rpg-divider-equipment').hide();
}
// Divider after Quests: shown if Quests is visible AND Music is visible
const showDividerAfterQuests = extensionSettings.showQuests && extensionSettings.enableSpotifyMusic;
if (showDividerAfterQuests) {
+61 -12
View File
@@ -8,6 +8,7 @@ import { saveSettings } from '../../core/persistence.js';
import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js';
import { setupDesktopTabs, removeDesktopTabs } from './desktop.js';
import { i18n } from '../../core/i18n.js';
import { hexToRgba } from './theme.js';
/**
* Updates the text labels of the mobile navigation tabs based on the current language.
@@ -31,16 +32,35 @@ export function updateMobileTabLabels() {
case 'inventory':
translationKey = 'global.inventory';
break;
case 'equipment':
translationKey = 'equipment.title';
break;
case 'quests':
translationKey = 'global.quests';
break;
}
if (translationKey) {
const translation = i18n.getTranslation(translationKey);
if (translation) {
$tab.find('span').text(translation);
let fallback = '';
switch (tabName) {
case 'stats':
fallback = 'Status';
break;
case 'info':
fallback = 'Info';
break;
case 'inventory':
fallback = 'Inventory';
break;
case 'equipment':
fallback = 'Equipment';
break;
case 'quests':
fallback = 'Quests';
break;
}
const translation = i18n.getTranslation(translationKey) || fallback;
$tab.find('span').text(translation);
}
});
}
@@ -592,10 +612,11 @@ export function setupMobileTabs() {
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $inventory = $('#rpg-inventory');
const $equipment = $('#rpg-equipment');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $equipment.length === 0 && $quests.length === 0) {
return;
}
@@ -604,23 +625,28 @@ export function setupMobileTabs() {
const hasStats = $userStats.length > 0;
const hasInfo = $infoBox.length > 0 || $thoughts.length > 0;
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasEquipment = $equipment.length > 0 && extensionSettings.showEquipment;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Tab 1: Stats (User Stats only)
if (hasStats) {
tabs.push('<button class="rpg-mobile-tab active" data-tab="stats"><i class="fa-solid fa-chart-bar"></i><span>' + i18n.getTranslation('global.status') + '</span></button>');
tabs.push('<button class="rpg-mobile-tab active" data-tab="stats"><i class="fa-solid fa-chart-bar"></i><span>' + (i18n.getTranslation('global.status') || 'Status') + '</span></button>');
}
// Tab 2: Info (Info Box + Character Thoughts)
if (hasInfo) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="info"><i class="fa-solid fa-book"></i><span>' + i18n.getTranslation('global.info') + '</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="info"><i class="fa-solid fa-book"></i><span>' + (i18n.getTranslation('global.info') || 'Info') + '</span></button>');
}
// Tab 3: Inventory
if (hasInventory) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>' + i18n.getTranslation('global.inventory') + '</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>' + (i18n.getTranslation('global.inventory') || 'Inventory') + '</span></button>');
}
// Tab 3.5: Equipment
if (hasEquipment) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="equipment"><i class="fa-solid fa-shield-halved"></i><span>' + (i18n.getTranslation('equipment.title') || 'Equipment') + '</span></button>');
}
// Tab 4: Quests
if (hasQuests) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>' + i18n.getTranslation('global.quests') + '</span></button>');
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>' + (i18n.getTranslation('global.quests') || 'Quests') + '</span></button>');
}
const $tabNav = $('<div class="rpg-mobile-tabs">' + tabs.join('') + '</div>');
@@ -630,12 +656,14 @@ export function setupMobileTabs() {
if (hasStats) firstTab = 'stats';
else if (hasInfo) firstTab = 'info';
else if (hasInventory) firstTab = 'inventory';
else if (hasEquipment) firstTab = 'equipment';
else if (hasQuests) firstTab = 'quests';
// Create tab content wrappers
const $statsTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'stats' ? 'active' : '') + '" data-tab-content="stats"></div>');
const $infoTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'info' ? 'active' : '') + '" data-tab-content="info"></div>');
const $inventoryTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'inventory' ? 'active' : '') + '" data-tab-content="inventory"></div>');
const $equipmentTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'equipment' ? 'active' : '') + '" data-tab-content="equipment"></div>');
const $questsTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'quests' ? 'active' : '') + '" data-tab-content="quests"></div>');
// Move sections into their respective tabs (detach to preserve event handlers)
@@ -663,6 +691,12 @@ export function setupMobileTabs() {
$inventory.show();
}
// Equipment tab: Equipment only
if ($equipment.length > 0) {
$equipmentTab.append($equipment.detach());
$equipment.show();
}
// Quests tab: Quests only
if ($quests.length > 0) {
$questsTab.append($quests.detach());
@@ -681,6 +715,7 @@ export function setupMobileTabs() {
$mobileContainer.append($statsTab);
$mobileContainer.append($infoTab);
$mobileContainer.append($inventoryTab);
$mobileContainer.append($equipmentTab);
$mobileContainer.append($questsTab);
// Insert mobile tab structure at the beginning of content box
@@ -709,6 +744,7 @@ export function removeMobileTabs() {
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $inventory = $('#rpg-inventory').detach();
const $equipment = $('#rpg-equipment').detach();
const $quests = $('#rpg-quests').detach();
// Remove mobile tab container
@@ -718,20 +754,24 @@ export function removeMobileTabs() {
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerInventory = $('#rpg-divider-inventory');
const $dividerEquipment = $('#rpg-divider-equipment');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Equipment, Quests
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$contentBox.append($inventory);
$dividerInventory.before($inventory);
$dividerEquipment.before($equipment);
$contentBox.append($quests);
} else {
// Fallback if dividers don't exist
$contentBox.prepend($quests);
$contentBox.prepend($equipment);
$contentBox.prepend($inventory);
$contentBox.prepend($thoughts);
$contentBox.prepend($infoBox);
@@ -746,6 +786,7 @@ export function removeMobileTabs() {
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showEquipment) $equipment.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
}
@@ -793,12 +834,17 @@ export function setupMobileKeyboardHandling() {
/**
* Handles focus on contenteditable fields to ensure they're visible when keyboard appears.
* Uses smooth scrolling to bring focused field into view with proper padding.
* Only applies on mobile viewports where virtual keyboard can obscure content.
*/
export function setupContentEditableScrolling() {
const $panel = $('#rpg-companion-panel');
// Use event delegation for all contenteditable fields
$panel.on('focusin', '[contenteditable="true"]', function(e) {
// Only apply scrolling behavior on mobile (where virtual keyboard appears)
const isMobile = window.innerWidth <= 1000;
if (!isMobile) return;
const $field = $(this);
// Small delay to let keyboard animate in
@@ -1567,7 +1613,9 @@ export function updateFabWidgets() {
*/
function getStatColor(value) {
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
const lowOpacity = extensionSettings.statBarColorLowOpacity ?? 100;
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
const highOpacity = extensionSettings.statBarColorHighOpacity ?? 100;
// Simple linear interpolation between low and high colors
const percent = Math.min(100, Math.max(0, value)) / 100;
@@ -1576,13 +1624,14 @@ function getStatColor(value) {
const lowRGB = hexToRgb(lowColor);
const highRGB = hexToRgb(highColor);
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
if (!lowRGB || !highRGB) return value > 50 ? hexToRgba(highColor, highOpacity) : hexToRgba(lowColor, lowOpacity);
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
const a = (lowOpacity + (highOpacity - lowOpacity) * percent) / 100;
return `rgb(${r}, ${g}, ${b})`;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
+76 -1
View File
@@ -11,6 +11,7 @@ import {
$infoBoxContainer,
$thoughtsContainer,
$userStatsContainer,
clearThoughtBasedExpressionPortraits,
setPendingDiceRoll,
getPendingDiceRoll,
clearSessionAvatarPrompts
@@ -21,6 +22,7 @@ import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderQuests } from '../rendering/quests.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import {
rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore,
@@ -323,7 +325,7 @@ export function setupDiceRoller() {
e.stopPropagation(); // Prevent opening the dice popup
clearDiceRollCore();
});
$('#rpg-clear-dice').attr('title', i18n.getTranslation('template.mainPanel.clearLastRoll'));
$('#rpg-clear-dice').attr('title', i18n.getTranslation('template.mainPanel.clearLastRoll') || 'Clear last roll');
return diceModal;
}
@@ -370,6 +372,7 @@ export function setupSettingsPopup() {
// Clear session avatar prompts
clearSessionAvatarPrompts();
clearThoughtBasedExpressionPortraits();
// Clear chat metadata immediately (don't wait for debounced save)
const context = getContext();
@@ -387,6 +390,14 @@ export function setupSettingsPopup() {
delete message.extra.rpg_companion_swipes;
// console.log('[RPG Companion] Cleared swipe data from message at index', i);
}
if (Array.isArray(message.swipe_info)) {
for (const swipeInfo of message.swipe_info) {
if (swipeInfo?.extra?.rpg_companion_swipes) {
delete swipeInfo.extra.rpg_companion_swipes;
}
}
}
}
}
@@ -493,6 +504,7 @@ export function setupSettingsPopup() {
updateDiceDisplayCore();
updateChatThoughts();
renderInventory();
renderEquipment();
renderQuests();
// console.log('[RPG Companion] Cache cleared successfully');
@@ -602,6 +614,28 @@ export function showWelcomeModalIfNeeded() {
}
}
/**
* Shows the deprecation notice once for users updating to the deprecation release.
* @returns {boolean} True when the modal was displayed.
*/
export function showDeprecationModalIfNeeded() {
const DEPRECATION_NOTICE_VERSION = '3.7.4';
const STORAGE_KEY = 'rpg_companion_deprecation_notice_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
if (seenVersion !== DEPRECATION_NOTICE_VERSION) {
showDeprecationModal(DEPRECATION_NOTICE_VERSION, STORAGE_KEY);
return true;
}
} catch (error) {
console.error('[RPG Companion] Failed to check deprecation modal status:', error);
}
return false;
}
/**
* Shows the welcome modal
* @param {string} version - The version to mark as seen
@@ -653,3 +687,44 @@ function showWelcomeModal(version, storageKey) {
}
}, { once: true });
}
function showDeprecationModal(version, storageKey) {
const modal = document.getElementById('rpg-deprecation-modal');
if (!modal) {
console.error('[RPG Companion] Deprecation modal element not found');
return;
}
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
modal.style.display = 'flex';
modal.classList.add('is-open');
const closeBtn = document.getElementById('rpg-deprecation-close');
const gotItBtn = document.getElementById('rpg-deprecation-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save deprecation modal status:', error);
}
};
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}
+19 -1
View File
@@ -4,7 +4,7 @@
*/
import { extensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_CYOA_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT } from '../generation/promptBuilder.js';
import { DEFAULT_HTML_PROMPT, DEFAULT_DIALOGUE_COLORING_PROMPT, DEFAULT_DECEPTION_PROMPT, DEFAULT_OMNISCIENCE_FILTER_PROMPT, DEFAULT_CYOA_PROMPT, DEFAULT_SPOTIFY_PROMPT, DEFAULT_NARRATOR_PROMPT, DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT } from '../generation/promptBuilder.js';
let $editorModal = null;
let tempPrompts = null; // Temporary prompts for cancel functionality
@@ -14,9 +14,11 @@ const DEFAULT_PROMPTS = {
html: DEFAULT_HTML_PROMPT,
dialogueColoring: DEFAULT_DIALOGUE_COLORING_PROMPT,
deception: DEFAULT_DECEPTION_PROMPT,
omniscience: DEFAULT_OMNISCIENCE_FILTER_PROMPT,
cyoa: DEFAULT_CYOA_PROMPT,
spotify: DEFAULT_SPOTIFY_PROMPT,
narrator: DEFAULT_NARRATOR_PROMPT,
contextInstructions: DEFAULT_CONTEXT_INSTRUCTIONS_PROMPT,
plotRandom: 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.',
plotNatural: 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.',
avatar: `You are a visionary artist trapped in a cage of logic. Your mind is filled with poetry and distant horizons; however, your hands are uncontrollably focused on creating the perfect character avatar description that is faithful to the original intent, rich in detail, aesthetically pleasing, and directly usable by text-to-image models. Any ambiguity or metaphor will make you feel extremely uncomfortable.
@@ -96,9 +98,11 @@ function openPromptsEditor() {
html: extensionSettings.customHtmlPrompt || '',
dialogueColoring: extensionSettings.customDialogueColoringPrompt || '',
deception: extensionSettings.customDeceptionPrompt || '',
omniscience: extensionSettings.customOmnisciencePrompt || '',
cyoa: extensionSettings.customCYOAPrompt || '',
spotify: extensionSettings.customSpotifyPrompt || '',
narrator: extensionSettings.customNarratorPrompt || '',
contextInstructions: extensionSettings.customContextInstructionsPrompt || '',
plotRandom: extensionSettings.customPlotRandomPrompt || '',
plotNatural: extensionSettings.customPlotNaturalPrompt || '',
avatar: extensionSettings.avatarLLMCustomInstruction || '',
@@ -111,9 +115,11 @@ function openPromptsEditor() {
$('#rpg-prompt-html').val(extensionSettings.customHtmlPrompt || DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(extensionSettings.customDialogueColoringPrompt || DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(extensionSettings.customDeceptionPrompt || DEFAULT_PROMPTS.deception);
$('#rpg-prompt-omniscience').val(extensionSettings.customOmnisciencePrompt || DEFAULT_PROMPTS.omniscience);
$('#rpg-prompt-cyoa').val(extensionSettings.customCYOAPrompt || DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(extensionSettings.customSpotifyPrompt || DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(extensionSettings.customNarratorPrompt || DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-context-instructions').val(extensionSettings.customContextInstructionsPrompt || DEFAULT_PROMPTS.contextInstructions);
$('#rpg-prompt-plot-random').val(extensionSettings.customPlotRandomPrompt || DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(extensionSettings.customPlotNaturalPrompt || DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(extensionSettings.avatarLLMCustomInstruction || DEFAULT_PROMPTS.avatar);
@@ -150,9 +156,11 @@ function savePrompts() {
extensionSettings.customHtmlPrompt = $('#rpg-prompt-html').val().trim();
extensionSettings.customDialogueColoringPrompt = $('#rpg-prompt-dialogue-coloring').val().trim();
extensionSettings.customDeceptionPrompt = $('#rpg-prompt-deception').val().trim();
extensionSettings.customOmnisciencePrompt = $('#rpg-prompt-omniscience').val().trim();
extensionSettings.customCYOAPrompt = $('#rpg-prompt-cyoa').val().trim();
extensionSettings.customSpotifyPrompt = $('#rpg-prompt-spotify').val().trim();
extensionSettings.customNarratorPrompt = $('#rpg-prompt-narrator').val().trim();
extensionSettings.customContextInstructionsPrompt = $('#rpg-prompt-context-instructions').val().trim();
extensionSettings.customPlotRandomPrompt = $('#rpg-prompt-plot-random').val().trim();
extensionSettings.customPlotNaturalPrompt = $('#rpg-prompt-plot-natural').val().trim();
extensionSettings.avatarLLMCustomInstruction = $('#rpg-prompt-avatar').val().trim();
@@ -182,6 +190,9 @@ function restorePromptToDefault(promptType) {
case 'deception':
extensionSettings.customDeceptionPrompt = '';
break;
case 'omniscience':
extensionSettings.customOmnisciencePrompt = '';
break;
case 'cyoa':
extensionSettings.customCYOAPrompt = '';
break;
@@ -191,6 +202,9 @@ function restorePromptToDefault(promptType) {
case 'narrator':
extensionSettings.customNarratorPrompt = '';
break;
case 'contextInstructions':
extensionSettings.customContextInstructionsPrompt = '';
break;
case 'plotRandom':
extensionSettings.customPlotRandomPrompt = '';
break;
@@ -221,9 +235,11 @@ function restoreAllToDefaults() {
$('#rpg-prompt-html').val(DEFAULT_PROMPTS.html);
$('#rpg-prompt-dialogue-coloring').val(DEFAULT_PROMPTS.dialogueColoring);
$('#rpg-prompt-deception').val(DEFAULT_PROMPTS.deception);
$('#rpg-prompt-omniscience').val(DEFAULT_PROMPTS.omniscience);
$('#rpg-prompt-cyoa').val(DEFAULT_PROMPTS.cyoa);
$('#rpg-prompt-spotify').val(DEFAULT_PROMPTS.spotify);
$('#rpg-prompt-narrator').val(DEFAULT_PROMPTS.narrator);
$('#rpg-prompt-context-instructions').val(DEFAULT_PROMPTS.contextInstructions);
$('#rpg-prompt-plot-random').val(DEFAULT_PROMPTS.plotRandom);
$('#rpg-prompt-plot-natural').val(DEFAULT_PROMPTS.plotNatural);
$('#rpg-prompt-avatar').val(DEFAULT_PROMPTS.avatar);
@@ -235,9 +251,11 @@ function restoreAllToDefaults() {
extensionSettings.customHtmlPrompt = '';
extensionSettings.customDialogueColoringPrompt = '';
extensionSettings.customDeceptionPrompt = '';
extensionSettings.customOmnisciencePrompt = '';
extensionSettings.customCYOAPrompt = '';
extensionSettings.customSpotifyPrompt = '';
extensionSettings.customNarratorPrompt = '';
extensionSettings.customContextInstructionsPrompt = '';
extensionSettings.customPlotRandomPrompt = '';
extensionSettings.customPlotNaturalPrompt = '';
extensionSettings.avatarLLMCustomInstruction = '';
+405
View File
@@ -0,0 +1,405 @@
/**
* Settings UI Event Listeners Module
* Handles all settings toggle/change event bindings for the RPG Companion panel.
* Extracted from index.js initUI() to reduce main entry point size.
*/
import { extensionSettings, updateExtensionSettings } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
/* eslint-disable no-unused-vars */
/* These functions receive jQuery-wrapped DOM elements from initUI() */
/* eslint-disable no-unused-vars */
/**
* Bind all settings panel event listeners.
* Called once during UI initialization after the DOM is ready.
*
* @param {Object} $ - jQuery (injected from SillyTavern global scope)
*/
export function bindSettingsListeners($) {
// Auto-update toggle
$('#rpg-toggle-auto-update').on('change', function() {
extensionSettings.autoUpdate = $(this).prop('checked');
saveSettings();
});
// Panel position selector
$('#rpg-position-select').on('change', function() {
extensionSettings.panelPosition = String($(this).val());
saveSettings();
applyPanelPosition();
updateChatThoughts();
});
// Update depth selector
$('#rpg-update-depth').on('change', function() {
const value = $(this).val();
extensionSettings.updateDepth = parseInt(String(value));
saveSettings();
});
// Generation mode selector
$('#rpg-generation-mode').on('change', async function() {
extensionSettings.generationMode = String($(this).val());
saveSettings();
updateGenerationModeUI();
});
// Section visibility toggles
bindSectionToggle($, 'rpg-toggle-user-stats', 'showUserStats', () => {});
bindSectionToggle($, 'rpg-toggle-info-box', 'showInfoBox', () => {});
bindSectionToggle($, 'rpg-toggle-inventory', 'showInventory', () => {});
bindSectionToggle($, 'rpg-toggle-equipment', 'showEquipment', () => {});
bindSectionToggle($, 'rpg-toggle-quests', 'showQuests', () => {});
// Thoughts section with render callback
$('#rpg-toggle-thoughts').on('change', function() {
extensionSettings.showCharacterThoughts = $(this).prop('checked');
saveSettings();
updateSectionVisibility();
renderThoughts();
});
// Alternate present characters
$('#rpg-toggle-alt-present-characters').on('change', function() {
extensionSettings.showAlternatePresentCharactersPanel = $(this).prop('checked');
saveSettings();
renderThoughts();
onAlternatePresentCharactersVisibilityChanged();
});
// Thought-based expressions
$('#rpg-toggle-thought-based-expressions').on('change', function() {
extensionSettings.enableThoughtBasedExpressions = $(this).prop('checked');
saveSettings();
onThoughtBasedExpressionsSettingChanged(extensionSettings.enableThoughtBasedExpressions);
});
// Hide default expressions
$('#rpg-toggle-hide-default-expressions').on('change', function() {
extensionSettings.hideDefaultExpressionDisplay = $(this).prop('checked');
saveSettings();
onHideDefaultExpressionDisplaySettingChanged(extensionSettings.hideDefaultExpressionDisplay);
});
// Lock icons toggle - re-renders all sections
$('#rpg-toggle-lock-icons').on('change', function() {
extensionSettings.showLockIcons = $(this).prop('checked');
saveSettings();
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
});
// Thoughts in chat toggle
$('#rpg-toggle-thoughts-in-chat').on('change', function() {
extensionSettings.showThoughtsInChat = $(this).prop('checked');
saveSettings();
updateChatThoughts();
});
// Inline thoughts style
$('#rpg-toggle-inline-thoughts').on('change', function() {
extensionSettings.thoughtsInChatStyle = $(this).prop('checked') ? 'inline' : 'corner';
saveSettings();
updateChatThoughts();
});
// HTML prompt toggle
$('#rpg-toggle-html-prompt').on('change', function() {
extensionSettings.enableHtmlPrompt = $(this).prop('checked');
saveSettings();
});
// Dialogue coloring
$('#rpg-toggle-dialogue-coloring').on('change', function() {
extensionSettings.enableDialogueColoring = $(this).prop('checked');
saveSettings();
});
// Deception system
$('#rpg-toggle-deception').on('change', function() {
extensionSettings.enableDeceptionSystem = $(this).prop('checked');
saveSettings();
});
// Omniscience filter
$('#rpg-toggle-omniscience').on('change', function() {
extensionSettings.enableOmniscienceFilter = $(this).prop('checked');
saveSettings();
});
// CYOA mode
$('#rpg-toggle-cyoa').on('change', function() {
extensionSettings.enableCYOA = $(this).prop('checked');
saveSettings();
});
// Spotify music
$('#rpg-toggle-spotify-music').on('change', function() {
extensionSettings.enableSpotifyMusic = $(this).prop('checked');
saveSettings();
updateSectionVisibility();
renderMusicPlayer($musicPlayerContainer[0]);
});
// Dynamic weather
$('#rpg-toggle-dynamic-weather').on('change', function() {
extensionSettings.enableDynamicWeather = $(this).prop('checked');
saveSettings();
toggleDynamicWeather(extensionSettings.enableDynamicWeather);
});
// Narrator mode
$('#rpg-toggle-narrator').on('change', function() {
extensionSettings.narratorMode = $(this).prop('checked');
saveSettings();
});
// Dismiss holiday promo
$('#rpg-dismiss-promo').on('click', function() {
extensionSettings.dismissedHolidayPromo = true;
saveSettings();
$('#rpg-holiday-promo').fadeOut(300);
});
// Skip injections for guided mode
$('#rpg-skip-guided-mode').on('change', function() {
extensionSettings.skipInjectionsForGuided = String($(this).val());
saveSettings();
});
// Save tracker history
$('#rpg-save-tracker-history').on('change', function() {
extensionSettings.saveTrackerHistory = $(this).prop('checked');
saveSettings();
});
// Randomized plot
$('#rpg-toggle-randomized-plot').on('change', function() {
extensionSettings.enableRandomizedPlot = $(this).prop('checked');
saveSettings();
togglePlotButtons();
});
// Natural plot
$('#rpg-toggle-natural-plot').on('change', function() {
extensionSettings.enableNaturalPlot = $(this).prop('checked');
saveSettings();
togglePlotButtons();
});
// Encounters toggle
$('#rpg-toggle-encounters').on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = { enabled: true, historyDepth: 8, autoSaveLogs: true };
}
extensionSettings.encounterSettings.enabled = $(this).prop('checked');
saveSettings();
togglePlotButtons();
});
// Encounter history depth
$('#rpg-encounter-history-depth').on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = { enabled: true, historyDepth: 8, autoSaveLogs: true };
}
const value = $(this).val();
extensionSettings.encounterSettings.historyDepth = parseInt(String(value));
saveSettings();
});
// Auto-save logs
$('#rpg-toggle-autosave-logs').on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = { enabled: true, historyDepth: 8, autoSaveLogs: true };
}
extensionSettings.encounterSettings.autoSaveLogs = $(this).prop('checked');
saveSettings();
});
// Combat narrative style settings
bindCombatNarrativeSetting($, 'rpg-combat-tense', 'tense');
bindCombatNarrativeSetting($, 'rpg-combat-person', 'person');
bindCombatNarrativeSetting($, 'rpg-combat-narration', 'narration');
bindCombatNarrativeSetting($, 'rpg-combat-pov', 'pov');
// Summary narrative style settings
bindSummaryNarrativeSetting($, 'rpg-summary-tense', 'tense');
bindSummaryNarrativeSetting($, 'rpg-summary-person', 'person');
bindSummaryNarrativeSetting($, 'rpg-summary-narration', 'narration');
bindSummaryNarrativeSetting($, 'rpg-summary-pov', 'pov');
// Theme selector
$('#rpg-theme-select').on('change', function() {
extensionSettings.theme = String($(this).val());
saveSettings();
applyTheme();
updateSettingsPopupTheme();
});
// Custom theme toggle
$('#rpg-toggle-custom-theme').on('change', function() {
extensionSettings.enableCustomTheme = $(this).prop('checked');
saveSettings();
toggleCustomColors();
});
// Custom theme color pickers
$('#rpg-custom-bg-color').on('input', function() {
extensionSettings.customBgColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-accent-color').on('input', function() {
extensionSettings.customAccentColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-text-color').on('input', function() {
extensionSettings.customTextColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-highlight-color').on('input', function() {
extensionSettings.customHighlightColor = $(this).val();
saveSettings();
applyCustomTheme();
});
$('#rpg-custom-border-color').on('input', function() {
extensionSettings.customBorderColor = $(this).val();
saveSettings();
applyCustomTheme();
});
// Custom reset button
$('#rpg-reset-custom-theme').on('click', function() {
extensionSettings.customBgColor = '';
extensionSettings.customAccentColor = '';
extensionSettings.customTextColor = '';
extensionSettings.customHighlightColor = '';
extensionSettings.customBorderColor = '';
saveSettings();
applyTheme();
updateSettingsPopupTheme();
});
// Animations toggle
$('#rpg-toggle-animations').on('change', function() {
extensionSettings.enableAnimations = $(this).prop('checked');
saveSettings();
toggleAnimations();
});
// Panel width
$('#rpg-panel-width').on('change', function() {
extensionSettings.panelWidth = $(this).val();
saveSettings();
updatePanelVisibility();
});
// Feature toggles visibility
$('#rpg-toggle-feature-toggles').on('change', function() {
extensionSettings.showFeatureToggles = $(this).prop('checked');
saveSettings();
updateFeatureTogglesVisibility();
});
// Weather sub-options visibility toggle
$('#rpg-toggle-dynamic-weather-suboptions').on('change', function() {
extensionSettings.showDynamicWeatherToggle = $(this).prop('checked');
saveSettings();
updateWeatherSubOptionsVisibility();
});
// Strip widgets
bindStripWidgetToggle($, 'rpg-strip-widget-weather', 'weatherIcon');
bindStripWidgetToggle($, 'rpg-strip-widget-clock', 'clock');
bindStripWidgetToggle($, 'rpg-strip-widget-date', 'date');
bindStripWidgetToggle($, 'rpg-strip-widget-location', 'location');
bindStripWidgetToggle($, 'rpg-strip-widget-stats', 'stats');
bindStripWidgetToggle($, 'rpg-strip-widget-attributes', 'attributes');
// Mobile panel position
$('#rpg-mobile-position-select').on('change', function() {
extensionSettings.mobilePanelPosition = String($(this).val());
saveSettings();
updateMobilePanelPosition();
});
}
/**
* Helper: Bind a simple section visibility toggle
*/
function bindSectionToggle($, elementId, settingKey, callback) {
$(`#${elementId}`).on('change', function() {
extensionSettings[settingKey] = $(this).prop('checked');
saveSettings();
updateSectionVisibility();
if (callback) callback();
});
}
/**
* Helper: Bind combat narrative setting
*/
function bindCombatNarrativeSetting($, elementId, key) {
$(`#${elementId}`).on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = {};
}
if (!extensionSettings.encounterSettings.combatNarrative) {
extensionSettings.encounterSettings.combatNarrative = {};
}
extensionSettings.encounterSettings.combatNarrative[key] = $(this).val();
saveSettings();
});
}
/**
* Helper: Bind summary narrative setting
*/
function bindSummaryNarrativeSetting($, elementId, key) {
$(`#${elementId}`).on('change', function() {
if (!extensionSettings.encounterSettings) {
extensionSettings.encounterSettings = {};
}
if (!extensionSettings.encounterSettings.summaryNarrative) {
extensionSettings.encounterSettings.summaryNarrative = {};
}
extensionSettings.encounterSettings.summaryNarrative[key] = $(this).val();
saveSettings();
});
}
/**
* Helper: Bind strip widget toggle
*/
function bindStripWidgetToggle($, elementId, widgetKey) {
$(`#${elementId}`).on('change', function() {
if (!extensionSettings.stripWidgets) {
extensionSettings.stripWidgets = { weatherIcon: true, clock: true, date: true, location: true, stats: true, attributes: true };
}
extensionSettings.stripWidgets[widgetKey] = $(this).prop('checked');
saveSettings();
updateStripWidgets();
});
}
/**
* Updates the visibility of weather sub-options in settings based on dynamic weather toggle
*/
export function updateWeatherSubOptionsVisibility() {
const $weatherSubOptions = $('#rpg-weather-suboptions');
const isDynamicWeatherEnabled = extensionSettings.showDynamicWeatherToggle ?? true;
if (isDynamicWeatherEnabled) {
$weatherSubOptions.show();
} else {
$weatherSubOptions.hide();
}
}
+60 -12
View File
@@ -4,6 +4,38 @@
*/
import { extensionSettings, $panelContainer } from '../../core/state.js';
import { syncAlternatePresentCharactersTheme } from './alternatePresentCharacters.js';
/**
* Converts hex color and opacity percentage to rgba string
* @param {string} hex - Hex color (e.g., '#ff0000')
* @param {number} opacity - Opacity percentage (0-100)
* @returns {string} - RGBA color string
*/
export function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const a = opacity / 100;
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
/**
* Gets stat bar colors with opacity applied
* @returns {{low: string, high: string}} RGBA color strings for stat bars
*/
export function getStatBarColors() {
return {
low: hexToRgba(
extensionSettings.statBarColorLow || '#cc3333',
extensionSettings.statBarColorLowOpacity ?? 100
),
high: hexToRgba(
extensionSettings.statBarColorHigh || '#33cc66',
extensionSettings.statBarColorHighOpacity ?? 100
)
};
}
/**
* Applies the selected theme to the panel.
@@ -65,6 +97,8 @@ export function applyTheme() {
$thoughtPanel.attr('data-theme', theme);
}
}
syncAlternatePresentCharactersTheme();
}
/**
@@ -75,24 +109,33 @@ export function applyCustomTheme() {
const colors = extensionSettings.customColors;
// Convert hex colors with opacity to rgba
const bgColor = hexToRgba(colors.bg, colors.bgOpacity ?? 100);
const accentColor = hexToRgba(colors.accent, colors.accentOpacity ?? 100);
const textColor = hexToRgba(colors.text, colors.textOpacity ?? 100);
const highlightColor = hexToRgba(colors.highlight, colors.highlightOpacity ?? 100);
// Create shadow with 50% opacity of highlight color
const shadowColor = hexToRgba(colors.highlight, (colors.highlightOpacity ?? 100) * 0.5);
// Apply custom CSS variables as inline styles to main panel
$panelContainer.css({
'--rpg-bg': colors.bg,
'--rpg-accent': colors.accent,
'--rpg-text': colors.text,
'--rpg-highlight': colors.highlight,
'--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
});
// Apply custom colors to mobile toggle and thought elements
const customStyles = {
'--rpg-bg': colors.bg,
'--rpg-accent': colors.accent,
'--rpg-text': colors.text,
'--rpg-highlight': colors.highlight,
'--rpg-border': colors.highlight,
'--rpg-shadow': `${colors.highlight}80`
'--rpg-bg': bgColor,
'--rpg-accent': accentColor,
'--rpg-text': textColor,
'--rpg-highlight': highlightColor,
'--rpg-border': highlightColor,
'--rpg-shadow': shadowColor
};
const $mobileToggle = $('#rpg-mobile-toggle');
@@ -110,6 +153,8 @@ export function applyCustomTheme() {
if ($thoughtPanel.length) {
$thoughtPanel.attr('data-theme', 'custom').css(customStyles);
}
syncAlternatePresentCharactersTheme();
}
/**
@@ -139,6 +184,7 @@ export function updateFeatureTogglesVisibility() {
const $htmlToggle = $('#rpg-html-toggle-wrapper');
const $dialogueColoringToggle = $('#rpg-dialogue-coloring-toggle-wrapper');
const $deceptionToggle = $('#rpg-deception-toggle-wrapper');
const $omniscienceToggle = $('#rpg-omniscience-toggle-wrapper');
const $cyoaToggle = $('#rpg-cyoa-toggle-wrapper');
const $spotifyToggle = $('#rpg-spotify-toggle-wrapper');
@@ -150,6 +196,7 @@ export function updateFeatureTogglesVisibility() {
$htmlToggle.toggle(extensionSettings.showHtmlToggle);
$dialogueColoringToggle.toggle(extensionSettings.showDialogueColoringToggle);
$deceptionToggle.toggle(extensionSettings.showDeceptionToggle ?? true);
$omniscienceToggle.toggle(extensionSettings.showOmniscienceToggle ?? true);
$cyoaToggle.toggle(extensionSettings.showCYOAToggle ?? true);
$spotifyToggle.toggle(extensionSettings.showSpotifyToggle);
@@ -161,6 +208,7 @@ export function updateFeatureTogglesVisibility() {
const anyVisible = extensionSettings.showHtmlToggle ||
extensionSettings.showDialogueColoringToggle ||
(extensionSettings.showDeceptionToggle ?? true) ||
(extensionSettings.showOmniscienceToggle ?? true) ||
(extensionSettings.showCYOAToggle ?? true) ||
extensionSettings.showSpotifyToggle ||
extensionSettings.showDynamicWeatherToggle ||
+88 -49
View File
@@ -31,6 +31,7 @@ import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { updateFabWidgets } from './mobile.js';
import { safeToSnake } from '../../utils/transformations.js';
let $editorModal = null;
let activeTab = 'userStats';
@@ -38,6 +39,36 @@ let tempConfig = null; // Temporary config for cancel functionality
let tempAssociation = null; // Temporary association state: { presetId: string|null, entityKey: string|null }
let originalAssociation = null; // Original association when editor opened
function set_ids_names(list_with_stats, index, value) {
list_with_stats[index].name = value;
const item = list_with_stats[index];
const oldId = item?.id;
item.name = value;
const ids = list_with_stats.filter((_, i) => i !== index).map(stat => stat.id);
const snake_value = safeToSnake(value); // new id format
if (snake_value !== value && !ids.includes(snake_value)) { // check if this id already exists
item.id = snake_value;
}
const newId = item.id;
// If the ID changed, migrate any stored values keyed by the old ID
if (oldId && newId && oldId !== newId) {
if (extensionSettings.userStats && Object.prototype.hasOwnProperty.call(extensionSettings.userStats, oldId)) {
extensionSettings.userStats[newId] = extensionSettings.userStats[oldId];
delete extensionSettings.userStats[oldId];
}
if (extensionSettings.classicStats && Object.prototype.hasOwnProperty.call(extensionSettings.classicStats, oldId)) {
extensionSettings.classicStats[newId] = extensionSettings.classicStats[oldId];
delete extensionSettings.classicStats[oldId];
}
}
return list_with_stats;
}
/**
* Initialize the tracker editor modal
*/
@@ -225,7 +256,7 @@ function updatePresetUI() {
// Update the default button appearance
const $defaultBtn = $('#rpg-preset-default');
if (isDefaultPreset(activePresetId)) {
$defaultBtn.addClass('rpg-btn-active').attr('title', 'This is the default preset');
$defaultBtn.addClass('rpg-btn-active').attr('title', i18n.getTranslation('preset.defaultPresetDescription') || 'This is the default preset');
} else {
$defaultBtn.removeClass('rpg-btn-active').attr('title', 'Set as Default Preset');
}
@@ -334,20 +365,20 @@ function resetToDefaults() {
extensionSettings.trackerConfig = {
userStats: {
customStats: [
{ id: 'health', name: 'Health', enabled: true, persistInHistory: false },
{ id: 'satiety', name: 'Satiety', enabled: true, persistInHistory: false },
{ id: 'energy', name: 'Energy', enabled: true, persistInHistory: false },
{ id: 'hygiene', name: 'Hygiene', enabled: true, persistInHistory: false },
{ id: 'arousal', name: 'Arousal', enabled: true, persistInHistory: false }
{ id: 'health', name: i18n.getTranslation('stats.health') || 'Health', enabled: true, persistInHistory: false },
{ id: 'satiety', name: i18n.getTranslation('stats.satiety') || 'Satiety', enabled: true, persistInHistory: false },
{ id: 'energy', name: i18n.getTranslation('stats.energy') || 'Energy', enabled: true, persistInHistory: false },
{ id: 'hygiene', name: i18n.getTranslation('stats.hygiene') || 'Hygiene', enabled: true, persistInHistory: false },
{ id: 'arousal', name: i18n.getTranslation('stats.arousal') || 'Arousal', enabled: true, persistInHistory: false }
],
showRPGAttributes: true,
rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: 'WIS', enabled: true, persistInHistory: false },
{ id: 'cha', name: 'CHA', enabled: true, persistInHistory: false }
{ id: 'str', name: i18n.getTranslation('stats.str') || 'STR', enabled: true, persistInHistory: false },
{ id: 'dex', name: i18n.getTranslation('stats.dex') || 'DEX', enabled: true, persistInHistory: false },
{ id: 'con', name: i18n.getTranslation('stats.con') || 'CON', enabled: true, persistInHistory: false },
{ id: 'int', name: i18n.getTranslation('stats.int') || 'INT', enabled: true, persistInHistory: false },
{ id: 'wis', name: i18n.getTranslation('stats.wis'), enabled: true, persistInHistory: false },
{ id: 'cha', name: i18n.getTranslation('stats.cha'), enabled: true, persistInHistory: false }
],
statusSection: {
enabled: true,
@@ -408,8 +439,8 @@ function resetToDefaults() {
characterStats: {
enabled: false,
customStats: [
{ id: 'health', name: 'Health', enabled: true, colorLow: '#ff4444', colorHigh: '#44ff44' },
{ id: 'energy', name: 'Energy', enabled: true, colorLow: '#ffaa00', colorHigh: '#44ffff' }
{ id: 'health', name: i18n.getTranslation('stats.health'), enabled: true, colorLow: '#ff4444', colorHigh: '#44ff44' },
{ id: 'energy', name: i18n.getTranslation('stats.energy'), enabled: true, colorLow: '#ffaa00', colorHigh: '#44ffff' }
]
}
}
@@ -635,7 +666,7 @@ function showImportModeDialog(migratedConfig, suggestedName, historyPersistence
</button>
<button id="rpg-import-as-new" class="rpg-btn-primary">
<i class="fa-solid fa-plus"></i>
Create New Preset
${i18n.getTranslation('preset.createNewPresetTitle') || 'Create New Preset'}
</button>
</div>
<button id="rpg-import-cancel" class="rpg-btn-cancel">Cancel</button>
@@ -728,15 +759,15 @@ function renderUserStatsTab() {
let html = '<div class="rpg-editor-section">';
// Custom Stats section
html += `<h4><i class="fa-solid fa-heart-pulse"></i> ${i18n.getTranslation('template.trackerEditorModal.userStatsTab.customStatsTitle')}</h4>`;
html += `<h4><i class="fa-solid fa-heart-pulse"></i> ${i18n.getTranslation('template.trackerEditorModal.userStatsTab.customStatsTitle') || 'Custom Stats'}</h4>`;
// Stats display mode toggle
const statsDisplayMode = config.statsDisplayMode || 'percentage';
html += '<div class="rpg-editor-toggle-row">';
html += '<label>Display Mode:</label>';
html += '<label>' + (i18n.getTranslation('stats.displayMode') || 'Display Mode:') + '</label>';
html += '<div class="rpg-radio-group">';
html += `<label><input type="radio" name="stats-display-mode" value="percentage" ${statsDisplayMode === 'percentage' ? 'checked' : ''}> Percentage</label>`;
html += `<label><input type="radio" name="stats-display-mode" value="number" ${statsDisplayMode === 'number' ? 'checked' : ''}> Number</label>`;
html += `<label><input type="radio" name="stats-display-mode" value="percentage" ${statsDisplayMode === 'percentage' ? 'checked' : ''}> ${i18n.getTranslation('stats.displayMode.percentage') || 'Percentage'}</label>`;
html += `<label><input type="radio" name="stats-display-mode" value="number" ${statsDisplayMode === 'number' ? 'checked' : ''}> ${i18n.getTranslation('stats.displayMode.number') || 'Number'}</label>`;
html += '</div>';
html += '</div>';
@@ -772,7 +803,7 @@ function renderUserStatsTab() {
const showLevel = config.showLevel !== undefined ? config.showLevel : true;
html += '<div class="rpg-editor-toggle-row">';
html += `<input type="checkbox" id="rpg-show-level" ${showLevel ? 'checked' : ''}>`;
html += `<label for="rpg-show-level">Show Level</label>`;
html += `<label for="rpg-show-level">${i18n.getTranslation('stats.showLevel') || 'Show Level'}</label>`;
html += '</div>';
// Always send attributes toggle
@@ -885,7 +916,9 @@ function setupUserStatsListeners() {
// Rename stat
$('.rpg-stat-name').off('blur').on('blur', function () {
const index = $(this).data('index');
extensionSettings.trackerConfig.userStats.customStats[index].name = $(this).val();
const value = $(this).val();
const list_with_stats = extensionSettings.trackerConfig.userStats.customStats
set_ids_names(list_with_stats, index, value);
});
// Change stat max value
@@ -943,7 +976,9 @@ function setupUserStatsListeners() {
// Rename attribute
$('.rpg-attr-name').off('blur').on('blur', function () {
const index = $(this).data('index');
extensionSettings.trackerConfig.userStats.rpgAttributes[index].name = $(this).val();
const value = $(this).val();
const list_with_stats = extensionSettings.trackerConfig.userStats.rpgAttributes
set_ids_names(list_with_stats, index, value);
});
// Enable/disable RPG Attributes section toggle
@@ -1006,8 +1041,8 @@ function renderInfoBoxTab() {
html += `<input type="checkbox" id="rpg-widget-date" ${config.widgets.date.enabled ? 'checked' : ''}>`;
html += `<label for="rpg-widget-date">${i18n.getTranslation('template.trackerEditorModal.infoBoxTab.dateWidget')}</label>`;
html += '<select id="rpg-date-format" class="rpg-select-mini">';
html += `<option value="Weekday, Month, Year" ${config.widgets.date.format === 'Weekday, Month, Year' ? 'selected' : ''}>Weekday, Month, Year</option>`;
html += `<option value="Day (Numerical), Month, Year" ${config.widgets.date.format === 'Day (Numerical), Month, Year' ? 'selected' : ''}>Day (Numerical), Month, Year</option>`;
html += `<option value="Weekday, Month, Year" ${config.widgets.date.format === 'Weekday, Month, Year' ? 'selected' : ''}>${i18n.getTranslation('dateFormat.weekdayMonthYear') || 'Weekday, Month, Year'}</option>`;
html += `<option value="Day (Numerical), Month, Year" ${config.widgets.date.format === 'Day (Numerical), Month, Year' ? 'selected' : ''}>${i18n.getTranslation('dateFormat.dayNumericalMonthYear') || 'Day (Numerical), Month, Year'}</option>`;
html += '</select>';
html += '</div>';
@@ -1394,7 +1429,9 @@ function setupPresentCharactersListeners() {
// Rename field
$('.rpg-field-label').off('blur').on('blur', function () {
const index = $(this).data('index');
extensionSettings.trackerConfig.presentCharacters.customFields[index].name = $(this).val();
const value = $(this).val();
const list_with_stats = extensionSettings.trackerConfig.presentCharacters.customFields
set_ids_names(list_with_stats, index, value);
});
// Update description
@@ -1443,7 +1480,9 @@ function setupPresentCharactersListeners() {
// Rename character stat
$('.rpg-char-stat-label').off('blur').on('blur', function () {
const index = $(this).data('index');
extensionSettings.trackerConfig.presentCharacters.characterStats.customStats[index].name = $(this).val();
const value = $(this).val();
const list_with_stats = extensionSettings.trackerConfig.presentCharacters.characterStats.customStats
set_ids_names(list_with_stats, index, value);
});
}
@@ -1467,48 +1506,48 @@ function renderHistoryPersistenceTab() {
let html = '<div class="rpg-editor-section">';
// Main toggle and settings
html += `<h4><i class="fa-solid fa-clock-rotate-left"></i> History Persistence Settings</h4>`;
html += `<p class="rpg-editor-hint">Inject selected tracker data into historical messages to help the AI maintain continuity for time-sensitive events, weather changes, and location tracking.</p>`;
html += `<h4><i class="fa-solid fa-clock-rotate-left"></i> ${i18n.getTranslation('historyPersistence.settingsTitle') || 'History Persistence Settings'}</h4>`;
html += `<p class="rpg-editor-hint">${i18n.getTranslation('historyPersistence.hint') || 'Inject selected tracker data into historical messages to help the AI maintain continuity for time-sensitive events, weather changes, and location tracking.'}</p>`;
// Enable toggle
html += '<div class="rpg-editor-toggle-row">';
html += `<input type="checkbox" id="rpg-history-persistence-enabled" ${historyPersistence.enabled ? 'checked' : ''}>`;
html += `<label for="rpg-history-persistence-enabled">Enable History Persistence</label>`;
html += `<label for="rpg-history-persistence-enabled">${i18n.getTranslation('historyPersistence.enable') || 'Enable History Persistence'}</label>`;
html += '</div>';
// External API Only toggle - only show for separate/external modes
if (generationMode === 'separate' || generationMode === 'external') {
html += '<div class="rpg-editor-toggle-row" style="margin-top: 8px;">';
html += `<input type="checkbox" id="rpg-history-send-all-enabled" ${historyPersistence.sendAllEnabledOnRefresh ? 'checked' : ''}>`;
html += `<label for="rpg-history-send-all-enabled">Send All Enabled Stats on Refresh</label>`;
html += `<label for="rpg-history-send-all-enabled">${i18n.getTranslation('historyPersistence.sendAllEnabledStats') || 'Send All Enabled Stats on Refresh'}</label>`;
html += '</div>';
html += `<p class="rpg-editor-hint" style="margin-top: 4px; margin-left: 24px;">When enabled, Refresh RPG Info will include all enabled stats from the preset in history context, ignoring the individual selections below.</p>`;
html += `<p class="rpg-editor-hint" style="margin-top: 4px; margin-left: 24px;">${i18n.getTranslation('historyPersistence.sendAllEnabledStatsHint') || 'When enabled, Refresh RPG Info will include all enabled stats from the preset in history context, ignoring the individual selections below.'}</p>`;
}
// Message count
html += '<div class="rpg-editor-input-row" style="margin-top: 12px;">';
html += `<label for="rpg-history-message-count">Number of messages to include (0 = all available):</label>`;
html += `<label for="rpg-history-message-count">${i18n.getTranslation('historyPersistence.numberOfMessages') || 'Number of messages to include (0 = all available):'}</label>`;
html += `<input type="number" id="rpg-history-message-count" min="0" max="50" value="${historyPersistence.messageCount}" class="rpg-input" style="width: 80px; margin-left: 8px;">`;
html += '</div>';
// Injection position
html += '<div class="rpg-editor-input-row" style="margin-top: 12px;">';
html += `<label for="rpg-history-injection-position">Injection Position:</label>`;
html += `<label for="rpg-history-injection-position">${i18n.getTranslation('historyPersistence.injectionPosition') || 'Injection Position:'}</label>`;
html += `<select id="rpg-history-injection-position" class="rpg-select" style="margin-left: 8px;">`;
html += `<option value="user_message_end" ${historyPersistence.injectionPosition === 'user_message_end' ? 'selected' : ''}>End of the User's Message</option>`;
html += `<option value="assistant_message_end" ${historyPersistence.injectionPosition === 'assistant_message_end' ? 'selected' : ''}>End of the Assistant's Message</option>`;
html += `<option value="user_message_end" ${historyPersistence.injectionPosition === 'user_message_end' ? 'selected' : ''}>${i18n.getTranslation('historyPersistence.injectionPosition.userMessageEnd') || 'End of the User\'s Message'}</option>`;
html += `<option value="assistant_message_end" ${historyPersistence.injectionPosition === 'assistant_message_end' ? 'selected' : ''}>${i18n.getTranslation('historyPersistence.injectionPosition.assistantMessageEnd') || 'End of the Assistant\'s Message'}</option>`;
html += `</select>`;
html += '</div>';
// Custom preamble
html += '<div class="rpg-editor-input-row" style="margin-top: 12px;">';
html += `<label for="rpg-history-context-preamble">Custom Context Preamble:</label>`;
html += `<input type="text" id="rpg-history-context-preamble" value="${historyPersistence.contextPreamble || ''}" class="rpg-text-input" placeholder="Context for that moment:" style="width: 100%; margin-top: 4px;">`;
html += `<label for="rpg-history-context-preamble">${i18n.getTranslation('historyPersistence.customContextPreamble') || 'Custom Context Preamble:'}</label>`;
html += `<input type="text" id="rpg-history-context-preamble" value="${historyPersistence.contextPreamble || ''}" class="rpg-text-input" placeholder="${i18n.getTranslation('historyPersistence.customContextPreamblePlaceholder') || 'Context for that moment:'}" style="width: 100%; margin-top: 4px;">`;
html += '</div>';
// User Stats section - which stats to persist
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-heart-pulse"></i> User Stats</h4>`;
html += `<p class="rpg-editor-hint">Select which stats should be included in historical messages.</p>`;
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-heart-pulse"></i> ${i18n.getTranslation('historyPersistence.userStatsSection') || 'User Stats'}</h4>`;
html += `<p class="rpg-editor-hint">${i18n.getTranslation('historyPersistence.userStatsHint') || 'Select which stats should be included in historical messages.'}</p>`;
// Custom stats
html += '<div class="rpg-history-persist-list">';
@@ -1528,7 +1567,7 @@ function renderHistoryPersistenceTab() {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-status" ${userStatsConfig.statusSection.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-status">Status (Mood/Conditions)</label>
<label for="rpg-history-status">${i18n.getTranslation('historyPersistence.statusSection') || 'Status (Mood/Conditions)'}</label>
</div>
`;
}
@@ -1538,7 +1577,7 @@ function renderHistoryPersistenceTab() {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-skills" ${userStatsConfig.skillsSection.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-skills">${userStatsConfig.skillsSection.label || 'Skills'}</label>
<label for="rpg-history-skills">${userStatsConfig.skillsSection.label || i18n.getTranslation('historyPersistence.skills') || 'Skills'}</label>
</div>
`;
}
@@ -1547,7 +1586,7 @@ function renderHistoryPersistenceTab() {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-inventory" ${userStatsConfig.inventoryPersistInHistory ? 'checked' : ''}>
<label for="rpg-history-inventory">Inventory</label>
<label for="rpg-history-inventory">${i18n.getTranslation('historyPersistence.inventory') || 'Inventory'}</label>
</div>
`;
@@ -1555,14 +1594,14 @@ function renderHistoryPersistenceTab() {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-quests" ${userStatsConfig.questsPersistInHistory ? 'checked' : ''}>
<label for="rpg-history-quests">Quests</label>
<label for="rpg-history-quests">${i18n.getTranslation('historyPersistence.quests') || 'Quests'}</label>
</div>
`;
html += '</div>';
// Info Box section - which widgets to persist
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-info-circle"></i> Info Box</h4>`;
html += `<p class="rpg-editor-hint">Select which info box fields should be included in historical messages. These are recommended for time tracking.</p>`;
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-info-circle"></i> ${i18n.getTranslation('historyPersistence.infoBoxSection') || 'Info Box'}</h4>`;
html += `<p class="rpg-editor-hint">${i18n.getTranslation('historyPersistence.infoBoxHint') || 'Select which info box fields should be included in historical messages. These are recommended for time tracking.'}</p>`;
html += '<div class="rpg-history-persist-list">';
const widgetLabels = {
@@ -1579,7 +1618,7 @@ function renderHistoryPersistenceTab() {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-widget-${widgetId}" class="rpg-history-widget-toggle" data-widget="${widgetId}" ${widget.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-widget-${widgetId}">${widgetLabels[widgetId] || widgetId}</label>
<label for="rpg-history-widget-${widgetId}">${i18n.getTranslation('historyPersistence.widget.' + widgetId) || widgetLabels[widgetId] || widgetId}</label>
</div>
`;
}
@@ -1587,8 +1626,8 @@ function renderHistoryPersistenceTab() {
html += '</div>';
// Present Characters section
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-users"></i> Present Characters</h4>`;
html += `<p class="rpg-editor-hint">Select which character fields should be included in historical messages.</p>`;
html += `<h4 style="margin-top: 20px;"><i class="fa-solid fa-users"></i> ${i18n.getTranslation('historyPersistence.presentCharactersSection') || 'Present Characters'}</h4>`;
html += `<p class="rpg-editor-hint">${i18n.getTranslation('historyPersistence.presentCharactersHint') || 'Select which character fields should be included in historical messages.'}</p>`;
html += '<div class="rpg-history-persist-list">';
@@ -1609,7 +1648,7 @@ function renderHistoryPersistenceTab() {
html += `
<div class="rpg-editor-toggle-row">
<input type="checkbox" id="rpg-history-thoughts" ${presentCharsConfig.thoughts.persistInHistory ? 'checked' : ''}>
<label for="rpg-history-thoughts">${presentCharsConfig.thoughts.name || 'Thoughts'}</label>
<label for="rpg-history-thoughts">${presentCharsConfig.thoughts.name || i18n.getTranslation('historyPersistence.thoughts') || 'Thoughts'}</label>
</div>
`;
}
+238 -31
View File
@@ -105,41 +105,116 @@ function getCurrentTime() {
return null;
}
// Patterns for specific weather conditions (order matters - combined effects first)
// Grouped by languages for easy editing
// EXPORTED: Used by jsonPromptHelpers.js to provide valid weather keywords to LLM
export const WEATHER_PATTERNS_BY_LANGUAGE = {
en: [
{ id: "blizzard", patterns: [ "blizzard" ] }, // Snow + Wind
{ id: "storm", patterns: [ "storm", "thunder", "lightning" ] }, // Rain + Lightning
{ id: "wind", patterns: [ "wind", "breeze", "gust", "gale" ] },
{ id: "snow", patterns: [ "snow", "flurries" ] },
{ id: "rain", patterns: [ "rain", "drizzle", "shower" ] },
{ id: "mist", patterns: [ "mist", "fog", "haze" ] },
{ id: "sunny", patterns: [ "sunny", "clear", "bright" ] },
{ id: "none", patterns: [ "cloud", "overcast", "indoor", "inside" ] },
],
ru: [
{ id: "blizzard", patterns: [ "метель" ] },
{ id: "storm", patterns: [ "гроза", "буря", "шторм" ] },
{ id: "wind", patterns: [ "ветер", "ветрено", "ветерок", "бриз", "легкий бриз", "слегка ветрено", "легкий ветер", "шквал,буря" ] },
{ id: "snow", patterns: [ "снег", "снегопад" ] },
{ id: "rain", patterns: [ "дождь", "морось", "ливень" ] },
{ id: "mist", patterns: [ "мгла", "туман", "туманно" ] },
{ id: "sunny", patterns: [ "солнечно", "ясно", "ярко", "ясное утро", "ясный день" ] },
{ id: "none", patterns: [ "облачно", "пасмурно", "в помещении", "внутри" ] },
],
"zh-cn": [
{ id: "blizzard", patterns: ["暴风雪"] },
{ id: "storm", patterns: ["风暴", "雷暴", "雷电"] },
{ id: "wind", patterns: ["风", "微风", "阵风", "大风"] },
{ id: "snow", patterns: ["雪", "小雪"] },
{ id: "rain", patterns: ["雨", "毛毛雨", "阵雨"] },
{ id: "mist", patterns: ["薄雾", "雾", "霾"] },
{ id: "sunny", patterns: ["晴朗", "晴天", "阳光明媚"] },
{ id: "none", patterns: ["多云", "阴天", "室内", "屋内"] },
],
}
/**
* Get valid weather keywords for LLM prompt injection.
* Returns weather patterns for specified language or all languages.
* This ensures LLM generates responses that exactly match our expected patterns.
*
* @param {string} [language] - Language code (e.g., 'en', 'ru'). If not specified, returns all languages.
* @returns {Object} Object with weather type IDs as keys and arrays of valid keywords as values
* @example
* // Returns: { blizzard: ["blizzard"], storm: ["storm", "thunder", "lightning"], ... }
* getWeatherKeywordsForPrompt('en');
*/
export function getWeatherKeywordsForPrompt(language) {
const result = {};
// Get patterns for specified language or merge all languages
const languagesToProcess = language && WEATHER_PATTERNS_BY_LANGUAGE[language]
? { [language]: WEATHER_PATTERNS_BY_LANGUAGE[language] }
: WEATHER_PATTERNS_BY_LANGUAGE;
for (const [lang, patterns] of Object.entries(languagesToProcess)) {
for (const { id, patterns: keywords } of patterns) {
if (!result[id]) {
result[id] = [];
}
// Add keywords, avoiding duplicates
for (const keyword of keywords) {
if (!result[id].includes(keyword)) {
result[id].push(keyword);
}
}
}
}
return result;
}
/**
* Get weather keywords as a formatted string for LLM instructions.
* Provides a clear template showing valid weather forecast values.
*
* @param {string} [language] - Language code. If not specified, uses all available patterns.
* @returns {string} Formatted string for prompt injection
* @example
* // Returns: 'Valid forecast values: "blizzard", "storm", "thunder", "lightning", "wind", ...'
* getWeatherKeywordsAsPromptString('en');
*/
export function getWeatherKeywordsAsPromptString(language) {
const keywords = getWeatherKeywordsForPrompt(language);
const allKeywords = [];
for (const patterns of Object.values(keywords)) {
allKeywords.push(...patterns);
}
return `Valid forecast values (use one of these exactly): ${allKeywords.map(k => `"${k}"`).join(', ')}`;
}
/**
* Parse weather text to determine effect type
*/
function parseWeatherType(weatherText) {
if (!weatherText) return 'none';
if (!weatherText) return "none";
const text = weatherText.toLowerCase();
// Check for specific weather conditions (order matters - check combined effects first)
if (text.includes('blizzard')) {
return 'blizzard'; // Snow + Wind
for (const language of Object.values(WEATHER_PATTERNS_BY_LANGUAGE)) {
for (const { id, patterns } of language) {
if (patterns.some(p => text.includes(p))) {
return id;
}
if (text.includes('storm') || text.includes('thunder') || text.includes('lightning')) {
return 'storm'; // Rain + Lightning
}
if (text.includes('wind') || text.includes('breeze') || text.includes('gust') || text.includes('gale')) {
return 'wind';
}
if (text.includes('snow') || text.includes('flurries')) {
return 'snow';
}
if (text.includes('rain') || text.includes('drizzle') || text.includes('shower')) {
return 'rain';
}
if (text.includes('mist') || text.includes('fog') || text.includes('haze')) {
return 'mist';
}
if (text.includes('sunny') || text.includes('clear') || text.includes('bright')) {
return 'sunny';
}
if (text.includes('cloud') || text.includes('overcast') || text.includes('indoor') || text.includes('inside')) {
return 'none';
}
return 'none';
return "none";
}
/**
@@ -237,9 +312,9 @@ function createMist() {
* Returns { left: vw%, top: dvh% }
*/
function calculateSunPosition(hour) {
// Daytime is roughly 6 AM to 8 PM (6-20)
// Daytime is roughly 5 AM to 8 PM (5-20)
// Map hour to position along an arc
// 6 AM = far left, low | 12 PM = center, high | 6 PM = far right, low
// 5 AM = far left, low | 12 PM = center, high | 8 PM = far right, low
if (hour === null) hour = 12; // Default to noon if unknown
@@ -249,14 +324,14 @@ function calculateSunPosition(hour) {
// Normalize to 0-1 range (5 AM = 0, 20 PM = 1)
const progress = (clampedHour - 5) / 15;
// Horizontal position: 5% to 85% (left to right)
const left = 5 + progress * 80;
// Horizontal position: 3% to 92% (left to right, wider range)
const left = 3 + progress * 89;
// Vertical position: parabolic arc (high at noon, low at dawn/dusk)
// At progress 0.5 (noon), top should be ~8% (high)
// At progress 0 or 1, top should be ~35% (low, near horizon)
// At progress 0 or 1, top should be ~40% (low, near horizon)
const normalizedProgress = (progress - 0.5) * 2; // -1 to 1
const top = 8 + 27 * (normalizedProgress * normalizedProgress);
const top = 8 + 32 * (normalizedProgress * normalizedProgress);
return { left, top };
}
@@ -327,6 +402,134 @@ function createSunshine(hour) {
return container;
}
/**
* Create sunrise effect (dawn - warm orange/pink sky gradient with low sun)
*/
function createSunrise(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-sunrise-weather';
// Create sunrise gradient overlay
const sunriseOverlay = document.createElement('div');
sunriseOverlay.className = 'rpg-weather-particle rpg-sunrise-overlay';
container.appendChild(sunriseOverlay);
// Calculate sun position (rising from left horizon)
const sunPos = calculateSunPosition(hour);
// Create the rising sun
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun rpg-sunrise-sun';
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
container.appendChild(sun);
// Create sun glow (more orange during sunrise)
const sunGlow = document.createElement('div');
sunGlow.className = 'rpg-weather-particle rpg-clear-sun-glow rpg-sunrise-glow';
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
container.appendChild(sunGlow);
// Create horizon glow
const horizonGlow = document.createElement('div');
horizonGlow.className = 'rpg-weather-particle rpg-sunrise-horizon-glow';
container.appendChild(horizonGlow);
// Add some fading stars (still visible at dawn)
for (let i = 0; i < 15; i++) {
const star = document.createElement('div');
star.className = 'rpg-weather-particle rpg-night-star rpg-sunrise-fading-star';
star.style.left = `${Math.random() * 100}vw`;
star.style.top = `${Math.random() * 40}dvh`;
star.style.animationDelay = `${Math.random() * 3}s`;
const size = 1 + Math.random() * 1.5;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
container.appendChild(star);
}
// Add some golden dust motes
for (let i = 0; i < 12; i++) {
const particle = document.createElement('div');
particle.className = 'rpg-weather-particle rpg-clear-dust-mote';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.top = `${Math.random() * 100}dvh`;
particle.style.animationDelay = `${Math.random() * 15}s`;
particle.style.animationDuration = `${12 + Math.random() * 8}s`;
const size = 2 + Math.random() * 3;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
container.appendChild(particle);
}
return container;
}
/**
* Create sunset effect (dusk - warm red/purple sky gradient with low sun)
*/
function createSunset(hour) {
const container = document.createElement('div');
container.className = 'rpg-weather-particles rpg-sunset-weather';
// Create sunset gradient overlay
const sunsetOverlay = document.createElement('div');
sunsetOverlay.className = 'rpg-weather-particle rpg-sunset-overlay';
container.appendChild(sunsetOverlay);
// Calculate sun position (setting on right horizon)
const sunPos = calculateSunPosition(hour);
// Create the setting sun
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun rpg-sunset-sun';
sun.style.left = `${sunPos.left}vw`;
sun.style.top = `${sunPos.top}dvh`;
container.appendChild(sun);
// Create sun glow (more red during sunset)
const sunGlow = document.createElement('div');
sunGlow.className = 'rpg-weather-particle rpg-clear-sun-glow rpg-sunset-glow';
sunGlow.style.left = `${sunPos.left}vw`;
sunGlow.style.top = `${sunPos.top}dvh`;
container.appendChild(sunGlow);
// Create horizon glow
const horizonGlow = document.createElement('div');
horizonGlow.className = 'rpg-weather-particle rpg-sunset-horizon-glow';
container.appendChild(horizonGlow);
// Add some early stars (appearing at dusk)
for (let i = 0; i < 20; i++) {
const star = document.createElement('div');
star.className = 'rpg-weather-particle rpg-night-star rpg-sunset-emerging-star';
star.style.left = `${Math.random() * 100}vw`;
star.style.top = `${Math.random() * 50}dvh`;
star.style.animationDelay = `${Math.random() * 5}s`;
const size = 1 + Math.random() * 1.5;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
container.appendChild(star);
}
// Add some golden/pink dust motes
for (let i = 0; i < 12; i++) {
const particle = document.createElement('div');
particle.className = 'rpg-weather-particle rpg-clear-dust-mote rpg-sunset-dust';
particle.style.left = `${Math.random() * 100}vw`;
particle.style.top = `${Math.random() * 100}dvh`;
particle.style.animationDelay = `${Math.random() * 15}s`;
particle.style.animationDuration = `${12 + Math.random() * 8}s`;
const size = 2 + Math.random() * 3;
particle.style.width = `${size}px`;
particle.style.height = `${size}px`;
container.appendChild(particle);
}
return container;
}
/**
* Create clear nighttime weather effect with moon, stars, and fireflies
*/
@@ -572,9 +775,13 @@ export function updateWeatherEffect() {
weatherContainer = createMist();
break;
case 'sunny':
// Use nighttime effect for clear weather at night
// Use appropriate effect based on time of day
if (timeOfDay === 'night') {
weatherContainer = createNighttime(hour);
} else if (timeOfDay === 'dawn') {
weatherContainer = createSunrise(hour);
} else if (timeOfDay === 'dusk') {
weatherContainer = createSunset(hour);
} else {
weatherContainer = createSunshine(hour);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Image URL Utilities Module
* Centralizes validation for image sources captured from DOM or settings.
*/
const DEFAULT_IMAGE_BASE_URL = typeof window !== 'undefined'
? window.location.href
: 'http://localhost/';
export function normalizeImageSrc(src) {
return String(src ?? '').trim();
}
export function resolveImageUrl(src, baseUrl = DEFAULT_IMAGE_BASE_URL) {
const normalized = normalizeImageSrc(src);
if (!normalized) {
return null;
}
try {
return new URL(normalized, baseUrl);
} catch {
return null;
}
}
export function isSafeImageSrc(src) {
const normalized = normalizeImageSrc(src);
if (!normalized) {
return false;
}
const candidate = resolveImageUrl(normalized);
if (!candidate) {
return false;
}
const protocol = candidate.protocol.toLowerCase();
if (protocol === 'http:' || protocol === 'https:' || protocol === 'blob:') {
return true;
}
if (protocol === 'data:') {
return normalized.toLowerCase().startsWith('data:image/');
}
return false;
}
export function getSafeImageSrc(src) {
const normalized = normalizeImageSrc(src);
return isSafeImageSrc(normalized) ? normalized : null;
}
+1 -1
View File
@@ -102,7 +102,7 @@ export function migrateUserStatsToJSON(textData) {
const statMatch = trimmed.match(/^-\s*([^:]+):\s*(\d+)%/);
if (statMatch) {
const name = statMatch[1].trim();
const id = name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
const id = name.toLowerCase().replace(/\s+/g, '_').replace(/[^\p{L}\p{N}_]/gu, '');
result.stats.push({
id: id,
name: name,
+41 -15
View File
@@ -3,6 +3,31 @@
* Handles parsing and repairing malformed JSON from AI responses
*/
/* ===== Pre-compiled regex patterns (module-level constants) ===== */
// Markdown code fence removal
const MARKDOWN_JSON_FENCE_RE = /```json\s*/gi;
const MARKDOWN_GENERIC_FENCE_RE = /```\s*/g;
// Thinking tag removal
const THINKING_TAG_RE = /<think>[\s\S]*?<\/think>/gi;
const THINKING_TAG_ALT_RE = /<thinking>[\s\S]*?<\/thinking>/gi;
// JSON repair patterns
const TRAILING_COMMA_RE = /,(\s*[}\]])/g;
const JS_LINE_COMMENT_RE = /\/\/.*$/gm;
const JS_BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
// JSON extraction patterns
const JSON_OBJECT_RE = /\{[\s\S]*\}/;
const JSON_ARRAY_RE = /\[[\s\S]*\]/;
// extractJSONFromText patterns
const FENCE_JSON_RE = /```json\s*([\s\S]*?)```/i;
const FENCE_GENERIC_RE = /```\s*([\s\S]*?)```/;
const STANDALONE_OBJECT_RE = /\{[\s\S]*\}/;
const STANDALONE_ARRAY_RE = /\[[\s\S]*\]/;
/**
* Repairs malformed JSON from AI responses
* Handles common AI mistakes like trailing commas, missing commas, wrong quotes, etc.
@@ -23,17 +48,17 @@ export function repairJSON(jsonString) {
}
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
cleaned = cleaned.replace(MARKDOWN_JSON_FENCE_RE, '');
cleaned = cleaned.replace(MARKDOWN_GENERIC_FENCE_RE, '');
// Remove thinking tags (model's internal reasoning)
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleaned = cleaned.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
cleaned = cleaned.replace(THINKING_TAG_RE, '');
cleaned = cleaned.replace(THINKING_TAG_ALT_RE, '');
// Fix common JSON errors:
// 1. Trailing commas before closing brackets
cleaned = cleaned.replace(/,(\s*[}\]])/g, '$1');
cleaned = cleaned.replace(TRAILING_COMMA_RE, '$1');
// 2. Missing commas between properties - DISABLED because it corrupts valid JSON
// Modern AI models send properly formatted JSON, so this aggressive repair is not needed
@@ -49,32 +74,33 @@ export function repairJSON(jsonString) {
// cleaned = cleaned.replace(/(\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":');
// 5. Remove JavaScript comments
cleaned = cleaned.replace(/\/\/.*$/gm, '');
cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, '');
cleaned = cleaned.replace(JS_LINE_COMMENT_RE, '');
cleaned = cleaned.replace(JS_BLOCK_COMMENT_RE, '');
// Attempt 1: Standard JSON.parse
try {
return JSON.parse(cleaned);
} catch (e) {
console.debug('[RPG JSON Repair] Attempt 1 (JSON.parse) failed:', e.message);
}
// Attempt 2: Extract JSON object between first { and last }
const objectMatch = cleaned.match(/\{[\s\S]*\}/);
const objectMatch = cleaned.match(JSON_OBJECT_RE);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch (e) {
// Silent fail, try next method
console.debug('[RPG JSON Repair] Attempt 2 (object extraction) failed:', e.message);
}
}
// Attempt 3: Try to extract JSON array between first [ and last ]
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
const arrayMatch = cleaned.match(JSON_ARRAY_RE);
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0]);
} catch (e) {
// Silent fail, try next method
console.debug('[RPG JSON Repair] Attempt 3 (array extraction) failed:', e.message);
}
}
@@ -149,14 +175,14 @@ export function extractJSONFromText(text) {
}
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
const fenceMatch = text.match(FENCE_JSON_RE);
if (fenceMatch && fenceMatch[1]) {
const trimmed = fenceMatch[1].trim();
if (trimmed) return trimmed;
}
// Try to extract from ``` code fence (without json label)
const genericFenceMatch = text.match(/```\s*([\s\S]*?)```/);
const genericFenceMatch = text.match(FENCE_GENERIC_RE);
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
@@ -166,13 +192,13 @@ export function extractJSONFromText(text) {
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
const objectMatch = text.match(STANDALONE_OBJECT_RE);
if (objectMatch && objectMatch[0].trim()) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
const arrayMatch = text.match(STANDALONE_ARRAY_RE);
if (arrayMatch && arrayMatch[0].trim()) {
return arrayMatch[0];
}
+244
View File
@@ -0,0 +1,244 @@
import { this_chid, characters } from '../../../../../../script.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
FALLBACK_AVATAR_DATA_URI
} from '../core/state.js';
import { getSafeThumbnailUrl } from './avatars.js';
export function stripBrackets(value) {
if (typeof value !== 'string') return value;
return value.replace(/^\[|\]$/g, '').trim();
}
export function extractFieldValue(fieldValue) {
if (fieldValue && typeof fieldValue === 'object' && 'value' in fieldValue) {
return fieldValue.value || '';
}
return fieldValue || '';
}
export function toSnakeCase(name) {
return name
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
export function namesMatch(cardName, aiName) {
if (!cardName || !aiName) return false;
if (cardName.toLowerCase() === aiName.toLowerCase()) return true;
const stripParens = (s) => s.replace(/\s*\([^)]*\)/g, '').trim();
const cardCore = stripParens(cardName).toLowerCase();
const aiCore = stripParens(aiName).toLowerCase();
if (cardCore === aiCore) return true;
const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`);
return wordBoundary.test(aiCore);
}
export function isPresentCharactersEnabled() {
return !!(
extensionSettings.showCharacterThoughts
|| extensionSettings.showAlternatePresentCharactersPanel
|| extensionSettings.showThoughtsInChat
);
}
export function getPresentCharactersTrackerData({ useCommittedFallback = true } = {}) {
return lastGeneratedData.characterThoughts || (useCommittedFallback ? committedTrackerData.characterThoughts : null) || '';
}
export function parsePresentCharacters(characterThoughtsData, { enabledFields = [], enabledCharStats = [] } = {}) {
if (!characterThoughtsData) {
return [];
}
let presentCharacters = [];
try {
const parsed = typeof characterThoughtsData === 'string'
? JSON.parse(characterThoughtsData)
: characterThoughtsData;
const charactersArray = Array.isArray(parsed) ? parsed : (parsed.characters || []);
if (charactersArray.length > 0) {
presentCharacters = charactersArray.map(char => {
const character = {
name: char.name,
emoji: char.emoji || '👤'
};
if (char.details) {
for (const field of enabledFields) {
if (char.details[field.name] !== undefined) {
character[field.name] = stripBrackets(char.details[field.name]);
} else {
const fieldKey = toSnakeCase(field.name);
if (char.details[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char.details[fieldKey]);
}
}
}
}
for (const field of enabledFields) {
if (character[field.name] === undefined) {
const fieldKey = toSnakeCase(field.name);
if (char[fieldKey] !== undefined) {
character[field.name] = stripBrackets(char[fieldKey]);
}
}
}
if (char.Relationship) {
character.Relationship = stripBrackets(char.Relationship);
} else if (char.relationship) {
character.Relationship = stripBrackets(char.relationship.status || char.relationship);
}
if (char.thoughts) {
character.ThoughtsContent = stripBrackets(char.thoughts.content || char.thoughts);
}
if (char.stats && enabledCharStats.length > 0) {
if (Array.isArray(char.stats)) {
for (const statObj of char.stats) {
if (statObj.name && statObj.value !== undefined) {
const matchingStat = enabledCharStats.find(s => s.name === statObj.name);
if (matchingStat) {
character[statObj.name] = statObj.value;
}
}
}
} else {
for (const stat of enabledCharStats) {
if (char.stats[stat.name] !== undefined) {
character[stat.name] = char.stats[stat.name];
}
}
}
}
return character;
});
}
} catch {
// Fall back to the legacy text format below.
}
if (presentCharacters.length > 0 || typeof characterThoughtsData !== 'string') {
return presentCharacters;
}
const lines = characterThoughtsData.split('\n');
let currentCharacter = null;
const thoughtsLabel = extensionSettings.trackerConfig?.presentCharacters?.thoughts?.name || 'Thoughts';
for (const line of lines) {
if (!line.trim()
|| line.includes('Present Characters')
|| line.includes('---')
|| line.trim().startsWith('```')
|| line.trim() === '- …'
|| line.includes('(Repeat the format')) {
continue;
}
if (line.trim().startsWith('- ')) {
const name = line.trim().substring(2).trim();
if (name && name.toLowerCase() !== 'unavailable') {
currentCharacter = { name };
presentCharacters.push(currentCharacter);
} else {
currentCharacter = null;
}
} else if (line.trim().startsWith('Details:') && currentCharacter) {
const detailsContent = line.substring(line.indexOf(':') + 1).trim();
const parts = detailsContent.split('|').map(p => p.trim());
if (parts.length > 0) {
currentCharacter.emoji = parts[0];
}
for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) {
currentCharacter[enabledFields[i].name] = parts[i + 1];
}
} else if (line.trim().startsWith('Relationship:') && currentCharacter) {
currentCharacter.Relationship = line.substring(line.indexOf(':') + 1).trim();
} else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) {
const statsContent = line.substring(line.indexOf(':') + 1).trim();
const statParts = statsContent.split('|').map(p => p.trim());
for (const statPart of statParts) {
const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/);
if (statMatch) {
currentCharacter[statMatch[1].trim()] = parseInt(statMatch[2], 10);
}
}
} else if (line.trim().startsWith(thoughtsLabel + ':') && currentCharacter) {
currentCharacter.ThoughtsContent = line.substring(line.indexOf(':') + 1).trim();
}
}
return presentCharacters;
}
export function resolvePresentCharacterPortrait(name) {
let characterPortrait = FALLBACK_AVATAR_DATA_URI;
if (!name) {
return characterPortrait;
}
if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) {
return extensionSettings.npcAvatars[name];
}
if (selected_group) {
try {
const groupMembers = getGroupMembers(selected_group);
const matchingMember = groupMembers?.find(member =>
member && member.name && namesMatch(member.name, name)
);
if (matchingMember?.avatar && matchingMember.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
} catch {
// Ignore avatar lookup issues and continue through fallback chain.
}
}
if (characters?.length > 0) {
const matchingCharacter = characters.find(character =>
character && character.name && namesMatch(character.name, name)
);
if (matchingCharacter?.avatar && matchingCharacter.avatar !== 'none') {
const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
}
if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, name)) {
const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar);
if (thumbnailUrl) {
return thumbnailUrl;
}
}
return characterPortrait;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Response Extractor Utility
*
* Handles extraction of text content from various API response formats.
* Fixes the "No message generated" error caused by Claude models with
* extended thinking, where the API response `content` field is an array
* of content blocks instead of a single string.
*
* Also provides a safe wrapper around SillyTavern's `generateRaw` that
* intercepts the raw fetch response as a fallback.
*/
import { generateRaw } from '../../../../../../../script.js';
/**
* Extracts text from any API response shape (Anthropic content-block arrays,
* OpenAI choices, plain strings, etc.).
*
* @param {*} response - The raw API response (string, array, or object)
* @returns {string} The extracted text content
*/
export function extractTextFromResponse(response) {
if (!response) return '';
if (typeof response === 'string') return response;
// Response itself is an array of content blocks (Anthropic extended thinking)
if (Array.isArray(response)) {
const texts = response
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
.map(b => b.text);
if (texts.length > 0) return texts.join('\n');
const strings = response.filter(item => typeof item === 'string');
if (strings.length > 0) return strings.join('\n');
return JSON.stringify(response);
}
// response.content (string or Anthropic content array)
if (response.content !== undefined && response.content !== null) {
if (typeof response.content === 'string') return response.content;
if (Array.isArray(response.content)) {
const texts = response.content
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
.map(b => b.text);
if (texts.length > 0) return texts.join('\n');
}
}
// OpenAI choices format
if (response.choices?.[0]?.message?.content) {
const c = response.choices[0].message.content;
if (typeof c === 'string') return c;
if (Array.isArray(c)) {
const texts = c
.filter(b => b && b.type === 'text' && typeof b.text === 'string')
.map(b => b.text);
if (texts.length > 0) return texts.join('\n');
}
}
// Other common fields
if (typeof response.text === 'string') return response.text;
if (typeof response.message === 'string') return response.message;
if (response.message?.content && typeof response.message.content === 'string') {
return response.message.content;
}
return JSON.stringify(response);
}
/**
* Safe wrapper around SillyTavern's `generateRaw`.
*
* Temporarily intercepts `window.fetch` to capture the raw API response.
* If `generateRaw` throws "No message generated" (e.g. because the first
* content block from Claude extended thinking is empty), we extract the
* real text from the captured raw data ourselves.
*
* @param {object} options - Options passed directly to `generateRaw`
* @param {Array<{role: string, content: string}>} options.prompt - Message array
* @param {boolean} [options.quietToLoud] - Whether to use quiet-to-loud mode
* @returns {Promise<string>} The generated text
*/
export async function safeGenerateRaw(options) {
let capturedRawData = null;
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const response = await originalFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (url.includes('/api/backends/chat-completions/generate') ||
(url.includes('/api/backends/') && url.includes('/generate'))) {
const clone = response.clone();
capturedRawData = await clone.json();
}
} catch (e) {
/* ignore clone/parse errors */
}
return response;
};
try {
const result = await generateRaw(options);
return result;
} catch (genErr) {
if (genErr.message?.includes('No message generated') && capturedRawData) {
console.warn(
'[RPG Companion] generateRaw failed (likely extended thinking). Extracting from raw API data.',
);
const extracted = extractTextFromResponse(capturedRawData);
if (!extracted || !extracted.trim()) {
throw new Error('Could not extract text from API response');
}
return extracted;
}
throw genErr; // Re-throw non-related errors
} finally {
window.fetch = originalFetch; // ALWAYS restore original fetch
}
}
+64 -38
View File
@@ -24,18 +24,34 @@ const BLOCKED_PROPERTY_NAMES = [
];
/**
* Validates and sanitizes storage location names.
* Prevents prototype pollution and object property shadowing attacks.
* Creates a generic string validator/sanitizer with configurable rules.
* Returns a function that validates and sanitizes input strings.
*
* @param {string} name - Location name to validate
* @returns {string|null} Sanitized location name or null if invalid/dangerous
* @param {Object} options - Validator configuration
* @param {string} options.label - Human-readable label for logging (e.g., 'location name', 'item name')
* @param {number} options.maxLength - Maximum allowed string length
* @param {string[]} [options.blockedNames] - Array of blocked names (defaults to BLOCKED_PROPERTY_NAMES)
* @param {string[]} [options.additionalRejects] - Additional values to reject (lowercase comparison, e.g., ['none'])
* @param {boolean} [options.checkBlockedNames] - Whether to check against blocked property names (default: true)
* @returns {Function} Sanitizer function: (string) => string|null
*
* @example
* sanitizeLocationName("Home") // "Home"
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
* const sanitizeLocation = createStringValidator({
* label: 'location name',
* maxLength: 200,
* checkBlockedNames: true
* });
* const sanitizeItem = createStringValidator({
* label: 'item name',
* maxLength: 500,
* additionalRejects: ['none'],
* checkBlockedNames: false
* });
*/
export function sanitizeLocationName(name) {
export function createStringValidator(options) {
const { label, maxLength, blockedNames = BLOCKED_PROPERTY_NAMES, additionalRejects = [], checkBlockedNames = true } = options;
return function sanitize(name) {
if (!name || typeof name !== 'string') {
return null;
}
@@ -47,23 +63,48 @@ export function sanitizeLocationName(name) {
return null;
}
// Check for dangerous property names (case-insensitive)
const lowerName = trimmed.toLowerCase();
if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) {
console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`);
// Additional rejects (e.g., 'none' for items)
if (additionalRejects.length > 0 && additionalRejects.includes(trimmed.toLowerCase())) {
return null;
}
// Max length check (reasonable location name)
const MAX_LOCATION_LENGTH = 200;
if (trimmed.length > MAX_LOCATION_LENGTH) {
console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`);
return trimmed.slice(0, MAX_LOCATION_LENGTH);
// Check for dangerous property names (case-insensitive)
if (checkBlockedNames) {
const lowerName = trimmed.toLowerCase();
if (blockedNames.some(blocked => lowerName === blocked.toLowerCase())) {
console.warn(`[RPG Companion] Blocked dangerous ${label}: "${trimmed}"`);
return null;
}
}
// Max length check
if (trimmed.length > maxLength) {
console.warn(`[RPG Companion] ${label.charAt(0).toUpperCase() + label.slice(1)} too long (${trimmed.length} chars), truncating to ${maxLength}`);
return trimmed.slice(0, maxLength);
}
return trimmed;
};
}
/**
* Validates and sanitizes storage location names.
* Prevents prototype pollution and object property shadowing attacks.
*
* @param {string} name - Location name to validate
* @returns {string|null} Sanitized location name or null if invalid/dangerous
*
* @example
* sanitizeLocationName("Home") // "Home"
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
*/
export const sanitizeLocationName = createStringValidator({
label: 'location name',
maxLength: 200,
checkBlockedNames: true
});
/**
* Validates and sanitizes item names.
* Prevents excessively long item names that could cause DoS or UI issues.
@@ -76,27 +117,12 @@ export function sanitizeLocationName(name) {
* sanitizeItemName("") // null
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
*/
export function sanitizeItemName(name) {
if (!name || typeof name !== 'string') {
return null;
}
const trimmed = name.trim();
// Empty check
if (trimmed === '' || trimmed.toLowerCase() === 'none') {
return null;
}
// Max length check (reasonable item name with description)
const MAX_ITEM_LENGTH = 500;
if (trimmed.length > MAX_ITEM_LENGTH) {
console.warn(`[RPG Companion] Item name too long (${trimmed.length} chars), truncating to ${MAX_ITEM_LENGTH}`);
return trimmed.slice(0, MAX_ITEM_LENGTH);
}
return trimmed;
}
export const sanitizeItemName = createStringValidator({
label: 'item name',
maxLength: 500,
additionalRejects: ['none'],
checkBlockedNames: false
});
/**
* Validates and cleans a stored inventory object.
+626
View File
@@ -0,0 +1,626 @@
import { Fuse } from '../../../../../../lib.js';
import {
characters,
event_types,
generateQuietPrompt,
generateRaw,
getRequestHeaders,
online_status,
substituteParams,
substituteParamsExtended,
this_chid
} from '../../../../../../script.js';
import { once as onceEvent } from '../core/events.js';
import {
doExtrasFetch,
extension_settings as stExtensionSettings,
getApiUrl,
modules
} from '../../../../../extensions.js';
import { selected_group, getGroupMembers } from '../../../../../group-chats.js';
import { removeReasoningFromString } from '../../../../../reasoning.js';
import { isJsonSchemaSupported } from '../../../../../textgen-settings.js';
import { trimToEndSentence, trimToStartSentence, waitUntilCondition } from '../../../../../utils.js';
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../../../../../extensions/shared.js';
import { namesMatch } from './presentCharacters.js';
import { normalizeImageSrc } from './imageUrls.js';
const EXPRESSIONS_EXTENSION_NAME = 'expressions';
const DEFAULT_FALLBACK_EXPRESSION = 'joy';
const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
const DEFAULT_EXPRESSIONS = [
'admiration',
'amusement',
'anger',
'annoyance',
'approval',
'caring',
'confusion',
'curiosity',
'desire',
'disappointment',
'disapproval',
'disgust',
'embarrassment',
'excitement',
'fear',
'gratitude',
'grief',
'joy',
'love',
'nervousness',
'optimism',
'pride',
'realization',
'relief',
'remorse',
'sadness',
'surprise',
'neutral'
];
export const EXPRESSION_API = {
local: 0,
extras: 1,
llm: 2,
webllm: 3,
none: 99
};
const PROMPT_TYPE = {
raw: 'raw',
full: 'full'
};
let expressionsListCache = null;
const spriteCache = new Map();
function getNormalizedExpressionsSettings() {
const settings = stExtensionSettings.expressions || {};
return {
api: Number.isInteger(settings.api) ? settings.api : EXPRESSION_API.none,
custom: Array.isArray(settings.custom) ? settings.custom.slice() : [],
showDefault: settings.showDefault === true,
translate: settings.translate === true,
fallbackExpression: typeof settings.fallback_expression === 'string' && settings.fallback_expression.trim()
? settings.fallback_expression.trim().toLowerCase()
: '',
llmPrompt: typeof settings.llmPrompt === 'string' && settings.llmPrompt.trim()
? settings.llmPrompt
: DEFAULT_LLM_PROMPT,
allowMultiple: settings.allowMultiple !== false,
rerollIfSame: settings.rerollIfSame === true,
filterAvailable: settings.filterAvailable === true,
promptType: settings.promptType === PROMPT_TYPE.full ? PROMPT_TYPE.full : PROMPT_TYPE.raw,
expressionOverrides: Array.isArray(stExtensionSettings.expressionOverrides)
? stExtensionSettings.expressionOverrides.slice()
: []
};
}
export function isExpressionsExtensionEnabled() {
return !stExtensionSettings.disabledExtensions?.includes(EXPRESSIONS_EXTENSION_NAME);
}
export function getExpressionsSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
api: settings.api,
custom: settings.custom,
showDefault: settings.showDefault,
translate: settings.translate,
fallbackExpression: settings.fallbackExpression,
llmPrompt: settings.llmPrompt,
allowMultiple: settings.allowMultiple,
rerollIfSame: settings.rerollIfSame,
filterAvailable: settings.filterAvailable,
promptType: settings.promptType,
expressionOverrides: settings.expressionOverrides
});
}
export function getExpressionClassificationSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
api: settings.api,
custom: settings.custom,
translate: settings.translate,
fallbackExpression: settings.fallbackExpression,
llmPrompt: settings.llmPrompt,
filterAvailable: settings.filterAvailable,
promptType: settings.promptType
});
}
export function getExpressionPortraitSettingsSignature() {
if (!isExpressionsExtensionEnabled()) {
return 'disabled';
}
const settings = getNormalizedExpressionsSettings();
return JSON.stringify({
custom: settings.custom,
showDefault: settings.showDefault,
fallbackExpression: settings.fallbackExpression,
allowMultiple: settings.allowMultiple,
rerollIfSame: settings.rerollIfSame
});
}
export function clearExpressionsCompatibilityCache() {
expressionsListCache = null;
spriteCache.clear();
}
function uniqueValues(values) {
return values.filter((value, index) => values.indexOf(value) === index);
}
function normalizeExpressionLabel(label) {
return String(label || '').trim().toLowerCase();
}
function stripExtension(fileName) {
return String(fileName || '').replace(/\.[^/.]+$/, '');
}
function resolveFolderOverride(folderName, expressionOverrides) {
const override = expressionOverrides.find(entry => entry?.name === folderName);
return override?.path ? String(override.path) : folderName;
}
function getAvatarFolderName(avatar) {
if (!avatar || avatar === 'none') {
return '';
}
return String(avatar).replace(/\.[^/.]+$/, '');
}
export function resolveSpriteFolderNameForCharacter(characterName) {
if (!characterName) {
return '';
}
const settings = getNormalizedExpressionsSettings();
const groupId = selected_group;
if (groupId) {
try {
const groupMembers = getGroupMembers(groupId) || [];
const matchingMember = groupMembers.find(member =>
member?.name && namesMatch(member.name, characterName));
const memberFolder = getAvatarFolderName(matchingMember?.avatar);
if (memberFolder) {
return resolveFolderOverride(memberFolder, settings.expressionOverrides);
}
} catch {
// Ignore group lookup issues and continue through the fallback chain.
}
}
if (Array.isArray(characters) && characters.length > 0) {
const matchingCharacter = characters.find(character =>
character?.name && namesMatch(character.name, characterName));
const characterFolder = getAvatarFolderName(matchingCharacter?.avatar);
if (characterFolder) {
return resolveFolderOverride(characterFolder, settings.expressionOverrides);
}
}
if (this_chid !== undefined && characters?.[this_chid]?.name && namesMatch(characters[this_chid].name, characterName)) {
const currentCharacterFolder = getAvatarFolderName(characters[this_chid].avatar);
if (currentCharacterFolder) {
return resolveFolderOverride(currentCharacterFolder, settings.expressionOverrides);
}
}
return '';
}
function sampleClassifyText(text, expressionsApi) {
if (!text) {
return '';
}
let result = substituteParams(text).replace(/[*"]/g, '');
if (expressionsApi === EXPRESSION_API.llm) {
return result.trim();
}
const SAMPLE_THRESHOLD = 500;
const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2;
if (text.length < SAMPLE_THRESHOLD) {
result = trimToEndSentence(result);
} else {
result = `${trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD))} ${trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD))}`;
}
return result.trim();
}
function getJsonSchema(labels) {
return {
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
properties: {
emotion: {
type: 'string',
enum: labels
}
},
required: ['emotion'],
additionalProperties: false
};
}
function buildFullContextThoughtPrompt(prompt, text) {
return [
prompt,
'',
'Classify the emotion of the following text instead of the last chat message.',
'Output exactly one label from the allowed list.',
'',
`Text: ${text}`
].join('\n');
}
function parseLlmResponse(emotionResponse, labels) {
try {
const parsedEmotion = JSON.parse(emotionResponse);
const response = parsedEmotion?.emotion?.trim()?.toLowerCase();
if (response && labels.includes(response)) {
return response;
}
} catch {
// Fall through to the fuzzy parse below.
}
const cleanedResponse = removeReasoningFromString(String(emotionResponse || ''));
const lowerCaseResponse = cleanedResponse.toLowerCase();
for (const label of labels) {
if (lowerCaseResponse.includes(label.toLowerCase())) {
return label;
}
}
const fuse = new Fuse(labels, { includeScore: true });
const match = fuse.search(cleanedResponse)[0];
if (match?.item) {
return match.item;
}
throw new Error('Could not parse expression label from response');
}
async function resolveExpressionsList() {
const settings = getNormalizedExpressionsSettings();
try {
if (settings.api === EXPRESSION_API.extras && modules.includes('classify')) {
const url = new URL(getApiUrl());
url.pathname = '/api/classify/labels';
const response = await doExtrasFetch(url, {
method: 'GET',
headers: { 'Bypass-Tunnel-Reminder': 'bypass' }
});
if (response.ok) {
const data = await response.json();
return Array.isArray(data?.labels)
? data.labels.map(normalizeExpressionLabel).filter(Boolean)
: DEFAULT_EXPRESSIONS.slice();
}
}
if (settings.api === EXPRESSION_API.local) {
const response = await fetch('/api/extra/classify/labels', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true })
});
if (response.ok) {
const data = await response.json();
return Array.isArray(data?.labels)
? data.labels.map(normalizeExpressionLabel).filter(Boolean)
: DEFAULT_EXPRESSIONS.slice();
}
}
} catch {
// Fall back to the built-in labels below.
}
return DEFAULT_EXPRESSIONS.slice();
}
async function getAvailableExpressionLabelsForCharacter(characterName) {
const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName);
if (!spriteFolderName) {
return [];
}
const expressions = await getSpritesList(spriteFolderName);
return expressions
.filter(expression => Array.isArray(expression?.files) && expression.files.length > 0)
.map(expression => String(expression.label || '').trim().toLowerCase())
.filter(Boolean);
}
export async function getExpressionsList({ characterName = '', filterAvailable = false } = {}) {
if (!Array.isArray(expressionsListCache)) {
expressionsListCache = await resolveExpressionsList();
}
const settings = getNormalizedExpressionsSettings();
const expressions = uniqueValues([...expressionsListCache, ...settings.custom.map(value => String(value).trim().toLowerCase())])
.filter(Boolean);
if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(settings.api)) {
return expressions;
}
const availableExpressions = await getAvailableExpressionLabelsForCharacter(characterName);
if (!availableExpressions.length) {
return expressions;
}
return expressions.filter(expression => availableExpressions.includes(expression));
}
async function getSpritesList(spriteFolderName) {
if (!spriteFolderName) {
return [];
}
if (spriteCache.has(spriteFolderName)) {
return spriteCache.get(spriteFolderName);
}
try {
const response = await fetch(`/api/sprites/get?name=${encodeURIComponent(spriteFolderName)}`);
const sprites = response.ok ? await response.json() : [];
const grouped = [];
for (const sprite of Array.isArray(sprites) ? sprites : []) {
const fileName = String(sprite?.path || '').split('/').pop()?.split('?')[0] || '';
const imageData = {
expression: normalizeExpressionLabel(sprite?.label),
fileName,
title: stripExtension(fileName),
imageSrc: String(sprite?.path || ''),
type: 'success',
isCustom: getNormalizedExpressionsSettings().custom.includes(normalizeExpressionLabel(sprite?.label))
};
let existing = grouped.find(entry => entry.label === imageData.expression);
if (!existing) {
existing = { label: imageData.expression, files: [] };
grouped.push(existing);
}
existing.files.push(imageData);
}
for (const expression of grouped) {
expression.files.sort((left, right) => {
if (left.title === expression.label) return -1;
if (right.title === expression.label) return 1;
return left.title.localeCompare(right.title);
});
}
spriteCache.set(spriteFolderName, grouped);
return grouped;
} catch {
spriteCache.set(spriteFolderName, []);
return [];
}
}
function chooseSpriteForExpression(expressions, expression, { previousSrc = null } = {}) {
const settings = getNormalizedExpressionsSettings();
let sprite = expressions.find(entry => entry.label === expression);
if (!(sprite?.files?.length > 0) && settings.fallbackExpression) {
sprite = expressions.find(entry => entry.label === settings.fallbackExpression);
}
if (!(sprite?.files?.length > 0)) {
return null;
}
let candidates = sprite.files;
if (settings.allowMultiple && sprite.files.length > 1) {
if (settings.rerollIfSame) {
const filtered = sprite.files.filter(file => !previousSrc || file.imageSrc !== previousSrc);
if (filtered.length > 0) {
candidates = filtered;
}
}
return candidates[Math.floor(Math.random() * candidates.length)] || null;
}
return candidates[0] || null;
}
function getDefaultExpressionImage(expression, customExpressions) {
let normalizedExpression = String(expression || '').trim().toLowerCase();
if (!normalizedExpression) {
return '';
}
if (customExpressions.includes(normalizedExpression)) {
normalizedExpression = DEFAULT_FALLBACK_EXPRESSION;
}
return `/img/default-expressions/${normalizedExpression}.png`;
}
export async function classifyExpressionText(text, { characterName = '' } = {}) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const settings = getNormalizedExpressionsSettings();
if (!text) {
return settings.fallbackExpression || '';
}
if (settings.api === EXPRESSION_API.none) {
return settings.fallbackExpression || '';
}
let processedText = text;
if (settings.translate && typeof globalThis.translate === 'function') {
processedText = await globalThis.translate(processedText, 'en');
}
processedText = sampleClassifyText(processedText, settings.api);
if (!processedText) {
return settings.fallbackExpression || '';
}
const labels = await getExpressionsList({
characterName,
filterAvailable: settings.filterAvailable === true
});
const fallbackLabels = labels.length > 0 ? labels : await getExpressionsList();
try {
switch (settings.api) {
case EXPRESSION_API.local: {
const response = await fetch('/api/extra/classify', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ text: processedText })
});
if (response.ok) {
const data = await response.json();
return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase();
}
break;
}
case EXPRESSION_API.extras: {
if (!modules.includes('classify')) {
return settings.fallbackExpression || '';
}
const url = new URL(getApiUrl());
url.pathname = '/api/classify';
const response = await doExtrasFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Bypass-Tunnel-Reminder': 'bypass'
},
body: JSON.stringify({ text: processedText })
});
if (response.ok) {
const data = await response.json();
return String(data?.classification?.[0]?.label || settings.fallbackExpression || '').trim().toLowerCase();
}
break;
}
case EXPRESSION_API.llm: {
await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
const labelsString = fallbackLabels.map(label => `"${label}"`).join(', ');
const basePrompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString });
const prompt = settings.promptType === PROMPT_TYPE.full
? buildFullContextThoughtPrompt(basePrompt, processedText)
: basePrompt;
const onReady = (args) => {
if (isJsonSchemaSupported()) {
Object.assign(args, {
top_k: 1,
stop: [],
stopping_strings: [],
custom_token_bans: [],
json_schema: getJsonSchema(fallbackLabels)
});
}
};
onceEvent(event_types.TEXT_COMPLETION_SETTINGS_READY, onReady);
const responseText = settings.promptType === PROMPT_TYPE.full
? await generateQuietPrompt({ quietPrompt: prompt })
: await generateRaw({ prompt: processedText, systemPrompt: prompt });
return parseLlmResponse(responseText, fallbackLabels);
}
case EXPRESSION_API.webllm: {
if (!isWebLlmSupported()) {
return settings.fallbackExpression || '';
}
const labelsString = fallbackLabels.map(label => `"${label}"`).join(', ');
const prompt = substituteParamsExtended(settings.llmPrompt, { labels: labelsString });
const responseText = await generateWebLlmChatPrompt([
{
role: 'user',
content: `${processedText}\n\n${prompt}`
}
]);
return parseLlmResponse(responseText, fallbackLabels);
}
default:
break;
}
} catch {
return settings.fallbackExpression || '';
}
return settings.fallbackExpression || '';
}
export async function resolveExpressionPortraitForCharacter(characterName, expression, { previousSrc = null } = {}) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const settings = getNormalizedExpressionsSettings();
const normalizedExpression = String(expression || '').trim().toLowerCase();
const spriteFolderName = resolveSpriteFolderNameForCharacter(characterName);
if (spriteFolderName) {
const expressions = await getSpritesList(spriteFolderName);
const spriteFile = chooseSpriteForExpression(expressions, normalizedExpression, { previousSrc });
const spriteSrc = normalizeImageSrc(spriteFile?.imageSrc || '');
if (spriteSrc) {
return spriteSrc;
}
}
if (settings.showDefault) {
const defaultExpression = normalizedExpression || settings.fallbackExpression;
const defaultImage = normalizeImageSrc(getDefaultExpressionImage(defaultExpression, settings.custom));
if (defaultImage) {
return defaultImage;
}
}
return null;
}
@@ -0,0 +1,73 @@
import {
thoughtBasedExpressionPortraits,
getThoughtBasedExpressionPortrait
} from '../core/state.js';
import {
isSafeImageSrc,
normalizeImageSrc,
resolveImageUrl
} from './imageUrls.js';
import { isExpressionsExtensionEnabled } from './sillyTavernExpressions.js';
function normalizeName(name) {
return String(name || '').trim().toLowerCase();
}
function namesMatch(a, b) {
const left = normalizeName(a);
const right = normalizeName(b);
if (!left || !right) {
return false;
}
return left === right || left.startsWith(right + ' ') || right.startsWith(left + ' ');
}
function isDocumentLikeUrl(src) {
const candidate = resolveImageUrl(src);
if (!candidate) {
return false;
}
const current = new URL(window.location.href);
return candidate.origin === current.origin
&& candidate.pathname === current.pathname
&& candidate.search === current.search;
}
export function isUsableThoughtBasedExpressionSrc(src) {
const normalized = normalizeImageSrc(src);
if (!normalized) {
return false;
}
if (isDocumentLikeUrl(normalized)) {
return false;
}
return isSafeImageSrc(normalized);
}
export function getThoughtBasedExpressionPortraitForCharacter(characterName) {
if (!isExpressionsExtensionEnabled()) {
return null;
}
const target = normalizeName(characterName);
if (!target) {
return null;
}
const exact = getThoughtBasedExpressionPortrait(target);
if (isUsableThoughtBasedExpressionSrc(exact)) {
return exact;
}
for (const [storedName, src] of Object.entries(thoughtBasedExpressionPortraits)) {
if (namesMatch(storedName, target) && isUsableThoughtBasedExpressionSrc(src)) {
return src;
}
}
return null;
}
+16
View File
@@ -0,0 +1,16 @@
const toSnake = (str) => str
// replace any sequence of non-alphanumeric characters with a single underscore
.replace(/[^0-9A-Za-z]+/g, '_')
// insert underscore between a lower-case letter/digit and an upper-case letter (but not between consecutive uppers)
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
// collapse multiple underscores
.replace(/_+/g, '_')
// trim leading/trailing underscores
.replace(/^_+|_+$/g, '')
// finally, lowercase the result
.toLowerCase();
export const safeToSnake = (str) => {
const res = toSnake(str);
return (res.length >= 2) ? res : str; // considering element with one symbol is too short to be safe
};
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more