Compare commits

...

4 Commits

Author SHA1 Message Date
ARIA 35cffa2696 Added Agents.md file 2026-07-03 11:22:30 +02:00
ARIA 10cfe581ac feat: add Equipment tab with slot-type validation
Add a new Equipment tab to manage player gear and stat bonuses.

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

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

Files:
- New: src/systems/equipment/constants.js
- New: src/systems/interaction/equipmentActions.js
- New: src/systems/rendering/equipment.js
- Modified: state.js, persistence.js, template.html, index.js
- Modified: userStats.js, desktop.js, mobile.js, layout.js, modals.js
- Modified: apiClient.js, sillytavern.js, style.css, en.json
2026-07-03 11:11:23 +02:00
Spicy_Marinara 38fb3d8c51 Fix tracker issues and add deprecation notice 2026-05-04 13:08:52 +02:00
Spicy Marinara 70792f8a2a Merge pull request #143 from SpicyMarinara/SpicyMarinara-patch-1
Update README to mark project as deprecated
2026-05-04 13:02:16 +02:00
22 changed files with 2178 additions and 56 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"MD013": false
}
+330
View File
@@ -0,0 +1,330 @@
# RPG Companion for SillyTavern — Agent Instructions
## What this is
A **browser extension for SillyTavern** (AI roleplay frontend). It is NOT a standalone Node.js application. All JavaScript runs in the browser context inside SillyTavern. There is **no build step** — files are loaded directly by SillyTavern's extension loader.
---
## Quick start
- **Entry point**: `index.js` (loaded by SillyTavern via `manifest.json`)
- **CSS**: `style.css` (single file, ~12,300 lines)
- **HTML templates**: `template.html` (main panel), `settings.html` (Extensions tab settings drawer)
- **Runtime**: Browser only. All code executes inside SillyTavern's page context.
- **jQuery**: Available globally as `$` / `jQuery`. Used extensively for DOM manipulation.
- **ES modules**: All `src/` files use `import`/`export` (browser ES module syntax).
---
## Manifest and loading
`manifest.json` tells SillyTavern how to load the extension:
```json
{
"display_name": "RPG Companion",
"loading_order": 100,
"js": "index.js",
"css": "style.css"
}
```
SillyTavern loads `index.js` and `style.css` relative to the extension directory. The extension name used internally is `third-party/rpg-companion-sillytavern`.
### Extension path detection
`src/core/config.js` auto-detects whether the extension is installed globally (`public/extensions/`) or per-user (`data/default-user/extensions/`) by inspecting `import.meta.url`. This determines template loading paths.
---
## SillyTavern integration
The extension imports from SillyTavern's global scripts using **relative paths** that assume the extension lives at:
- `scripts/extensions/third-party/rpg-companion-sillytavern/` (global install), or
- `data/default-user/extensions/third-party/rpg-companion-sillytavern/` (user install)
Key imports in `index.js`:
```js
import { getContext, renderExtensionTemplateAsync, extension_settings as st_extension_settings }
from '../../../extensions.js';
import { eventSource, event_types, substituteParams, chat, saveSettingsDebounced, ... }
from '../../../../script.js';
import { selected_group, getGroupMembers } from '../../../group-chats.js';
import { power_user } from '../../../power-user.js';
```
The deeper relative imports in `src/` files (e.g., `../../../../../../script.js`) follow the same pattern adjusted for directory depth.
### SillyTavern globals available at runtime
- `self.SillyTavern` — global namespace object
- `$` / `jQuery` — jQuery
- `toastr` — toast notification library
- `eventSource` — SillyTavern's event emitter
- `chat` — current chat message array
- `chat_metadata` — per-chat metadata store
- `characters` — loaded character array
- `getContext()` — returns current chat context
---
## Architecture
### Source tree (`src/`)
```
src/
├── core/ # Core infrastructure
│ ├── config.js # Extension name, folder path, default settings
│ ├── state.js # Centralized state variables + getters/setters
│ ├── persistence.js # Save/load settings, chat data, migrations
│ ├── events.js # SillyTavern event system wrapper
│ └── i18n.js # Translation system (fetches JSON, applies data-i18n-key)
├── i18n/ # Translation JSON files
│ ├── en.json # English (reference, 513 lines)
│ ├── fr.json # French
│ ├── ru.json # Russian
│ ├── zh-cn.json # Simplified Chinese
│ ├── zh-tw.json # Traditional Chinese
│ └── validator.js # Node.js script to validate translation consistency
├── systems/ # Feature systems (organized by concern)
│ ├── generation/ # AI prompt generation, parsing, API calls
│ │ ├── promptBuilder.js # Builds tracker prompts for AI
│ │ ├── parser.js # Parses AI responses to extract tracker data
│ │ ├── apiClient.js # Makes API calls for separate/external generation
│ │ ├── injector.js # Injects tracker prompts into generation context
│ │ ├── lockManager.js # Manages locked tracker fields
│ │ ├── suppression.js # Controls when to skip tracker injection
│ │ ├── encounterPrompts.js # Combat encounter prompt building
│ │ ├── inventoryParser.js # Parses inventory data from AI responses
│ │ └── jsonPromptHelpers.js # JSON formatting helpers for prompts
│ ├── rendering/ # UI rendering functions
│ │ ├── userStats.js # Renders user stats panel
│ │ ├── infoBox.js # Renders info box (date, weather, location, events)
│ │ ├── thoughts.js # Renders character thoughts + chat bubbles
│ │ ├── inventory.js # Renders inventory section
│ │ ├── equipment.js # Renders equipment section
│ │ ├── quests.js # Renders quests section
│ │ └── musicPlayer.js # Renders Spotify music player
│ ├── ui/ # UI components and helpers
│ │ ├── theme.js # Theme system (default, sci-fi, fantasy, cyberpunk, custom)
│ │ ├── modals.js # Settings modal, dice modal, deprecation modal
│ │ ├── layout.js # Panel positioning, collapse/expand, section visibility
│ │ ├── mobile.js # Mobile FAB button, mobile tabs, keyboard handling
│ │ ├── desktop.js # Desktop strip widgets
│ │ ├── trackerEditor.js # Tracker configuration editor
│ │ ├── promptsEditor.js # Custom prompt editor
│ │ ├── checkpointUI.js # Chapter checkpoint buttons
│ │ ├── encounterUI.js # Combat encounter modal
│ │ ├── snowflakes.js # Snowflake animation effect
│ │ ├── weatherEffects.js # Dynamic weather visual effects
│ │ └── alternatePresentCharacters.js # Below-chat character panel
│ ├── integration/ # SillyTavern event integration
│ │ ├── sillytavern.js # Event handlers: message sent/received, swipe, etc.
│ │ └── thoughtBasedExpressions.js # Integrates with ST Character Expressions
│ ├── interaction/ # User interaction handlers
│ │ ├── inventoryActions.js # Inventory click/edit handlers
│ │ ├── equipmentActions.js # Equipment click/edit handlers
│ │ └── inventoryEdit.js # Inventory inline editing
│ └── features/ # Feature modules
│ ├── plotProgression.js # Plot progression buttons
│ ├── classicStats.js # D&D-style attribute buttons
│ ├── dice.js # Dice roller
│ ├── htmlCleaning.js # Regex-based HTML tag cleaning
│ ├── jsonCleaning.js # Regex-based JSON cleanup in messages
│ ├── musicPlayer.js # Spotify URL parsing and embedding
│ ├── chapterCheckpoint.js # Save/restore tracker state checkpoints
│ ├── avatarGenerator.js # Auto-generate character avatars
│ └── encounterState.js # Combat encounter state management
├── types/ # JSDoc type definitions
│ └── inventory.js # InventoryV2 type definition
└── utils/ # Utility functions
├── avatars.js # Avatar URL handling
├── imageUrls.js # Image URL utilities
├── itemParser.js # Item string parsing
├── jsonMigration.js # v2 to v3 JSON format migration
├── jsonRepair.js # JSON repair for malformed AI responses
├── migration.js # Settings migration helpers
├── presentCharacters.js # Present character data helpers
├── responseExtractor.js # Extract tracker data from AI responses
├── security.js # Input validation and sanitization
├── sillyTavernExpressions.js # ST expression mapping
├── thoughtBasedExpressionPortraits.js # Expression-to-portrait mapping
└── transformations.js # Data transformation utilities
```
### Data flow
1. **User sends message**`MESSAGE_SENT` event fires
2. **Extension hooks** `onMessageSent` in `sillytavern.js`
3. **Together mode**: Tracker prompt is injected into the generation context via `injector.js`
4. **Separate mode**: After main response, `apiClient.js` makes a separate API call
5. **External API mode**: Uses user-configured external API endpoint
6. **AI responds**`MESSAGE_RECEIVED` event fires
7. **Extension hooks** `onMessageReceived``parser.js` extracts tracker data
8. **Rendering**: `renderUserStats()`, `renderInfoBox()`, `renderThoughts()`, etc. update the UI
9. **Persistence**: Tracker data is saved to `chat_metadata` per chat, and per-swipe
### State management
All extension state lives in `src/core/state.js` as module-level `let` variables with getter/setter functions. Key state:
- `extensionSettings` — persisted settings object (merged with defaults on load)
- `lastGeneratedData` — tracker data from the last AI generation
- `committedTrackerData` — tracker data committed after a message
- `lastActionWasSwipe` — flag to handle swipe-specific logic
- `isGenerating` — flag to prevent re-entrant generation
- `pendingDiceRoll` — dice roll result to inject into next generation
- DOM element caches: `$panelContainer`, `$userStatsContainer`, etc.
### Persistence
`src/core/persistence.js` handles:
- Extension settings: saved via SillyTavern's `saveSettingsDebounced()`
- Chat-specific data: saved in `chat_metadata.rpg_companion` per chat
- Per-swipe data: stored on each chat message object
- Settings versioning: automatic migration via `CURRENT_SETTINGS_VERSION` (currently 7)
- Deferred saves: batches chat data saves to avoid excessive writes
---
## Development commands
```bash
# Validate translation files (check missing keys, type mismatches, empty values)
npm run validate_locale_once
# Watch mode: re-validate on file changes
npm run validate_locale
```
No build, lint, test, or typecheck commands exist. The code runs directly in the browser.
---
## i18n system
### How it works
1. Translation files are flat JSON in `src/i18n/{locale}.json`
2. Keys use dot notation: `"template.settingsModal.display.showUserStats"`
3. HTML elements use `data-i18n-key`, `data-i18n-title`, or `data-i18n-aria-label` attributes
4. `src/core/i18n.js` fetches translations via `fetch()` from the extension path
5. `applyTranslations()` walks the DOM and updates `textContent`, `title`, and `aria-label`
### Adding translations
1. Add keys to `src/i18n/en.json` (reference locale)
2. Add corresponding keys to all other locale files
3. Add `data-i18n-key="your.key.here"` to HTML elements in `template.html` or `settings.html`
4. For dynamically generated text, use `i18n.getTranslation('your.key.here')` in JS
### Validator
`src/i18n/validator.js` is a **Node.js script** (uses `require()`, not ES modules). It:
- Compares all locales against the reference locale for missing/extra keys
- Checks for type mismatches
- Checks for empty strings and null values
- Scans `.html`/`.js`/`.jsx` files for unlocalized text (text in tags without `data-i18n-key`)
---
## Key conventions
### jQuery usage
DOM manipulation uses jQuery throughout. Element caches are stored as jQuery objects with `$` prefix:
```js
$panelContainer = $('#rpg-companion-panel')
$userStatsContainer = $('#rpg-user-stats')
```
### Event system
All SillyTavern event registration goes through `src/core/events.js`:
```js
registerAllEvents({
[event_types.MESSAGE_SENT]: onMessageSent,
[event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar],
});
```
### CSS naming
All CSS classes use `rpg-` prefix to avoid conflicts with SillyTavern and other extensions:
- `.rpg-panel`, `.rpg-section`, `.rpg-stat-bar`, `.rpg-btn-primary`, etc.
- CSS custom properties: `--rpg-bg`, `--rpg-accent`, `--rpg-text`, `--rpg-highlight`, `--rpg-border`, `--rpg-shadow`
### Panel structure
The main panel (`template.html`) has these sections in order:
1. Collapse toggle button
2. Strip widget container (shown when panel is collapsed)
3. Game container with header
4. Dice roll display
5. Content box with sections: User Stats → Info Box → Thoughts → Inventory → Equipment → Quests → Music Player
6. Feature toggles row (HTML, Dialogue Coloring, Deception, Omniscience, CYOA, Spotify, Weather, Narrator, Auto Avatars)
7. Manual update button
8. Settings and Edit Trackers buttons
### Generation modes
Three modes control how tracker data is generated:
- **`together`**: Tracker prompt injected into main generation. Single API call, faster, but tracker JSON mixed in response.
- **`separate`**: Two API calls — one for roleplay, one for tracker data. Cleaner roleplay, slower.
- **`external`**: Uses user-configured external API (OpenAI-compatible endpoint) for tracker generation.
### Guided generation compatibility
The `skipInjectionsForGuided` setting controls tracker injection behavior during guided generations:
- `'none'` — always inject (default)
- `'impersonation'` — skip only for impersonation-style guided generations
- `'guided'` — skip for any guided/instruct or quiet_prompt generation
---
## Important gotchas
1. **No bundler**: All imports use relative paths that depend on the extension's directory structure within SillyTavern. Changing file locations requires updating import paths.
2. **Relative import depth matters**: Files in `src/` use deeper relative paths (`../../../../../../script.js`) than `index.js` (`../../../../script.js`). When moving files between directories, adjust accordingly.
3. **Settings merge**: On load, saved settings are deep-merged with defaults from `src/core/state.js`. New settings fields should be added to both the state module (runtime defaults) AND `src/core/config.js` (documentation defaults).
4. **Chat metadata**: Per-chat tracker data lives in `chat_metadata.rpg_companion`. Per-swipe data lives on individual chat message objects in the `swipe_store`.
5. **i18n fetch path**: Translations are fetched at `/scripts/extensions/third-party/rpg-companion-sillytavern/src/i18n/{lang}.json`. The hardcoded path in `i18n.js` must match the extension's actual URL path.
6. **Extension enable/disable**: The extension can be toggled from SillyTavern's Extensions tab. When disabled, all UI elements are removed from the DOM. When re-enabled, `initUI()` rebuilds everything.
7. **Mobile vs desktop**: Viewport breakpoint is 1000px. Below: FAB button + mobile tabs. Above: fixed panel + desktop tabs.
8. **External API key**: Stored in `localStorage` as `rpg_companion_external_api_key`, NOT in extension settings (for security).
9. **The `package.json` has no runtime dependencies**. The `devDependencies` (chokidar, fs-extra, glob) are only for the i18n validator.
10. **Deprecation status**: The README states this extension is deprecated in favor of Marinara Engine. The code shows a deprecation modal that displays on first load.
---
## File reference
| File | Purpose |
|------|---------|
| `index.js` | Main entry point. All imports, UI init, event registration, startup logic. |
| `manifest.json` | SillyTavern extension manifest (name, loading order, JS/CSS files). |
| `style.css` | All CSS (~12,300 lines). Themes, animations, responsive breakpoints. |
| `template.html` | Main panel HTML template. Rendered by SillyTavern's `renderExtensionTemplateAsync`. |
| `settings.html` | Extensions tab settings drawer. Minimal — most settings are in the panel modal. |
| `src/core/state.js` | Central state module. All extension-wide variables live here. |
| `src/core/persistence.js` | Settings/chat data save/load, version migrations. |
| `src/core/config.js` | Extension name, path detection, default settings documentation. |
| `src/core/events.js` | SillyTavern event system wrapper with bulk register/unregister. |
| `src/core/i18n.js` | Translation loader and DOM applier. |
| `src/systems/integration/sillytavern.js` | All SillyTavern event handlers (message lifecycle, swipes, chat changes). |
| `src/systems/generation/promptBuilder.js` | Builds AI prompts for tracker generation. |
| `src/systems/generation/parser.js` | Parses AI responses to extract tracker JSON. |
| `src/systems/generation/apiClient.js` | API client for separate/external generation modes. |
| `src/systems/generation/injector.js` | Injects tracker prompts into SillyTavern's generation context. |
| `src/i18n/validator.js` | Node.js script for validating translation consistency. |
+11 -4
View File
@@ -11,7 +11,7 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
Moving on to developing the Marinara Engine frontend, the extension will now be maintained by the community!
https://github.com/Pasta-Devs/Marinara-Engine
<https://github.com/Pasta-Devs/Marinara-Engine>
## 📥 Installation
@@ -21,7 +21,7 @@ https://github.com/Pasta-Devs/Marinara-Engine
3. Go to Install extension
4. Copy-paste this link: https://github.com/SpicyMarinara/rpg-companion-sillytavern
4. Copy-paste this link: <https://github.com/SpicyMarinara/rpg-companion-sillytavern>
5. Press Install for all users/Install just for me
@@ -99,11 +99,13 @@ AI: Trackers + Full roleplay response
↓ Main chat shows clean roleplay text
Pros:
- Single API call
- Faster response
- Simpler setup
Cons:
- Tracker formatting mixed in AI response
- May affect roleplay quality slightly
@@ -127,11 +129,13 @@ AI: Separate call with just the tracker data
↓ Context summary injected into the next generation
Pros:
- Clean roleplay responses
- Better roleplay quality
- Contextual summary enhances immersion
Cons:
- Extra API call
- Slightly slower
@@ -163,16 +167,19 @@ You can edit most fields by clicking on them:
Access comprehensive customization through the Tracker Settings button:
**User Stats Configuration:**
- Add/remove custom stats with unique names
- Configure Status section (mood emoji + custom fields)
- Configure Skills section with custom skill fields
- Toggle RPG attributes display
**Info Box Configuration:**
- Enable/disable individual widgets (Date, Weather, Temperature, Time, Location, Recent Events)
- Choose temperature unit (Celsius/Fahrenheit)
**Present Characters Configuration:**
- Add custom character fields (appearance, action, demeanor, etc.)
- Configure relationship status options
- Enable character-specific stats tracking
@@ -199,11 +206,11 @@ This extension detects when a "guided generation" prompt is submitted (for examp
If you want tracker prompts to apply during a guided generation, run the update via separate generation or temporarily disable guided generation in the other extension.
There is a new setting "Skip Tracker & HTML Injections during Guided Generations" in the RPG Companion settings (Advanced section). It now supports three modes:
- none: never skip (always inject the tracker prompts as usual, default)
- impersonation: only skip when an impersonation-style guided generation is detected
- guided: skip whenever a guided `instruct` or `quiet_prompt` generation is detected
## 🎨 Themes
Choose from 6 beautiful themes:
@@ -286,4 +293,4 @@ SpicyMarinara, Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDea
Made with ❤️ by Marinara
PS I'm looking for a job or a sponsor to fund my custom AI frontend, contact me if interested:
mgrabower97@gmail.com
[mgrabower97@gmail.com](mailto:mgrabower97@gmail.com)
+24 -5
View File
@@ -21,6 +21,7 @@ import {
$infoBoxContainer,
$thoughtsContainer,
$inventoryContainer,
$equipmentContainer,
$questsContainer,
$musicPlayerContainer,
setExtensionSettings,
@@ -38,6 +39,7 @@ import {
setInfoBoxContainer,
setThoughtsContainer,
setInventoryContainer,
setEquipmentContainer,
setQuestsContainer,
setMusicPlayerContainer,
clearSessionAvatarPrompts
@@ -69,6 +71,7 @@ import {
createThoughtPanel
} from './src/systems/rendering/thoughts.js';
import { renderInventory } from './src/systems/rendering/inventory.js';
import { renderEquipment } from './src/systems/rendering/equipment.js';
import { renderQuests } from './src/systems/rendering/quests.js';
import { renderMusicPlayer } from './src/systems/rendering/musicPlayer.js';
import { toggleSnowflakes, initSnowflakes } from './src/systems/ui/snowflakes.js';
@@ -76,6 +79,7 @@ import { toggleDynamicWeather, initWeatherEffects, updateWeatherEffect } from '.
// Interaction modules
import { initInventoryEventListeners } from './src/systems/interaction/inventoryActions.js';
import { initEquipmentEventListeners } from './src/systems/interaction/equipmentActions.js';
// UI Systems modules
import {
@@ -95,7 +99,8 @@ import {
updateDiceDisplay,
addDiceQuickReply,
getSettingsModal,
showWelcomeModalIfNeeded
showWelcomeModalIfNeeded,
showDeprecationModalIfNeeded
} from './src/systems/ui/modals.js';
import {
initTrackerEditor
@@ -312,6 +317,7 @@ async function initUI() {
setInfoBoxContainer($('#rpg-info-box'));
setThoughtsContainer($('#rpg-thoughts'));
setInventoryContainer($('#rpg-inventory'));
setEquipmentContainer($('#rpg-equipment'));
setQuestsContainer($('#rpg-quests'));
setMusicPlayerContainer($('#rpg-music-player'));
@@ -388,6 +394,12 @@ async function initUI() {
updateSectionVisibility();
});
$('#rpg-toggle-equipment').on('change', function() {
extensionSettings.showEquipment = $(this).prop('checked');
saveSettings();
updateSectionVisibility();
});
$('#rpg-toggle-quests').on('change', function() {
extensionSettings.showQuests = $(this).prop('checked');
saveSettings();
@@ -402,6 +414,7 @@ async function initUI() {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
});
@@ -861,7 +874,7 @@ async function initUI() {
if (lastAssistantIndex !== -1) {
commitTrackerDataFromPriorMessage(lastAssistantIndex);
}
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, renderEquipment);
});
// Strip widget refresh button - same functionality as main refresh button
@@ -880,7 +893,7 @@ async function initUI() {
if (lastAssistantIndex !== -1) {
commitTrackerDataFromPriorMessage(lastAssistantIndex);
}
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory);
await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, renderEquipment);
});
$('#rpg-stat-bar-color-low').on('change', function() {
@@ -1120,6 +1133,7 @@ async function initUI() {
$('#rpg-toggle-thought-based-expressions').prop('checked', extensionSettings.enableThoughtBasedExpressions === true);
$('#rpg-toggle-hide-default-expressions').prop('checked', extensionSettings.hideDefaultExpressionDisplay === true);
$('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory);
$('#rpg-toggle-equipment').prop('checked', extensionSettings.showEquipment);
$('#rpg-toggle-quests').prop('checked', extensionSettings.showQuests);
$('#rpg-toggle-lock-icons').prop('checked', extensionSettings.showLockIcons ?? true);
$('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat);
@@ -1270,6 +1284,7 @@ async function initUI() {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
updateDiceDisplay();
@@ -1283,6 +1298,7 @@ async function initUI() {
setupMobileKeyboardHandling();
setupContentEditableScrolling();
initInventoryEventListeners();
initEquipmentEventListeners();
// Initialize chapter checkpoint UI
initChapterCheckpointUI();
@@ -1511,11 +1527,14 @@ jQuery(async () => {
// Non-critical - continue without it
}
// Show welcome modal for v3.0 on first launch
// Show deprecation notice once for this release; otherwise keep the old welcome flow.
try {
const deprecationModalShown = showDeprecationModalIfNeeded();
if (!deprecationModalShown) {
showWelcomeModalIfNeeded();
}
} catch (error) {
console.error('[RPG Companion] Welcome modal failed:', error);
console.error('[RPG Companion] Startup modal failed:', error);
// Non-critical - continue without it
}
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Marinara",
"version": "3.7.3",
"version": "3.7.4",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "rpg-complanion-sillytavern",
"version": "3.7.3",
"version": "3.7.4",
"description": "",
"main": "index.js",
"scripts": {
+342 -10
View File
@@ -3,7 +3,7 @@
* Handles saving/loading extension settings and chat data
*/
import { saveSettingsDebounced, chat_metadata, saveChatDebounced } from '../../../../../../script.js';
import { saveSettingsDebounced, chat_metadata, saveChatDebounced, getCurrentChatId } from '../../../../../../script.js';
import { getContext } from '../../../../../extensions.js';
import {
extensionSettings,
@@ -23,6 +23,245 @@ import { validateStoredInventory, cleanItemString } from '../utils/security.js';
import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
const CURRENT_SETTINGS_VERSION = 7;
const DEFAULT_USER_STATS = {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None',
skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
};
const DEFAULT_EXTENSION_SETTINGS = cloneSerializable(extensionSettings);
DEFAULT_EXTENSION_SETTINGS.settingsVersion = CURRENT_SETTINGS_VERSION;
let hasDeferredChatDataSave = false;
function cloneSerializable(value) {
if (value === undefined) {
return undefined;
}
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value));
}
}
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function mergeWithDefaults(defaultValue, savedValue) {
if (savedValue === undefined) {
return cloneSerializable(defaultValue);
}
if (isPlainObject(defaultValue) && isPlainObject(savedValue)) {
const merged = cloneSerializable(defaultValue);
for (const [key, value] of Object.entries(savedValue)) {
merged[key] = mergeWithDefaults(defaultValue[key], value);
}
return merged;
}
return cloneSerializable(savedValue);
}
function parseMaybeJSON(value) {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function stringifyInventoryItems(items) {
if (typeof items === 'string') {
return items.trim() || 'None';
}
if (!Array.isArray(items)) {
return 'None';
}
const text = items
.map(item => {
if (isPlainObject(item) && item.name) {
const quantity = Number(item.quantity);
return quantity > 1 ? `${quantity}x ${item.name}` : item.name;
}
return String(item || '').trim();
})
.filter(Boolean)
.join(', ');
return text || 'None';
}
function normalizeStoredInventory(stored) {
if (!isPlainObject(stored)) {
return {};
}
const normalized = {};
for (const [location, items] of Object.entries(stored)) {
normalized[location] = stringifyInventoryItems(items);
}
return normalized;
}
function normalizeInventoryValue(inventory) {
const parsedInventory = parseMaybeJSON(inventory);
if (isPlainObject(parsedInventory) && (
Array.isArray(parsedInventory.onPerson)
|| Array.isArray(parsedInventory.clothing)
|| Array.isArray(parsedInventory.assets)
|| isPlainObject(parsedInventory.stored)
)) {
return {
version: 2,
onPerson: stringifyInventoryItems(parsedInventory.onPerson),
clothing: stringifyInventoryItems(parsedInventory.clothing),
stored: normalizeStoredInventory(parsedInventory.stored),
assets: stringifyInventoryItems(parsedInventory.assets)
};
}
const migrationResult = migrateInventory(parsedInventory);
return mergeWithDefaults(DEFAULT_USER_STATS.inventory, migrationResult.inventory);
}
function normalizeUserStatsValue(userStats) {
const parsedStats = parseMaybeJSON(userStats);
const normalized = cloneSerializable(DEFAULT_USER_STATS);
if (!isPlainObject(parsedStats)) {
return normalized;
}
if (Array.isArray(parsedStats.stats)) {
for (const stat of parsedStats.stats) {
if (!stat || typeof stat !== 'object') continue;
const id = stat.id || stat.name?.toLowerCase?.();
if (id && stat.value !== undefined) {
normalized[id] = stat.value;
}
}
} else {
for (const [key, value] of Object.entries(parsedStats)) {
if (!['stats', 'status', 'inventory', 'quests'].includes(key) && value !== undefined) {
normalized[key] = cloneSerializable(value);
}
}
}
if (isPlainObject(parsedStats.status)) {
for (const [key, value] of Object.entries(parsedStats.status)) {
if (value !== undefined) {
normalized[key] = cloneSerializable(value);
}
}
}
if (parsedStats.inventory !== undefined) {
normalized.inventory = normalizeInventoryValue(parsedStats.inventory);
}
for (const [key, defaultValue] of Object.entries(DEFAULT_USER_STATS)) {
if (typeof defaultValue !== 'number') continue;
const numericValue = Number(normalized[key]);
normalized[key] = Number.isFinite(numericValue) ? numericValue : defaultValue;
}
return mergeWithDefaults(DEFAULT_USER_STATS, normalized);
}
function normalizeQuestValue(quest) {
let value = quest;
while (isPlainObject(value) && value.value !== undefined) {
value = value.value;
}
if (typeof value === 'string') {
return value.trim() || 'None';
}
if (isPlainObject(value)) {
return value.title || value.description || JSON.stringify(value);
}
return value == null ? 'None' : String(value);
}
function normalizeQuestsValue(quests) {
if (!isPlainObject(quests)) {
return { main: 'None', optional: [] };
}
const optionalSource = Array.isArray(quests.optional)
? quests.optional
: (Array.isArray(quests.active) ? quests.active : []);
return {
main: normalizeQuestValue(quests.main),
optional: optionalSource
.map(normalizeQuestValue)
.filter(quest => quest && quest !== 'None')
};
}
function normalizeSettings(savedSettings) {
const sourceSettings = isPlainObject(savedSettings) ? savedSettings : {};
const normalized = mergeWithDefaults(DEFAULT_EXTENSION_SETTINGS, sourceSettings);
const savedVersion = Number(sourceSettings.settingsVersion);
normalized.settingsVersion = Number.isFinite(savedVersion) && savedVersion > 0 ? savedVersion : 1;
normalized.userStats = normalizeUserStatsValue(sourceSettings.userStats);
const parsedUserStats = parseMaybeJSON(sourceSettings.userStats);
if (sourceSettings.quests !== undefined) {
normalized.quests = normalizeQuestsValue(sourceSettings.quests);
} else if (isPlainObject(parsedUserStats) && parsedUserStats.quests !== undefined) {
normalized.quests = normalizeQuestsValue(parsedUserStats.quests);
}
return {
settings: normalized,
changed: JSON.stringify(normalized) !== JSON.stringify(savedSettings)
};
}
function isChatDataSaveReady() {
return !!(
chat_metadata
&& typeof chat_metadata === 'object'
&& chat_metadata.integrity
&& getCurrentChatId()
);
}
function hasTrackerPayload(payload) {
return !!(payload && typeof payload === 'object' && (
@@ -273,7 +512,8 @@ function validateSettings(settings) {
// Check for required top-level properties
if (typeof settings.enabled !== 'boolean' ||
typeof settings.autoUpdate !== 'boolean' ||
!settings.userStats || typeof settings.userStats !== 'object') {
!settings.userStats || typeof settings.userStats !== 'object' ||
Array.isArray(settings.userStats)) {
console.warn('[RPG Companion] Settings validation failed: missing required properties');
return false;
}
@@ -282,7 +522,8 @@ function validateSettings(settings) {
const stats = settings.userStats;
if (typeof stats.health !== 'number' ||
typeof stats.satiety !== 'number' ||
typeof stats.energy !== 'number') {
typeof stats.energy !== 'number' ||
!stats.inventory || typeof stats.inventory !== 'object') {
console.warn('[RPG Companion] Settings validation failed: invalid userStats structure');
return false;
}
@@ -307,21 +548,23 @@ export function loadSettings() {
if (extension_settings[extensionName]) {
const savedSettings = extension_settings[extensionName];
const normalizedResult = normalizeSettings(savedSettings);
const normalizedSettings = normalizedResult.settings;
// Validate loaded settings
if (!validateSettings(savedSettings)) {
// Validate loaded settings after schema repair/normalization
if (!validateSettings(normalizedSettings)) {
console.warn('[RPG Companion] Loaded settings failed validation, using defaults');
console.warn('[RPG Companion] Invalid settings:', savedSettings);
console.warn('[RPG Companion] Invalid settings:', normalizedSettings);
// Save valid defaults to replace corrupt data
saveSettings();
return;
}
updateExtensionSettings(savedSettings);
updateExtensionSettings(normalizedSettings);
// Perform settings migrations based on version
const currentVersion = extensionSettings.settingsVersion || 1;
let settingsChanged = false;
let settingsChanged = normalizedResult.changed;
// Migration to version 2: Enable dynamic weather for existing users
if (currentVersion < 2) {
@@ -373,6 +616,69 @@ export function loadSettings() {
settingsChanged = true;
}
// Migration to version 6: Add equipment data structure
if (currentVersion < 6) {
// console.log('[RPG Companion] Migrating settings to version 6 (adding equipment)');
if (!extensionSettings.userStats.equipment) {
extensionSettings.userStats.equipment = {
items: [],
slots: {
helmet: null,
ring1: null,
ring2: null,
ring3: null,
ring4: null,
ring5: null,
ring6: null,
ring7: null,
ring8: null,
ring9: null,
ring10: null,
necklace: null,
bodyArmor: null,
pants: null,
shoes: null,
gloves: null,
accessory1: null,
accessory2: null,
accessory3: null
}
};
}
if (extensionSettings.showEquipment === undefined) {
extensionSettings.showEquipment = true;
}
extensionSettings.settingsVersion = 6;
settingsChanged = true;
}
// Migration to version 7: Convert equipment types to generic categories + add item.slot
if (currentVersion < 7) {
const equipment = extensionSettings.userStats?.equipment;
if (equipment) {
const typeMap = {
ring1: 'ring', ring2: 'ring', ring3: 'ring', ring4: 'ring', ring5: 'ring',
ring6: 'ring', ring7: 'ring', ring8: 'ring', ring9: 'ring', ring10: 'ring',
accessory1: 'accessory', accessory2: 'accessory', accessory3: 'accessory'
};
for (const item of equipment.items || []) {
if (!item.slot && equipment.slots) {
for (const [slotId, itemId] of Object.entries(equipment.slots)) {
if (itemId === item.id) {
item.slot = slotId;
break;
}
}
}
if (item.type && typeMap[item.type]) {
item.type = typeMap[item.type];
}
}
}
extensionSettings.settingsVersion = 7;
settingsChanged = true;
}
// Normalize additive settings without introducing another schema bump.
if (!extensionSettings.thoughtsInChatStyle) {
extensionSettings.thoughtsInChatStyle = 'corner';
@@ -455,7 +761,8 @@ export function saveSettings() {
* Saves RPG data to the current chat's metadata.
*/
export function saveChatData() {
if (!chat_metadata) {
if (!isChatDataSaveReady()) {
hasDeferredChatDataSave = true;
return;
}
@@ -480,6 +787,16 @@ export function saveChatData() {
saveChatDebounced();
}
export function flushDeferredChatDataSave() {
if (!hasDeferredChatDataSave || !isChatDataSaveReady()) {
return false;
}
hasDeferredChatDataSave = false;
saveChatData();
return true;
}
/**
* Mirrors a tracker data entry into message.swipe_info so it survives page reloads.
* ST only serializes swipe_info to disk; message.extra is in-memory only.
@@ -711,6 +1028,7 @@ export function loadChatData() {
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
@@ -830,6 +1148,7 @@ function validateInventoryStructure(inventory, source) {
extensionSettings.userStats.inventory = {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
};
@@ -861,6 +1180,20 @@ function validateInventoryStructure(inventory, source) {
}
}
// Validate clothing field
if (typeof inventory.clothing !== 'string') {
console.warn(`[RPG Companion] Invalid clothing from ${source}, resetting to "None"`);
inventory.clothing = "None";
needsSave = true;
} else {
const cleanedClothing = cleanItemString(inventory.clothing);
if (cleanedClothing !== inventory.clothing) {
console.warn(`[RPG Companion] Cleaned corrupted items from clothing inventory (${source})`);
inventory.clothing = cleanedClothing;
needsSave = true;
}
}
// Validate stored field (CRITICAL for Bug #3)
if (!inventory.stored || typeof inventory.stored !== 'object' || Array.isArray(inventory.stored)) {
console.error(`[RPG Companion] Corrupted stored inventory from ${source}, resetting to empty object`);
@@ -1559,4 +1892,3 @@ export function importPresets(importData, overwrite = false) {
return importCount;
}
+44 -18
View File
@@ -10,7 +10,7 @@
* Extension settings - persisted to SillyTavern settings
*/
export let extensionSettings = {
settingsVersion: 4, // Version number for settings migrations
settingsVersion: 6, // Version number for settings migrations
enabled: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
@@ -22,6 +22,7 @@ export let extensionSettings = {
enableThoughtBasedExpressions: false,
hideDefaultExpressionDisplay: false,
showInventory: true, // Show inventory section (v2 system)
showEquipment: true, // Show equipment section
showQuests: true, // Show quests section
showThoughtsInChat: true, // Show thoughts overlay in chat
thoughtsInChatStyle: 'corner', // 'corner' or 'inline'
@@ -108,27 +109,47 @@ export let extensionSettings = {
stats: { enabled: true }, // All stats as compact numbers
attributes: { enabled: true } // Compact RPG attributes display
},
userStats: JSON.stringify({
stats: [
{ id: 'health', name: 'Health', value: 100 },
{ id: 'satiety', name: 'Satiety', value: 100 },
{ id: 'energy', name: 'Energy', value: 100 },
{ id: 'hygiene', name: 'Hygiene', value: 100 },
{ id: 'arousal', name: 'Arousal', value: 0 }
],
status: {
userStats: {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None'
},
conditions: 'None',
skills: [],
inventory: {
onPerson: [],
stored: []
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
},
quests: {
active: [],
completed: []
equipment: {
items: [], // Array of {id, name, type, slot, stats: {str: 2, dex: 1, ...}, description}
slots: {
helmet: null,
ring1: null,
ring2: null,
ring3: null,
ring4: null,
ring5: null,
ring6: null,
ring7: null,
ring8: null,
ring9: null,
ring10: null,
necklace: null,
bodyArmor: null,
pants: null,
shoes: null,
gloves: null,
accessory1: null,
accessory2: null,
accessory3: null
}
}, null, 2),
}
},
statNames: {
health: 'Health',
satiety: 'Satiety',
@@ -476,6 +497,7 @@ export let $thoughtsContainer = null;
export let $inventoryContainer = null;
export let $questsContainer = null;
export let $musicPlayerContainer = null;
export let $equipmentContainer = null;
/**
* State setters - provide controlled mutation of state variables
@@ -568,3 +590,7 @@ export function setQuestsContainer($element) {
export function setMusicPlayerContainer($element) {
$musicPlayerContainer = $element;
}
export function setEquipmentContainer($element) {
$equipmentContainer = $element;
}
+46
View File
@@ -459,6 +459,52 @@
"global.locked": "Locked",
"global.unlocked": "Unlocked",
"global.confirm": "Confirm",
"global.equipment": "Equipment",
"equipment.title": "Equipment",
"equipment.createItem": "Create Equipment",
"equipment.createItemTitle": "Create Equipment",
"equipment.editItemTitle": "Edit Equipment",
"equipment.name": "Name",
"equipment.namePlaceholder": "Enter equipment name...",
"equipment.type": "Type",
"equipment.stats": "Stats",
"equipment.description": "Description",
"equipment.descriptionPlaceholder": "Enter description (optional)...",
"equipment.emptySlot": "Empty",
"equipment.unequip": "Unequip",
"equipment.equip": "Equip",
"equipment.editItem": "Edit item",
"equipment.deleteItem": "Delete item",
"equipment.inventoryTitle": "Inventory",
"equipment.slots.helmet": "Helmet",
"equipment.slots.necklace": "Necklace",
"equipment.slots.bodyArmor": "Body Armor",
"equipment.slots.gloves": "Gloves",
"equipment.slots.pants": "Pants",
"equipment.slots.shoes": "Shoes",
"equipment.slots.ring1": "Ring 1",
"equipment.slots.ring2": "Ring 2",
"equipment.slots.ring3": "Ring 3",
"equipment.slots.ring4": "Ring 4",
"equipment.slots.ring5": "Ring 5",
"equipment.slots.ring6": "Ring 6",
"equipment.slots.ring7": "Ring 7",
"equipment.slots.ring8": "Ring 8",
"equipment.slots.ring9": "Ring 9",
"equipment.slots.ring10": "Ring 10",
"equipment.slots.accessory1": "Accessory 1",
"equipment.slots.accessory2": "Accessory 2",
"equipment.slots.accessory3": "Accessory 3",
"equipment.types.helmet": "Helmet",
"equipment.types.necklace": "Necklace",
"equipment.types.bodyArmor": "Body Armor",
"equipment.types.gloves": "Gloves",
"equipment.types.pants": "Pants",
"equipment.types.shoes": "Shoes",
"equipment.types.ring": "Ring",
"equipment.types.accessory": "Accessory",
"template.settingsModal.display.showEquipment": "Show Equipment",
"template.settingsModal.display.showEquipmentNote": "Manage equipped gear and stat bonuses from items.",
"inventory.addItemPlaceholder": "Enter item name...",
"inventory.stored.removeLocationConfirm": "Remove \"{location}\"? This will delete all items stored there.",
"userStats.clickToEdit": "Click to edit",
+41
View File
@@ -0,0 +1,41 @@
/**
* Equipment Constants
* Shared definitions for the equipment system
*/
/**
* Equipment category definitions: maps type to allowed slots, max equipped, and icon
*/
export const EQUIPMENT_CATEGORIES = {
helmet: { slots: ['helmet'], maxEquipped: 1, icon: 'fa-hat-cowboy-side' },
necklace: { slots: ['necklace'], maxEquipped: 1, icon: 'fa-circle-nodes' },
bodyArmor: { slots: ['bodyArmor'], maxEquipped: 1, icon: 'fa-vest' },
gloves: { slots: ['gloves'], maxEquipped: 1, icon: 'fa-hand' },
pants: { slots: ['pants'], maxEquipped: 1, icon: 'fa-socks' },
shoes: { slots: ['shoes'], maxEquipped: 1, icon: 'fa-shoe-prints' },
ring: { slots: ['ring1', 'ring2', 'ring3', 'ring4', 'ring5', 'ring6', 'ring7', 'ring8', 'ring9', 'ring10'], maxEquipped: 10, icon: 'fa-ring' },
accessory: { slots: ['accessory1', 'accessory2', 'accessory3'], maxEquipped: 3, icon: 'fa-gem' }
};
/**
* Flat list of all slots with their category info
*/
export const SLOTS_LIST = Object.entries(EQUIPMENT_CATEGORIES).flatMap(([type, def]) =>
def.slots.map(slotId => ({
id: slotId,
type,
icon: def.icon
}))
);
/**
* Escapes HTML special characters to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
export function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+2
View File
@@ -32,6 +32,7 @@ import { renderInfoBox } from '../rendering/infoBox.js';
import { removeLocks } from './lockManager.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
import { i18n } from '../../core/i18n.js';
@@ -356,6 +357,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
+8 -1
View File
@@ -33,7 +33,8 @@ import {
setMessageSwipeTrackerData,
getSwipeData,
commitTrackerDataFromPriorMessage,
inheritSwipeDataFromPriorMessage
inheritSwipeDataFromPriorMessage,
flushDeferredChatDataSave
} from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
@@ -49,6 +50,7 @@ import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderQuests } from '../rendering/quests.js';
import { renderMusicPlayer } from '../rendering/musicPlayer.js';
@@ -326,6 +328,7 @@ function rerenderRpgState() {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
updateFabWidgets();
@@ -389,6 +392,7 @@ export function onChatLoaded() {
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
flushDeferredChatDataSave();
scheduleChatStateRehydration();
updateAllCheckpointIndicators();
}
@@ -547,6 +551,7 @@ export async function onMessageReceived(data) {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
@@ -658,6 +663,7 @@ export function onCharacterChanged() {
// Load chat-specific data when switching chats
loadChatData();
flushDeferredChatDataSave();
// chat_metadata may not reflect the actual chat tail for branches, so
// loadChatData() may have just restored stale data from the parent chat.
@@ -767,6 +773,7 @@ export function onMessageSwiped(messageIndex) {
renderInfoBox();
renderThoughts();
renderInventory();
renderEquipment();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
+578
View File
@@ -0,0 +1,578 @@
/**
* Equipment Actions Module
* Handles all user interactions with the equipment system
*/
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { renderEquipment } from '../rendering/equipment.js';
import { renderUserStats } from '../rendering/userStats.js';
import { EQUIPMENT_CATEGORIES, escapeHtml } from '../equipment/constants.js';
import { i18n } from '../../core/i18n.js';
/**
* Check if a given slot is currently occupied by any item
* @param {string} slotId - Slot to check
* @param {Array} items - Equipment items array
* @returns {boolean}
*/
function isSlotOccupied(slotId, items) {
return items.some(item => item.slot === slotId);
}
/**
* Find the first available slot for a given equipment type
* @param {string} type - Equipment type (helmet, ring, accessory, etc.)
* @param {Array} items - Equipment items array
* @returns {string|null} Available slot ID or null if all full
*/
function findAvailableSlot(type, items) {
const category = EQUIPMENT_CATEGORIES[type];
if (!category) return null;
return category.slots.find(slot => !isSlotOccupied(slot, items)) || null;
}
/**
* Get the slot ID currently assigned to an item
* @param {Object} item - Equipment item
* @returns {string|null}
*/
function getItemSlot(item) {
return item.slot || null;
}
/**
* Convert old specific slot type (e.g. 'ring3') to generic category (e.g. 'ring')
* @param {string} type - Equipment type
* @returns {string} Generic type
*/
function normalizeType(type) {
if (!type) return type;
if (type.startsWith('ring') && type !== 'ring') return 'ring';
if (type.startsWith('accessory') && type !== 'accessory') return 'accessory';
return type;
}
/**
* Migrate old item types in the entire items array (for v6 → v7 migration)
* @param {Array} items - Equipment items array
*/
function migrateItemTypes(items) {
for (const item of items) {
if (item.type) {
item.type = normalizeType(item.type);
}
}
}
/**
* Generate a unique ID for equipment items
* @returns {string} Unique ID
*/
function generateItemId() {
return 'eq_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6);
}
/**
* Updates lastGeneratedData and committedTrackerData to include current equipment state
*/
function updateEquipmentData() {
const equipment = extensionSettings.userStats.equipment;
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
jsonData.equipment = JSON.parse(JSON.stringify(equipment));
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Equipment] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fallback: rebuild text format
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
let text = '';
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
text += `${stat.name}: ${value}%\n`;
}
if (config.statusSection?.enabled) {
if (config.statusSection.showMoodEmoji) {
text += `${stats.mood}: `;
}
text += `${stats.conditions || 'None'}\n`;
}
// Include equipment data in fallback
const equipped = equipment.items.filter(item => item.slot);
if (equipped.length > 0) {
text += `\nEquipment:\n`;
for (const item of equipped) {
const bonuses = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `${key.toUpperCase()}+${val}`)
.join(' ');
text += `- ${item.slot}: ${item.name}${bonuses ? ' (' + bonuses + ')' : ''}\n`;
}
}
lastGeneratedData.userStats = text.trim();
committedTrackerData.userStats = text.trim();
}
/**
* Shows the equipment creation modal
*/
export function showCreateModal() {
const modalHtml = generateCreateModalHTML();
$('body').append(modalHtml);
const $modal = $('#rpg-equipment-modal');
$modal.hide().fadeIn(200);
$('#rpg-eq-name').focus();
initStatCheckboxes();
}
/**
* Shows the equipment edit modal
* @param {string} itemId - ID of the item to edit
*/
export function showEditModal(itemId) {
const equipment = extensionSettings.userStats.equipment;
const item = equipment.items.find(i => i.id === itemId);
if (!item) return;
const modalHtml = generateEditModalHTML(item);
$('body').append(modalHtml);
const $modal = $('#rpg-equipment-modal');
$modal.hide().fadeIn(200);
initStatCheckboxes();
}
/**
* Closes the equipment modal
*/
export function closeModal() {
$('#rpg-equipment-modal').fadeOut(150, function() {
$(this).remove();
});
}
/**
* Generates HTML for the create modal
* @returns {string} Modal HTML
*/
function generateCreateModalHTML() {
const attributes = getAvailableAttributes();
const typeOptions = generateTypeOptions(null);
const statCheckboxes = generateStatCheckboxes(attributes, null);
return `
<div id="rpg-equipment-modal" class="rpg-equipment-modal" role="dialog" aria-modal="true">
<div class="rpg-equipment-modal-content">
<header class="rpg-equipment-modal-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span>${i18n.getTranslation('equipment.createItemTitle') || 'Create Equipment'}</span>
</h3>
<button id="rpg-equipment-modal-close" class="rpg-popup-close" type="button">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-equipment-modal-body">
<div class="rpg-equipment-form-group">
<label for="rpg-eq-name">${i18n.getTranslation('equipment.name') || 'Name'}:</label>
<input type="text" id="rpg-eq-name" class="rpg-input" maxlength="50" placeholder="${i18n.getTranslation('equipment.namePlaceholder') || 'Enter equipment name...'}" />
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-type">${i18n.getTranslation('equipment.type') || 'Type'}:</label>
<select id="rpg-eq-type" class="rpg-select">${typeOptions}</select>
</div>
<div class="rpg-equipment-form-group">
<label>${i18n.getTranslation('equipment.stats') || 'Stats'}:</label>
<div class="rpg-eq-stats-grid">${statCheckboxes}</div>
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-description">${i18n.getTranslation('equipment.description') || 'Description'}:</label>
<textarea id="rpg-eq-description" class="rpg-textarea" maxlength="200" rows="2" placeholder="${i18n.getTranslation('equipment.descriptionPlaceholder') || 'Enter description (optional)...'}"></textarea>
</div>
</div>
<footer class="rpg-equipment-modal-footer">
<button id="rpg-equipment-modal-cancel" class="rpg-btn-secondary" type="button">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="rpg-equipment-modal-save" class="rpg-btn-primary" type="button">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</footer>
</div>
</div>
`;
}
/**
* Generates HTML for the edit modal
* @param {Object} item - The item to edit
* @returns {string} Modal HTML
*/
function generateEditModalHTML(item) {
const attributes = getAvailableAttributes();
const typeOptions = generateTypeOptions(item.type);
const statCheckboxes = generateStatCheckboxes(attributes, item);
return `
<div id="rpg-equipment-modal" class="rpg-equipment-modal" role="dialog" aria-modal="true">
<div class="rpg-equipment-modal-content">
<header class="rpg-equipment-modal-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span>${i18n.getTranslation('equipment.editItemTitle') || 'Edit Equipment'}</span>
</h3>
<button id="rpg-equipment-modal-close" class="rpg-popup-close" type="button">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-equipment-modal-body">
<div class="rpg-equipment-form-group">
<label for="rpg-eq-name">${i18n.getTranslation('equipment.name') || 'Name'}:</label>
<input type="text" id="rpg-eq-name" class="rpg-input" maxlength="50" value="${escapeHtml(item.name)}" placeholder="${i18n.getTranslation('equipment.namePlaceholder') || 'Enter equipment name...'}" />
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-type">${i18n.getTranslation('equipment.type') || 'Type'}:</label>
<select id="rpg-eq-type" class="rpg-select">${typeOptions}</select>
</div>
<div class="rpg-equipment-form-group">
<label>${i18n.getTranslation('equipment.stats') || 'Stats'}:</label>
<div class="rpg-eq-stats-grid">${statCheckboxes}</div>
</div>
<div class="rpg-equipment-form-group">
<label for="rpg-eq-description">${i18n.getTranslation('equipment.description') || 'Description'}:</label>
<textarea id="rpg-eq-description" class="rpg-textarea" maxlength="200" rows="2" placeholder="${i18n.getTranslation('equipment.descriptionPlaceholder') || 'Enter description (optional)...'}">${escapeHtml(item.description || '')}</textarea>
</div>
</div>
<footer class="rpg-equipment-modal-footer">
<button id="rpg-equipment-modal-cancel" class="rpg-btn-secondary" type="button">
<i class="fa-solid fa-times"></i> ${i18n.getTranslation('global.cancel') || 'Cancel'}
</button>
<button id="rpg-equipment-modal-save" class="rpg-btn-primary" type="button" data-edit-id="${item.id}">
<i class="fa-solid fa-check"></i> ${i18n.getTranslation('global.save') || 'Save'}
</button>
</footer>
</div>
</div>
`;
}
/**
* Generates type dropdown options
* @param {string|null} selectedType - Currently selected type or null
* @returns {string} HTML options
*/
function generateTypeOptions(selectedType) {
const types = Object.entries(EQUIPMENT_CATEGORIES).map(([value, def]) => ({
value,
label: def.slots[0] === value
? def.slots[0].charAt(0).toUpperCase() + def.slots[0].slice(1)
: value.charAt(0).toUpperCase() + value.slice(1) + (def.slots.length > 1 ? ` (max ${def.maxEquipped})` : '')
}));
return types.map(type => {
const selected = selectedType === type.value ? 'selected' : '';
return `<option value="${type.value}" ${selected}>${type.label}</option>`;
}).join('');
}
/**
* Generates stat checkboxes HTML
* @param {string[]} attributes - Available attribute IDs
* @param {Object|null} item - Current item for editing or null
* @returns {string} HTML for stat checkboxes
*/
function generateStatCheckboxes(attributes, item) {
return attributes.map(attr => {
const checked = item && item.stats && item.stats[attr] > 0;
const val = checked ? item.stats[attr] : 1;
return `
<label class="rpg-eq-stat-checkbox">
<input type="checkbox" class="rpg-eq-stat-check" data-attr="${attr}" ${checked ? 'checked' : ''} />
<span class="rpg-eq-stat-check-label">${attr.toUpperCase()}</span>
<input type="number" class="rpg-eq-stat-value-input" data-attr="${attr}" value="${val}" min="1" max="20" ${!checked ? 'disabled' : ''} />
</label>
`;
}).join('');
}
/**
* Gets available RPG attribute IDs from config
* @returns {string[]} Array of attribute IDs
*/
function getAvailableAttributes() {
const config = extensionSettings.trackerConfig?.userStats || {};
const rpgAttributes = config.rpgAttributes || [];
return rpgAttributes
.filter(attr => attr && attr.enabled && attr.id)
.map(attr => attr.id);
}
/**
* Initializes stat checkbox change handlers
*/
function initStatCheckboxes() {
$('.rpg-eq-stat-check').on('change', function() {
const $input = $(this).siblings('.rpg-eq-stat-value-input');
$input.prop('disabled', !$(this).prop('checked'));
});
}
/**
* Saves the equipment item from the modal form
*/
export function saveEquipmentItem() {
const name = $('#rpg-eq-name').val().trim();
const type = $('#rpg-eq-type').val();
const description = $('#rpg-eq-description').val().trim();
const editId = $('#rpg-equipment-modal-save').data('edit-id');
if (!name) {
$('#rpg-eq-name').focus();
return;
}
// Collect stats
const stats = {};
$('.rpg-eq-stat-check:checked').each(function() {
const attr = $(this).data('attr');
const val = parseInt($(this).siblings('.rpg-eq-stat-value-input').val()) || 1;
stats[attr] = Math.max(1, Math.min(20, val));
});
const equipment = extensionSettings.userStats.equipment;
// Migrate old types (ring1-ring10 -> ring, accessory1-3 -> accessory)
migrateItemTypes(equipment.items);
if (editId) {
// Edit existing item
const item = equipment.items.find(i => i.id === editId);
if (item) {
const wasEquipped = !!item.slot;
item.name = name;
item.type = normalizeType(type);
item.stats = stats;
item.description = description;
// If type changed and item was equipped, re-equip to valid slot for new type
if (wasEquipped) {
const newCategory = EQUIPMENT_CATEGORIES[item.type];
if (newCategory && !newCategory.slots.includes(item.slot)) {
// Old slot is invalid for new type, find a new one
const newSlot = findAvailableSlot(item.type, equipment.items.filter(i => i.id !== editId));
item.slot = newSlot || null;
}
}
}
} else {
// Create new item
const newItem = {
id: generateItemId(),
name: name,
type: normalizeType(type),
stats: stats,
description: description,
slot: null
};
equipment.items.push(newItem);
// Auto-equip to first available slot
const availableSlot = findAvailableSlot(newItem.type, equipment.items);
if (availableSlot) {
newItem.slot = availableSlot;
} else {
console.warn(`[RPG Equipment] Created "${name}" but no available slot for type "${newItem.type}". Item added to inventory unequipped.`);
}
}
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
closeModal();
renderEquipment();
renderUserStats();
}
/**
* Equips an item to its designated slot
* @param {string} itemId - ID of the item to equip
*/
export function equipItem(itemId) {
const equipment = extensionSettings.userStats.equipment;
const item = equipment.items.find(i => i.id === itemId);
if (!item) return;
const type = normalizeType(item.type);
item.type = type;
const category = EQUIPMENT_CATEGORIES[type];
if (!category) return;
const availableSlot = findAvailableSlot(type, equipment.items);
if (!availableSlot) {
console.warn(`[RPG Equipment] No available slot for type "${type}". All ${EQUIPMENT_CATEGORIES[type].maxEquipped} slots are full.`);
return;
}
item.slot = availableSlot;
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Unequips an item from a slot
* @param {string} slotId - The slot to unequip from
*/
export function unequipItem(slotId) {
const equipment = extensionSettings.userStats.equipment;
const item = equipment.items.find(i => i.slot === slotId);
if (item) {
item.slot = null;
}
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Deletes an equipment item
* @param {string} itemId - ID of the item to delete
*/
export function deleteItem(itemId) {
const equipment = extensionSettings.userStats.equipment;
// Unequip if currently equipped
const item = equipment.items.find(i => i.id === itemId);
if (item && item.slot) {
item.slot = null;
}
// Remove from items array
equipment.items = equipment.items.filter(i => i.id !== itemId);
updateEquipmentData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderEquipment();
renderUserStats();
}
/**
* Calculates total equipment bonuses per attribute from currently equipped items
* @returns {Object} Map of attribute ID to total bonus value
*/
export function getEquipmentBonuses() {
const equipment = extensionSettings.userStats.equipment;
const bonuses = {};
const items = equipment.items || [];
for (const item of items) {
if (!item.slot || !item.stats) continue;
for (const [attr, val] of Object.entries(item.stats)) {
if (val > 0) {
bonuses[attr] = (bonuses[attr] || 0) + val;
}
}
}
return bonuses;
}
/**
* Initializes all event listeners for equipment interactions
*/
export function initEquipmentEventListeners() {
// Show create modal
$(document).on('click', '[data-action="show-create-modal"]', function(e) {
e.preventDefault();
showCreateModal();
});
// Equip item from inventory
$(document).on('click', '[data-action="equip"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
equipItem(itemId);
});
// Unequip item from slot
$(document).on('click', '[data-action="unequip"]', function(e) {
e.preventDefault();
const slot = $(this).closest('.rpg-equipment-slot').data('slot');
unequipItem(slot);
});
// Edit item
$(document).on('click', '[data-action="edit-item"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
showEditModal(itemId);
});
// Delete item
$(document).on('click', '[data-action="delete-item"]', function(e) {
e.preventDefault();
const itemId = $(this).data('item-id');
deleteItem(itemId);
});
// Modal close button
$(document).on('click', '#rpg-equipment-modal-close', closeModal);
// Modal cancel button
$(document).on('click', '#rpg-equipment-modal-cancel', closeModal);
// Modal save button
$(document).on('click', '#rpg-equipment-modal-save', saveEquipmentItem);
// Close modal on backdrop click
$(document).on('click', '#rpg-equipment-modal', function(e) {
if (e.target === this) {
closeModal();
}
});
// Enter key to save in modal inputs
$(document).on('keypress', '#rpg-eq-name', function(e) {
if (e.which === 13) {
e.preventDefault();
saveEquipmentItem();
}
});
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Equipment Rendering Module
* Handles UI rendering for the equipment grid and item creation
*/
import { extensionSettings, $equipmentContainer } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { EQUIPMENT_CATEGORIES, SLOTS_LIST, escapeHtml } from '../equipment/constants.js';
/**
* Renders a single equipment slot
* @param {Object} slotDef - Slot definition from SLOTS_LIST
* @param {Object|null} item - The equipped item or null
* @returns {string} HTML for the slot
*/
function renderSlot(slotDef, item) {
const slotId = slotDef.id;
const slotName = i18n.getTranslation(`equipment.slots.${slotId}`) || slotId.replace(/(\d+)/, ' $1');
const equippedClass = item ? 'equipped' : '';
if (item) {
const statsText = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
.join('');
return `
<div class="rpg-equipment-slot ${equippedClass}" data-slot="${slotId}" data-item-id="${item.id}">
<div class="rpg-equipment-slot-header">
<i class="fa-solid ${slotDef.icon}"></i>
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
<button class="rpg-equipment-unequip-btn" data-action="unequip" title="${i18n.getTranslation('equipment.unequip') || 'Unequip'}">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="rpg-equipment-item-name">${escapeHtml(item.name)}</div>
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
${item.description ? `<div class="rpg-equipment-description">${escapeHtml(item.description)}</div>` : ''}
<div class="rpg-equipment-item-actions">
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
<i class="fa-solid fa-pen"></i>
</button>
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
}
return `
<div class="rpg-equipment-slot" data-slot="${slotId}">
<div class="rpg-equipment-slot-header">
<i class="fa-solid ${slotDef.icon}"></i>
<span class="rpg-equipment-slot-name">${escapeHtml(slotName)}</span>
</div>
<div class="rpg-equipment-empty">${i18n.getTranslation('equipment.emptySlot') || 'Empty'}</div>
</div>
`;
}
/**
* Generates the full equipment section HTML
* @returns {string} Complete HTML for the equipment section
*/
function generateEquipmentHTML() {
const equipment = extensionSettings.userStats.equipment;
const slots = equipment.slots || {};
const items = equipment.items || [];
let html = '<div class="rpg-equipment-container">';
// Header with add button
html += `
<div class="rpg-equipment-header">
<h3>
<i class="fa-solid fa-shield-halved"></i>
<span data-i18n-key="equipment.title">${i18n.getTranslation('equipment.title') || 'Equipment'}</span>
</h3>
<button class="rpg-equipment-add-btn" data-action="show-create-modal" title="${i18n.getTranslation('equipment.createItem') || 'Create new equipment'}">
<i class="fa-solid fa-plus"></i> ${i18n.getTranslation('equipment.createItem') || 'Create Equipment'}
</button>
</div>
`;
// Equipment grid
html += '<div class="rpg-equipment-grid">';
// Render each slot
for (const slotDef of SLOTS_LIST) {
const item = items.find(i => i.slot === slotDef.id);
html += renderSlot(slotDef, item);
}
html += '</div>';
// Inventory list (items not currently equipped)
const unequipped = items.filter(item => !item.slot);
if (unequipped.length > 0) {
html += '<div class="rpg-equipment-inventory">';
html += `<h4>${i18n.getTranslation('equipment.inventoryTitle') || 'Inventory'}</h4>`;
html += '<div class="rpg-equipment-inventory-list">';
for (const item of unequipped) {
const category = EQUIPMENT_CATEGORIES[item.type];
const statsText = Object.entries(item.stats || {})
.filter(([_, val]) => val > 0)
.map(([key, val]) => `<span class="rpg-eq-stat"><span class="rpg-eq-stat-label">${escapeHtml(key.toUpperCase())}</span><span class="rpg-eq-stat-value">+${val}</span></span>`)
.join('');
html += `
<div class="rpg-equipment-inventory-item" data-item-id="${item.id}">
<div class="rpg-equipment-inventory-item-header">
<i class="fa-solid ${category ? category.icon : 'fa-circle'}"></i>
<span class="rpg-equipment-inventory-item-name">${escapeHtml(item.name)}</span>
<span class="rpg-equipment-inventory-item-type">${category ? (i18n.getTranslation(`equipment.types.${item.type}`) || item.type) : escapeHtml(item.type)}</span>
</div>
${statsText ? `<div class="rpg-equipment-stats">${statsText}</div>` : ''}
<div class="rpg-equipment-item-actions">
<button class="rpg-equipment-equip-btn" data-action="equip" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.equip') || 'Equip'}">
<i class="fa-solid fa-hand"></i>
</button>
<button class="rpg-equipment-edit-btn" data-action="edit-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.editItem') || 'Edit item'}">
<i class="fa-solid fa-pen"></i>
</button>
<button class="rpg-equipment-delete-btn" data-action="delete-item" data-item-id="${item.id}" title="${i18n.getTranslation('equipment.deleteItem') || 'Delete item'}">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
`;
}
html += '</div>';
html += '</div>';
}
html += '</div>';
return html;
}
/**
* Main equipment rendering function
* Gets data from state/settings and updates DOM directly.
*/
export function renderEquipment() {
if (!$equipmentContainer || !extensionSettings.showEquipment) {
return;
}
const html = generateEquipmentHTML();
$equipmentContainer.html(html);
// Re-apply translations
i18n.applyTranslations($equipmentContainer[0]);
}
+1 -1
View File
@@ -219,7 +219,7 @@ export function renderOptionalQuestsView(optionalQuests) {
* Main render function for quests
*/
export function renderQuests() {
if (!extensionSettings.showInventory || !$questsContainer) {
if (!extensionSettings.showQuests || !$questsContainer) {
return;
}
+5 -1
View File
@@ -23,6 +23,7 @@ import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
import { getStatBarColors } from '../ui/theme.js';
import { getEquipmentBonuses } from '../interaction/equipmentActions.js';
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
@@ -410,6 +411,7 @@ export function renderUserStats() {
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
if (enabledAttributes.length > 0) {
const equipmentBonuses = getEquipmentBonuses();
html += `
<div class="rpg-stats-right">
<div class="rpg-classic-stats">
@@ -418,12 +420,14 @@ export function renderUserStats() {
enabledAttributes.forEach(attr => {
const value = extensionSettings.classicStats[attr.id] !== undefined ? extensionSettings.classicStats[attr.id] : 10;
const bonus = equipmentBonuses[attr.id] || 0;
const bonusHtml = bonus > 0 ? `<span class="rpg-classic-stat-bonus" title="Equipment bonus: +${bonus}"> +${bonus}</span>` : '';
html += `
<div class="rpg-classic-stat" data-stat="${attr.id}">
<span class="rpg-classic-stat-label">${attr.name}</span>
<div class="rpg-classic-stat-buttons">
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="${attr.id}"></button>
<span class="rpg-classic-stat-value">${value}</span>
<span class="rpg-classic-stat-value">${value}${bonusHtml}</span>
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="${attr.id}">+</button>
</div>
</div>
+28 -3
View File
@@ -292,16 +292,18 @@ export function setupDesktopTabs() {
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $inventory = $('#rpg-inventory');
const $equipment = $('#rpg-equipment');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $equipment.length === 0 && $quests.length === 0) {
return;
}
// Build tab navigation dynamically based on enabled settings
const tabButtons = [];
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasEquipment = $equipment.length > 0 && extensionSettings.showEquipment;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Status tab (always present if any status content exists)
@@ -322,6 +324,16 @@ export function setupDesktopTabs() {
`);
}
// Equipment tab (only if enabled in settings)
if (hasEquipment) {
tabButtons.push(`
<button class="rpg-tab-btn" data-tab="equipment">
<i class="fa-solid fa-shield-halved"></i>
<span data-i18n-key="equipment.title">Equipment</span>
</button>
`);
}
// Quests tab (only if enabled in settings)
if (hasQuests) {
tabButtons.push(`
@@ -337,6 +349,7 @@ export function setupDesktopTabs() {
// Create tab content containers
const $statusTab = $('<div class="rpg-tab-content active" data-tab-content="status"></div>');
const $inventoryTab = $('<div class="rpg-tab-content" data-tab-content="inventory"></div>');
const $equipmentTab = $('<div class="rpg-tab-content" data-tab-content="equipment"></div>');
const $questsTab = $('<div class="rpg-tab-content" data-tab-content="quests"></div>');
// Move sections into their respective tabs (detach to preserve event handlers)
@@ -361,6 +374,11 @@ export function setupDesktopTabs() {
// Only show if enabled (will be part of tab structure)
if (hasInventory) $inventory.show();
}
if ($equipment.length > 0) {
$equipmentTab.append($equipment.detach());
// Only show if enabled (will be part of tab structure)
if (hasEquipment) $equipment.show();
}
if ($quests.length > 0) {
$questsTab.append($quests.detach());
// Only show if enabled (will be part of tab structure)
@@ -378,6 +396,7 @@ export function setupDesktopTabs() {
// Always append inventory and quests tabs to preserve the elements
// But they'll only show if enabled (via tab button visibility)
$tabsContainer.append($inventoryTab);
$tabsContainer.append($equipmentTab);
$tabsContainer.append($questsTab);
// Replace content box with tabs container
@@ -410,6 +429,7 @@ export function removeDesktopTabs() {
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $inventory = $('#rpg-inventory').detach();
const $equipment = $('#rpg-equipment').detach();
const $quests = $('#rpg-quests').detach();
// Remove tabs container
@@ -419,16 +439,19 @@ export function removeDesktopTabs() {
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerInventory = $('#rpg-divider-inventory');
const $dividerEquipment = $('#rpg-divider-equipment');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Equipment, Quests
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$contentBox.append($inventory);
$dividerInventory.before($inventory);
$dividerEquipment.before($equipment);
$contentBox.append($quests);
} else {
// Fallback if dividers don't exist
@@ -436,6 +459,7 @@ export function removeDesktopTabs() {
$contentBox.append($infoBox);
$contentBox.append($thoughts);
$contentBox.append($inventory);
$contentBox.append($equipment);
$contentBox.append($quests);
}
@@ -447,6 +471,7 @@ export function removeDesktopTabs() {
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showEquipment) $equipment.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
}
+19 -5
View File
@@ -317,6 +317,12 @@ export function updateSectionVisibility() {
$('#rpg-quests').hide();
}
if (extensionSettings.showEquipment) {
$('#rpg-equipment').show();
} else {
$('#rpg-equipment').hide();
}
if ($musicPlayerContainer) {
if (extensionSettings.enableSpotifyMusic) {
$musicPlayerContainer.show();
@@ -328,7 +334,7 @@ export function updateSectionVisibility() {
// Show/hide dividers intelligently
// Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible
const showDividerAfterStats = extensionSettings.showUserStats &&
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
(extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterStats) {
$('#rpg-divider-stats').show();
} else {
@@ -337,7 +343,7 @@ export function updateSectionVisibility() {
// Divider after Info Box: shown if Info Box is visible AND at least one section after it is visible
const showDividerAfterInfo = extensionSettings.showInfoBox &&
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showQuests);
(extensionSettings.showCharacterThoughts || extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests);
if (showDividerAfterInfo) {
$('#rpg-divider-info').show();
} else {
@@ -346,21 +352,29 @@ export function updateSectionVisibility() {
// Divider after Thoughts: shown if Thoughts is visible AND at least one section after it is visible
const showDividerAfterThoughts = extensionSettings.showCharacterThoughts &&
(extensionSettings.showInventory || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
(extensionSettings.showInventory || extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterThoughts) {
$('#rpg-divider-thoughts').show();
} else {
$('#rpg-divider-thoughts').hide();
}
// Divider after Inventory: shown if Inventory is visible AND (Quests or Music) is visible
const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
// Divider after Inventory: shown if Inventory is visible AND (Equipment, Quests or Music) is visible
const showDividerAfterInventory = extensionSettings.showInventory && (extensionSettings.showEquipment || extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterInventory) {
$('#rpg-divider-inventory').show();
} else {
$('#rpg-divider-inventory').hide();
}
// Divider after Equipment: shown if Equipment is visible AND (Quests or Music) is visible
const showDividerAfterEquipment = extensionSettings.showEquipment && (extensionSettings.showQuests || extensionSettings.enableSpotifyMusic);
if (showDividerAfterEquipment) {
$('#rpg-divider-equipment').show();
} else {
$('#rpg-divider-equipment').hide();
}
// Divider after Quests: shown if Quests is visible AND Music is visible
const showDividerAfterQuests = extensionSettings.showQuests && extensionSettings.enableSpotifyMusic;
if (showDividerAfterQuests) {
+30 -3
View File
@@ -32,6 +32,9 @@ export function updateMobileTabLabels() {
case 'inventory':
translationKey = 'global.inventory';
break;
case 'equipment':
translationKey = 'equipment.title';
break;
case 'quests':
translationKey = 'global.quests';
break;
@@ -49,6 +52,9 @@ export function updateMobileTabLabels() {
case 'inventory':
fallback = 'Inventory';
break;
case 'equipment':
fallback = 'Equipment';
break;
case 'quests':
fallback = 'Quests';
break;
@@ -606,10 +612,11 @@ export function setupMobileTabs() {
const $infoBox = $('#rpg-info-box');
const $thoughts = $('#rpg-thoughts');
const $inventory = $('#rpg-inventory');
const $equipment = $('#rpg-equipment');
const $quests = $('#rpg-quests');
// If no sections exist, nothing to organize
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $equipment.length === 0 && $quests.length === 0) {
return;
}
@@ -618,6 +625,7 @@ export function setupMobileTabs() {
const hasStats = $userStats.length > 0;
const hasInfo = $infoBox.length > 0 || $thoughts.length > 0;
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
const hasEquipment = $equipment.length > 0 && extensionSettings.showEquipment;
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
// Tab 1: Stats (User Stats only)
@@ -632,6 +640,10 @@ export function setupMobileTabs() {
if (hasInventory) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="inventory"><i class="fa-solid fa-box"></i><span>' + (i18n.getTranslation('global.inventory') || 'Inventory') + '</span></button>');
}
// Tab 3.5: Equipment
if (hasEquipment) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="equipment"><i class="fa-solid fa-shield-halved"></i><span>' + (i18n.getTranslation('equipment.title') || 'Equipment') + '</span></button>');
}
// Tab 4: Quests
if (hasQuests) {
tabs.push('<button class="rpg-mobile-tab ' + (tabs.length === 0 ? 'active' : '') + '" data-tab="quests"><i class="fa-solid fa-scroll"></i><span>' + (i18n.getTranslation('global.quests') || 'Quests') + '</span></button>');
@@ -644,12 +656,14 @@ export function setupMobileTabs() {
if (hasStats) firstTab = 'stats';
else if (hasInfo) firstTab = 'info';
else if (hasInventory) firstTab = 'inventory';
else if (hasEquipment) firstTab = 'equipment';
else if (hasQuests) firstTab = 'quests';
// Create tab content wrappers
const $statsTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'stats' ? 'active' : '') + '" data-tab-content="stats"></div>');
const $infoTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'info' ? 'active' : '') + '" data-tab-content="info"></div>');
const $inventoryTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'inventory' ? 'active' : '') + '" data-tab-content="inventory"></div>');
const $equipmentTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'equipment' ? 'active' : '') + '" data-tab-content="equipment"></div>');
const $questsTab = $('<div class="rpg-mobile-tab-content ' + (firstTab === 'quests' ? 'active' : '') + '" data-tab-content="quests"></div>');
// Move sections into their respective tabs (detach to preserve event handlers)
@@ -677,6 +691,12 @@ export function setupMobileTabs() {
$inventory.show();
}
// Equipment tab: Equipment only
if ($equipment.length > 0) {
$equipmentTab.append($equipment.detach());
$equipment.show();
}
// Quests tab: Quests only
if ($quests.length > 0) {
$questsTab.append($quests.detach());
@@ -695,6 +715,7 @@ export function setupMobileTabs() {
$mobileContainer.append($statsTab);
$mobileContainer.append($infoTab);
$mobileContainer.append($inventoryTab);
$mobileContainer.append($equipmentTab);
$mobileContainer.append($questsTab);
// Insert mobile tab structure at the beginning of content box
@@ -723,6 +744,7 @@ export function removeMobileTabs() {
const $infoBox = $('#rpg-info-box').detach();
const $thoughts = $('#rpg-thoughts').detach();
const $inventory = $('#rpg-inventory').detach();
const $equipment = $('#rpg-equipment').detach();
const $quests = $('#rpg-quests').detach();
// Remove mobile tab container
@@ -732,20 +754,24 @@ export function removeMobileTabs() {
const $dividerStats = $('#rpg-divider-stats');
const $dividerInfo = $('#rpg-divider-info');
const $dividerThoughts = $('#rpg-divider-thoughts');
const $dividerInventory = $('#rpg-divider-inventory');
const $dividerEquipment = $('#rpg-divider-equipment');
// Restore original sections to content box in correct order
const $contentBox = $('.rpg-content-box');
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Equipment, Quests
if ($dividerStats.length) {
$dividerStats.before($userStats);
$dividerInfo.before($infoBox);
$dividerThoughts.before($thoughts);
$contentBox.append($inventory);
$dividerInventory.before($inventory);
$dividerEquipment.before($equipment);
$contentBox.append($quests);
} else {
// Fallback if dividers don't exist
$contentBox.prepend($quests);
$contentBox.prepend($equipment);
$contentBox.prepend($inventory);
$contentBox.prepend($thoughts);
$contentBox.prepend($infoBox);
@@ -760,6 +786,7 @@ export function removeMobileTabs() {
}
if (extensionSettings.showCharacterThoughts) $thoughts.show();
if (extensionSettings.showInventory) $inventory.show();
if (extensionSettings.showEquipment) $equipment.show();
if (extensionSettings.showQuests) $quests.show();
$('.rpg-divider').show();
}
+65
View File
@@ -22,6 +22,7 @@ import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js';
import { renderQuests } from '../rendering/quests.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderEquipment } from '../rendering/equipment.js';
import {
rollDice as rollDiceCore,
clearDiceRoll as clearDiceRollCore,
@@ -503,6 +504,7 @@ export function setupSettingsPopup() {
updateDiceDisplayCore();
updateChatThoughts();
renderInventory();
renderEquipment();
renderQuests();
// console.log('[RPG Companion] Cache cleared successfully');
@@ -612,6 +614,28 @@ export function showWelcomeModalIfNeeded() {
}
}
/**
* Shows the deprecation notice once for users updating to the deprecation release.
* @returns {boolean} True when the modal was displayed.
*/
export function showDeprecationModalIfNeeded() {
const DEPRECATION_NOTICE_VERSION = '3.7.4';
const STORAGE_KEY = 'rpg_companion_deprecation_notice_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
if (seenVersion !== DEPRECATION_NOTICE_VERSION) {
showDeprecationModal(DEPRECATION_NOTICE_VERSION, STORAGE_KEY);
return true;
}
} catch (error) {
console.error('[RPG Companion] Failed to check deprecation modal status:', error);
}
return false;
}
/**
* Shows the welcome modal
* @param {string} version - The version to mark as seen
@@ -663,3 +687,44 @@ function showWelcomeModal(version, storageKey) {
}
}, { once: true });
}
function showDeprecationModal(version, storageKey) {
const modal = document.getElementById('rpg-deprecation-modal');
if (!modal) {
console.error('[RPG Companion] Deprecation modal element not found');
return;
}
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
modal.style.display = 'flex';
modal.classList.add('is-open');
const closeBtn = document.getElementById('rpg-deprecation-close');
const gotItBtn = document.getElementById('rpg-deprecation-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save deprecation modal status:', error);
}
};
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}
+386
View File
@@ -11908,3 +11908,389 @@ body.documentstyle .rpg-inline-thoughts {
line-height: 1.45;
}
/* ============================================
Equipment Section Styles
============================================ */
.rpg-equipment-section {
display: none;
}
.rpg-equipment-container {
padding: 8px 0;
}
.rpg-equipment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-equipment-header h3 {
margin: 0;
font-size: 14px;
color: var(--SmartThemeTitleColor);
display: flex;
align-items: center;
gap: 6px;
}
.rpg-equipment-add-btn {
background: var(--SmartThemeAccentColor);
color: var(--SmartThemeBodyColor);
border: none;
border-radius: 4px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s;
}
.rpg-equipment-add-btn:hover {
background: var(--SmartThemeButtonColor);
}
.rpg-equipment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.rpg-equipment-slot {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 6px;
padding: 8px;
min-height: 70px;
transition: border-color 0.2s, background-color 0.2s;
}
.rpg-equipment-slot.equipped {
border-color: var(--SmartThemeAccentColor);
background: rgba(0, 0, 0, 0.3);
}
.rpg-equipment-slot-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
font-size: 10px;
color: var(--SmartThemeTitleColor);
opacity: 0.7;
}
.rpg-equipment-slot-header i {
font-size: 9px;
}
.rpg-equipment-slot-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-unequip-btn {
background: none;
border: none;
color: var(--SmartThemeBodyColor);
opacity: 0.5;
cursor: pointer;
padding: 2px;
font-size: 9px;
transition: opacity 0.2s;
}
.rpg-equipment-unequip-btn:hover {
opacity: 1;
}
.rpg-equipment-item-name {
font-size: 12px;
font-weight: 600;
color: var(--SmartThemeTitleColor);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-empty {
font-size: 11px;
color: var(--SmartThemeBodyColor);
opacity: 0.4;
font-style: italic;
padding: 4px 0;
}
.rpg-equipment-stats {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin: 4px 0;
}
.rpg-eq-stat {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 9px;
background: rgba(0, 0, 0, 0.3);
padding: 1px 4px;
border-radius: 3px;
}
.rpg-eq-stat-label {
color: var(--SmartThemeBodyColor);
opacity: 0.7;
}
.rpg-eq-stat-value {
color: #4caf50;
font-weight: 600;
}
.rpg-equipment-description {
font-size: 10px;
color: var(--SmartThemeBodyColor);
opacity: 0.6;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-item-actions {
display: flex;
gap: 4px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.rpg-equipment-edit-btn,
.rpg-equipment-delete-btn,
.rpg-equipment-equip-btn {
background: none;
border: none;
color: var(--SmartThemeBodyColor);
opacity: 0.5;
cursor: pointer;
padding: 2px 4px;
font-size: 10px;
transition: opacity 0.2s, color 0.2s;
}
.rpg-equipment-edit-btn:hover {
opacity: 1;
color: #4fc3f7;
}
.rpg-equipment-delete-btn:hover {
opacity: 1;
color: #f44336;
}
.rpg-equipment-equip-btn:hover {
opacity: 1;
color: #4caf50;
}
/* Equipment Inventory (unequipped items) */
.rpg-equipment-inventory {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--SmartThemeBorderColor);
}
.rpg-equipment-inventory h4 {
margin: 0 0 8px 0;
font-size: 12px;
color: var(--SmartThemeTitleColor);
opacity: 0.8;
}
.rpg-equipment-inventory-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.rpg-equipment-inventory-item {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 6px;
padding: 8px;
transition: border-color 0.2s;
}
.rpg-equipment-inventory-item:hover {
border-color: var(--SmartThemeAccentColor);
}
.rpg-equipment-inventory-item-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.rpg-equipment-inventory-item-header i {
font-size: 10px;
opacity: 0.6;
}
.rpg-equipment-inventory-item-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: var(--SmartThemeTitleColor);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rpg-equipment-inventory-item-type {
font-size: 9px;
color: var(--SmartThemeBodyColor);
opacity: 0.5;
background: rgba(0, 0, 0, 0.2);
padding: 1px 5px;
border-radius: 3px;
}
/* Equipment Modal */
.rpg-equipment-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.rpg-equipment-modal-content {
background: var(--SmartThemeDialogBgColor);
border-radius: 8px;
width: 90%;
max-width: 420px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.rpg-equipment-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--SmartThemeBorderColor);
}
.rpg-equipment-modal-header h3 {
margin: 0;
font-size: 14px;
color: var(--SmartThemeTitleColor);
display: flex;
align-items: center;
gap: 6px;
}
.rpg-equipment-modal-body {
padding: 16px;
}
.rpg-equipment-form-group {
margin-bottom: 12px;
}
.rpg-equipment-form-group label {
display: block;
font-size: 11px;
color: var(--SmartThemeTitleColor);
margin-bottom: 4px;
opacity: 0.8;
}
.rpg-equipment-form-group .rpg-input,
.rpg-equipment-form-group .rpg-select,
.rpg-equipment-form-group .rpg-textarea {
width: 100%;
padding: 6px 8px;
background: var(--SmartThemeInputBg);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 4px;
color: var(--SmartThemeBodyColor);
font-size: 12px;
}
.rpg-equipment-form-group .rpg-textarea {
resize: vertical;
}
.rpg-eq-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.rpg-eq-stat-checkbox {
display: flex;
align-items: center;
gap: 3px;
font-size: 10px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
padding: 3px 6px;
border-radius: 3px;
}
.rpg-eq-stat-checkbox input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.rpg-eq-stat-check-label {
color: var(--SmartThemeBodyColor);
opacity: 0.8;
}
.rpg-eq-stat-value-input {
width: 30px;
padding: 1px 2px;
background: var(--SmartThemeInputBg);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 2px;
color: var(--SmartThemeBodyColor);
font-size: 10px;
text-align: center;
}
.rpg-equipment-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--SmartThemeBorderColor);
}
/* Equipment bonus display on RPG attributes */
.rpg-classic-stat-bonus {
color: #4caf50;
font-size: 0.85em;
font-weight: 400;
margin-left: 2px;
}
+53
View File
@@ -96,6 +96,14 @@
<!-- Divider after Inventory -->
<div id="rpg-divider-inventory" class="rpg-divider"></div>
<!-- Equipment Section -->
<div id="rpg-equipment" class="rpg-section rpg-equipment-section">
<!-- Content will be populated by JavaScript -->
</div>
<!-- Divider after Equipment -->
<div id="rpg-divider-equipment" class="rpg-divider"></div>
<!-- Quests Section -->
<div id="rpg-quests" class="rpg-section rpg-quests-section">
<!-- Content will be populated by JavaScript -->
@@ -212,6 +220,42 @@
</div>
</div>
<!-- Deprecation Notice Modal -->
<div id="rpg-deprecation-modal" class="rpg-settings-popup" role="dialog" aria-modal="true"
aria-labelledby="rpg-deprecation-title" style="display: none;">
<div class="rpg-settings-popup-content" style="max-width: 640px;">
<header class="rpg-settings-popup-header">
<h3 id="rpg-deprecation-title">
<i class="fa-solid fa-circle-info"></i>
<span data-i18n-key="deprecation.title">RPG Companion becomes deprecated!</span>
</h3>
<button id="rpg-deprecation-close" class="rpg-popup-close" type="button"
aria-label="Close Deprecation Notice">
<i class="fa-solid fa-times"></i>
</button>
</header>
<div class="rpg-settings-popup-body" style="max-height: 520px; overflow-y: auto; padding: 20px;">
<p data-i18n-key="deprecation.body.support">Thank you all for the continuous support. The extension will continue to function in its current state and will receive occasional bug fixes/features if provided by the community members. However, I (Marinara) won't be actively developing it further.</p>
<p>Why? The reason is simple, <strong data-i18n-key="deprecation.body.reasonEmphasis">I am no longer using SillyTavern as a frontend, and have instead moved on to develop my own frontend called MarinaraEngine.</strong> It's free, open-source, and plug-and-play, centered around utilizing agents, and already has all the RPG Companion's features built in and comes with a multitude of other, custom features (such as different chat modes, for Discord-styled conversations, classic Roleplay, and a brand new game mode that offers you an RPG with VN-style visuals).</p>
<p data-i18n-key="deprecation.linkIntro">If you're interested, check it out here:</p>
<p>
<a href="https://github.com/Pasta-Devs/Marinara-Engine" target="_blank" rel="noopener noreferrer" class="menu_button" style="display: inline-block;">
<i class="fa-brands fa-github"></i> MarinaraEngine
</a>
</p>
<p style="margin-top: 24px;"><strong><em data-i18n-key="deprecation.signoff">Cheers and happy gooning!</em></strong></p>
</div>
<footer class="rpg-settings-popup-footer">
<button id="rpg-deprecation-got-it" class="rpg-btn-primary" type="button" style="width: 100%;">
<i class="fa-solid fa-check"></i> <span data-i18n-key="global.gotIt">Got it!</span>
</button>
</footer>
</div>
</div>
<!-- Settings Modal -->
<div id="rpg-settings-popup" class="rpg-settings-popup" role="dialog" aria-modal="true"
aria-labelledby="rpg-settings-title">
@@ -412,6 +456,15 @@
Track items carried, clothing worn, stored items, and assets.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-equipment" />
<span data-i18n-key="template.settingsModal.display.showEquipment">Show Equipment</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;"
data-i18n-key="template.settingsModal.display.showEquipmentNote">
Manage equipped gear and stat bonuses from items.
</small>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-quests" />
<span data-i18n-key="template.settingsModal.display.showQuests">Show Quests</span>