Added advanced section and fixed linter

This commit is contained in:
Chris Bell 2025-07-02 21:39:42 -05:00
parent f6e04df6ac
commit e0891f0c7f
5 changed files with 200 additions and 45 deletions

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using SessionZero.Pages;
namespace SessionZero.Data; namespace SessionZero.Data;
@ -138,6 +139,29 @@ public abstract class SzfObject
return result; return result;
} }
/// <summary>
/// Checks if required metadata fields are present and adds errors if they are missing.
/// </summary>
/// <param name="metadata">Dictionary containing metadata fields</param>
/// <param name="requiredFields">Array of required field names</param>
/// <param name="errors">List to add errors to</param>
protected void CheckRequiredMetadata(Dictionary<string, object> metadata, string[] requiredFields, List<SzfEditor.SzfError> errors)
{
if (metadata == null)
{
errors.Add(new SzfEditor.SzfError(0, "Missing Metadata section"));
return;
}
foreach (var field in requiredFields)
{
if (!metadata.ContainsKey(field) || metadata[field] == null || string.IsNullOrEmpty(metadata[field].ToString()))
{
errors.Add(new SzfEditor.SzfError(0, $"Missing required metadata: {field}"));
}
}
}
} }
public class SzfValidationResult public class SzfValidationResult

View File

