mirror of
https://github.com/onlyworlds/obsidian-plugin.git
synced 2026-07-22 11:00:31 +00:00
readElement returns id OUTSIDE .fields; the Phase B guards in Export and Copy
returned parsed.fields bare, so on a fully-migrated world Upload World would
silently skip EVERY element (the sweep's if-no-id-continue) while reporting
success, and Copy World would emit id-less JSON that breaks paste dedupe.
Both guards now return { id, ...fields }. Smoke step 17 strengthened to check
the uploaded count and spot-check ids in copied JSON, so this class stays dead
at the user layer too.
Gate: tsc clean, build clean, npm test 21/21 (orchestrator hands).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
577 lines
30 KiB
TypeScript
577 lines
30 KiB
TypeScript
import { Category } from 'enums';
|
|
import { ValidateCopyResultModal } from 'Modals/ValidateCopyResultModal';
|
|
import { WorldSelectionModal } from 'Modals/WorldSelectionModal';
|
|
import { App, normalizePath, Notice, PluginManifest, TFile, TFolder } from 'obsidian';
|
|
import { WorldService } from 'Scripts/WorldService';
|
|
import { ValidateWorldCommand } from './ValidateWorldCommand';
|
|
import { decodeHtmlEntities } from '../Scripts/htmlEntities';
|
|
import { readElement } from '../vault/element-file';
|
|
import { isSpanFormat } from '../vault/element-transform';
|
|
|
|
export class CopyWorldCommand {
|
|
app: App;
|
|
manifest: PluginManifest;
|
|
worldService: WorldService;
|
|
|
|
constructor(app: App, manifest: PluginManifest, worldService: WorldService) {
|
|
this.app = app;
|
|
this.manifest = manifest;
|
|
this.worldService = worldService;
|
|
}
|
|
|
|
async execute() {
|
|
const activeWorldName = await this.worldService.getWorldName(); // Fetch the active world name
|
|
const worldFolders = await this.getWorldFolders();
|
|
|
|
// If only one world exists, skip the selection modal
|
|
if (worldFolders.length === 1) {
|
|
await this.processWorldCopy(worldFolders[0]);
|
|
return;
|
|
}
|
|
|
|
// If multiple worlds exist, show selection modal
|
|
new WorldSelectionModal(this.app, async (worldFolder: string) => {
|
|
await this.processWorldCopy(worldFolder);
|
|
}, activeWorldName).open(); // Pass the active world name to the modal
|
|
}
|
|
|
|
async processWorldCopy(worldFolder: string) {
|
|
const validator = new ValidateWorldCommand(this.app, this.manifest, this.worldService, false);
|
|
await validator.execute(worldFolder);
|
|
|
|
const validationModal = new ValidateCopyResultModal(this.app, validator.errors, validator.elementCount, validator.errorCount, worldFolder);
|
|
validationModal.setExportCallback(async () => {
|
|
if (validator.errorCount === 0) {
|
|
await this.copyWorldData(worldFolder); // Only copy data if there are no errors
|
|
}
|
|
});
|
|
validationModal.open();
|
|
}
|
|
|
|
async copyWorldData(worldFolder: string) {
|
|
const worldFolderPath = normalizePath(`OnlyWorlds/Worlds/${worldFolder}`);
|
|
const worldFilePath = `${worldFolderPath}/World.md`;
|
|
const worldDataPath = `${worldFolderPath}/World Data File.md`;
|
|
|
|
// Retrieve the 'World.md' file as a TFile instance
|
|
const worldFile = this.app.vault.getAbstractFileByPath(worldFilePath) as TFile | null;
|
|
|
|
if (worldFile instanceof TFile) {
|
|
try {
|
|
const worldData = await this.collectWorldData(worldFolderPath); // Assuming this collects data correctly
|
|
const worldDataJSON = JSON.stringify(worldData, null, 4);
|
|
|
|
// Check if the World Data File exists, create new or modify existing
|
|
let worldDataFile = this.app.vault.getAbstractFileByPath(worldDataPath) as TFile | null;
|
|
if (worldDataFile instanceof TFile) {
|
|
// Modify existing World Data File
|
|
await this.app.vault.modify(worldDataFile, worldDataJSON);
|
|
} else {
|
|
// Create new World Data File if it does not exist
|
|
worldDataFile = await this.app.vault.create(worldDataPath, worldDataJSON);
|
|
}
|
|
|
|
// Copy to clipboard
|
|
navigator.clipboard.writeText(worldDataJSON);
|
|
|
|
// Modal confirmation removed
|
|
new Notice(`World ${worldFolder} copied to clipboard.`);
|
|
} catch (error) {
|
|
console.error('Error during world data processing:', error);
|
|
new Notice('Failed to process world data.');
|
|
}
|
|
} else {
|
|
new Notice('World file does not exist.');
|
|
}
|
|
}
|
|
|
|
|
|
async collectWorldData(worldFolder: string) {
|
|
const fs = this.app.vault.adapter;
|
|
let worldData: Record<string, any> = {}; // Change from any[] to any for flexible indexing
|
|
|
|
// Extract world name from folder path
|
|
const worldName = worldFolder.split('/').pop() || 'Unknown World';
|
|
// console.log(`Collecting world data for: ${worldName}`);
|
|
|
|
// Path to the 'World' file inside the selected world folder
|
|
const worldFilePath = normalizePath(`${worldFolder}/World.md`);
|
|
// Read the 'World' file content and parse it
|
|
try {
|
|
const worldFileContent = await fs.read(worldFilePath);
|
|
const worldInfo = this.parseWorldFile(worldFileContent);
|
|
|
|
// Add missing fields with default values to match mobile app format
|
|
const completeWorldInfo = {
|
|
id: worldInfo.id || this.generateUUID(),
|
|
user: worldInfo.user || worldInfo.user_id || 'default_user_id',
|
|
name: worldInfo.name || 'OnlyWorld',
|
|
description: worldInfo.description || '',
|
|
version: worldInfo.version || '00.30',
|
|
image_url: worldInfo.image_url || worldInfo.image || 'default_image_url',
|
|
focus_text: worldInfo.focus_text || '',
|
|
time_format_names: worldInfo.time_format_names || [],
|
|
time_format_equivalents: worldInfo.time_format_equivalents || [],
|
|
time_basic_unit: worldInfo.time_basic_unit || 'Year',
|
|
time_current: worldInfo.time_current || 0,
|
|
time_range_min: worldInfo.time_range_min || 0,
|
|
time_range_max: worldInfo.time_range_max || 100,
|
|
api_key: worldInfo.api_key || '0000000000'
|
|
};
|
|
|
|
worldData['World'] = completeWorldInfo;
|
|
} catch (error) {
|
|
console.error('Error reading World file:', error);
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
new Notice('Failed to read World file: ' + msg);
|
|
return {}; // Stop further processing if the World file cannot be read
|
|
}
|
|
|
|
// Iterate over categories to collect their data
|
|
for (const categoryKey in Category) {
|
|
const category = Category[categoryKey];
|
|
if (isNaN(Number(category))) {
|
|
const categoryDirectory = normalizePath(`${worldFolder}/Elements/${category}`);
|
|
const files = this.app.vault.getFiles().filter(file => file.path.startsWith(categoryDirectory));
|
|
|
|
const categoryData = await Promise.all(files.map(async (file) => {
|
|
const fileContent = await fs.read(file.path);
|
|
// Phase B: frontmatter notes read via the shared reader; legacy
|
|
// span notes fall through to the span parser (mixed worlds copy
|
|
// correctly either way).
|
|
let parsedElement: Record<string, any>;
|
|
if (!isSpanFormat(fileContent) && file instanceof TFile) {
|
|
const parsed = await readElement(this.app, file);
|
|
// id rides OUTSIDE parsed.fields — without it the copied
|
|
// JSON loses element identity and paste-dedupe breaks
|
|
// (gate finding, 2026-07-16).
|
|
parsedElement = parsed
|
|
? { id: parsed.id, ...parsed.fields }
|
|
: await this.parseTemplate(fileContent, worldName);
|
|
} else {
|
|
parsedElement = await this.parseTemplate(fileContent, worldName);
|
|
}
|
|
return this.addMissingElementFields(parsedElement, category);
|
|
}));
|
|
|
|
// Filter out empty entries
|
|
worldData[category] = categoryData.filter(item => Object.keys(item).length > 0);
|
|
}
|
|
}
|
|
|
|
// console.log(`Final world data: ${JSON.stringify(worldData)}`);
|
|
return worldData;
|
|
}
|
|
|
|
private parseWorldFile(content: string): Record<string, any> {
|
|
let currentSection: string | null = null;
|
|
const data: Record<string, any> = {};
|
|
|
|
const sectionPattern = /^##\s*(.+)$/; // Pattern to identify sections
|
|
const keyValuePattern = /- \*\*(.*?):\*\* (.*)/; // Pattern for key-value pairs
|
|
|
|
const lines = content.split('\n');
|
|
lines.forEach(line => {
|
|
const sectionMatch = line.match(sectionPattern);
|
|
if (sectionMatch) {
|
|
currentSection = this.toSnakeCase(sectionMatch[1]);
|
|
return;
|
|
}
|
|
|
|
const match = line.match(keyValuePattern);
|
|
if (match) {
|
|
let key = this.toSnakeCase(match[1].replace(/\*\*/g, ''));
|
|
let value = match[2].trim();
|
|
|
|
// Fix field name mappings to match mobile app format
|
|
if (key === 'time_formats') {
|
|
key = 'time_format_names';
|
|
} else if (key === 'basic_time_unit') {
|
|
key = 'time_basic_unit';
|
|
} else if (key === 'current_time') {
|
|
key = 'time_current';
|
|
}
|
|
|
|
// Handle HTML entity decoding (incl. apostrophe forms '/'/')
|
|
value = decodeHtmlEntities(value);
|
|
|
|
// Special handling for image field
|
|
if (key === 'image' || key === 'image_url') {
|
|
// If the image value is 'None', set it to empty string
|
|
if (value === 'None' || value === 'No image set') {
|
|
data[key] = '';
|
|
} else {
|
|
data[key] = value;
|
|
}
|
|
} else if (key === 'time_format_names' || key === 'time_format_equivalents') {
|
|
// Support both formats:
|
|
// 1. If the value is already a JSON array string (starts with '[')
|
|
if (value.startsWith('[') && value.endsWith(']')) {
|
|
try {
|
|
// Parse as JSON array
|
|
data[key] = JSON.parse(value);
|
|
} catch (e) {
|
|
// If parsing fails, fall back to comma-separated string
|
|
data[key] = value.split(',').map(item => item.trim());
|
|
}
|
|
} else {
|
|
// 2. Parse as comma-separated string
|
|
data[key] = value.split(',').map(item => item.trim());
|
|
}
|
|
} else if (this.isWorldNumericField(key)) {
|
|
// Convert numeric fields to numbers
|
|
const numValue = parseFloat(value);
|
|
data[key] = isNaN(numValue) ? 0 : numValue;
|
|
} else {
|
|
data[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
return data;
|
|
}
|
|
|
|
|
|
|
|
private async extractLinkedIds(linkedText: string, lineText: string, worldName?: string): Promise<string[]> {
|
|
const linkPattern = /\[\[(.*?)\]\]/g;
|
|
const ids: string[] = [];
|
|
let match;
|
|
|
|
// Use provided world name or extract from active file path as fallback
|
|
const resolvedWorldName = worldName || (
|
|
() => {
|
|
const currentFile = this.app.workspace.getActiveFile();
|
|
return currentFile ? this.extractWorldName(currentFile.path) : "Unknown World";
|
|
}
|
|
)();
|
|
|
|
// Extract the element type from the surrounding line context
|
|
const elementTypeMatch = /data-tooltip="(Single|Multi) ([^"]+)"/.exec(lineText);
|
|
const elementType = elementTypeMatch ? elementTypeMatch[2] : null;
|
|
|
|
if (!elementType) {
|
|
console.warn("Element type not found in the linked text. Attempting to extract IDs without type filtering.");
|
|
// Reset the regex for the fallback search
|
|
const fallbackLinkPattern = /\[\[(.*?)\]\]/g;
|
|
let fallbackMatch;
|
|
while ((fallbackMatch = fallbackLinkPattern.exec(linkedText)) !== null) {
|
|
const noteName = fallbackMatch[1];
|
|
// Search for the file across all element categories
|
|
const foundId = await this.findElementIdByName(noteName, resolvedWorldName);
|
|
if (foundId) {
|
|
ids.push(foundId);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
while ((match = linkPattern.exec(linkedText)) !== null) {
|
|
const linkedName = match[1];
|
|
|
|
// Search for the element by name content instead of constructing file path
|
|
const elementId = await this.findElementIdByNameInCategory(linkedName, elementType, resolvedWorldName);
|
|
if (elementId) {
|
|
ids.push(elementId);
|
|
} else {
|
|
console.warn(`Linked file not found: ${linkedName}`);
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
private async findElementIdByNameInCategory(elementName: string, elementType: string, worldName: string): Promise<string | null> {
|
|
const categoryDirectory = normalizePath(`OnlyWorlds/Worlds/${worldName}/Elements/${elementType}`);
|
|
const files = this.app.vault.getFiles().filter(file => file.path.startsWith(categoryDirectory));
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const fileContent = await this.app.vault.read(file);
|
|
const { name, id } = this.parseElement(fileContent);
|
|
|
|
// Match by name content rather than filename
|
|
if (name === elementName) {
|
|
return id;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error reading file ${file.path}:`, error);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async parseTemplate(content: string, worldName?: string): Promise<Record<string, any>> {
|
|
let currentSection: string | null = null;
|
|
const data: Record<string, any> = {};
|
|
|
|
const sectionPattern = /^##\s*(.+)$/; // Pattern to identify sections
|
|
const keyValuePattern = /- <span class="([^"]+)" data-tooltip="([^"]+)">(.+?)<\/span>:\s*(.*)/; // Capture class and tooltip
|
|
|
|
const lines = content.split('\n');
|
|
for (const line of lines) {
|
|
const sectionMatch = line.match(sectionPattern);
|
|
if (sectionMatch) {
|
|
currentSection = this.toSnakeCase(sectionMatch[1]);
|
|
continue;
|
|
}
|
|
|
|
const match = line.match(keyValuePattern);
|
|
if (match) {
|
|
const spanClass = match[1];
|
|
const tooltip = match[2];
|
|
let key = this.toSnakeCase(match[3].replace(/\*\*/g, ''));
|
|
// Decode entities from pre-noEscape notes so names/text and
|
|
// [[wikilink]] display text are raw.
|
|
let value = decodeHtmlEntities(match[4].trim());
|
|
|
|
// Determine field type from CSS class and tooltip
|
|
const fieldType = this.getFieldType(spanClass, tooltip);
|
|
|
|
if (value.startsWith('[[')) {
|
|
// If value contains links, extract IDs as an array
|
|
const ids = await this.extractLinkedIds(value, line, worldName);
|
|
data[key] = fieldType === 'single_link' ? (ids.length > 0 ? ids[0] : '') : ids;
|
|
} else {
|
|
// Process based on detected field type
|
|
switch (fieldType) {
|
|
case 'number':
|
|
const numValue = parseFloat(value);
|
|
data[key] = isNaN(numValue) ? 0 : numValue;
|
|
break;
|
|
case 'multi_link':
|
|
case 'array':
|
|
if (value === '' || value === 'None') {
|
|
data[key] = [];
|
|
} else {
|
|
data[key] = value.split(',').map(item => item.trim()).filter(item => item !== '');
|
|
}
|
|
break;
|
|
case 'single_link':
|
|
data[key] = value === '' || value === 'None' ? '' : value;
|
|
break;
|
|
case 'text':
|
|
default:
|
|
// Special handling for image fields
|
|
if (key === 'image' || key === 'image_url') {
|
|
data[key] = (value === 'None' || value === 'No image set') ? '' : value;
|
|
} else {
|
|
data[key] = value;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
|
|
private extractWorldName(filePath: string): string {
|
|
const pathParts = filePath.split('/');
|
|
const worldIndex = pathParts.indexOf('Worlds');
|
|
if (worldIndex !== -1 && pathParts.length > worldIndex + 1) {
|
|
return pathParts[worldIndex + 1];
|
|
}
|
|
return "Unknown World"; // Default if the world name cannot be determined
|
|
}
|
|
|
|
private parseElement(content: string): { name: string, id: string } {
|
|
// Adjust the regex to capture the full ID including dashes
|
|
const idMatch = content.match(/<span class="text-field" data-tooltip="Text">Id<\/span>:\s*([^\r\n<]+)/);
|
|
const nameMatch = content.match(/<span class="text-field" data-tooltip="Text">Name<\/span>:\s*([^\r\n<]+)/);
|
|
|
|
const id = idMatch ? idMatch[1].trim() : "Unknown Id";
|
|
// Decode so name matches decoded [[wikilink]] text in findElementIdByNameInCategory.
|
|
const name = nameMatch ? decodeHtmlEntities(nameMatch[1].trim()) : "Unnamed Element";
|
|
|
|
return { id, name };
|
|
}
|
|
|
|
// stop at dash
|
|
// private parseElement(content: string): { id: string } {
|
|
// const idMatch = content.match(/<span class="text-field" data-tooltip="Text">ID<\/span>:\s*([^<\r\n-]+)/);
|
|
// const id = idMatch ? idMatch[1].trim() : "Unknown ID";
|
|
// return { id };
|
|
// }
|
|
|
|
|
|
toSnakeCase(input: string): string {
|
|
return input.toLowerCase().replace(/\s+/g, '_').replace(/\(|\)|,/g, '');
|
|
}
|
|
|
|
// Dynamic field type detection based on CSS classes and tooltips
|
|
private getFieldType(spanClass: string, tooltip: string): string {
|
|
// Check CSS classes for field type indicators
|
|
if (spanClass.includes('number-field') || spanClass.includes('integer')) {
|
|
return 'number';
|
|
}
|
|
if (spanClass.includes('multi-link-field') || tooltip.toLowerCase().startsWith('multi')) {
|
|
return 'multi_link';
|
|
}
|
|
if (spanClass.includes('link-field') || tooltip.toLowerCase().startsWith('single')) {
|
|
return 'single_link';
|
|
}
|
|
if (spanClass.includes('text-field') || spanClass.includes('string')) {
|
|
return 'text';
|
|
}
|
|
|
|
// Fallback to text type
|
|
return 'text';
|
|
}
|
|
|
|
// Helper method for World file numeric fields (minimal hardcoding for World-specific fields only)
|
|
private isWorldNumericField(key: string): boolean {
|
|
const worldNumericFields = [
|
|
'time_current', 'time_range_min', 'time_range_max'
|
|
];
|
|
return worldNumericFields.includes(key);
|
|
}
|
|
|
|
// Helper method to find element ID by name across all categories
|
|
private async findElementIdByName(noteName: string, worldName: string): Promise<string | null> {
|
|
// console.log(`Searching for element: ${noteName} in world: ${worldName}`);
|
|
|
|
// Search across all categories for a file with this name
|
|
for (const categoryKey in Category) {
|
|
const category = Category[categoryKey];
|
|
if (isNaN(Number(category))) {
|
|
const possiblePath = normalizePath(`OnlyWorlds/Worlds/${worldName}/Elements/${category}/${noteName}.md`);
|
|
const file = this.app.vault.getAbstractFileByPath(possiblePath);
|
|
|
|
if (file && file instanceof TFile) {
|
|
// console.log(`Found file at: ${possiblePath}`);
|
|
try {
|
|
const fileContent = await this.app.vault.read(file);
|
|
const { id } = this.parseElement(fileContent);
|
|
if (id && id !== "Unknown Id") {
|
|
// console.log(`Extracted ID: ${id} for ${noteName}`);
|
|
return id;
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Error reading file ${possiblePath}:`, error);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
console.warn(`Could not find element with name: ${noteName} in any category`);
|
|
return null;
|
|
}
|
|
|
|
// Helper method to generate UUID
|
|
private generateUUID(): string {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
const r = Math.random() * 16 | 0;
|
|
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
return v.toString(16);
|
|
});
|
|
}
|
|
|
|
// Simplified method to ensure essential fields exist without hardcoding category schemas
|
|
private addMissingElementFields(element: Record<string, any>, category: string): Record<string, any> {
|
|
// Only ensure absolutely essential fields exist
|
|
const result: Record<string, any> = {
|
|
id: element.id || this.generateUUID(),
|
|
name: element.name || 'Unnamed',
|
|
description: element.description || '',
|
|
supertype: element.supertype || 'None',
|
|
subtype: element.subtype || 'None',
|
|
image_url: element.image_url || element.image || '',
|
|
...element // Spread all existing parsed fields
|
|
};
|
|
|
|
// Handle special case field name mappings for TTRPG stats
|
|
if (element.str !== undefined) {
|
|
result.STR = element.str;
|
|
delete result.str; // Remove lowercase version
|
|
}
|
|
if (element.dex !== undefined) {
|
|
result.DEX = element.dex;
|
|
delete result.dex; // Remove lowercase version
|
|
}
|
|
if (element.con !== undefined) {
|
|
result.CON = element.con;
|
|
delete result.con; // Remove lowercase version
|
|
}
|
|
if (element.int !== undefined) {
|
|
result.INT = element.int;
|
|
delete result.int; // Remove lowercase version
|
|
}
|
|
if (element.wis !== undefined) {
|
|
result.WIS = element.wis;
|
|
delete result.wis; // Remove lowercase version
|
|
}
|
|
if (element.cha !== undefined) {
|
|
result.CHA = element.cha;
|
|
delete result.cha; // Remove lowercase version
|
|
}
|
|
|
|
// Remove invalid 'world' field that shouldn't be in elements
|
|
delete result.world;
|
|
|
|
return result;
|
|
}
|
|
|
|
async getWorldFolders(): Promise<string[]> {
|
|
const worldsPath = normalizePath('OnlyWorlds/Worlds/');
|
|
const worldsFolder = this.app.vault.getAbstractFileByPath(worldsPath);
|
|
|
|
if (!(worldsFolder instanceof TFolder)) {
|
|
console.error('Expected worlds folder not found.');
|
|
return [];
|
|
}
|
|
|
|
return worldsFolder.children
|
|
.filter(child => child instanceof TFolder && child.children.some(file => file instanceof TFile && file.name === "World.md"))
|
|
.map(folder => folder.name);
|
|
}
|
|
|
|
// Helper method to get WorldTyping data
|
|
private getWorldTypingData(): Record<string, string> {
|
|
return {
|
|
ability_string: "A special skill or capacity in an entity to perform actions",
|
|
ability_grouping: "Mechanics(activation,duration,potency,range,effects,challenges,talents,requisites)World(prevalence,tradition,source,locus,systems,instruments)",
|
|
character_string: "An actor with agency and autonomy",
|
|
character_grouping: "Constitution(physicality,mentality,height,weight,species,traits,abilities)Origins(background,motivations,birth_date,birthplace,languages)World(reputation,location,objects,institutions)Personality(charisma,coercion,competence,compassion,creativity,courage)Social(family,friends,rivals)TTRPG(level,STR,DEX,CON,INT,WIS,CHA,hit_points)",
|
|
collective_string: "A group of actors regarded as a whole and defined by a common feature or goal",
|
|
collective_grouping: "Formation(composition,count,formation_date,operator,equipment)Dynamics(activity,disposition,state,abilities,symbolism)World(species,characters,creatures,phenomena)",
|
|
construct_string: "An abstract concept or theoretical entity devised or described by a mind",
|
|
construct_grouping: "Nature(rationale,history,status,reach,start_date,end_date,founder,custodian)Involves(characters,objects,locations,species,creatures,institutions,traits,collectives,zones,abilities,phenomena,languages,families,relations,titles,constructs,events)",
|
|
creature_string: "A living being with instinctual behaviour",
|
|
creature_grouping: "Biology(appearance,weight,height,species)Behaviour(habits,demeanor,traits,abilities,languages)World(status,birth_date,location,zone)TTRPG(challenge_rating,hit_points,armor_class,speed,actions)",
|
|
event_string: "A significant occurrence within the world's timeline",
|
|
event_grouping: "Nature(history,challenges,consequences,start_date,end_date,triggers)Involves(characters,objects,locations,species,creatures,institutions,traits,collectives,zones,abilities,phenomena,languages,families,relations,titles,constructs)",
|
|
family_string: "Kinship groupings, related by blood or affinity",
|
|
family_grouping: "Identity(spirit,history,traditions,traits,abilities,languages,ancestors)World(reputation,estates,governs,heirlooms,creatures)",
|
|
institution_string: "A structure of purpose and execution for actors to operate in or with",
|
|
institution_grouping: " Foundation(doctrine,founding_date,parent_institution,dominion)Claims(zones,objects,creatures)World(status,allies,adversaries,constructs)Operation(characters,locations,collectives,titles,events)",
|
|
language_string: "Words, rules, and the art of stringing them together",
|
|
language_grouping: "Structure(phonology,grammar,lexicon,writing,classification)World(status,spread,dialects,species)",
|
|
law_string: "The fine print in the world's contract",
|
|
law_grouping: "Code(declaration,purpose,date,parent_law,penalties)World(author,locations,zones,prohibitions,adjudicators,enforcers,employers)",
|
|
location_string: "A defined area or place, significant within its context",
|
|
location_grouping: "Setting(form,function,founding_date,parent_location,populations)Politics(political_climate,primary_power,governing_title,secondary_powers,zone,rival,partner)Production(extraction_methods,extraction_goods,industry_methods,industry_goods)Commerce(infrastructure,extraction_markets,industry_markets,currencies)Construction(architecture,buildings,building_methods)World(customs,founders,cults,delicacies)Defense(defensibility,elevation,fighters,defensive_objects)",
|
|
map_string: "A visual representation of a stretch of terrain",
|
|
map_grouping: "Group(field)",
|
|
object_string: "The stuff of the physical realm",
|
|
object_grouping: "Form(aesthetics,weight,amount,parent_object,materials,technology,components)Function(utility,effects,abilities,consumes)World(origins,location,titles,claimants,language,affinities)",
|
|
phenomenon_string: "A remarkable or extraordinary occurrence",
|
|
phenomenon_grouping: "Mechanics(expression,effects,duration,catalysts,empowerments)World(mythology,system,triggers,wielders,environments)",
|
|
pin_string: "Map placement component",
|
|
pin_grouping: "Group(field)",
|
|
relation_string: "Strings that tie the elements together ",
|
|
relation_grouping: "Nature(background,start_date,end_date,intensity,actor,events)Involves(characters,objects,locations,species,creatures,institutions,traits,collectives,zones,abilities,phenomena,languages,families,titles,constructs,narratives)",
|
|
species_string: "Classifications of organisms sharing common characteristics",
|
|
species_grouping: " Biology(appearance,life_span,weight,nourishment,reproduction,adaptations)Psychology(instincts,sociality,temperament,communication,aggression,traits)World(role,parent_species,locations,zones,affinities,predators)",
|
|
title_string: "Mandate(authority,eligibility,grant_date,revoke_date,issuer,body,superior_title,holders,symbols)World(status,history,characters,institutions,families,zones,locations,objects,constructs,laws,collectives,creatures,phenomena,species,languages)",
|
|
title_grouping: "Mandate(authority,eligibility,grant_date,revoke_date,issuer,body,superior_title,holders,symbols)World(status,history,characters,institutions,families,zones,locations,objects,constructs,laws,collectives,creatures,phenomena,species,languages)",
|
|
trait_string: "An inherent quality or characteristic",
|
|
trait_grouping: "Qualitative(social_effects,physical_effects,functional_effects,personality_effects,behaviour_effects)Quantitative(charisma,coercion,competence,compassion,creativity,courage)World(significance,anti_trait,empowered_abilities,characters,creatures)",
|
|
zone_string: "A designated area",
|
|
zone_grouping: "Scope(role,start_date,end_date,phenomena,linked_zones)World(context,populations,titles,principles)"
|
|
};
|
|
}
|
|
|
|
}
|