diff --git a/LanguageAccessManager/1.0.0/LanguageAccessManager.js b/LanguageAccessManager/1.0.0/LanguageAccessManager.js new file mode 100644 index 000000000..c1af0f786 --- /dev/null +++ b/LanguageAccessManager/1.0.0/LanguageAccessManager.js @@ -0,0 +1,1041 @@ +/* + * Language Access Manager + * Version 1.0.0 + * Author: Kingkiller546 + * License: MIT (SPDX-License-Identifier: MIT) + * + * Roll20 Mod/API script for managing temporary access to hidden language + * character sheets. Designed first for Tal'Tamaira with a non-destructive + * setup wizard, generated GM macros, and extension points for public release. + * + * Commands (GM only): + * !lang help + * !lang list + * !lang grant + * !lang restore + * !lang status + * !lang setup + * !lang setup validate + * !lang setup create-sheets + * !lang setup grant-gm + * !lang rebuildmacros + * !lang playermacro + * !lang manage + * !lang permanent + * !lang registry preset + * !lang registry add + * !lang registry remove + * + * Quote names containing spaces, for example: + * !lang grant "Bob Whatshisface" "Deep Speech, Elvish" + */ + +var LanguageAccessManager = LanguageAccessManager || (function () { + 'use strict'; + + var SCRIPT = 'Language Access Manager'; + var VERSION = '1.0.0'; + var STATE_KEY = 'LanguageAccessManager'; + var SCHEMA_VERSION = 2; + + // Registry is deliberately separate from permission and command logic. + // TODO: move registry data into state with validation/import commands. + var TAL_TAMAIRA_REGISTRY = [ + { display: 'Ademic', sheet: 'Sheltek Ademic Language' }, + { display: 'Druidic', sheet: 'Druvo Druidic Language' }, + { display: 'Dwarvish', sheet: 'Kharzun Dwarvish Language' }, + { display: 'Elvish', sheet: 'Aeltharyn Elvish Language' }, + { display: 'Giant', sheet: 'Druumeg Giant Language' }, + { display: 'Gnomish', sheet: 'Tivri Gnomish Language' }, + { display: 'Halfling', sheet: 'Lethwynn Half-ling Language' }, + { display: 'Orcish', sheet: 'Ghorvakh Orcish Language' }, + { display: 'Aarakocran', sheet: 'Kaaril Aarakocran Language' }, + { display: 'Abyssal', sheet: 'Gorazhul Abyssal Language' }, + { display: 'Celestial', sheet: 'Aelhael Celestial Language' }, + { display: 'Deep Speech', sheet: "Uul'Qhess DeepSpeech Language" }, + { display: 'Draconic', sheet: 'Kharzul Draconic Language' }, + { display: 'Goblin', sheet: 'Grik-Tak Goblin Language' }, + { display: 'Gnoll', sheet: 'Kharzra Gnoll Language' }, + { display: 'Infernal', sheet: 'Ishkarul Infernal Language' }, + { display: 'Leonin', sheet: "Rha'Savari Leonin Language" }, + { display: 'Sylvan', sheet: 'Vaeliri Sylvan Language' }, + { display: 'Undercommon', sheet: 'Xulvryn Undercommon Language' }, + { display: 'Primordial', sheet: 'Uraval Primordial Language' }, + { display: 'Aquan', sheet: 'Oshuren Aquan Language' }, + { display: 'Auran', sheet: 'Selayin Auran Language' }, + { display: 'Ignan', sheet: 'Avarakh Ignan Language' }, + { display: 'Terran', sheet: 'Durnak Terran Language' } + ]; + + var STANDARD_REGISTRY = [ + 'Common', 'Dwarvish', 'Elvish', 'Giant', 'Gnomish', 'Goblin', 'Halfling', 'Orc', + 'Abyssal', 'Celestial', 'Draconic', 'Deep Speech', 'Infernal', 'Primordial', 'Sylvan', 'Undercommon' + ].map(function (display) { + return { display: display, sheet: display + ' Language' }; + }); + + var REGISTRY = []; + + function cloneRegistry(registry) { + return registry.map(function (entry) { + return { display: entry.display, sheet: entry.sheet }; + }); + } + + function initialiseState() { + if (!state[STATE_KEY]) { + state[STATE_KEY] = { + schemaVersion: SCHEMA_VERSION, + sessions: {}, + effects: {}, + registryName: 'standard', + registry: cloneRegistry(STANDARD_REGISTRY) + }; + } else if (state[STATE_KEY].schemaVersion === 1) { + state[STATE_KEY].schemaVersion = SCHEMA_VERSION; + state[STATE_KEY].registryName = 'tal-tamaira'; + state[STATE_KEY].registry = cloneRegistry(TAL_TAMAIRA_REGISTRY); + } + state[STATE_KEY].sessions = state[STATE_KEY].sessions || {}; + state[STATE_KEY].effects = state[STATE_KEY].effects || {}; + state[STATE_KEY].registryName = state[STATE_KEY].registryName || 'custom'; + state[STATE_KEY].registry = state[STATE_KEY].registry || cloneRegistry(STANDARD_REGISTRY); + REGISTRY = state[STATE_KEY].registry; + } + + function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, function (character) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]; + }); + } + + function whisperGM(title, body) { + var style = 'border:1px solid #5b4636;background:#f4efe6;padding:8px;border-radius:4px;'; + sendChat(SCRIPT, '/w gm
' + escapeHtml(title) + + '
' + body + '
'); + } + + function normalise(value) { + return String(value || '').trim().toLowerCase(); + } + + function unique(objects) { + var seen = {}; + return objects.filter(function (object) { + if (!object || seen[object.id]) { + return false; + } + seen[object.id] = true; + return true; + }); + } + + function tokenise(input) { + var tokens = []; + var matcher = /"([^"]*)"|'([^']*)'|(\S+)/g; + var match; + while ((match = matcher.exec(input)) !== null) { + tokens.push(match[1] !== undefined ? match[1] : (match[2] !== undefined ? match[2] : match[3])); + } + return tokens; + } + + function findExact(type, property, query) { + var wanted = normalise(query); + return findObjs({ _type: type }).filter(function (object) { + return normalise(object.get(property)) === wanted; + }); + } + + function resolveTarget(query) { + var players = []; + var byId = getObj('player', query); + if (byId) { + players.push(byId); + } + players = players.concat(findExact('player', '_displayname', query)); + + var characters = findExact('character', 'name', query); + characters.forEach(function (character) { + String(character.get('controlledby') || '').split(',').forEach(function (id) { + id = id.trim(); + if (id && id !== 'all') { + players.push(getObj('player', id)); + } + }); + }); + players = unique(players); + + if (players.length === 1) { + return { player: players[0] }; + } + if (players.length > 1) { + return { error: '“' + escapeHtml(query) + '” resolves to multiple players. Use the exact player display name.' }; + } + if (characters.length && characters.some(function (character) { + return String(character.get('controlledby') || '').split(',').indexOf('all') !== -1; + })) { + return { error: 'That character is controlled by all players, so it cannot identify one permission recipient.' }; + } + return { error: 'No exact player display name or character name matched “' + escapeHtml(query) + '”.' }; + } + + function resolveLanguages(request) { + if (normalise(request) === 'all') { + return { entries: REGISTRY.slice(), unknown: [] }; + } + var wanted = String(request || '').split(',').map(function (part) { return normalise(part); }).filter(Boolean); + var entries = []; + var unknown = []; + wanted.forEach(function (name) { + var entry = REGISTRY.filter(function (item) { + return normalise(item.display) === name || normalise(item.sheet) === name; + })[0]; + if (entry) { + if (entries.indexOf(entry) === -1) { + entries.push(entry); + } + } else { + unknown.push(name); + } + }); + return { entries: entries, unknown: unknown }; + } + + function findLanguageSheet(entry) { + var matches = findExact('character', 'name', entry.sheet); + if (matches.length === 1) { + return { sheet: matches[0] }; + } + if (matches.length > 1) { + return { error: entry.display + ': multiple character sheets are named “' + entry.sheet + '”.' }; + } + return { error: entry.display + ': missing hidden sheet “' + entry.sheet + '”.' }; + } + + function playerHasAccess(controlledBy, playerId) { + var controllers = String(controlledBy || '').split(',').map(function (id) { return id.trim(); }); + return controllers.indexOf('all') !== -1 || controllers.indexOf(playerId) !== -1; + } + + function addPlayer(controlledBy, playerId) { + var controllers = String(controlledBy || '').split(',').map(function (id) { return id.trim(); }).filter(Boolean); + if (controllers.indexOf('all') === -1 && controllers.indexOf(playerId) === -1) { + controllers.push(playerId); + } + return controllers.join(','); + } + + function removePlayer(controlledBy, playerId) { + return String(controlledBy || '').split(',').map(function (id) { + return id.trim(); + }).filter(function (id) { + return id && id !== playerId; + }).join(','); + } + + function grant(player, entries) { + var playerId = player.id; + var session = state[STATE_KEY].sessions[playerId] || { + playerName: player.get('_displayname'), + started: Date.now(), + sheets: {} + }; + var granted = []; + var already = []; + var errors = []; + + entries.forEach(function (entry) { + var found = findLanguageSheet(entry); + if (found.error) { + errors.push(found.error); + return; + } + var sheet = found.sheet; + var before = String(sheet.get('controlledby') || ''); + if (!Object.prototype.hasOwnProperty.call(session.sheets, sheet.id)) { + session.sheets[sheet.id] = { + display: entry.display, + sheetName: entry.sheet, + controlledby: before + }; + } + if (playerHasAccess(before, playerId)) { + already.push(entry.display); + } else { + sheet.set('controlledby', addPlayer(before, playerId)); + granted.push(entry.display); + } + }); + + if (Object.keys(session.sheets).length) { + state[STATE_KEY].sessions[playerId] = session; + } + return { granted: granted, already: already, errors: errors }; + } + + function restore(player) { + var session = state[STATE_KEY].sessions[player.id]; + var restored = []; + var errors = []; + if (!session) { + return { none: true, restored: restored, errors: errors }; + } + Object.keys(session.sheets).forEach(function (sheetId) { + var snapshot = session.sheets[sheetId]; + var sheet = getObj('character', sheetId); + if (!sheet) { + errors.push(snapshot.display + ': the original sheet no longer exists (ID ' + sheetId + ').'); + return; + } + sheet.set('controlledby', snapshot.controlledby); + restored.push(snapshot.display); + }); + // Keep failed records so a later repair/retry cannot lose the snapshot. + if (errors.length) { + Object.keys(session.sheets).forEach(function (sheetId) { + if (getObj('character', sheetId)) { + delete session.sheets[sheetId]; + } + }); + } else { + delete state[STATE_KEY].sessions[player.id]; + } + return { restored: restored, errors: errors }; + } + + function formatItems(items) { + return items.length ? items.map(escapeHtml).join(', ') : 'none'; + } + + function showHelp() { + whisperGM(SCRIPT + ' v' + VERSION, + '
!lang list
' + + '
!lang grant "Player or Character" all
' + + '
!lang grant "Player or Character" "Elvish, Deep Speech"
' + + '
!lang status "Player or Character"
' + + '
!lang restore "Player or Character"
' + + '
!lang setup
' + + '
!lang rebuildmacros
' + + '
!lang playermacro "Player or Character"
' + + '
!lang manage "Player or Character"
' + + '
Names with spaces must be quoted. Commands are GM-only.
'); + } + + function showList() { + var rows = REGISTRY.map(function (entry) { + var found = findLanguageSheet(entry); + return '
' + escapeHtml(entry.display) + ' → ' + escapeHtml(entry.sheet) + + (found.error ? ' [problem]' : ' [found]') + '
'; + }); + whisperGM('Language registry (' + REGISTRY.length + ')', rows.join('')); + } + + function setRegistryPreset(name) { + var preset = name === 'standard' ? STANDARD_REGISTRY : (name === 'tal-tamaira' ? TAL_TAMAIRA_REGISTRY : null); + if (!preset) { + whisperGM('Unknown registry preset', 'Use standard or tal-tamaira.'); + return; + } + state[STATE_KEY].registryName = name; + state[STATE_KEY].registry = cloneRegistry(preset); + REGISTRY = state[STATE_KEY].registry; + whisperGM('Registry preset selected', + '
Preset: ' + escapeHtml(name) + '
Languages: ' + REGISTRY.length + '
' + + '
No sheets or macros were deleted or changed. Validate, create missing sheets, then rebuild macros.
'); + showSetup(); + } + + function addRegistryEntry(display, sheet) { + if (!display || !sheet) { + whisperGM('Registry entry not added', 'Both display name and exact sheet name are required.'); + return; + } + if (REGISTRY.some(function (entry) { return normalise(entry.display) === normalise(display); })) { + whisperGM('Registry entry not added', 'A display language named “' + escapeHtml(display) + '” already exists.'); + return; + } + if (REGISTRY.some(function (entry) { return normalise(entry.sheet) === normalise(sheet); })) { + whisperGM('Registry entry not added', 'The sheet name “' + escapeHtml(sheet) + '” is already assigned.'); + return; + } + REGISTRY.push({ display: display.trim(), sheet: sheet.trim() }); + state[STATE_KEY].registryName = 'custom'; + whisperGM('Registry entry added', escapeHtml(display) + ' → ' + escapeHtml(sheet)); + } + + function removeRegistryEntry(display) { + var matches = REGISTRY.filter(function (entry) { return normalise(entry.display) === normalise(display); }); + if (matches.length !== 1) { + whisperGM('Registry entry not removed', 'No exact display-language match was found.'); + return; + } + var index = REGISTRY.indexOf(matches[0]); + REGISTRY.splice(index, 1); + state[STATE_KEY].registryName = 'custom'; + whisperGM('Registry entry removed', escapeHtml(matches[0].display) + + ' was removed from the registry. Its character sheet was not deleted or changed.'); + } + + function button(label, command) { + return '' + + escapeHtml(label) + ''; + } + + function validateRegistry() { + var found = []; + var missing = []; + var duplicates = []; + REGISTRY.forEach(function (entry) { + var matches = findExact('character', 'name', entry.sheet); + if (matches.length === 1) { + found.push(entry); + } else if (!matches.length) { + missing.push(entry); + } else { + duplicates.push(entry); + } + }); + return { found: found, missing: missing, duplicates: duplicates }; + } + + function showSetup() { + var validation = validateRegistry(); + whisperGM('Language setup', + '
Registry: ' + escapeHtml(state[STATE_KEY].registryName) + ' (' + REGISTRY.length + ' languages)
' + + '
Sheets found: ' + validation.found.length + '
' + + '
Missing: ' + validation.missing.length + '
' + + '
Duplicate names: ' + validation.duplicates.length + '
' + + '
' + + button('Validate sheets', '!lang setup validate') + + button('Create missing sheets', '!lang setup create-sheets') + + button('Give me GM access', '!lang setup grant-gm') + + button('Rebuild GM macros', '!lang rebuildmacros') + + button('Build player macro', '!lang playermacro "?{Exact player or character name}"') + + '
' + button('Standard preset', '!lang registry preset standard') + + button('Tal\'Tamaira preset', '!lang registry preset tal-tamaira') + + button('Add custom language', '!lang registry add "?{Display language}" "?{Exact sheet name}"') + + '
Creation is idempotent. Existing sheets are never changed.
'); + } + + function showValidation() { + var validation = validateRegistry(); + var body = '
Found: ' + validation.found.length + ' / ' + REGISTRY.length + '
'; + if (validation.missing.length) { + body += '
Missing:
' + validation.missing.map(function (entry) { + return escapeHtml(entry.display + ' → ' + entry.sheet); + }).join('
') + '
'; + } + if (validation.duplicates.length) { + body += '
Duplicate exact sheet names:
' + validation.duplicates.map(function (entry) { + return escapeHtml(entry.display + ' → ' + entry.sheet); + }).join('
') + '
'; + } + if (!validation.missing.length && !validation.duplicates.length) { + body += '
Validation passed. Every language has exactly one sheet.
'; + } + whisperGM('Registry validation', body); + } + + function createMissingSheets(ownerId) { + var validation = validateRegistry(); + var created = []; + validation.missing.forEach(function (entry) { + var sheet = createObj('character', { + name: entry.sheet, + controlledby: ownerId, + inplayerjournals: '', + archived: false + }); + if (sheet) { + created.push(entry.sheet); + } + }); + whisperGM('Create language sheets', + '
Created: ' + formatItems(created) + '
' + + '
Already present: ' + validation.found.length + '
' + + (validation.duplicates.length ? '
Not changed—duplicate exact names: ' + + formatItems(validation.duplicates.map(function (entry) { return entry.sheet; })) + '
' : '') + + '
New sheets are controlled by the invoking GM and have no player-journal visibility.
'); + } + + function grantGmAccess(ownerId) { + var changed = []; + var already = []; + var errors = []; + REGISTRY.forEach(function (entry) { + var found = findLanguageSheet(entry); + if (found.error) { + errors.push(found.error); + return; + } + var controlledBy = String(found.sheet.get('controlledby') || ''); + if (playerHasAccess(controlledBy, ownerId)) { + already.push(entry.display); + } else { + found.sheet.set('controlledby', addPlayer(controlledBy, ownerId)); + changed.push(entry.display); + } + }); + whisperGM('GM language-sheet access', + '
Access added: ' + formatItems(changed) + '
' + + '
Already accessible: ' + formatItems(already) + '
' + + (errors.length ? '
Problems:
' + errors.map(escapeHtml).join('
') + '
' : '') + + '
Existing player controllers and journal visibility were preserved.
'); + } + + function languageQuery() { + return '?{Language|' + REGISTRY.map(function (entry) { + return entry.display.replace(/,/g, '') + ',"' + entry.sheet.replace(/"/g, '') + '"'; + }).join('|') + '}'; + } + + function macroDefinitions() { + var query = languageQuery(); + return [ + { + name: 'Language-Spoken-GM', + action: '/em ?{NPC Name|} speaks in a foreign tongue, "?{Foreign Language Text?}"\n' + + '!lang route --mode|spoken --language|' + query + ' --translation|?{Translation}' + }, + { + name: 'Language-Inscription-GM', + action: '/em Inscription reads, "?{Foreign Language Text?}"\n' + + '!lang route --mode|written --language|' + query + ' --translation|?{Translation}' + } + ]; + } + + function rebuildGmMacros(ownerId) { + var created = []; + var updated = []; + var errors = []; + macroDefinitions().forEach(function (definition) { + var owned = findObjs({ _type: 'macro', _playerid: ownerId }).filter(function (macro) { + return macro.get('name') === definition.name; + }); + if (owned.length > 1) { + errors.push(definition.name + ': you own multiple macros with this name; none were changed.'); + } else if (owned.length === 1) { + owned[0].set({ action: definition.action, visibleto: '' }); + updated.push(definition.name); + } else { + var macro = createObj('macro', { + name: definition.name, + action: definition.action, + _playerid: ownerId, + visibleto: '' + }); + if (macro) { + created.push(definition.name); + } else { + errors.push(definition.name + ': Roll20 did not create the macro.'); + } + } + }); + whisperGM('Rebuild GM language macros', + '
Created: ' + formatItems(created) + '
' + + '
Updated: ' + formatItems(updated) + '
' + + (errors.length ? '
Not changed:
' + errors.map(escapeHtml).join('
') + '
' : '') + + '
Only macros owned by the invoking GM were considered. They remain GM-private.
'); + } + + function permanentLanguages(player) { + var session = state[STATE_KEY].sessions[player.id]; + var entries = []; + var errors = []; + REGISTRY.forEach(function (entry) { + var found = findLanguageSheet(entry); + if (found.error) { + errors.push(found.error); + return; + } + var controlledBy = found.sheet.get('controlledby'); + if (session && Object.prototype.hasOwnProperty.call(session.sheets, found.sheet.id)) { + controlledBy = session.sheets[found.sheet.id].controlledby; + } + if (playerHasAccess(controlledBy, player.id)) { + entries.push(entry); + } + }); + return { entries: entries, errors: errors }; + } + + function showManage(player) { + var access = permanentLanguages(player); + if (access.errors.length) { + whisperGM('Cannot manage languages', + '
Registry validation must pass first.
' + + access.errors.map(escapeHtml).join('
') + '
'); + return; + } + var knownNames = {}; + access.entries.forEach(function (entry) { knownNames[entry.display] = true; }); + var unknown = REGISTRY.filter(function (entry) { return !knownNames[entry.display]; }); + var playerId = player.id; + var session = state[STATE_KEY].sessions[playerId]; + var removeButtons = access.entries.map(function (entry) { + return button('− ' + entry.display, '!lang permanent remove ' + playerId + ' "' + entry.display + '"'); + }).join(''); + var addButtons = unknown.map(function (entry) { + return button('+ ' + entry.display, '!lang permanent add ' + playerId + ' "' + entry.display + '"'); + }).join(''); + + whisperGM('Manage languages: ' + player.get('_displayname'), + '
Permanent languages: ' + formatItems(access.entries.map(function (entry) { + return entry.display; + })) + '
' + + (removeButtons ? '
Remove:
' + removeButtons + '
' : '') + + (addButtons ? '
Add:
' + addButtons + '
' : '') + + '
' + + button('Rebuild player macro', '!lang playermacro ' + playerId) + + button('Grant all temporarily', '!lang grant ' + playerId + ' all') + + (session ? button('Restore temporary access', '!lang restore ' + playerId) : '') + + button('Refresh', '!lang manage ' + playerId) + + '
Temporary snapshot: ' + + (session ? 'active' : 'none') + '
'); + } + + function changePermanentLanguage(player, entry, operation) { + var found = findLanguageSheet(entry); + if (found.error) { + return { error: found.error }; + } + var sheet = found.sheet; + var current = String(sheet.get('controlledby') || ''); + var session = state[STATE_KEY].sessions[player.id]; + var snapshot = session && session.sheets[sheet.id]; + var baseline = snapshot ? String(snapshot.controlledby || '') : current; + var baselineControllers = baseline.split(',').map(function (id) { return id.trim(); }); + + if (operation === 'add') { + if (playerHasAccess(baseline, player.id)) { + return { unchanged: true, message: entry.display + ' is already permanent.' }; + } + var addedBaseline = addPlayer(baseline, player.id); + if (snapshot) { + snapshot.controlledby = addedBaseline; + } + sheet.set('controlledby', addPlayer(current, player.id)); + return { changed: true, message: entry.display + ' added permanently.' }; + } + + if (baselineControllers.indexOf('all') !== -1) { + return { error: entry.display + ' is controlled by “all”. One player cannot be removed without changing access for everyone.' }; + } + if (baselineControllers.indexOf(player.id) === -1) { + return { unchanged: true, message: entry.display + ' is not a permanent language for this player.' }; + } + var removedBaseline = removePlayer(baseline, player.id); + if (snapshot) { + // Keep current temporary access; restore will apply the new baseline. + snapshot.controlledby = removedBaseline; + } else { + sheet.set('controlledby', removePlayer(current, player.id)); + } + return { changed: true, message: entry.display + ' removed permanently.' }; + } + + function parseFields(content) { + var fields = {}; + content.replace(/--([a-z]+)\|([\s\S]*?)(?=\s--[a-z]+\||$)/gi, function (all, key, value) { + fields[key.toLowerCase()] = value.trim().replace(/^"|"$/g, ''); + return all; + }); + return fields; + } + + function effectPlayers(effectName) { + return Object.keys(state[STATE_KEY].effects).filter(function (id) { + return state[STATE_KEY].effects[id][effectName] && getObj('player', id); + }).map(function (id) { return getObj('player', id); }); + } + + function routeTranslation(fields) { + var resolved = resolveLanguages(fields.language); + if (resolved.entries.length !== 1) { + whisperGM('Translation not routed', 'The selected language was not resolved.'); + return; + } + var entry = resolved.entries[0]; + var found = findLanguageSheet(entry); + if (found.error) { + whisperGM('Translation not routed', escapeHtml(found.error)); + return; + } + var label = fields.mode === 'written' ? 'Inscription Reads' : 'Translation'; + var safeText = escapeHtml(fields.translation || ''); + sendChat(SCRIPT, '/w "' + entry.sheet.replace(/"/g, '') + '" **' + label + ':** "' + safeText + '"'); + var extras = effectPlayers('comprehend'); + if (fields.mode === 'spoken') { + extras = extras.concat(effectPlayers('tongues')); + } + unique(extras).forEach(function (player) { + if (!playerHasAccess(found.sheet.get('controlledby'), player.id)) { + sendChat(SCRIPT, '/w "' + String(player.get('_displayname')).replace(/"/g, '') + '" **' + label + ':** "' + safeText + '"'); + } + }); + } + + function whisperPlayer(player, label, text) { + sendChat(SCRIPT, '/w "' + String(player.get('_displayname')).replace(/"/g, '') + + '" **' + label + ':** "' + escapeHtml(text || '') + '"'); + } + + function routePlayerSpeech(message) { + var player = getObj('player', message.playerid); + var fields = parseFields(message.content); + if (!player) { + return; + } + var effects = state[STATE_KEY].effects[player.id] || {}; + if (fields.language === '__tongues__') { + if (!effects.tongues) { + whisperPlayer(player, 'Language Access Manager', 'Tongues is not active.'); + return; + } + var listeners = findObjs({ _type: 'player' }).filter(function (candidate) { + return permanentLanguages(candidate).entries.length > 0; + }).concat(effectPlayers('comprehend'), effectPlayers('tongues')); + unique(listeners).forEach(function (listener) { + whisperPlayer(listener, 'Character Says', fields.message); + }); + return; + } + var resolved = resolveLanguages(fields.language); + var permanent = permanentLanguages(player).entries; + if (resolved.entries.length !== 1 || permanent.indexOf(resolved.entries[0]) === -1) { + whisperPlayer(player, 'Language Access Manager', 'That is not one of your permanent languages.'); + return; + } + var entry = resolved.entries[0]; + var found = findLanguageSheet(entry); + if (found.error) { + whisperPlayer(player, 'Language Access Manager', found.error); + return; + } + sendChat(SCRIPT, '/w "' + entry.sheet.replace(/"/g, '') + '" **Character Says:** "' + + escapeHtml(fields.message || '') + '"'); + unique(effectPlayers('comprehend').concat(effectPlayers('tongues'))).forEach(function (listener) { + if (!playerHasAccess(found.sheet.get('controlledby'), listener.id)) { + whisperPlayer(listener, 'Character Says', fields.message); + } + }); + } + + function syncPlayerMacroIfPresent(player) { + if (findObjs({ _type: 'macro', _playerid: player.id }).some(function (macro) { + return macro.get('name') === 'Language-Speak'; + })) { + rebuildPlayerMacro(player); + } + } + + function setNamedEffect(player, effect, enabled) { + var effects = state[STATE_KEY].effects[player.id] || {}; + if (enabled) { + effects[effect] = true; + state[STATE_KEY].effects[player.id] = effects; + } else { + delete effects[effect]; + if (!Object.keys(effects).length) { + delete state[STATE_KEY].effects[player.id]; + } + } + whisperGM((enabled ? 'Effect started: ' : 'Effect ended: ') + effect, + escapeHtml(player.get('_displayname')) + (effect === 'tongues' ? + ' can understand spoken languages. Written translations remain unavailable.' : + ' can understand spoken and written languages. Speaking is not granted.')); + if (effect === 'tongues') { + syncPlayerMacroIfPresent(player); + } + } + + function playerMacroAction(entries, tonguesActive) { + var options = entries.map(function (entry) { + return { display: entry.display, value: entry.display }; + }); + if (tonguesActive) { + options.push({ display: 'Tongues', value: '__tongues__' }); + } + var languageValue; + if (options.length === 1) { + languageValue = options[0].value; + } else { + languageValue = '?{Language|' + options.map(function (option) { + return option.display + ',' + option.value; + }).join('|') + '}'; + } + return '/em speaks in a foreign tongue,\n' + + '!lang playerroute --language|' + languageValue + ' --message|?{Message}'; + } + + function rebuildPlayerMacro(player) { + var access = permanentLanguages(player); + if (access.errors.length) { + whisperGM('Player macro not changed', + '
Registry validation must pass before building a player macro.
' + + '
' + access.errors.map(escapeHtml).join('
') + '
'); + return; + } + if (!access.entries.length) { + whisperGM('Player macro not changed', escapeHtml(player.get('_displayname')) + + ' has no permanent language-sheet permissions. Assign at least one language first.'); + return; + } + + var name = 'Language-Speak'; + var owned = findObjs({ _type: 'macro', _playerid: player.id }).filter(function (macro) { + return macro.get('name') === name; + }); + if (owned.length > 1) { + whisperGM('Player macro not changed', escapeHtml(player.get('_displayname')) + + ' owns multiple macros named ' + name + '. Resolve the duplicates first.'); + return; + } + + var effects = state[STATE_KEY].effects[player.id] || {}; + var action = playerMacroAction(access.entries, !!effects.tongues); + var result; + if (owned.length === 1) { + owned[0].set({ action: action, visibleto: '' }); + result = 'Updated'; + } else { + var macro = createObj('macro', { + name: name, + action: action, + _playerid: player.id, + visibleto: '' + }); + if (!macro) { + whisperGM('Player macro not created', 'Roll20 did not create the macro for ' + + escapeHtml(player.get('_displayname')) + '.'); + return; + } + result = 'Created'; + } + whisperGM('Player language macro', + '
Player: ' + escapeHtml(player.get('_displayname')) + '
' + + '
' + result + ': ' + name + '
' + + '
Permanent languages: ' + formatItems(access.entries.map(function (entry) { + return entry.display; + })) + '
' + + '
The macro is owned by that player. Temporary grants are excluded.
'); + } + + function showStatus(player) { + var session = state[STATE_KEY].sessions[player.id]; + var accessible = []; + var missing = []; + REGISTRY.forEach(function (entry) { + var found = findLanguageSheet(entry); + if (found.error) { + missing.push(found.error); + } else if (playerHasAccess(found.sheet.get('controlledby'), player.id)) { + accessible.push(entry.display); + } + }); + var temporary = session ? Object.keys(session.sheets).map(function (id) { + return session.sheets[id].display; + }) : []; + whisperGM('Status: ' + player.get('_displayname'), + '
Current access: ' + formatItems(accessible) + '
' + + '
Sheets protected by active snapshot: ' + formatItems(temporary) + '
' + + (missing.length ? '
Registry problems:
' + missing.map(escapeHtml).join('
') + '
' : '')); + } + + function handleInput(message) { + if (message.type !== 'api' || !/^!lang(?:\s|$)/i.test(message.content)) { + return; + } + if (/^!lang\s+playerroute(?:\s|$)/i.test(message.content)) { + routePlayerSpeech(message); + return; + } + if (!playerIsGM(message.playerid)) { + sendChat(SCRIPT, '/w "' + String((getObj('player', message.playerid) || { get: function () { return 'player'; } }).get('_displayname')).replace(/"/g, '') + '" This command is GM-only.'); + return; + } + + var tokens = tokenise(message.content.replace(/^!lang\s*/i, '')); + var command = normalise(tokens.shift() || 'help').replace(/^-+/, ''); + if (command === 'help') { + showHelp(); + return; + } + if (command === 'list') { + showList(); + return; + } + if (command === 'registry') { + var registryAction = normalise(tokens.shift()); + if (registryAction === 'preset') { + setRegistryPreset(normalise(tokens.shift())); + } else if (registryAction === 'add' && tokens.length >= 2) { + addRegistryEntry(tokens.shift(), tokens.join(' ')); + } else if (registryAction === 'remove' && tokens.length) { + removeRegistryEntry(tokens.join(' ')); + } else if (registryAction === 'list') { + showList(); + } else { + whisperGM('Registry commands', + '
!lang registry preset standard
' + + '
!lang registry preset tal-tamaira
' + + '
!lang registry add "Display" "Exact Sheet Name"
' + + '
!lang registry remove "Display"
'); + } + return; + } + if (command === 'setup') { + var setupAction = normalise(tokens.shift() || 'menu'); + if (setupAction === 'menu') { + showSetup(); + } else if (setupAction === 'validate') { + showValidation(); + } else if (setupAction === 'create-sheets') { + createMissingSheets(message.playerid); + } else if (setupAction === 'grant-gm') { + grantGmAccess(message.playerid); + } else { + whisperGM('Unknown setup action', 'Use !lang setup to open the setup menu.'); + } + return; + } + if (command === 'rebuildmacros') { + rebuildGmMacros(message.playerid); + return; + } + if (command === 'route') { + routeTranslation(parseFields(message.content)); + return; + } + if (command === 'comprehend' || command === 'tongues') { + var effectTarget = resolveTarget(tokens.join(' ')); + if (effectTarget.error) { + whisperGM('Target not resolved', effectTarget.error); + return; + } + setNamedEffect(effectTarget.player, command, true); + return; + } + if (command === 'end') { + var effectName = normalise(tokens.pop()); + var endTarget = resolveTarget(tokens.join(' ')); + if (['comprehend', 'tongues'].indexOf(effectName) === -1 || endTarget.error) { + whisperGM('Effect not ended', endTarget.error || 'Use comprehend or tongues as the effect name.'); + return; + } + setNamedEffect(endTarget.player, effectName, false); + return; + } + if (command === 'playermacro') { + if (!tokens.length) { + whisperGM('Missing target', 'Provide an exact player display name or character name. Quote names containing spaces.'); + return; + } + var macroTarget = resolveTarget(tokens.join(' ')); + if (macroTarget.error) { + whisperGM('Target not resolved', macroTarget.error); + return; + } + rebuildPlayerMacro(macroTarget.player); + return; + } + if (command === 'manage') { + if (!tokens.length) { + whisperGM('Missing target', 'Provide an exact player display name or character name. Quote names containing spaces.'); + return; + } + var manageTarget = resolveTarget(tokens.join(' ')); + if (manageTarget.error) { + whisperGM('Target not resolved', manageTarget.error); + return; + } + showManage(manageTarget.player); + return; + } + if (command === 'permanent') { + var operation = normalise(tokens.shift()); + if (['add', 'remove'].indexOf(operation) === -1 || tokens.length < 2) { + whisperGM('Invalid permanent-language command', + 'Use !lang permanent add "Player or Character" "Language" or replace add with remove.'); + return; + } + var permanentTarget = resolveTarget(tokens.shift()); + if (permanentTarget.error) { + whisperGM('Target not resolved', permanentTarget.error); + return; + } + var permanentLanguage = resolveLanguages(tokens.join(' ')); + if (permanentLanguage.entries.length !== 1 || permanentLanguage.unknown.length) { + whisperGM('Language not resolved', 'Provide exactly one registered display-language or sheet name.'); + return; + } + var permanentResult = changePermanentLanguage(permanentTarget.player, permanentLanguage.entries[0], operation); + whisperGM('Permanent language: ' + permanentTarget.player.get('_displayname'), + permanentResult.error ? '' + escapeHtml(permanentResult.error) + '' : + escapeHtml(permanentResult.message) + + (permanentResult.changed ? '
Rebuild the player macro when assignments are complete.
' : '')); + showManage(permanentTarget.player); + return; + } + if (['grant', 'restore', 'status'].indexOf(command) === -1) { + whisperGM('Unknown command', 'Use !lang help to see the available commands.'); + return; + } + if (!tokens.length) { + whisperGM('Missing target', 'Provide an exact player display name or character name. Quote names containing spaces.'); + return; + } + + var target = resolveTarget(tokens.shift()); + if (target.error) { + whisperGM('Target not resolved', target.error); + return; + } + if (command === 'status') { + showStatus(target.player); + return; + } + if (command === 'restore') { + var restored = restore(target.player); + whisperGM('Restore: ' + target.player.get('_displayname'), + restored.none ? 'No active temporary session was found.' : + '
Restored: ' + formatItems(restored.restored) + '
' + + (restored.errors.length ? '
Not restored:
' + restored.errors.map(escapeHtml).join('
') + '
' : '')); + return; + } + if (!tokens.length) { + whisperGM('Missing language', 'Use a display language, comma-separated languages, or all.'); + return; + } + + var resolved = resolveLanguages(tokens.join(' ')); + if (!resolved.entries.length) { + whisperGM('No languages matched', 'Unknown: ' + formatItems(resolved.unknown) + '. Use !lang list.'); + return; + } + var result = grant(target.player, resolved.entries); + whisperGM('Temporary grant: ' + target.player.get('_displayname'), + '
Granted: ' + formatItems(result.granted) + '
' + + '
Already accessible: ' + formatItems(result.already) + '
' + + (resolved.unknown.length ? '
Unknown: ' + formatItems(resolved.unknown) + '
' : '') + + (result.errors.length ? '
Sheet problems:
' + result.errors.map(escapeHtml).join('
') + '
' : '')); + } + + function registerEventHandlers() { + on('chat:message', handleInput); + } + + on('ready', function () { + initialiseState(); + registerEventHandlers(); + log(SCRIPT + ' v' + VERSION + ' ready. Registry entries: ' + REGISTRY.length + '.'); + }); + + return { + version: VERSION + // TODO: add state-backed custom registries and optional bulk player-macro rebuilding. + }; +}()); diff --git a/LanguageAccessManager/LICENSE b/LanguageAccessManager/LICENSE new file mode 100644 index 000000000..00e9e2a7e --- /dev/null +++ b/LanguageAccessManager/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kingkiller546 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LanguageAccessManager/README.md b/LanguageAccessManager/README.md new file mode 100644 index 000000000..563a79453 --- /dev/null +++ b/LanguageAccessManager/README.md @@ -0,0 +1,111 @@ +# Language Access Manager v1.0.0 + +Language Access Manager provides permanent and temporary language permissions, rules-aware Comprehend Languages and Tongues effects, generated GM/player macros, and non-destructive setup for Roll20. It ships with a standard D&D registry, a Tal'Tamaira preset, and safe custom-language commands. + +## Why I built this + +I originally built Language Access Manager for my own Tal'Tamaira campaign, where I use a full set of custom languages rather than treating language as a simple line on a character sheet. Each language has its own hidden Roll20 character sheet, and controlling that sheet acts as membership of the language group. When the GM whispers a translation to that hidden sheet, only the players whose characters understand the language receive it. + +That system made languages feel meaningful in play, but managing it manually became increasingly awkward. Granting Comprehend Languages could mean adding one player to more than twenty language sheets and then removing them all later without accidentally taking away languages the character already knew. Tongues introduced a different problem because it affects spoken communication without granting the ability to read inscriptions. Permanent language changes, temporary magical effects, player-specific language menus, and custom campaign terminology all needed to coexist without damaging existing permissions. + +Language Access Manager grew from that need. It keeps the hidden-sheet approach, automates the repetitive permission work, distinguishes spoken and written understanding, and restores temporary changes safely. I later expanded it with a standard language preset, configurable registries, setup tools, and generated macros so other GMs can use the same system without first building the entire structure by hand. + +## Install + +1. Open the campaign's Mod/API Scripts page. +2. Create a new script named `LanguageAccessManager`. +3. Paste in the complete contents of `LanguageAccessManager.js`, save it, and confirm the API console reports `Language Access Manager v1.0.0 ready`. +4. Run `!lang setup`, choose a registry preset, create missing sheets, grant GM access, and rebuild the GM macros. + +All commands are GM-only. Put names containing spaces in straight quotation marks. + +## Commands + +```text +!lang help +!lang list +!lang grant "Player Display Name" all +!lang grant "Player Display Name" Elvish +!lang grant "Player Display Name" "Elvish, Deep Speech" +!lang restore "Player Display Name" +!lang status "Player Display Name" +!lang setup +!lang setup validate +!lang setup create-sheets +!lang setup grant-gm +!lang rebuildmacros +!lang playermacro "Player or Character" +!lang manage "Player or Character" +!lang permanent add "Player or Character" "Elvish" +!lang permanent remove "Player or Character" "Elvish" +!lang comprehend "Player or Character" +!lang tongues "Player or Character" +!lang end "Player or Character" comprehend +!lang end "Player or Character" tongues +!lang registry preset standard +!lang registry preset tal-tamaira +!lang registry add "Display Language" "Exact Sheet Name" +!lang registry remove "Display Language" +``` + +An exact character name may replace the player name when that character has exactly one controller. If it resolves to several players—including a GM who also controls it—the command stops and asks for an exact player display name. + +## Safe first test + +1. Choose one test player and note one hidden sheet they permanently control and one they do not. +2. Run `!lang list` and confirm the hidden sheets are found. +3. Run `!lang status "Test Player"` and save the reported current access. +4. Run `!lang grant "Test Player" all`. +5. Run `!lang status "Test Player"`; all found languages should now be accessible. +6. Optionally run the same grant again. It must not replace the original baseline. +7. Run `!lang restore "Test Player"`. +8. Run `!lang status "Test Player"`. The access list should match step 3, including the permanently known language. + +Test in a copy of the campaign first. JavaScript syntax checking cannot prove Roll20 sandbox behaviour. + +## Architecture and extension points + +- `REGISTRY`: display-language to hidden-sheet mapping, isolated from the permission engine. +- `state.LanguageAccessManager.sessions[playerId]`: persistent per-player session containing each touched sheet ID and its exact original `controlledby` value. +- Resolver layer: exact player ID/display-name or exact character-name resolution, rejecting ambiguous recipients. +- Permission layer: snapshot-once grant and exact restore, independent for every player. +- Command/UI layer: native `sendChat()` feedback with no ScriptCards dependency. + +## Setup and generated GM macros + +New installations default to the 16-language standard D&D preset. The standard sheet names are `Common Language`, `Elvish Language`, `Deep Speech Language`, and so on. Selecting another preset changes only the registry: it never deletes or renames sheets, macros, or player permissions. + +Custom additions are persistent. Removing a registry entry does not delete its character sheet. After any registry change, validate sheets and rebuild the generated macros. + +`!lang setup` opens a native GM-only setup card. Validation is read-only. **Create missing sheets** creates only registry sheets whose exact names do not exist; it never edits an existing sheet. New sheets are controlled by the GM who invokes creation and have blank `inplayerjournals` fields. + +`!lang setup grant-gm` safely adds the invoking GM to all existing language sheets while preserving every existing player controller and journal-visibility setting. This is useful for sheets created by an earlier version. + +`!lang rebuildmacros` creates or updates these two macros owned by the GM who runs the command: + +- `Language-Spoken-GM` +- `Language-Inscription-GM` + +Both language menus are generated from the registry. Existing macros owned by other users are not touched. If the invoking GM owns duplicate macros with one of these names, the script reports the ambiguity without changing them. + +## Optional player macro + +After assigning a player to their permanent language sheets, run: + +```text +!lang playermacro "Ayla Ytger" +``` + +This creates or updates `Language-Speak`, owned by the resolved player. Its language query contains only that player's permanently assigned languages. If the player currently has a temporary grant, the generator reads the saved pre-grant snapshot so temporary languages are not added to the macro. Run the command again whenever permanent language assignments change. + +When a player knows exactly one language, the generated macro whispers directly to that language sheet instead of presenting a one-option language query. + +The command refuses to create an empty macro, refuses to proceed while registry sheets are missing or duplicated, and does not change duplicate same-name macros owned by the target player. + +## Player language management + +`!lang manage "Player or Character"` opens a GM-only card showing permanent languages, add/remove buttons, temporary-session status, and controls to rebuild the player's macro, grant all temporarily, restore, or refresh the card. + +Permanent changes are safe during an active temporary grant. The script updates the saved baseline: adding a permanent language ensures it remains after restore, while removing one prevents restore from reinstating it. The player macro is rebuilt only when the GM clicks its button, allowing several language changes to be made first. + +Remaining planned phases are marked as TODOs in the source: a state-backed configurable registry and an optional bulk player-macro rebuild command. diff --git a/LanguageAccessManager/script.json b/LanguageAccessManager/script.json new file mode 100644 index 000000000..6877747a6 --- /dev/null +++ b/LanguageAccessManager/script.json @@ -0,0 +1,13 @@ +{ + "name": "Language Access Manager", + "script": "LanguageAccessManager.js", + "version": "1.0.0", + "previousversions": [], + "description": "Language Access Manager uses hidden character sheets as language permission groups. It provides non-destructive setup, configurable standard and Tal'Tamaira registries, permanent and temporary access, exact restoration, rules-aware Comprehend Languages and Tongues routing, GM spoken/inscription macros, and player-specific speaking macros. Open the GM setup menu with `!lang setup`.", + "authors": "Kingkiller546", + "roll20userid": "2978722", + "useroptions": [], + "dependencies": [], + "modifies": {}, + "conflicts": [] +}