Compare commits

..

26 Commits

Author SHA1 Message Date
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
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
Spicy Marinara c14250e467 Merge pull request #104 from IDeathByte/main
Ru language
2026-01-13 13:53:27 +01:00
IDeathByte acf119d4b4 Add russian language 2026-01-13 14:35:06 +05:00
IDeathByte 6582095cc1 add russian 2026-01-13 13:51:16 +05:00
IDeathByte 8aaf258ba3 add russian 2026-01-13 13:50:33 +05:00
IDeathByte 7c1c140a2a add russian 2026-01-13 13:49:48 +05:00
17 changed files with 1252 additions and 413 deletions
+3 -8
View File
@@ -7,15 +7,10 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
## 🆕 What's New
### v3.6.0
### v3.6.2
- You can now choose whether stats are displayed as percentages or numbers.
- Added collapsed strip widgets for desktop.
- Added new effects for the dynamic weather.
- Changed the displayed clock format in the Info Box.
- Fixed customized status field to work.
- Fixed date format toggles.
- Minor CSS and bug fixes.
- Various bug fixes.
- Added the ability to add present characters manually.
**Special thanks to all the other contributors for this project:**
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
-2
View File
@@ -151,7 +151,6 @@ import {
onMessageReceived,
onCharacterChanged,
onMessageSwiped,
onMessageDeleted,
updatePersonaAvatar,
clearExtensionPrompts,
onGenerationEnded,
@@ -1255,7 +1254,6 @@ jQuery(async () => {
[event_types.GENERATION_ENDED]: onGenerationEnded,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts],
[event_types.MESSAGE_SWIPED]: onMessageSwiped,
[event_types.MESSAGE_DELETED]: onMessageDeleted,
[event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar,
[event_types.SETTINGS_UPDATED]: updatePersonaAvatar
});
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Marinara",
"version": "3.6.0",
"version": "3.6.3",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+2 -1
View File
@@ -15,6 +15,7 @@
<select id="rpg-companion-language-select" class="text_pole">
<option value="en" data-i18n-key="settings.language.option.en">English</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>
</select>
</div>
@@ -48,7 +49,7 @@
</div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.6.0
v3.6.2
</div>
</div>
</div>
+1
View File
@@ -2,6 +2,7 @@
"settings.language.label": "Language",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"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",
+236
View File
@@ -0,0 +1,236 @@
{
"settings.language.label": "Язык",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"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": "Скай-фай (Synthwave)",
"template.settingsModal.themeOptions.fantasy": "Фэнтези (Rustic Parchment)",
"template.settingsModal.themeOptions.cyberpunk": "Киберпанк (Neon Grid)",
"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": "Вы можете вкючить/отключить расширение RPG Companion во вкладке расширений для SillyTavern.",
"template.settingsModal.display.panelPosition": "Положение боковой панели:",
"template.settingsModal.display.panelPositionOptions.right": "Справа",
"template.settingsModal.display.panelPositionOptions.left": "Слева",
"template.settingsModal.display.toggleAutoUpdate": "Авто-обновление после ответа",
"template.settingsModal.display.toggleAutoUpdateNote": "Автоматически обновлять информацию в трекрере после каждого ответа.",
"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.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": "Отображать значки блокировки/разблокировки на элементах трекера, чтобы предотвратить их изменение ИИ.",
"template.settingsModal.display.showThoughtsInChat": "Показывать мысли",
"template.settingsModal.display.showThoughtsInChatNote": "Отображать мысли персонажей в виде всплывающих пузырьков рядом с их сообщениями.",
"template.settingsModal.display.alwaysShowThoughtBubble": "Всегда показывать пузырь мыслей",
"template.settingsModal.display.alwaysShowThoughtBubbleNote": "Автоматически раскрывать пузырь мыслей без предварительного нажатия на значок",
"template.settingsModal.display.enableAnimations": "Включить анимации",
"template.settingsModal.display.enableAnimationsNote": "Плавные переходы для характеристик, обновления контента и бросков кубиков.",
"template.settingsModal.display.showImmersiveHtmlToggle": "Показывать переключатель Immersive HTML",
"template.settingsModal.display.showImmersiveHtmlToggleNote": "Отображать кнопку переключения для включения/отключения HTML-форматирования в сообщениях.",
"template.settingsModal.display.showDialogueColoringToggle": "Показывать переключатель цветных диалогов",
"template.settingsModal.display.showDialogueColoringToggleNote": "Отображать кнопку переключения для включения/отключения цветного форматирования диалогов.",
"template.settingsModal.display.showSpotifyMusicToggle": "Показывать переключатель музыки Spotify",
"template.settingsModal.display.showSpotifyMusicToggleNote": "Отображать музыкальный проигрыватель Spotify с предложенными ИИ треками, подходящими для сцены.",
"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": "Отображать кнопку для генерации ИИ случайных подсказок для развития сюжета.",
"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.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": "Базовый URL API",
"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": "Максимальное количество токенов",
"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. Пресет будет автоматически переключаться во время генерации и восстанавливаться после нее. Выберите желаемую модель в этом пресете и убедитесь, что переключатель \"Bind presets to API connections\" включен (рядом с кнопками импорта/экспорта пресетов).",
"template.settingsModal.advanced.skipInjections": "Пропускать инъекции во время управляемых генераций:",
"template.settingsModal.advanced.skipInjectionsOptions.none": "Никогда не пропускать",
"template.settingsModal.advanced.skipInjectionsOptions.impersonation": "Только при запросах олицетворения",
"template.settingsModal.advanced.skipInjectionsOptions.guided": "Всегда для управляемых или тихих подсказок",
"template.settingsModal.advanced.skipInjectionsNote": "При установке расширение не будет внедрять подсказки трекеров, примеры или HTML-инструкции в соответствии с выбранным режимом при обнаружении управляемой генерации (через `instruct` или `quiet_prompt`). Полезно при использовании GuidedGenerations или аналогичных расширений.",
"template.settingsModal.advanced.customHtmlPromptTitle": "Пользовательская HTML-подсказка:",
"template.settingsModal.advanced.restoreDefaultHtmlPrompt": "Восстановить по умолчанию",
"template.settingsModal.advanced.customHtmlPromptNote": "Настройте HTML-подсказку, которая внедряется при включенной опции \"Enable Immersive HTML\". Подсказка по умолчанию показана выше - вы можете редактировать ее напрямую или полностью заменить. Нажмите \"Восстановить по умолчанию\" для сброса. Это влияет на все режимы генерации (together, separate и plot progression).",
"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": "Инструкция для ИИ:",
"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": "Immersive HTML",
"template.mainPanel.coloredDialogues": "Цветные диалоги",
"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": "Информация",
"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": "Активы включают транспортные средства (автомобили, мотоциклы), недвижимость (дома, квартиры) и крупное оборудование (инструменты для мастерской, специальные предметы).",
"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": "Дополнительные задания - это побочные цели, которые дополняют основную историю.",
"checkpoint.setChapterStart": "Установить начало главы",
"checkpoint.clearChapterStart": "Очистить начало главы",
"checkpoint.indicator": "Начало главы",
"checkpoint.tooltip": "Сообщения до этой точки исключаются из контекста",
"musicPlayer.title": "Музыка сцены",
"musicPlayer.noMusic": "ИИ будет предлагать музыку, когда это уместно для сцены",
"errors.parsingError": "Ошибка парсинга RPG Companion Trackers! Модель вернула неправильный формат. Если проблема сохраняется, рассмотрите возможность смены модели для генераций.",
"settings.recommendedModels.title": "Рекомендуемые модели",
"settings.recommendedModels.description": "Для правильной работы расширения **не рекомендуется использовать модели с базой обчучения ниже 20B, особенно если они старые.** Оно лучше всего работает с современными моделями, такими как Deepseek, Claude, GPT или Gemini."
}
+1
View File
@@ -2,6 +2,7 @@
"settings.language.label": "語言",
"settings.language.option.en": "English",
"settings.language.option.zh-tw": "繁體中文",
"settings.language.option.ru": "Русский",
"settings.extensionEnabled": "啟用 RPG Companion",
"settings.note": "切換開關以啟用/停用 RPG Companion。其他設定可在面板內配置。",
"template.settingsTitle": "RPG Companion 設定",
+70 -14
View File
@@ -121,7 +121,7 @@ export async function buildEncounterInitPrompt() {
// console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length });
if (worldInfoString && worldInfoString.trim()) {
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
// console.log('[RPG Companion] ✅ Added world info from getWorldInfoPrompt');
@@ -258,6 +258,7 @@ export async function buildEncounterInitPrompt() {
initInstruction += `The combat starts now.\n\n`;
initInstruction += `Based on everything above, generate the initial combat state. Analyze who is in the party fighting alongside ${userName} (if anyone), and who the enemies are. Replace placeholders in [brackets] and X with actual values. Return ONLY a JSON object with the following structure:\n\n`;
initInstruction += `FORMAT:\n`;
initInstruction += `{\n`;
initInstruction += ` "party": [\n`;
initInstruction += ` {\n`;
@@ -268,7 +269,7 @@ export async function buildEncounterInitPrompt() {
initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`;
initInstruction += ` "items": ["Item1", "Item2"],\n`;
initInstruction += ` "items": ["Item Name x3", "Another Item x1"],\n`;
initInstruction += ` "statuses": [],\n`;
initInstruction += ` "isPlayer": true\n`;
initInstruction += ` }\n`;
@@ -302,11 +303,14 @@ export async function buildEncounterInitPrompt() {
initInstruction += ` - "single-target": Can only target one character (enemy or ally)\n`;
initInstruction += ` - "AoE": Area of Effect - targets all enemies, but some AoE attacks (like storms, explosions) can also harm allies if the attack is indiscriminate\n`;
initInstruction += ` - "both": Player can choose to target a single enemy OR use as AoE\n`;
initInstruction += `- For items array: Include quantities using format "Item Name xN" (e.g., "Health Potion x3", "Bomb x1")\n`;
initInstruction += ` - If only one item exists, you can use "Item Name x1" or just "Item Name"\n`;
initInstruction += ` - Items will be consumed when used - the quantity will decrease in future turns\n`;
initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\n`;
initInstruction += ` - Each status has a format: {"name": "Status Name", "emoji": "💀", "duration": X}\n`;
initInstruction += ` - Examples: Poisoned (🧪), Burning (🔥), Blessed (✨), Stunned (💫), Weakened (⬇️), Strengthened (⬆️)\n\n`;
initInstruction += `The styleNotes object will be used to visually style the combat window - choose ONE value from each category that best fits the environment described in the chat history.\n\n`;
initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory. Set HP based on their current Health stat if available.\n\n`;
initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory WITH QUANTITIES (e.g., "Health Potion x3"). Set HP based on their current Health stat if available.\n\n`;
initInstruction += `Ensure all party members and enemies have realistic HP values based on the setting and their descriptions. Return ONLY the JSON object, no other text.`;
// Only add the instruction if it has meaningful content
@@ -364,7 +368,7 @@ export async function buildCombatActionPrompt(action, combatStats) {
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) {
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
@@ -483,12 +487,25 @@ export async function buildCombatActionPrompt(action, combatStats) {
stateMessage += `Party Members:\n`;
combatStats.party.forEach(member => {
stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`;
if (member.attacks && member.attacks.length > 0) {
stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (member.items && member.items.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`;
// For the player, use playerActions if available, otherwise fall back to member data
if (member.isPlayer && currentEncounter.playerActions) {
if (currentEncounter.playerActions.attacks && currentEncounter.playerActions.attacks.length > 0) {
stateMessage += ` Attacks: ${currentEncounter.playerActions.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (currentEncounter.playerActions.items && currentEncounter.playerActions.items.length > 0) {
stateMessage += ` Items: ${currentEncounter.playerActions.items.join(', ')}\n`;
}
} else {
// For non-player party members, use their own data
if (member.attacks && member.attacks.length > 0) {
stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (member.items && member.items.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`;
}
}
if (member.statuses && member.statuses.length > 0) {
const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) {
@@ -515,11 +532,39 @@ export async function buildCombatActionPrompt(action, combatStats) {
});
stateMessage += `\n${userName}'s Action: ${action}\n\n`;
stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment:\n`;
stateMessage += `Respond with the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. DO NOT regenerate character descriptions, sprites, or environment.\n\n`;
stateMessage += `IMPORTANT - Update ${userName}'s attacks and items arrays based on what happens in combat:\n`;
stateMessage += `- ${userName}'s action is already specified above - do NOT regenerate it. Only update ${userName}'s attacks/items arrays if their action consumed resources (used item, lost ability, etc.).\n`;
stateMessage += `- If they use an item, decrement its quantity ("Health Potion x3" becomes "Health Potion x2"). If quantity reaches 0, remove the item entirely.\n`;
stateMessage += `- If they gain or lose an ability due to status effects, add or remove it from their attacks array.\n`;
stateMessage += ` Examples: Disarmed → remove weapon attacks. Bound → remove all attacks or set to []. Freed → restore attacks.\n`;
stateMessage += `- If they pick up a weapon/item during combat, add it to their items or attacks array.\n`;
stateMessage += `- If environmental changes enable new actions (near water → "Splash Attack"), add them. If they disable actions (fire goes out → remove "Ignite"), remove them.\n`;
stateMessage += `- Status effects should persist and decrease duration each turn. Remove statuses when duration reaches 0.\n\n`;
stateMessage += `FORMAT:\n`;
stateMessage += `{\n`;
stateMessage += ` "combatStats": {\n`;
stateMessage += ` "party": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }],\n`;
stateMessage += ` "enemies": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }]\n`;
stateMessage += ` "party": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}],\n`;
stateMessage += ` "isPlayer": true|false\n`;
stateMessage += ` }\n`;
stateMessage += ` ],\n`;
stateMessage += ` "enemies": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}]\n`;
stateMessage += ` }\n`;
stateMessage += ` ]\n`;
stateMessage += ` },\n`;
stateMessage += ` "playerActions": {\n`;
stateMessage += ` "attacks": [{"name": "Attack", "type": "single-target|AoE|both"}],\n`;
stateMessage += ` "items": ["Item Name x3", "Another Item x1"]\n`;
stateMessage += ` },\n`;
stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`;
@@ -587,7 +632,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) {
if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim();
worldInfoAdded = true;
}
@@ -659,7 +704,9 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
summaryMessage += `\n\nProvide a narrative summary of the entire fight in a way that fits the style from the chat history. Start with [FIGHT CONCLUDED] on the first line, then provide the description.\n\n`;
summaryMessage += `Write with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`;
summaryMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. If you last started with a narration, begin this one with dialogue; if with an action, switch to an internal thought. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Minimize asterisks, ellipses, and em-dashes. Explicit content is allowed. Never end on handover cues; finish naturally.\n\n`;
summaryMessage += `Express ${userName}'s actions and dialogue using indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help"). The summary should be 2-4 paragraphs and capture the essence of the battle.\n\n`;
summaryMessage += `Dialogue Guidelines:\n`;
summaryMessage += `- Include ALL dialogue lines spoken by enemies and NPC party members during the encounter in direct quotes.\n`;
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)) {
@@ -721,6 +768,12 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
*/
export function parseEncounterJSON(response) {
try {
// Ensure response is a string
if (!response || typeof response !== 'string') {
console.error('[RPG Companion] parseEncounterJSON received non-string input:', typeof response);
return null;
}
// Remove code blocks if present
let cleaned = response.trim();
@@ -736,6 +789,9 @@ export function parseEncounterJSON(response) {
if (firstBrace !== -1 && lastBrace !== -1) {
cleaned = cleaned.substring(firstBrace, lastBrace + 1);
} else {
console.error('[RPG Companion] No JSON object found in response');
return null;
}
// Try to parse directly first
+35 -35
View File
@@ -86,8 +86,8 @@ function buildHistoricalContextMap() {
// For user_message_end: start from the last assistant message (we need its context for the preceding user message)
// For assistant_message_end: start from before the last assistant message (it gets current context via setExtensionPrompt)
let processedCount = 0;
const startIndex = position === 'user_message_end'
? lastAssistantIndex
const startIndex = position === 'user_message_end'
? lastAssistantIndex
: (lastAssistantIndex > 0 ? lastAssistantIndex - 1 : chat.length - 2);
for (let i = startIndex; i >= 0 && (messageCount === 0 || processedCount < maxMessages); i--) {
@@ -201,7 +201,7 @@ function prepareHistoricalContextInjection() {
/**
* Finds the best match position for message content in the prompt.
* Tries full content first, then progressively smaller suffixes.
*
*
* @param {string} prompt - The prompt to search in
* @param {string} messageContent - The message content to find
* @returns {{start: number, end: number}|null} - Position info or null if not found
@@ -213,7 +213,7 @@ function findMessageInPrompt(prompt, messageContent) {
// Try to find the full content first
let searchIndex = prompt.lastIndexOf(messageContent);
if (searchIndex !== -1) {
return { start: searchIndex, end: searchIndex + messageContent.length };
}
@@ -221,15 +221,15 @@ function findMessageInPrompt(prompt, messageContent) {
// If full content not found, try last N characters with progressively smaller chunks
// This handles cases where messages are truncated in the prompt
const searchLengths = [500, 300, 200, 100, 50];
for (const len of searchLengths) {
if (messageContent.length <= len) {
continue;
}
const searchContent = messageContent.slice(-len);
searchIndex = prompt.lastIndexOf(searchContent);
if (searchIndex !== -1) {
return { start: searchIndex, end: searchIndex + searchContent.length };
}
@@ -241,7 +241,7 @@ function findMessageInPrompt(prompt, messageContent) {
/**
* Injects historical context into a text completion prompt string.
* Searches for message content in the prompt and appends context after matches.
*
*
* @param {string} prompt - The text completion prompt
* @returns {string} - The modified prompt with injected context
*/
@@ -268,7 +268,7 @@ function injectContextIntoTextPrompt(prompt) {
// Find the message content in the prompt
const position = findMessageInPrompt(modifiedPrompt, message.mes);
if (!position) {
// Message not found in prompt (might be truncated or not included)
console.debug(`[RPG Companion] Could not find message ${msgIdx} in prompt for context injection`);
@@ -290,7 +290,7 @@ function injectContextIntoTextPrompt(prompt) {
/**
* Injects historical context into a chat completion message array.
* Modifies the content of messages in the array directly.
*
*
* @param {Array} chatMessages - The chat completion message array
* @returns {Array} - The modified message array with injected context
*/
@@ -315,7 +315,7 @@ function injectContextIntoChatPrompt(chatMessages) {
// Find this message in the chat completion array by matching content
// Try full content first, then progressively smaller suffixes
let found = false;
for (const promptMsg of chatMessages) {
if (!promptMsg.content || typeof promptMsg.content !== 'string') {
continue;
@@ -335,7 +335,7 @@ function injectContextIntoChatPrompt(chatMessages) {
if (messageContent.length <= len) {
continue;
}
const searchContent = messageContent.slice(-len);
if (promptMsg.content.includes(searchContent)) {
promptMsg.content = promptMsg.content + ctxContent;
@@ -344,12 +344,12 @@ function injectContextIntoChatPrompt(chatMessages) {
break;
}
}
if (found) {
break;
}
}
if (!found) {
console.debug(`[RPG Companion] Could not find message ${msgIdx} in chat prompt for context injection`);
}
@@ -365,7 +365,7 @@ function injectContextIntoChatPrompt(chatMessages) {
/**
* Injects historical context into finalMesSend message array (text completion).
* Iterates through chat and finalMesSend in order, matching by content to skip injected messages.
*
*
* @param {Array} finalMesSend - The array of message objects {message: string, extensionPrompts: []}
* @returns {number} - Number of injections made
*/
@@ -381,20 +381,20 @@ function injectContextIntoFinalMesSend(finalMesSend) {
}
let injectedCount = 0;
// Build a map from chat index to finalMesSend index by matching content in order
// This handles injected messages (author's note, OOC, etc.) that exist in finalMesSend but not in chat
const chatToMesSendMap = new Map();
let mesSendIdx = 0;
for (let chatIdx = 0; chatIdx < chat.length && mesSendIdx < finalMesSend.length; chatIdx++) {
const chatMsg = chat[chatIdx];
if (!chatMsg || chatMsg.is_system) {
continue;
}
const chatContent = chatMsg.mes || '';
// Look for this chat message in finalMesSend starting from current position
// Skip any finalMesSend entries that don't match (they're injected content)
while (mesSendIdx < finalMesSend.length) {
@@ -403,40 +403,40 @@ function injectContextIntoFinalMesSend(finalMesSend) {
mesSendIdx++;
continue;
}
// Check if this finalMesSend message contains the chat content
// Use a substring match since instruct formatting adds prefixes/suffixes
// Match with sufficient content (first 50 chars or full message if shorter)
const matchContent = chatContent.length > 50
? chatContent.substring(0, 50)
const matchContent = chatContent.length > 50
? chatContent.substring(0, 50)
: chatContent;
if (matchContent && mesSendObj.message.includes(matchContent)) {
// Found a match - record the mapping
chatToMesSendMap.set(chatIdx, mesSendIdx);
mesSendIdx++;
break;
}
// This finalMesSend entry doesn't match - it's injected content, skip it
mesSendIdx++;
}
}
// Now inject context using the map
for (const [chatIdx, ctxContent] of pendingContextMap) {
const targetMesSendIdx = chatToMesSendMap.get(chatIdx);
if (targetMesSendIdx === undefined) {
console.debug(`[RPG Companion] Chat message ${chatIdx} not found in finalMesSend mapping`);
continue;
}
const mesSendObj = finalMesSend[targetMesSendIdx];
if (!mesSendObj || !mesSendObj.message) {
continue;
}
// Append context to this message
mesSendObj.message = mesSendObj.message + ctxContent;
injectedCount++;
@@ -450,7 +450,7 @@ function injectContextIntoFinalMesSend(finalMesSend) {
* Event handler for GENERATE_BEFORE_COMBINE_PROMPTS (text completion).
* Injects historical context into the finalMesSend array before prompt combination.
* This is more reliable than post-combine string searching.
*
*
* @param {Object} eventData - Event data with finalMesSend and other properties
*/
function onGenerateBeforeCombinePrompts(eventData) {
@@ -478,7 +478,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.
*
*
* @param {Object} eventData - Event data with prompt property
*/
function onGenerateAfterCombinePrompts(eventData) {
@@ -508,7 +508,7 @@ function onGenerateAfterCombinePrompts(eventData) {
/**
* Event handler for CHAT_COMPLETION_PROMPT_READY.
* Injects historical context into the chat message array.
*
*
* @param {Object} eventData - Event data with chat property
*/
function onChatCompletionPromptReady(eventData) {
@@ -938,16 +938,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
// Primary: BEFORE_COMBINE for text completion (more reliable - modifies message objects)
eventSource.on(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);
// Chat completion (OpenAI, etc.)
eventSource.on(event_types.CHAT_COMPLETION_PROMPT_READY, onChatCompletionPromptReady);
console.log('[RPG Companion] History injection listeners initialized');
}
+10 -2
View File
@@ -198,7 +198,9 @@ export function parseResponse(responseText) {
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
extractedObjects.push(jsonContent);
if (jsonContent) {
extractedObjects.push(jsonContent);
}
i = j;
} else {
i++;
@@ -307,6 +309,9 @@ export function parseResponse(responseText) {
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
if (!jsonContent) continue;
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
@@ -363,6 +368,9 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim();
if (!jsonContent) continue;
const parsed = repairJSON(jsonContent);
if (parsed) {
@@ -524,7 +532,7 @@ export function parseUserStats(statsText) {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
+30 -8
View File
@@ -546,12 +546,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 +585,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 +594,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 +725,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`;
}
+25 -208
View File
@@ -359,8 +359,7 @@ 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;
@@ -380,79 +379,39 @@ export function onMessageSwiped(messageIndex) {
setLastActionWasSwipe(true);
setIsAwaitingNewMessage(true);
// 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];
// console.log('[RPG Companion] Loading data for swipe', currentSwipeId);
// Load swipe data into lastGeneratedData for display
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
// IMPORTANT: onMessageSwiped is for DISPLAY only!
// lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION
// It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check
if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) {
const swipeData = message.extra.rpg_companion_swipes[currentSwipeId];
// Parse user stats if available
if (swipeData.userStats) {
parseUserStats(swipeData.userStats);
}
// Load swipe data into lastGeneratedData for display (both modes)
lastGeneratedData.userStats = swipeData.userStats || null;
lastGeneratedData.infoBox = swipeData.infoBox || null;
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
// 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 {
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
lastGeneratedData.characterThoughts = swipeData.characterThoughts || null;
}
// DON'T parse user stats when loading swipe data
// This would overwrite manually edited fields (like Conditions) with old swipe data
// The lastGeneratedData is loaded for display purposes only
// parseUserStats() updates extensionSettings.userStats which should only be modified
// by new generations or manual edits, not by swipe navigation
// console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId);
} else {
// console.log('[RPG Companion] ️ No stored data for swipe:', currentSwipeId);
}
// Re-render the panels
@@ -467,148 +426,6 @@ export function onMessageSwiped(messageIndex) {
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) {
if (!extensionSettings.enabled) {
return;
}
// console.log('[RPG Companion] 🗑️ EVENT: onMessageDeleted at index:', messageIndex);
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
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;
}
}
}
// 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();
}
/**
* Update the persona avatar image when user switches personas
*/
+252 -36
View File
@@ -512,17 +512,20 @@ export function renderThoughts() {
const fieldNameLower = field.name.toLowerCase();
// Skip lock icons for thoughts field
const showLock = !fieldNameLower.includes('thought');
// Add placeholder for empty fields
const placeholder = fieldValue ? '' : `data-placeholder="${field.name}"`;
const emptyClass = fieldValue ? '' : ' rpg-empty-field';
if (showLock) {
const lockIconHtml = getLockIconHtml('characters', `${char.name}.${field.name}`);
html += `
<div class="rpg-character-field rpg-character-${fieldId}" style="position: relative;">
${lockIconHtml}
<span class="rpg-editable" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}">${fieldValue}</span>
<span class="rpg-editable${emptyClass}" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}" ${placeholder}>${fieldValue}</span>
</div>
`;
} else {
html += `
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}">${fieldValue}</div>
<div class="rpg-character-field rpg-character-${fieldId} rpg-editable${emptyClass}" contenteditable="true" data-character="${char.name}" data-field="${field.name}" title="Click to edit ${field.name}" ${placeholder}>${fieldValue}</div>
`;
}
}
@@ -564,6 +567,16 @@ export function renderThoughts() {
}
debugLog('[RPG Thoughts] Finished building all character cards');
// Add "Add Character" button if data exists (inside rpg-thoughts-content)
if (presentCharacters.length > 0) {
html += `
<button class="rpg-add-character-btn" title="Add a new character">
<i class="fa-solid fa-plus"></i> Add Character
</button>
`;
}
html += '</div>';
}
@@ -662,6 +675,31 @@ export function renderThoughts() {
fileInput.trigger('click');
});
// Add event listener for "Add Character" button (support both click and touch for mobile)
$thoughtsContainer.find('.rpg-add-character-btn').on('click touchend', function(e) {
e.preventDefault();
e.stopPropagation();
addNewCharacter();
});
// Handle empty field focus - remove placeholder styling on focus
$thoughtsContainer.find('.rpg-editable.rpg-empty-field').on('focus', function() {
$(this).removeClass('rpg-empty-field');
$(this).removeAttr('data-placeholder');
});
// Restore placeholder if field becomes empty on blur (after the main blur handler)
$thoughtsContainer.find('.rpg-editable').on('blur', function() {
const $this = $(this);
if (!$this.text().trim()) {
const field = $this.data('field');
if (field) {
$this.addClass('rpg-empty-field');
$this.attr('data-placeholder', field);
}
}
});
// Remove updating class after animation
if (extensionSettings.enableAnimations) {
setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
@@ -788,6 +826,136 @@ export function removeCharacter(characterName) {
renderThoughts();
}
/**
* Adds a new blank character to Present Characters data.
* Creates a character with empty fields based on the tracker template.
*/
export function addNewCharacter() {
const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters;
const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || [];
const characterStats = presentCharsConfig?.characterStats;
const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || [];
const hasRelationship = presentCharsConfig?.relationshipFields?.length > 0;
// Check if data is in JSON format
let isJSON = false;
let parsedData = null;
try {
parsedData = typeof lastGeneratedData.characterThoughts === 'string'
? JSON.parse(lastGeneratedData.characterThoughts)
: lastGeneratedData.characterThoughts;
if (Array.isArray(parsedData) || (parsedData && parsedData.characters)) {
isJSON = true;
}
} catch (e) {
// Not JSON, treat as text format
}
if (isJSON) {
// JSON format - add new character object
const charactersArray = Array.isArray(parsedData) ? parsedData : (parsedData.characters || []);
const newCharacter = {
name: 'New Character',
emoji: '👤',
details: {}
};
// Add all enabled custom fields as empty
for (const field of enabledFields) {
newCharacter.details[field.name] = '';
}
// Add relationship if enabled
if (hasRelationship) {
newCharacter.relationship = 'Neutral';
}
// Add stats if enabled
if (enabledCharStats.length > 0) {
newCharacter.stats = {};
for (const stat of enabledCharStats) {
newCharacter.stats[stat.name] = 100;
}
}
charactersArray.push(newCharacter);
// Save back as JSON string
lastGeneratedData.characterThoughts = JSON.stringify(
Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray },
null,
2
);
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
} else {
// Text format - add new character block
const lines = lastGeneratedData.characterThoughts.split('\n');
const dividerIndex = lines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newCharacterLines = ['- New Character'];
// Add custom detail fields as standalone lines
for (const customField of enabledFields) {
newCharacterLines.push(` ${customField.name}: `);
}
// Add Relationship field if enabled
if (hasRelationship) {
newCharacterLines.push(` Relationship: Neutral`);
}
// Add Stats if enabled
if (enabledCharStats.length > 0) {
const statsParts = enabledCharStats.map(s => `${s.name}: 100%`);
newCharacterLines.push(` Stats: ${statsParts.join(' | ')}`);
}
// Find the last character and add after it, or after divider if no characters
let insertIndex = dividerIndex + 1;
for (let i = lines.length - 1; i > dividerIndex; i--) {
if (lines[i].trim().startsWith('- ')) {
// Find the end of this character block
insertIndex = i + 1;
while (insertIndex < lines.length && lines[insertIndex].trim() && !lines[insertIndex].trim().startsWith('- ')) {
insertIndex++;
}
break;
}
}
lines.splice(insertIndex, 0, ...newCharacterLines);
lastGeneratedData.characterThoughts = lines.join('\n');
committedTrackerData.characterThoughts = lines.join('\n');
}
}
// Update message swipe data
const chat = getContext().chat;
if (chat && chat.length > 0) {
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
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].characterThoughts = lastGeneratedData.characterThoughts;
}
}
break;
}
}
}
saveChatData();
// Re-render to show new character
renderThoughts();
}
/**
* Updates a specific character field in Present Characters data and re-renders.
* Works with the new multi-line format.
@@ -855,18 +1023,27 @@ export function updateCharacterField(characterName, field, value) {
} else if (field === 'emoji') {
char.emoji = value;
} else if (field === 'Relationship') {
// Store relationship as text, converting emoji if needed
// Store relationship in the correct nested format
// Remove old flat format if it exists
if (char.Relationship) {
delete char.Relationship;
}
// First check if it's an emoji → convert to text
let relationshipValue;
if (emojiToRelationship[value]) {
char.Relationship = emojiToRelationship[value];
relationshipValue = emojiToRelationship[value];
} else {
// It's text - find matching relationship name (case-insensitive)
const matchingRelationship = Object.keys(relationshipEmojis).find(
name => name.toLowerCase() === value.toLowerCase()
);
char.Relationship = matchingRelationship || value;
relationshipValue = matchingRelationship || value;
}
// console.log('[RPG Companion] After update - char.Relationship:', char.Relationship);
// Store in the correct nested format
char.relationship = { status: relationshipValue };
// console.log('[RPG Companion] After update - char.relationship:', char.relationship);
// console.log('[RPG Companion] relationshipEmojis:', relationshipEmojis);
// console.log('[RPG Companion] emojiToRelationship:', emojiToRelationship);
} else if (field.toLowerCase() === 'thoughts' || field === (presentCharsConfig?.thoughts?.name || 'Thoughts')) {
@@ -882,15 +1059,44 @@ export function updateCharacterField(characterName, field, value) {
numValue = Math.max(0, Math.min(100, numValue));
char.stats[field] = numValue;
} else {
// It's a custom detail field
// It's a custom detail field - store in details object
if (!char.details) char.details = {};
char.details[field] = value;
// Clean up snake_case version if it exists (from AI generation)
const fieldKey = toSnakeCase(field);
if (fieldKey !== field && char.details[fieldKey] !== undefined) {
delete char.details[fieldKey];
}
// Clean up old root-level field if it exists (from v2 format)
if (char[field] !== undefined && field !== 'name' && field !== 'emoji') {
delete char[field];
}
if (char[fieldKey] !== undefined && fieldKey !== 'name' && fieldKey !== 'emoji') {
delete char[fieldKey];
}
}
}
// Clean up ALL duplicate snake_case fields in details (not just the edited field)
// This prevents duplicates from AI-generated data
if (char.details) {
for (const customField of enabledFields) {
const fieldName = customField.name;
const snakeCaseKey = toSnakeCase(fieldName);
// If both versions exist, keep the properly-cased one and remove snake_case
if (snakeCaseKey !== fieldName &&
char.details[fieldName] !== undefined &&
char.details[snakeCaseKey] !== undefined) {
delete char.details[snakeCaseKey];
}
}
}
}
// Save back to lastGeneratedData
lastGeneratedData.characterThoughts = Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray };
// Save back to lastGeneratedData as JSON string (consistent with infoBox and userStats)
lastGeneratedData.characterThoughts = JSON.stringify(Array.isArray(parsedData) ? charactersArray : { ...parsedData, characters: charactersArray }, null, 2);
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] Saved to lastGeneratedData.characterThoughts:', JSON.stringify(lastGeneratedData.characterThoughts));
@@ -971,6 +1177,9 @@ export function updateCharacterField(characterName, field, value) {
const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts';
const isThoughtsField = field.toLowerCase() === 'thoughts' || field === thoughtsFieldName;
// Track if field was found and updated
let fieldUpdated = false;
// First pass: check if Stats line exists and update other fields
for (let i = characterStartIndex; i < characterEndIndex; i++) {
const line = lines[i].trim();
@@ -978,35 +1187,37 @@ export function updateCharacterField(characterName, field, value) {
if (line.startsWith('Stats:')) {
statsLineExists = true;
statsLineIndex = i;
continue; // Skip to next line
}
// Check for name update
if (field === 'name' && line.startsWith('- ')) {
lines[i] = `- ${value}`;
fieldUpdated = true;
continue;
}
else if (field === 'emoji' && line.startsWith('Details:')) {
const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim());
parts[0] = value;
lines[i] = `Details: ${parts.join(' | ')}`;
}
else if (line.startsWith('Details:')) {
const fieldIndex = enabledFields.findIndex(f => f.name === field);
if (fieldIndex !== -1) {
const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim());
if (parts.length > fieldIndex + 1) {
parts[fieldIndex + 1] = value;
lines[i] = `Details: ${parts.join(' | ')}`;
}
}
}
else if (field === 'Relationship' && line.startsWith('Relationship:')) {
// Check for Relationship field
if (field === 'Relationship' && line.startsWith('Relationship:')) {
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
const relationshipValue = emojiToRelationship[value] || value;
lines[i] = `Relationship: ${relationshipValue}`;
fieldUpdated = true;
continue;
}
else if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) {
// Update thoughts field
lines[i] = `${thoughtsFieldName}: ${value}`;
// console.log('[RPG Companion] Updated thoughts:', lines[i]);
// Check for Thoughts field
if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) {
lines[i] = ` ${thoughtsFieldName}: ${value}`;
fieldUpdated = true;
continue;
}
// Check for v3 text format standalone field lines (e.g., "Appearance: ...", "Demeanor: ...")
if (line.startsWith(field + ':')) {
lines[i] = ` ${field}: ${value}`;
fieldUpdated = true;
// Don't break - update ALL instances of this field (in case of duplicates from previous bugs)
}
}
@@ -1073,23 +1284,28 @@ export function updateCharacterField(characterName, field, value) {
}
}
} else {
// Create new character block
// Create new character block (v3 text format only)
const dividerIndex = lines.findIndex(line => line.includes('---'));
if (dividerIndex >= 0) {
const newCharacterLines = [`- ${characterName}`];
let detailsParts = [field === 'emoji' ? value : '😊'];
for (let i = 0; i < enabledFields.length; i++) {
detailsParts.push(field === enabledFields[i].name ? value : '');
// Add custom detail fields as standalone lines
for (const customField of enabledFields) {
if (field === customField.name) {
newCharacterLines.push(` ${customField.name}: ${value}`);
} else {
newCharacterLines.push(` ${customField.name}: `);
}
}
newCharacterLines.push(`Details: ${detailsParts.join(' | ')}`);
// Add Relationship field if enabled
if (presentCharsConfig?.relationshipFields?.length > 0) {
const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' };
const relationshipValue = field === 'Relationship' ? (emojiToRelationship[value] || value) : 'Neutral';
newCharacterLines.push(`Relationship: ${relationshipValue}`);
newCharacterLines.push(` Relationship: ${relationshipValue}`);
}
// Add Stats if enabled
if (enabledCharStats.length > 0) {
const statsParts = enabledCharStats.map(s => {
if (field === s.name) {
@@ -1104,7 +1320,7 @@ export function updateCharacterField(characterName, field, value) {
}
return `${s.name}: 0%`;
});
newCharacterLines.push(`Stats: ${statsParts.join(' | ')}`);
newCharacterLines.push(` Stats: ${statsParts.join(' | ')}`);
}
lines.splice(dividerIndex + 1, 0, ...newCharacterLines);
+117 -41
View File
@@ -397,7 +397,7 @@ export class EncounterModal {
</div>
<!-- Player Controls -->
${this.renderPlayerControls(combatData.party)}
${this.renderPlayerControls(combatData.party, currentEncounter.playerActions)}
</div>
`;
@@ -599,7 +599,7 @@ export class EncounterModal {
if (member.isPlayer && user_avatar) {
avatarIcon = `<img src="${getSafeThumbnailUrl('persona', user_avatar)}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`;
} else {
const avatarUrl = this.getPartyMemberAvatar(member.name);
const avatarUrl = this.getCharacterAvatar(member.name);
if (avatarUrl) {
avatarIcon = `<img src="${avatarUrl}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`;
}
@@ -657,12 +657,16 @@ export class EncounterModal {
* @param {Array} party - Party data
* @returns {string} HTML for controls
*/
renderPlayerControls(party) {
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>';
}
// Use playerActions if provided, otherwise fall back to player data
const attacks = playerActions?.attacks || player.attacks || [];
const items = playerActions?.items || player.items || [];
return `
<div class="rpg-encounter-controls">
<h3><i class="fa-solid fa-hand-fist"></i> Your Actions</h3>
@@ -670,7 +674,7 @@ export class EncounterModal {
<div class="rpg-encounter-action-buttons">
<div class="rpg-encounter-button-group">
<h4>Attacks</h4>
${player.attacks.map(attack => {
${attacks.map(attack => {
// Support both old string format and new object format
const attackName = typeof attack === 'string' ? attack : attack.name;
const attackType = typeof attack === 'string' ? 'single-target' : (attack.type || 'single-target');
@@ -688,10 +692,10 @@ export class EncounterModal {
}).join('')}
</div>
${player.items && player.items.length > 0 ? `
${items && items.length > 0 ? `
<div class="rpg-encounter-button-group">
<h4>Items</h4>
${player.items.map(item => `
${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}
</button>
@@ -718,21 +722,27 @@ export class EncounterModal {
* @param {Array} party - Party data for reference
*/
attachControlListeners(party) {
// Attack and item buttons
this.modal.querySelectorAll('.rpg-encounter-action-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const actionType = e.currentTarget.dataset.action;
const value = e.currentTarget.dataset.value;
const attackType = e.currentTarget.dataset.attackType;
// Only attach once - event delegation on the modal means listeners persist
if (this._listenersAttached) {
return;
}
// Store handlers as instance properties so we can remove them if needed
this._actionHandler = async (e) => {
// Handle action buttons (attack/item)
const actionBtn = e.target.closest('.rpg-encounter-action-btn');
if (actionBtn && !actionBtn.disabled && !this.isProcessing) {
const actionType = actionBtn.dataset.action;
const value = actionBtn.dataset.value;
const attackType = actionBtn.dataset.attackType;
const context = getContext();
const userName = context.name1;
let actionText = '';
if (actionType === 'attack') {
// Show target selection for attacks
const target = await this.showTargetSelection(attackType, currentEncounter.combatStats);
if (!target) return; // User cancelled
if (!target) return;
if (target === 'all-enemies') {
actionText = `${userName} uses ${value} targeting all enemies!`;
@@ -740,40 +750,46 @@ export class EncounterModal {
actionText = `${userName} uses ${value} on ${target}!`;
}
} else if (actionType === 'item') {
// Show target selection for items (default to single-target)
const target = await this.showTargetSelection('single-target', currentEncounter.combatStats);
if (!target) return; // User cancelled
if (!target) return;
actionText = `${userName} uses ${value} on ${target}!`;
}
await this.processCombatAction(actionText);
});
});
return;
}
// Custom action submit
const customInput = this.modal.querySelector('#rpg-encounter-custom-input');
const customSubmit = this.modal.querySelector('#rpg-encounter-custom-submit');
const submitCustomAction = async () => {
const action = customInput.value.trim();
if (!action) return;
await this.processCombatAction(action);
customInput.value = '';
// Handle custom submit button
const submitBtn = e.target.closest('#rpg-encounter-custom-submit');
if (submitBtn && !submitBtn.disabled && !this.isProcessing) {
const input = this.modal.querySelector('#rpg-encounter-custom-input');
if (input) {
const action = input.value.trim();
if (action) {
await this.processCombatAction(action);
input.value = '';
}
}
}
};
if (customSubmit) {
customSubmit.addEventListener('click', submitCustomAction);
}
if (customInput) {
customInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
submitCustomAction();
this._keypressHandler = async (e) => {
const input = e.target.closest('#rpg-encounter-custom-input');
if (input && e.key === 'Enter' && !this.isProcessing) {
const action = input.value.trim();
if (action) {
await this.processCombatAction(action);
input.value = '';
}
});
}
}
};
// Attach to the modal itself (which never gets replaced)
this.modal.addEventListener('click', this._actionHandler);
this.modal.addEventListener('keypress', this._keypressHandler);
this._listenersAttached = true;
}
/**
@@ -820,7 +836,8 @@ export class EncounterModal {
// Update encounter state
updateCurrentEncounter({
combatStats: result.combatStats
combatStats: result.combatStats,
playerActions: result.playerActions
});
// Collect log entries in order: enemy actions, party actions, then narration
@@ -935,16 +952,75 @@ export class EncounterModal {
}
});
// Re-render controls if player died
// Re-render controls if player died OR if player's actions changed
const player = combatStats.party.find(m => m.isPlayer);
const controlsContainer = this.modal.querySelector('.rpg-encounter-controls');
if (player && player.hp <= 0) {
const controlsContainer = this.modal.querySelector('.rpg-encounter-controls');
if (controlsContainer) {
controlsContainer.innerHTML = '<p class="rpg-encounter-defeated">You have been defeated...</p>';
}
} else if (currentEncounter.playerActions && controlsContainer) {
// Check if actions have changed by comparing with previous state
const actionsChanged = this.haveActionsChanged(currentEncounter.playerActions);
if (actionsChanged) {
// Store the new actions for next comparison
this._previousPlayerActions = {
attacks: currentEncounter.playerActions.attacks ? JSON.parse(JSON.stringify(currentEncounter.playerActions.attacks)) : [],
items: currentEncounter.playerActions.items ? [...currentEncounter.playerActions.items] : []
};
// Re-render the entire controls section with new actions
const newControlsHTML = this.renderPlayerControls(combatStats.party, currentEncounter.playerActions);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newControlsHTML;
const newControls = tempDiv.firstElementChild;
if (newControls) {
controlsContainer.replaceWith(newControls);
}
}
}
}
/**
* Checks if player's available actions have changed
* @param {Object} playerActions - Current player actions data with attacks and items
* @returns {boolean} True if actions changed
*/
haveActionsChanged(playerActions) {
if (!this._previousPlayerActions) {
// First time - store initial actions
this._previousPlayerActions = {
attacks: playerActions.attacks ? JSON.parse(JSON.stringify(playerActions.attacks)) : [],
items: playerActions.items ? [...playerActions.items] : []
};
return false;
}
const currentAttacks = playerActions.attacks || [];
const currentItems = playerActions.items || [];
const prevAttacks = this._previousPlayerActions.attacks || [];
const prevItems = this._previousPlayerActions.items || [];
// Check if attacks changed
if (currentAttacks.length !== prevAttacks.length) return true;
for (let i = 0; i < currentAttacks.length; i++) {
const curr = typeof currentAttacks[i] === 'string' ? currentAttacks[i] : currentAttacks[i].name;
const prev = typeof prevAttacks[i] === 'string' ? prevAttacks[i] : prevAttacks[i].name;
if (curr !== prev) return true;
}
// Check if items changed
if (currentItems.length !== prevItems.length) return true;
for (let i = 0; i < currentItems.length; i++) {
if (currentItems[i] !== prevItems[i]) return true;
}
return false;
}
/**
* Adds multiple log entries sequentially with animation
* @param {Array} entries - Array of {message, type} objects
+186 -47
View File
@@ -105,41 +105,48 @@ function getCurrentTime() {
return null;
}
// Patterns for specific weather conditions (order matters - combined effects first)
// Grouped by languages for easy editing
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: [ "облачно", "пасмурно", "в помещении", "внутри" ] },
],
}
/**
* 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
}
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';
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;
}
}
}
return 'none';
return "none";
}
/**
@@ -237,27 +244,27 @@ 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
// Clamp to daytime hours
const clampedHour = Math.max(5, Math.min(20, 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 };
}
@@ -270,7 +277,7 @@ function createSunshine(hour) {
// Create the sun based on current hour
const sunPos = calculateSunPosition(hour);
const sun = document.createElement('div');
sun.className = 'rpg-weather-particle rpg-clear-sun';
sun.style.left = `${sunPos.left}vw`;
@@ -327,6 +334,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
*/
@@ -444,9 +579,9 @@ function calculateMoonPosition(hour) {
// Nighttime is roughly 8 PM to 5 AM (20-5)
// Map hour to position along an arc
// 8 PM = far left, low | midnight = center-left, high | 5 AM = far right, low
if (hour === null) hour = 0; // Default to midnight if unknown
// Normalize night hours to 0-1 range
// 20 (8 PM) = 0, 0 (midnight) = ~0.44, 5 (5 AM) = 1
let progress;
@@ -457,16 +592,16 @@ function calculateMoonPosition(hour) {
// Midnight to 5 AM: 0-5 maps to 0.44-1
progress = (hour + 4) / 9;
}
// Horizontal position: 10% to 80% (left to right)
const left = 10 + progress * 70;
// Vertical position: parabolic arc (high at ~2 AM, low at dusk/dawn)
// Peak should be around progress 0.67 (~2 AM)
const peakProgress = 0.5;
const normalizedProgress = (progress - peakProgress) * 2; // -1 to 1
const top = 8 + 25 * (normalizedProgress * normalizedProgress);
return { left, top };
}
@@ -479,7 +614,7 @@ function updateCelestialPosition(hour) {
// Update sun position if it exists
const sun = weatherContainer.querySelector('.rpg-clear-sun');
const sunGlow = weatherContainer.querySelector('.rpg-clear-sun-glow');
if (sun && sunGlow) {
const sunPos = calculateSunPosition(hour);
sun.style.left = `${sunPos.left}vw`;
@@ -492,7 +627,7 @@ function updateCelestialPosition(hour) {
// Update moon position if it exists
const moon = weatherContainer.querySelector('.rpg-night-moon');
const moonGlow = weatherContainer.querySelector('.rpg-night-moon-glow');
if (moon && moonGlow) {
const moonPos = calculateMoonPosition(hour);
moon.style.left = `${moonPos.left}vw`;
@@ -572,9 +707,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);
}
+11 -6
View File
@@ -11,13 +11,17 @@
* @returns {object|null} Repaired JSON object or null if repair fails
*/
export function repairJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input:', typeof jsonString);
if (typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input type:', typeof jsonString);
return null;
}
let cleaned = jsonString.trim();
if (!cleaned) {
return null;
}
// Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, '');
@@ -147,7 +151,8 @@ export function extractJSONFromText(text) {
// Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) {
return fenceMatch[1].trim();
const trimmed = fenceMatch[1].trim();
if (trimmed) return trimmed;
}
// Try to extract from ``` code fence (without json label)
@@ -155,20 +160,20 @@ export function extractJSONFromText(text) {
if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
if (content && (content.startsWith('{') || content.startsWith('['))) {
return content;
}
}
// Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) {
if (objectMatch && objectMatch[0].trim()) {
return objectMatch[0];
}
// Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
if (arrayMatch && arrayMatch[0].trim()) {
return arrayMatch[0];
}
+272 -4
View File
@@ -2329,6 +2329,40 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
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 {
@@ -2340,12 +2374,20 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
word-wrap: break-word;
}
/* Placeholder for empty editable character fields */
.rpg-character-field.rpg-editable:empty::before {
content: 'Click to edit...';
/* Empty field placeholder using data-placeholder attribute */
.rpg-editable.rpg-empty-field:empty::before {
content: attr(data-placeholder);
color: var(--rpg-highlight);
opacity: 0.5;
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 */
@@ -9898,6 +9940,200 @@ body[data-theme="cyberpunk"] .rpg-music-widget-play {
}
}
/* ===== Sunrise Effects (Dawn) ===== */
.rpg-sunrise-weather {
overflow: hidden;
}
/* Sunrise sky gradient overlay */
.rpg-sunrise-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100dvh;
background: linear-gradient(to bottom,
rgba(40, 30, 80, 0.1) 0%,
rgba(120, 60, 120, 0.08) 15%,
rgba(200, 100, 100, 0.1) 35%,
rgba(255, 140, 100, 0.12) 55%,
rgba(255, 180, 120, 0.1) 75%,
rgba(255, 200, 150, 0.08) 100%);
animation: rpg-sunrise-sky-transition 30s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes rpg-sunrise-sky-transition {
0% {
opacity: 0.8;
}
100% {
opacity: 1;
}
}
/* Sunrise sun - more orange/red */
.rpg-sunrise-sun {
background: radial-gradient(circle at 40% 40%,
rgba(255, 255, 220, 1) 0%,
rgba(255, 220, 150, 1) 30%,
rgba(255, 160, 80, 0.9) 60%,
rgba(255, 100, 50, 0.6) 80%,
rgba(255, 80, 30, 0) 100%) !important;
box-shadow:
0 0 40px 15px rgba(255, 180, 100, 0.6),
0 0 80px 30px rgba(255, 140, 80, 0.4),
0 0 120px 50px rgba(255, 100, 50, 0.2) !important;
}
/* Sunrise sun glow - warm orange */
.rpg-sunrise-glow {
background: radial-gradient(circle at center,
rgba(255, 200, 150, 0.35) 0%,
rgba(255, 160, 100, 0.2) 30%,
rgba(255, 120, 80, 0.1) 50%,
transparent 70%) !important;
}
/* Horizon glow for sunrise */
.rpg-sunrise-horizon-glow {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 40dvh;
background: linear-gradient(to top,
rgba(255, 160, 100, 0.15) 0%,
rgba(255, 140, 90, 0.1) 20%,
rgba(255, 120, 80, 0.05) 50%,
rgba(255, 100, 70, 0.02) 75%,
transparent 100%);
animation: rpg-horizon-glow-pulse 8s ease-in-out infinite;
pointer-events: none;
}
@keyframes rpg-horizon-glow-pulse {
0%, 100% {
opacity: 0.7;
}
50% {
opacity: 1;
}
}
/* Fading stars at sunrise */
.rpg-sunrise-fading-star {
opacity: 0.3 !important;
animation: rpg-star-fade-out 4s ease-in-out infinite !important;
}
@keyframes rpg-star-fade-out {
0%, 100% {
opacity: 0.2;
}
50% {
opacity: 0.4;
}
}
/* ===== Sunset Effects (Dusk) ===== */
.rpg-sunset-weather {
overflow: hidden;
}
/* Sunset sky gradient overlay */
.rpg-sunset-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100dvh;
background: linear-gradient(to bottom,
rgba(30, 20, 60, 0.12) 0%,
rgba(80, 40, 100, 0.1) 15%,
rgba(150, 60, 90, 0.12) 30%,
rgba(220, 80, 70, 0.12) 50%,
rgba(255, 120, 80, 0.12) 70%,
rgba(255, 160, 100, 0.1) 85%,
rgba(255, 180, 120, 0.06) 100%);
animation: rpg-sunset-sky-transition 30s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes rpg-sunset-sky-transition {
0% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Sunset sun - more red/deep orange */
.rpg-sunset-sun {
background: radial-gradient(circle at 40% 40%,
rgba(255, 240, 200, 1) 0%,
rgba(255, 180, 100, 1) 30%,
rgba(255, 120, 60, 0.9) 60%,
rgba(255, 80, 40, 0.6) 80%,
rgba(200, 50, 30, 0) 100%) !important;
box-shadow:
0 0 40px 15px rgba(255, 140, 80, 0.6),
0 0 80px 30px rgba(255, 100, 60, 0.4),
0 0 120px 50px rgba(200, 60, 40, 0.2) !important;
}
/* Sunset sun glow - deep orange/red */
.rpg-sunset-glow {
background: radial-gradient(circle at center,
rgba(255, 160, 120, 0.35) 0%,
rgba(255, 120, 80, 0.2) 30%,
rgba(200, 80, 60, 0.1) 50%,
transparent 70%) !important;
}
/* Horizon glow for sunset */
.rpg-sunset-horizon-glow {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 45dvh;
background: linear-gradient(to top,
rgba(255, 120, 60, 0.18) 0%,
rgba(255, 100, 50, 0.12) 20%,
rgba(220, 70, 50, 0.06) 45%,
rgba(150, 50, 60, 0.02) 70%,
transparent 100%);
animation: rpg-horizon-glow-pulse 8s ease-in-out infinite;
pointer-events: none;
}
/* Emerging stars at sunset */
.rpg-sunset-emerging-star {
animation: rpg-star-emerge 5s ease-in-out infinite !important;
}
@keyframes rpg-star-emerge {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.7;
}
}
/* Sunset dust motes - pinkish tint */
.rpg-sunset-dust {
background: radial-gradient(circle at 30% 30%,
rgba(255, 200, 180, 0.9) 0%,
rgba(255, 180, 160, 0.6) 50%,
rgba(255, 160, 140, 0) 100%) !important;
box-shadow: 0 0 6px 2px rgba(255, 180, 160, 0.4) !important;
}
/* Lens flare effect */
.rpg-clear-lens-flare {
position: fixed;
@@ -10272,6 +10508,12 @@ body[data-theme="cyberpunk"] .rpg-music-widget-play {
.rpg-night-shooting-star {
animation-duration: 18s;
}
/* Sunrise/Sunset mobile optimizations */
.rpg-sunrise-horizon-glow,
.rpg-sunset-horizon-glow {
height: 35%;
}
}
/* Foreground mode - reduced opacity for celestial bodies to not obstruct content */
@@ -10319,6 +10561,32 @@ body[data-theme="cyberpunk"] .rpg-music-widget-play {
opacity: 0.6;
}
/* Sunrise/Sunset foreground mode */
.rpg-weather-foreground .rpg-sunrise-overlay,
.rpg-weather-foreground .rpg-sunset-overlay {
opacity: 0.4;
}
.rpg-weather-foreground .rpg-sunrise-horizon-glow,
.rpg-weather-foreground .rpg-sunset-horizon-glow {
opacity: 0.3;
}
.rpg-weather-foreground .rpg-sunrise-sun,
.rpg-weather-foreground .rpg-sunset-sun {
opacity: 0.5 !important;
}
.rpg-weather-foreground .rpg-sunrise-glow,
.rpg-weather-foreground .rpg-sunset-glow {
opacity: 0.3 !important;
}
.rpg-weather-foreground .rpg-sunrise-fading-star,
.rpg-weather-foreground .rpg-sunset-emerging-star {
opacity: 0.2 !important;
}
/* Lightning flash effect */
.rpg-lightning {
position: fixed;