@ -13,6 +13,10 @@
<img src="res/icons/outline/user/users-group.svg" class="nav-icon" /> <img src="res/icons/outline/user/users-group.svg" class="nav-icon" />
Characters Characters
</NavLink> </NavLink>
<NavLink class="nav-link" href="advanced">
<img src="res/icons/outline/general/bug.svg" class="nav-icon" />
Advanced
</NavLink>
</div> </div>
@code { @code {

View File

@ -0,0 +1,17 @@
@page "/Advanced"
@inject NavigationManager NavigationManager
<h1 class="page-title">Advanced Features</h1>
<div class="library-card-container">
<NavLink class="card" onclick="@(() => { NavigationManager.NavigateTo("/szf-editor"); })">
<img src="res/icons/outline/general/edit.svg" class="icon" />
<h1>SZF Editor</h1>
<p>Manually create and edit SZF files with syntax highlighting and basic linting</p>
</NavLink>
<NavLink class="card" onclick="@(() => { NavigationManager.NavigateTo("/szf-parser-test"); })">
<img src="res/icons/outline/general/bug.svg" class="icon"/>
<h1>SZF Testing Grounds</h1>
<p>Internal development tool for testing SZF parsing, saving, and retrieving</p>
</NavLink>
</div>

View File

@ -1,5 +1,9 @@
@page "/" @page "/"
@inject NavigationManager NavigationManager
<PageTitle>Home</PageTitle> <PageTitle>Home</PageTitle>
<h1 class="page-title">Welcome to SessionZero</h1> <h1 class="page-title">Welcome to SessionZero</h1>
<p class="page-description">SessionZero is a tool designed to help you manage your tabletop RPG sessions, characters, and campaigns.</p>

View File

@ -85,47 +85,13 @@
private static readonly HashSet<string> ValidTypes = new() private static readonly HashSet<string> ValidTypes = new()
{ {
"text", "text-field", "number", "bool", "group", "calculated", "text", "text-field", "number", "bool", "calculated",
"system", "dataset-reference", "dataset-type", "system", "entry-reference", "entry-reference-list"
"dataset-reference-multiple", "dataset-type-multiple"
}; };
protected override void OnInitialized() protected override void OnInitialized()
{ {
var exampleSzf = @"!type: character_template var exampleSzf = "";
!schema: 1.0.0
bad line here
# This is a comment. It will be ignored by the parser.
# It's useful for adding notes to your templates.
[Metadata]
Name (text) = Robust Fantasy Character
Author (text) = Gemini
GameSystem (text) = Fantasy RPG Universal
Description (text-field) = A character sheet with calculated values and dependencies.
InvalidType (faketype) = some value
[Required_Datasets]
Classes (dataset-reference) = Core Classes|c3d4e5f6-g7h8-9012-cdef-123456789012|1.5.0
[Section: Ability Scores]
Strength (number) = 14
Dexterity (number) = 12
Constitution (number) = 15
IsCool (bool) = true
[Section.Ability_Scores.Modifiers]
# Modifiers are calculated automatically.
StrengthMod (calculated) = (Strength - 10) / 2
DexterityMod (calculated) = (Dexterity - 10) / 2
ConstitutionMod (calculated) = (Constitution - 10) / 2
[Section: Combat]
ProficiencyBonus (number) = 2
HitPoints (calculated) = 10 + ConstitutionMod
ArmorClass (calculated) = 10 + DexterityMod
";
SetContent(exampleSzf); SetContent(exampleSzf);
} }
@ -265,7 +231,34 @@ ArmorClass (calculated) = 10 + DexterityMod
var current = structure; var current = structure;
var path = new List<string>(); var path = new List<string>();
var errors = new List<SzfError>(); var errors = new List<SzfError>();
var fileType = "";
var schemaVersion = "";
var hasRequiredHeader = false;
// First pass - verify header requirements
if (lines.Length >= 2)
{
var typeMatch = Regex.Match(lines[0].Trim(), @"^!type:\s*([\w_-]+)$");
var schemaMatch = Regex.Match(lines[1].Trim(), @"^!schema:\s*([\d.]+)$");
if (typeMatch.Success && schemaMatch.Success)
{
fileType = typeMatch.Groups[1].Value;
schemaVersion = schemaMatch.Groups[1].Value;
hasRequiredHeader = true;
}
}
if (!hasRequiredHeader)
{
errors.Add(new SzfError(0, "Missing required header. First two lines must be !type: and !schema:"));
}
else if (!new[] { "dataset", "character_template", "character", "session" }.Contains(fileType))
{
errors.Add(new SzfError(0, $"Invalid file type: {fileType}. Must be one of: dataset, character_template, character, session"));
}
// Second pass - parse content
for (int idx = 0; idx < lines.Length; idx++) for (int idx = 0; idx < lines.Length; idx++)
{ {
var line = lines[idx]; var line = lines[idx];
@ -288,7 +281,32 @@ ArmorClass (calculated) = 10 + DexterityMod
} }
else if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) else if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{ {
// Extract section name (remove brackets)
var sectionPath = trimmed.Substring(1, trimmed.Length - 2).Replace(": ", "."); var sectionPath = trimmed.Substring(1, trimmed.Length - 2).Replace(": ", ".");
// Parse the section path and validate each segment
var sections = sectionPath.Split('.');
for (int i = 0; i < sections.Length; i++)
{
var section = sections[i].Trim();
if (string.IsNullOrWhiteSpace(section))
{
errors.Add(new SzfError(idx, "Empty section name is not allowed"));
continue;
}
// Check for Entry: prefix for datasets
if (fileType == "dataset" && i == 0 && section.StartsWith("Entry:"))
{
// This is valid for datasets
}
// Otherwise, check if section follows PascalCase
else if (!Regex.IsMatch(section, @"^([A-Z][a-z0-9]*)+$") && section != "Required_Datasets")
{
errors.Add(new SzfError(idx, $"Section name '{section}' must use PascalCase (e.g., AbilityScores, CharacterInfo)"));
}
}
path = sectionPath.Split('.').Where(p => !string.IsNullOrWhiteSpace(p)).ToList(); path = sectionPath.Split('.').Where(p => !string.IsNullOrWhiteSpace(p)).ToList();
current = structure; current = structure;
@ -305,6 +323,27 @@ ArmorClass (calculated) = 10 + DexterityMod
} }
else else
{ {
// For character files, the field syntax is different (no type)
if (fileType == "character")
{
var characterFieldMatch = Regex.Match(trimmed, @"^([\w\d_]+)\s*=\s*(.*)$");
if (characterFieldMatch.Success)
{
var name = characterFieldMatch.Groups[1].Value;
var value = characterFieldMatch.Groups[2].Value.Trim();
// Validate PascalCase for field names in character files
if (!Regex.IsMatch(name, @"^([A-Z][a-z0-9]*)+$") && !name.Contains("."))
{
errors.Add(new SzfError(idx, $"Field name '{name}' must use PascalCase (e.g., CharacterName, MaxHealth)"));
}
current[name] = value;
continue;
}
}
// Standard field definition with type
var match = Regex.Match(trimmed, @"^([\w\d_]+)\s+\(([\w\-]+)\)\s*=\s*(.*)$"); var match = Regex.Match(trimmed, @"^([\w\d_]+)\s+\(([\w\-]+)\)\s*=\s*(.*)$");
if (!match.Success) if (!match.Success)
{ {
@ -313,22 +352,89 @@ ArmorClass (calculated) = 10 + DexterityMod
continue; continue;
} }
var name = match.Groups[1].Value; var fieldName = match.Groups[1].Value;
var type = match.Groups[2].Value; var fieldType = match.Groups[2].Value;
var value = match.Groups[3].Value.Trim(); var fieldValue = match.Groups[3].Value.Trim();
if (!ValidTypes.Contains(type)) // Validate PascalCase for field names
if (!Regex.IsMatch(fieldName, @"^([A-Z][a-z0-9]*)+$"))
{ {
errors.Add(new SzfError(idx, $"Unknown field type: \"{type}\"")); errors.Add(new SzfError(idx, $"Field name '{fieldName}' must use PascalCase (e.g., CharacterName, MaxHealth)"));
} }
current[name] = new Dictionary<string, object> { ["type"] = type, ["value"] = value }; // Validate field type
if (!ValidTypes.Contains(fieldType))
{
errors.Add(new SzfError(idx, $"Unknown field type: \"{fieldType}\""));
}
// Validate specific field value formats
if (fieldType == "number" && !string.IsNullOrEmpty(fieldValue) &&
!Regex.IsMatch(fieldValue, @"^-?\d+(\.\d+)?$"))
{
errors.Add(new SzfError(idx, $"Invalid number format: {fieldValue}"));
}
else if (fieldType == "bool" && !string.IsNullOrEmpty(fieldValue) &&
!Regex.IsMatch(fieldValue.ToLower(), @"^(true|false)$"))
{
errors.Add(new SzfError(idx, $"Boolean values must be 'true' or 'false', got: {fieldValue}"));
}
current[fieldName] = new Dictionary<string, object> { ["type"] = fieldType, ["value"] = fieldValue };
}
}
// Verify required metadata sections based on file type
if (hasRequiredHeader)
{
if (!structure.ContainsKey("Metadata") && !(structure.ContainsKey("Metadata") && structure["Metadata"] is Dictionary<string, object>))
{
errors.Add(new SzfError(0, "Missing [Metadata] section which is required for all file types"));
}
else
{
var metadata = structure["Metadata"] as Dictionary<string, object>;
// Check file type specific requirements
if (fileType == "dataset")
{
CheckRequiredMetadata(metadata, new[] {"Name", "Type", "Version"}, errors);
}
else if (fileType == "character_template")
{
CheckRequiredMetadata(metadata, new[] {"Name", "Version"}, errors);
// Check for RequiredDatasets section if referenced
if (structure.ContainsValue("dataset-reference") || structure.ContainsValue("entry-reference"))
{
if (!structure.ContainsKey("RequiredDatasets") && !structure.ContainsKey("Required_Datasets"))
{
errors.Add(new SzfError(0, "Template uses dataset references but is missing [RequiredDatasets] section"));
}
}
}
else if (fileType == "character")
{
CheckRequiredMetadata(metadata, new[] {"TemplateRef"}, errors);
}
} }
} }
return (structure, errors); return (structure, errors);
} }
private void CheckRequiredMetadata(Dictionary<string, object> metadata, string[] requiredFields, List<SzfError> errors)
{
foreach (var field in requiredFields)
{
if (!metadata.ContainsKey(field))
{
// Reporting the error on a general line since this check runs after initial parsing.
errors.Add(new SzfError(0, $"Missing required metadata field: '{field}'"));
}
}
}
private bool HasErrorOnLine(int lineIndex) private bool HasErrorOnLine(int lineIndex)
{ {
return _errors.Any(e => e.Line == lineIndex); return _errors.Any(e => e.Line == lineIndex);