+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Immutable container for the data loaded from a directory of random-name
+ * XML files. References between rules, rulesets, and lists are resolved
+ * at load time, so generation reads only from these maps.
+ *
+ * {@link #unresolvedReferences()} lists every {@code GETLIST}/{@code GETRULE}
+ * whose target id was not present after the load completed. The loader
+ * skips such parts (matching the legacy engine's silent behaviour); the
+ * list lets callers and tests detect data-file bugs.
+ */
+public record NameGenData(
+ Map lists,
+ Map rulesets,
+ Map> categories,
+ List unresolvedReferences)
+{
+ public NameGenData
+ {
+ lists = Map.copyOf(lists);
+ rulesets = Map.copyOf(rulesets);
+ categories = Map.copyOf(categories);
+ unresolvedReferences = List.copyOf(unresolvedReferences);
+ }
+
+ /** A {@code GETLIST}/{@code GETRULE} whose target id wasn't found. */
+ public record UnresolvedReference(Kind kind, String targetId)
+ {
+ public enum Kind
+ {
+ GETLIST, GETRULE
+ }
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/NameGenDataLoader.java b/code/src/java/pcgen/core/namegen/NameGenDataLoader.java
new file mode 100644
index 00000000000..0891f43c12d
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/NameGenDataLoader.java
@@ -0,0 +1,540 @@
+/*
+ * Copyright 2003 (C) Devon Jones
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.DocumentType;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.EntityResolver;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import pcgen.util.Logging;
+
+/**
+ * Loads random-name XML files from a directory into a {@link NameGenData}
+ * snapshot, keeping the data model independent of any UI toolkit.
+ *
+ * Uses the JDK-bundled {@code javax.xml.parsers} API so the project
+ * doesn't need a third-party XML library.
+ *
+ *
Loading is two-pass: first parse every file and gather raw
+ * {@code }/{@code } elements with their ids, then resolve
+ * cross-references and build the immutable model. The two passes let
+ * rulesets refer to each other freely without forward-declaration issues.
+ */
+public final class NameGenDataLoader
+{
+ private static final String ATTR_TITLE = "title";
+ private static final String ATTR_IDREF = "idref";
+ private static final String TAG_GETLIST = "GETLIST";
+ private static final String TAG_GETRULE = "GETRULE";
+
+ private NameGenDataLoader()
+ {
+ }
+
+ /**
+ * Load every {@code *.xml} file in the given directory.
+ *
+ * @param dataDir directory containing {@code generator.dtd} and the XML
+ * files to parse
+ * @return populated {@link NameGenData}
+ * @throws IOException if {@code dataDir} is not a directory or any file
+ * fails to parse
+ */
+ public static NameGenData load(File dataDir) throws IOException
+ {
+ if (!dataDir.isDirectory())
+ {
+ throw new IOException("Not a directory: " + dataDir);
+ }
+ File[] dataFiles = dataDir.listFiles((dir, name) -> name.endsWith(".xml"));
+ if (dataFiles == null)
+ {
+ throw new IOException("Cannot list files in: " + dataDir);
+ }
+
+ DocumentBuilder builder = newDocumentBuilder();
+ builder.setEntityResolver(dtdResolver(dataDir));
+
+ Map rawLists = new LinkedHashMap<>();
+ Map rawRuleSets = new LinkedHashMap<>();
+ Map> rawCategories = new LinkedHashMap<>();
+
+ // Pass 1: parse each file, harvest raw LIST and RULESET elements.
+ for (File dataFile : dataFiles)
+ {
+ RawFile rf = parseOne(dataFile, builder);
+ for (Element list : rf.lists)
+ {
+ rawLists.put(list.getAttribute("id"), list);
+ }
+ for (Element ruleSet : rf.ruleSets)
+ {
+ String id = ruleSet.getAttribute("id");
+ rawRuleSets.put(id, new RawRuleSet(ruleSet, id));
+ }
+ }
+
+ // Pass 2a: build NameList records (no cross-refs).
+ Map lists = rawLists.values().stream()
+ .map(NameGenDataLoader::buildList)
+ .collect(Collectors.toMap(
+ NameList::id,
+ nl -> nl,
+ (a, b) -> b,
+ LinkedHashMap::new));
+
+ // Pass 2b: build RuleSet records. RuleSetRef parts share a single
+ // map instance that gets populated as we go, so a ruleset can
+ // reference any other ruleset regardless of file order.
+ Map rulesets = new LinkedHashMap<>();
+ List unresolved = new ArrayList<>();
+ for (RawRuleSet raw : rawRuleSets.values())
+ {
+ RuleSet rs = buildRuleSet(raw, lists, rulesets, rawRuleSets, unresolved);
+ rulesets.put(raw.id, rs);
+ collectCategories(raw.element, raw.id, rawCategories);
+ }
+
+ // Pass 3: resolve category ids to RuleSet records. Categories
+ // declared on rulesets that ultimately failed to build are skipped.
+ Map> categories = rawCategories.entrySet().stream()
+ .collect(Collectors.toMap(
+ Map.Entry::getKey,
+ e -> e.getValue().stream()
+ .map(rulesets::get)
+ .filter(Objects::nonNull)
+ .toList(),
+ (a, b) -> b,
+ LinkedHashMap::new));
+
+ return new NameGenData(lists, rulesets, categories, unresolved);
+ }
+
+ private static RawFile parseOne(File dataFile, DocumentBuilder builder) throws IOException
+ {
+ try
+ {
+ Document nameSet = builder.parse(dataFile);
+ DocumentType dt = nameSet.getDoctype();
+ if (dt == null || !"GENERATOR".equals(dt.getName()))
+ {
+ return RawFile.EMPTY;
+ }
+ Element generator = nameSet.getDocumentElement();
+ return new RawFile(
+ childElements(generator, "LIST"),
+ childElements(generator, "RULESET"));
+ } catch (SAXException | NumberFormatException e)
+ {
+ Logging.errorPrint("Failed to parse " + dataFile.getName(), e);
+ throw new IOException("XML error in file " + dataFile.getName(), e);
+ }
+ }
+
+ /**
+ * Lazy-loader phase 1. Parses a single file's DOM, registers every
+ * {@code } from this file into the live {@code lists} map, and
+ * returns the raw ruleset elements together with the idrefs the file
+ * reaches into. The lazy caller demand-parses the owners of any
+ * not-yet-loaded ids before invoking
+ * {@link #buildRuleSetsForLazy(LazyFilePrepared, Map, Map, List, UnaryOperator)}.
+ */
+ static LazyFilePrepared prepareFileForLazy(File dataFile, File dataDir,
+ Map lists) throws IOException
+ {
+ DocumentBuilder builder = newDocumentBuilder();
+ builder.setEntityResolver(dtdResolver(dataDir));
+ RawFile raw = parseOne(dataFile, builder);
+
+ for (Element listEl : raw.lists)
+ {
+ NameList nl = buildList(listEl);
+ lists.put(nl.id(), nl);
+ }
+
+ Map localRawRuleSets = raw.ruleSets.stream()
+ .collect(Collectors.toMap(
+ rsEl -> rsEl.getAttribute("id"),
+ rsEl -> new RawRuleSet(rsEl, rsEl.getAttribute("id")),
+ (a, b) -> b,
+ LinkedHashMap::new));
+
+ Set referencedListIds = new LinkedHashSet<>();
+ Set referencedRuleSetIds = new LinkedHashSet<>();
+ for (Element rsEl : raw.ruleSets)
+ {
+ for (Element ruleEl : childElements(rsEl, "RULE"))
+ {
+ for (Element child : childElements(ruleEl))
+ {
+ switch (child.getTagName())
+ {
+ case TAG_GETLIST -> referencedListIds.add(child.getAttribute(ATTR_IDREF));
+ case TAG_GETRULE -> referencedRuleSetIds.add(child.getAttribute(ATTR_IDREF));
+ default ->
+ {
+ // nothing
+ }
+ }
+ }
+ }
+ }
+
+ return new LazyFilePrepared(localRawRuleSets, referencedListIds, referencedRuleSetIds);
+ }
+
+ /**
+ * Lazy-loader phase 2. Builds and registers {@link RuleSet} records for
+ * a file whose phase-1 result was returned by {@link
+ * #prepareFileForLazy(File, File, Map)}.
+ *
+ * {@code crossFileRuleSetTitle} is consulted whenever a {@code GETRULE}
+ * points at an id not declared in the current file. Returning {@code null}
+ * from the resolver records an unresolved reference; returning a title
+ * string produces a {@link RulePart.RuleSetRef} that will resolve through
+ * the shared {@code rulesets} map at generation time.
+ */
+ static void buildRuleSetsForLazy(LazyFilePrepared prepared,
+ Map lists,
+ Map rulesets,
+ List unresolved,
+ UnaryOperator crossFileRuleSetTitle)
+ {
+ for (RawRuleSet rrs : prepared.localRawRuleSets().values())
+ {
+ RuleSet rs = buildRuleSetLazy(rrs, lists, rulesets,
+ prepared.localRawRuleSets(), unresolved, crossFileRuleSetTitle);
+ rulesets.put(rrs.id, rs);
+ }
+ }
+
+ private static RuleSet buildRuleSetLazy(RawRuleSet raw,
+ Map lists,
+ Map rulesets,
+ Map localRawRuleSets,
+ List unresolved,
+ UnaryOperator crossFileRuleSetTitle)
+ {
+ List rules = childElements(raw.element, "RULE").stream()
+ .map(rule -> buildRuleLazy(rule, lists, rulesets,
+ localRawRuleSets, unresolved, crossFileRuleSetTitle))
+ .toList();
+ return new RuleSet(raw.id,
+ raw.element.getAttribute(ATTR_TITLE),
+ raw.element.getAttribute("usage"),
+ rules);
+ }
+
+ private static Rule buildRuleLazy(Element rule,
+ Map lists,
+ Map rulesets,
+ Map localRawRuleSets,
+ List unresolved,
+ UnaryOperator crossFileRuleSetTitle)
+ {
+ List parts = new ArrayList<>();
+ StringBuilder label = new StringBuilder();
+ for (Element child : childElements(rule))
+ {
+ RulePart part = switch (child.getTagName())
+ {
+ case TAG_GETLIST -> resolveListRef(child.getAttribute(ATTR_IDREF), lists, unresolved);
+ case TAG_GETRULE -> resolveRuleSetRefLazy(child.getAttribute(ATTR_IDREF),
+ rulesets, localRawRuleSets, unresolved, crossFileRuleSetTitle);
+ case "SPACE" -> RulePart.Literal.SPACE;
+ case "HYPHEN" -> RulePart.Literal.HYPHEN;
+ case "CR" -> RulePart.Literal.CR;
+ default -> null;
+ };
+ if (part != null)
+ {
+ parts.add(part);
+ label.append(part.label());
+ }
+ }
+ return new Rule(parseWeight(rule), label.toString(), parts);
+ }
+
+ private static RulePart resolveRuleSetRefLazy(String idref,
+ Map rulesets,
+ Map localRawRuleSets,
+ List unresolved,
+ UnaryOperator crossFileRuleSetTitle)
+ {
+ // Same-file target: title from the local raw element so it works
+ // even when the target hasn't been built yet.
+ RawRuleSet local = localRawRuleSets.get(idref);
+ if (local != null)
+ {
+ return new RulePart.RuleSetRef(idref, local.element.getAttribute(ATTR_TITLE), rulesets);
+ }
+ String title = crossFileRuleSetTitle.apply(idref);
+ if (title == null)
+ {
+ unresolved.add(new NameGenData.UnresolvedReference(
+ NameGenData.UnresolvedReference.Kind.GETRULE, idref));
+ return null;
+ }
+ return new RulePart.RuleSetRef(idref, title, rulesets);
+ }
+
+ /**
+ * Phase-1 output for {@link #prepareFileForLazy(File, File, Map)}.
+ */
+ record LazyFilePrepared(Map localRawRuleSets,
+ Set referencedListIds,
+ Set referencedRuleSetIds)
+ {
+ }
+
+ private static DocumentBuilder newDocumentBuilder() throws IOException
+ {
+ try
+ {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ // XXE hardening: data files are local but the parser shouldn't fetch external entities or evaluate
+ // parameter entities. We keep external-DTD loading on so generator.dtd still resolves
+ // through the EntityResolver.
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ factory.setXIncludeAware(false);
+ factory.setExpandEntityReferences(false);
+ return factory.newDocumentBuilder();
+ } catch (ParserConfigurationException e)
+ {
+ throw new IOException("Cannot create XML parser", e);
+ }
+ }
+
+ private static NameList buildList(Element list)
+ {
+ String id = list.getAttribute("id");
+ String title = list.getAttribute(ATTR_TITLE);
+ List values = new ArrayList<>();
+ for (Element child : childElements(list, "VALUE"))
+ {
+ WeightedDataValue dv = new WeightedDataValue(directText(child), parseWeight(child));
+ childElements(child, "SUBVALUE").forEach(sub ->
+ dv.addSubValue(sub.getAttribute("type"), directText(sub)));
+ values.add(dv);
+ }
+ return new NameList(id, title, values);
+ }
+
+ private static RuleSet buildRuleSet(RawRuleSet raw,
+ Map lists,
+ Map rulesets,
+ Map rawRuleSets,
+ List unresolved)
+ {
+ List rules = childElements(raw.element, "RULE").stream()
+ .map(rule -> buildRule(rule, lists, rulesets, rawRuleSets, unresolved))
+ .toList();
+ return new RuleSet(raw.id,
+ raw.element.getAttribute(ATTR_TITLE),
+ raw.element.getAttribute("usage"),
+ rules);
+ }
+
+ private static Rule buildRule(Element rule,
+ Map lists,
+ Map rulesets,
+ Map rawRuleSets,
+ List unresolved)
+ {
+ List parts = new ArrayList<>();
+ StringBuilder label = new StringBuilder();
+ for (Element child : childElements(rule))
+ {
+ RulePart part = switch (child.getTagName())
+ {
+ case TAG_GETLIST -> resolveListRef(child.getAttribute(ATTR_IDREF), lists, unresolved);
+ case TAG_GETRULE ->
+ resolveRuleSetRef(child.getAttribute(ATTR_IDREF), rulesets, rawRuleSets, unresolved);
+ case "SPACE" -> RulePart.Literal.SPACE;
+ case "HYPHEN" -> RulePart.Literal.HYPHEN;
+ case "CR" -> RulePart.Literal.CR;
+ default -> null;
+ };
+ if (part != null)
+ {
+ parts.add(part);
+ label.append(part.label());
+ }
+ }
+ return new Rule(parseWeight(rule), label.toString(), parts);
+ }
+
+ private static RulePart resolveListRef(String idref,
+ Map lists,
+ List unresolved)
+ {
+ NameList target = lists.get(idref);
+ if (target == null)
+ {
+ unresolved.add(new NameGenData.UnresolvedReference(
+ NameGenData.UnresolvedReference.Kind.GETLIST, idref));
+ return null;
+ }
+ return new RulePart.ListRef(target);
+ }
+
+ private static RulePart resolveRuleSetRef(String idref,
+ Map rulesets,
+ Map rawRuleSets,
+ List unresolved)
+ {
+ RawRuleSet raw = rawRuleSets.get(idref);
+ if (raw == null)
+ {
+ unresolved.add(new NameGenData.UnresolvedReference(
+ NameGenData.UnresolvedReference.Kind.GETRULE, idref));
+ return null;
+ }
+ // Title comes from the raw element so forward references work
+ // before the target ruleset has been built.
+ String title = raw.element.getAttribute(ATTR_TITLE);
+ return new RulePart.RuleSetRef(idref, title, rulesets);
+ }
+
+ private static void collectCategories(Element ruleSet, String id,
+ Map> rawCategories)
+ {
+ for (Element category : childElements(ruleSet, "CATEGORY"))
+ {
+ rawCategories.computeIfAbsent(category.getAttribute(ATTR_TITLE), k -> new ArrayList<>()).add(id);
+ }
+ }
+
+ private static List childElements(Element parent)
+ {
+ NodeList nodes = parent.getChildNodes();
+ return IntStream.range(0, nodes.getLength())
+ .mapToObj(nodes::item)
+ .filter(n -> n.getNodeType() == Node.ELEMENT_NODE)
+ .map(Element.class::cast)
+ .toList();
+ }
+
+ /**
+ * Reads the {@code weight} attribute and defaults to 1 when absent or
+ * blank, matching the DTD's {@code weight CDATA "1"} default. The
+ * project uses a non-validating parser, so DTD-defaulted attributes
+ * arrive here as {@code ""} rather than {@code "1"}.
+ */
+ private static int parseWeight(Element element)
+ {
+ String raw = element.getAttribute("weight");
+ if (raw.isBlank())
+ {
+ return 1;
+ }
+ return Integer.parseInt(raw.trim());
+ }
+
+ private static List childElements(Element parent, String tagName)
+ {
+ NodeList nodes = parent.getChildNodes();
+ return IntStream.range(0, nodes.getLength())
+ .mapToObj(nodes::item)
+ .filter(n -> n.getNodeType() == Node.ELEMENT_NODE)
+ .map(Element.class::cast)
+ .filter(e -> tagName.equals(e.getTagName()))
+ .toList();
+ }
+
+ /**
+ * Returns the concatenation of direct child text nodes only,
+ * excluding text inside descendant elements. Needed because the
+ * data files use mixed content like
+ * {@code Donn... } where the
+ * value is just {@code "Donn"} — {@code Element.getTextContent()}
+ * would return {@code "Donn"} concatenated with the subvalue's text.
+ */
+ private static String directText(Element parent)
+ {
+ NodeList nodes = parent.getChildNodes();
+ StringBuilder sb = new StringBuilder();
+ IntStream.range(0, nodes.getLength())
+ .mapToObj(nodes::item)
+ .filter(n -> n.getNodeType() == Node.TEXT_NODE
+ || n.getNodeType() == Node.CDATA_SECTION_NODE)
+ .forEach(n -> sb.append(n.getNodeValue()));
+ return sb.toString();
+ }
+
+ /**
+ * Parsed DOM element + id, captured in pass 1 for use in pass 2.
+ */
+ record RawRuleSet(Element element, String id)
+ {
+ }
+
+ /**
+ * Per-file pass-1 result: the LIST and RULESET elements harvested.
+ */
+ private record RawFile(List lists, List ruleSets)
+ {
+ static final RawFile EMPTY = new RawFile(List.of(), List.of());
+ }
+
+ /**
+ * Returns an {@link EntityResolver} that resolves {@code generator.dtd}
+ * from the given directory rather than the network. Other system ids are
+ * delegated to the parser's default resolution.
+ */
+ private static EntityResolver dtdResolver(File parent)
+ {
+ return (publicId, systemId) ->
+ {
+ if (systemId == null || !systemId.endsWith("generator.dtd"))
+ {
+ return null;
+ }
+ File dtd = new File(parent, "generator.dtd");
+ try
+ {
+ return new InputSource(Files.newInputStream(dtd.toPath()));
+ } catch (IOException e)
+ {
+ Logging.errorPrint("Cannot open " + dtd, e);
+ return null;
+ }
+ };
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/NameGenIndex.java b/code/src/java/pcgen/core/namegen/NameGenIndex.java
new file mode 100644
index 00000000000..f287274840a
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/NameGenIndex.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ */
+package pcgen.core.namegen;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.xml.XMLConstants;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+
+import org.apache.commons.lang3.StringUtils;
+
+import pcgen.util.Logging;
+
+/**
+ * Lightweight pre-scan of the random-name XML directory. Reads only top-level
+ * {@code } / {@code } attributes and the {@code }
+ * children of each ruleset — never the {@code } or {@code }
+ * bodies — so it is dramatically cheaper than a full DOM parse.
+ *
+ * Drives the lazy-load path: at startup the UI only needs to know which
+ * categories exist and which rulesets/lists each file declares. The bodies
+ * are parsed on demand by {@link NameGenLazyData} once the user picks a
+ * category.
+ */
+public final class NameGenIndex
+{
+ private static final String TAG_RULESET = "RULESET";
+ private static final String TAG_CATEGORY = "CATEGORY";
+ private static final String TAG_LIST = "LIST";
+ private static final String ATTR_ID = "id";
+ private static final String ATTR_TITLE = "title";
+ private static final String ATTR_USAGE = "usage";
+
+ private final Map rulesetsById;
+ private final Map listIdToFile;
+ private final Map> rulesetIdsByCategory;
+
+ private NameGenIndex(Map rulesetsById,
+ Map listIdToFile,
+ Map> rulesetIdsByCategory)
+ {
+ this.rulesetsById = Map.copyOf(rulesetsById);
+ this.listIdToFile = Map.copyOf(listIdToFile);
+ this.rulesetIdsByCategory = unmodifiableDeep(rulesetIdsByCategory);
+ }
+
+ /**
+ * Scan every {@code *.xml} file in {@code dataDir} for ruleset/list/
+ * category metadata. Returns an empty index if no files match.
+ *
+ * @throws IOException if {@code dataDir} is not a directory or any file
+ * fails to scan
+ */
+ public static NameGenIndex scan(File dataDir) throws IOException
+ {
+ if (!dataDir.isDirectory())
+ {
+ throw new IOException("Not a directory: " + dataDir);
+ }
+ File[] dataFiles = dataDir.listFiles((dir, name) -> name.endsWith(".xml"));
+ if (dataFiles == null)
+ {
+ throw new IOException("Cannot list files in: " + dataDir);
+ }
+ Arrays.sort(dataFiles);
+
+ XMLInputFactory factory = newSecureInputFactory();
+ Map rulesetsById = new LinkedHashMap<>();
+ Map listIdToFile = new LinkedHashMap<>();
+ Map> rulesetIdsByCategory = new LinkedHashMap<>();
+
+ for (File file : dataFiles)
+ {
+ scanOne(file, factory, rulesetsById, listIdToFile, rulesetIdsByCategory);
+ }
+ return new NameGenIndex(rulesetsById, listIdToFile, rulesetIdsByCategory);
+ }
+
+ /** All ruleset ids declared anywhere, mapped to their metadata. */
+ public Map rulesetsById()
+ {
+ return rulesetsById;
+ }
+
+ /** Owning file for a {@code }, or {@code null} if unknown. */
+ public File fileForList(String listId)
+ {
+ return listIdToFile.get(listId);
+ }
+
+ /** Owning file for a {@code }, or {@code null} if unknown. */
+ public File fileForRuleset(String rulesetId)
+ {
+ RuleSetMeta meta = rulesetsById.get(rulesetId);
+ return meta == null ? null : meta.file();
+ }
+
+ /**
+ * Ordered ruleset ids declared under each {@code }.
+ * Order matches scan order (file alphabetical, then declaration order).
+ */
+ public Map> rulesetIdsByCategory()
+ {
+ return rulesetIdsByCategory;
+ }
+
+ /**
+ * Per-ruleset metadata gathered without parsing rule bodies: enough to
+ * populate the UI's category/title/gender pickers.
+ */
+ public record RuleSetMeta(File file, String id, String title, String usage,
+ List categoryTitles)
+ {
+ public RuleSetMeta
+ {
+ categoryTitles = List.copyOf(categoryTitles);
+ }
+ }
+
+ private static void scanOne(File file, XMLInputFactory factory,
+ Map rulesetsById,
+ Map listIdToFile,
+ Map> rulesetIdsByCategory) throws IOException
+ {
+ try (InputStream in = new FileInputStream(file))
+ {
+ // Pass a systemId so the parser can resolve the DOCTYPE's
+ // relative SYSTEM "generator.dtd" against the data file's URL.
+ XMLStreamReader reader = factory.createXMLStreamReader(
+ file.toURI().toString(), in);
+ try
+ {
+ scanStream(reader, file, rulesetsById, listIdToFile, rulesetIdsByCategory);
+ }
+ finally
+ {
+ reader.close();
+ }
+ }
+ catch (XMLStreamException e)
+ {
+ Logging.errorPrint("Failed to pre-scan " + file.getName(), e);
+ throw new IOException("XML error scanning " + file.getName(), e);
+ }
+ }
+
+ /**
+ * Drives the StAX state machine over one document. The body of every
+ * {@code } is skipped via a depth counter on {@link ScanState}
+ * rather than parsed; only attribute headers on {@code },
+ * {@code }, and {@code } matter to the index.
+ */
+ private static void scanStream(XMLStreamReader reader, File file,
+ Map rulesetsById,
+ Map listIdToFile,
+ Map> rulesetIdsByCategory) throws XMLStreamException
+ {
+ ScanState state = new ScanState(file, rulesetsById, listIdToFile, rulesetIdsByCategory);
+ while (reader.hasNext())
+ {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT)
+ {
+ state.handleStart(reader);
+ }
+ else if (event == XMLStreamConstants.END_ELEMENT)
+ {
+ state.handleEnd(reader.getLocalName());
+ }
+ }
+ }
+
+ /**
+ * Streaming-scan accumulator. Holds the in-flight {@code }'s
+ * attributes and a depth counter for skipping {@code } bodies, and
+ * commits a {@link RuleSetMeta} record on each {@code
}.
+ */
+ private static final class ScanState
+ {
+ private final File file;
+ private final Map rulesetsById;
+ private final Map listIdToFile;
+ private final Map> rulesetIdsByCategory;
+
+ private String currentRulesetId;
+ private String currentRulesetTitle;
+ private String currentRulesetUsage;
+ private List currentCategories;
+ private int listDepth;
+
+ ScanState(File file,
+ Map rulesetsById,
+ Map listIdToFile,
+ Map> rulesetIdsByCategory)
+ {
+ this.file = file;
+ this.rulesetsById = rulesetsById;
+ this.listIdToFile = listIdToFile;
+ this.rulesetIdsByCategory = rulesetIdsByCategory;
+ }
+
+ void handleStart(XMLStreamReader reader)
+ {
+ if (listDepth > 0)
+ {
+ // Inside a ; ignore everything until close.
+ listDepth++;
+ return;
+ }
+ switch (reader.getLocalName())
+ {
+ case TAG_RULESET -> beginRuleSet(reader);
+ case TAG_CATEGORY -> addCategory(reader);
+ case TAG_LIST -> beginList(reader);
+ default ->
+ {
+ // We don't care about RULE/GETLIST/etc. for the index.
+ }
+ }
+ }
+
+ void handleEnd(String localName)
+ {
+ if (listDepth > 0)
+ {
+ listDepth--;
+ return;
+ }
+ if (TAG_RULESET.equals(localName) && currentRulesetId != null)
+ {
+ commitRuleSet();
+ }
+ }
+
+ private void beginRuleSet(XMLStreamReader reader)
+ {
+ currentRulesetId = reader.getAttributeValue(null, ATTR_ID);
+ currentRulesetTitle = StringUtils.defaultString(reader.getAttributeValue(null, ATTR_TITLE));
+ currentRulesetUsage = StringUtils.defaultString(reader.getAttributeValue(null, ATTR_USAGE));
+ currentCategories = new ArrayList<>();
+ }
+
+ private void addCategory(XMLStreamReader reader)
+ {
+ if (currentCategories == null)
+ {
+ return;
+ }
+ String title = reader.getAttributeValue(null, ATTR_TITLE);
+ if (title != null)
+ {
+ currentCategories.add(title);
+ }
+ }
+
+ private void beginList(XMLStreamReader reader)
+ {
+ String id = reader.getAttributeValue(null, ATTR_ID);
+ if (id != null)
+ {
+ listIdToFile.put(id, file);
+ }
+ listDepth = 1;
+ }
+
+ private void commitRuleSet()
+ {
+ RuleSetMeta meta = new RuleSetMeta(file, currentRulesetId,
+ currentRulesetTitle, currentRulesetUsage,
+ currentCategories);
+ rulesetsById.put(currentRulesetId, meta);
+ for (String cat : currentCategories)
+ {
+ rulesetIdsByCategory
+ .computeIfAbsent(cat, k -> new ArrayList<>())
+ .add(currentRulesetId);
+ }
+ currentRulesetId = null;
+ currentRulesetTitle = null;
+ currentRulesetUsage = null;
+ currentCategories = null;
+ }
+ }
+
+ private static XMLInputFactory newSecureInputFactory()
+ {
+ XMLInputFactory factory = XMLInputFactory.newInstance();
+ // XXE hardening. The data files declare so we must allow `file` access for the local DTD,
+ // but not network access. External entity references stay disabled.
+ factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false);
+ factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, "file");
+ return factory;
+ }
+
+ private static Map> unmodifiableDeep(Map> src)
+ {
+ return src.entrySet().stream()
+ .collect(Collectors.toMap(
+ Map.Entry::getKey,
+ e -> List.copyOf(e.getValue()),
+ (a, b) -> b,
+ LinkedHashMap::new));
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/NameGenLazyData.java b/code/src/java/pcgen/core/namegen/NameGenLazyData.java
new file mode 100644
index 00000000000..504168cc2e9
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/NameGenLazyData.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ */
+package pcgen.core.namegen;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * On-demand backend that mirrors what {@link NameGenData} exposes but only
+ * parses each XML file when one of its rulesets is actually used. Built on
+ * top of {@link NameGenIndex} (cheap per-file pre-scan) and the per-file
+ * full-parse helpers in {@link NameGenDataLoader}.
+ *
+ * A {@link RuleSet} returned to a caller is always fully materialised
+ * (its {@link RuleSet#rules() rules} list is real, not a stub). The {@code
+ * GETRULE} cross-references it contains keep working through
+ * {@link RulePart.RuleSetRef}'s shared map; that map grows as more files are
+ * parsed, so a ruleset that today references a not-yet-loaded ruleset will
+ * resolve once that target's owning file is parsed.
+ *
+ *
Not thread-safe. The UI calls into this serially on the FX thread.
+ */
+public final class NameGenLazyData
+{
+ private final File dataDir;
+ private final NameGenIndex index;
+
+ // Live, mutable accumulators populated as files are parsed.
+ private final Map lists = new HashMap<>();
+ private final Map rulesets = new HashMap<>();
+ private final List unresolved = new ArrayList<>();
+ private final Set parsedFiles = new LinkedHashSet<>();
+ private final Set inProgressFiles = new LinkedHashSet<>();
+
+ private NameGenLazyData(File dataDir, NameGenIndex index)
+ {
+ this.dataDir = dataDir;
+ this.index = index;
+ }
+
+ /**
+ * Build a lazy data layer over {@code dataDir}. Performs the StAX
+ * pre-scan immediately; XML bodies are not parsed until needed.
+ */
+ public static NameGenLazyData open(File dataDir) throws IOException
+ {
+ NameGenIndex index = NameGenIndex.scan(dataDir);
+ return new NameGenLazyData(dataDir, index);
+ }
+
+ /**
+ * Sorted display category titles, exactly as a UI combo would show.
+ * Reads only index metadata — no XML bodies parsed.
+ */
+ public List categoryTitles()
+ {
+ return List.copyOf(index.rulesetIdsByCategory().keySet());
+ }
+
+ /**
+ * Ruleset metadata for every ruleset declared under {@code category}.
+ * Reads index only — no parse triggered. Returns an empty list if the
+ * category is unknown.
+ */
+ public List rulesetMetaFor(String category)
+ {
+ return index.rulesetIdsByCategory().getOrDefault(category, List.of()).stream()
+ .map(index.rulesetsById()::get)
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ /**
+ * Returns whether the ruleset id {@code candidate} is also declared
+ * under the {@code Sex: } bucket. Reads index only — no parse
+ * triggered.
+ */
+ public boolean isInGenderBucket(String candidateId, String gender)
+ {
+ List ids = index.rulesetIdsByCategory()
+ .getOrDefault("Sex: " + gender, List.of());
+ return ids.contains(candidateId);
+ }
+
+ /**
+ * Genders declared for ruleset id {@code rulesetId}. Reads index only.
+ */
+ public List gendersForRuleset(String rulesetId)
+ {
+ return index.rulesetIdsByCategory().entrySet().stream()
+ .filter(e -> e.getKey().startsWith("Sex:"))
+ .filter(e -> e.getValue().contains(rulesetId))
+ .map(e -> e.getKey().substring("Sex:".length()).trim())
+ .toList();
+ }
+
+ /**
+ * Fully-materialised {@link RuleSet} for {@code rulesetId}. Triggers
+ * parsing of the owning file (and any files it transitively references
+ * via {@code GETLIST}/{@code GETRULE}). Returns {@code null} if the id
+ * is unknown.
+ */
+ public RuleSet ruleSet(String rulesetId)
+ {
+ File owner = index.fileForRuleset(rulesetId);
+ if (owner == null)
+ {
+ return null;
+ }
+ ensureFileParsed(owner);
+ return rulesets.get(rulesetId);
+ }
+
+ /**
+ * Live ruleset map — every entry is fully materialised.
+ */
+ Map liveRulesets()
+ {
+ return rulesets;
+ }
+
+ /**
+ * Live name-list map — entries appear as their owning files are parsed.
+ */
+ Map liveLists()
+ {
+ return lists;
+ }
+
+ /**
+ * All ruleset metadata known to the index.
+ */
+ Map rulesetMeta()
+ {
+ return index.rulesetsById();
+ }
+
+ /**
+ * Index's category map (raw): category title -> ruleset ids.
+ */
+ Map> rulesetIdsByCategory()
+ {
+ return index.rulesetIdsByCategory();
+ }
+
+ /**
+ * Live unresolved-references list, mutated as files get parsed.
+ */
+ public List unresolvedReferences()
+ {
+ return Collections.unmodifiableList(unresolved);
+ }
+
+ /**
+ * Files parsed so far. Useful for benchmarks/tests.
+ */
+ public Set parsedFiles()
+ {
+ return Collections.unmodifiableSet(parsedFiles);
+ }
+
+ private String crossFileRuleSetTitle(String rulesetId)
+ {
+ NameGenIndex.RuleSetMeta meta = index.rulesetsById().get(rulesetId);
+ return meta == null ? null : meta.title();
+ }
+
+ /**
+ * Idempotently parse {@code file} and every file it transitively pulls
+ * in via {@code GETLIST}/{@code GETRULE}. The {@code parsedFiles} guard
+ * keeps us from reparsing; {@code inProgressFiles} short-circuits
+ * cycles between mutually-referencing files. {@link RulePart.RuleSetRef}
+ * resolves through a shared map at generation time, so a cycle just
+ * means one ruleset's reference temporarily points at an unbuilt
+ * target — populated correctly once the outer call's phase 2 returns.
+ */
+ private void ensureFileParsed(File file)
+ {
+ if (parsedFiles.contains(file) || inProgressFiles.contains(file))
+ {
+ return;
+ }
+ // Two-phase parse so cross-file GETLIST/GETRULE references can be
+ // satisfied before this file's rule bodies are built. Phase 1
+ // registers this file's s into the live map and returns the
+ // idrefs this file points at. We then transitively parse any
+ // referenced files (which goes through the same two-phase dance).
+ // Phase 2 finally builds this file's rulesets, by which time every
+ // referenced list/ruleset is in the live maps.
+ inProgressFiles.add(file);
+ NameGenDataLoader.LazyFilePrepared prepared;
+ try
+ {
+ try
+ {
+ prepared = NameGenDataLoader.prepareFileForLazy(file, dataDir, lists);
+ } catch (IOException e)
+ {
+ throw new UncheckedIOException(e);
+ }
+ // Mark as parsed BEFORE recursing, otherwise a cycle (file A's
+ // rules reference file B which references file A) causes
+ // infinite recursion. The in-progress guard already short-
+ // circuits, but adding to parsedFiles here also lets us return
+ // early on identity-cycles without redoing prepare.
+ parsedFiles.add(file);
+
+ for (String idref : prepared.referencedListIds())
+ {
+ if (lists.containsKey(idref))
+ {
+ continue;
+ }
+ File target = index.fileForList(idref);
+ if (target != null)
+ {
+ ensureFileParsed(target);
+ }
+ }
+ for (String idref : prepared.referencedRuleSetIds())
+ {
+ if (rulesets.containsKey(idref))
+ {
+ continue;
+ }
+ File target = index.fileForRuleset(idref);
+ if (target != null)
+ {
+ ensureFileParsed(target);
+ }
+ }
+
+ // Phase 2: build this file's rulesets now that every cross-file
+ // dependency has been registered.
+ NameGenDataLoader.buildRuleSetsForLazy(prepared, lists, rulesets, unresolved,
+ this::crossFileRuleSetTitle);
+ } finally
+ {
+ inProgressFiles.remove(file);
+ }
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/NameGenerator.java b/code/src/java/pcgen/core/namegen/NameGenerator.java
new file mode 100644
index 00000000000..6813e457c35
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/NameGenerator.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ */
+package pcgen.core.namegen;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * UI-toolkit-independent facade for the random-name engine. Backed by the
+ * lazy {@link NameGenLazyData} loader: at construction time only a cheap
+ * StAX index pre-scan runs; XML rule bodies are parsed when the user
+ * actually selects a category/title/gender combination.
+ *
+ * Categories on disk are keyed by display name; gender categories use
+ * the {@code "Sex: Female"} / {@code "Sex: Male"} / {@code "Sex: Other"}
+ * convention. This facade hides that representation: callers see
+ * {@link #getCategories()} (no {@code Sex:} entries, no {@code All}) and
+ * a separate {@link #getGendersFor(String, String)} method.
+ */
+public final class NameGenerator
+{
+ private static final String SEX_PREFIX = "Sex:";
+ private static final String ALL_CATEGORY = "All";
+ private static final String FINAL_USAGE = "final";
+
+ private final NameGenLazyData backing;
+
+ public NameGenerator(File dataDir) throws IOException
+ {
+ this.backing = NameGenLazyData.open(dataDir);
+ }
+
+ /**
+ * Sorted display-only categories (no {@code Sex:} entries, no
+ * {@code All}). Suitable for direct use as a combo-box model.
+ */
+ public List getCategories()
+ {
+ return backing.categoryTitles().stream()
+ .filter(key -> !key.startsWith(SEX_PREFIX))
+ .filter(key -> !ALL_CATEGORY.equals(key))
+ .sorted()
+ .toList();
+ }
+
+ /**
+ * Sorted titles available within {@code category} (final-usage rulesets
+ * only). A title appearing under multiple categories will be returned
+ * for each of them.
+ *
+ * Reads index metadata only — no XML body parsed.
+ */
+ public List getTitlesFor(String category)
+ {
+ return backing.rulesetMetaFor(category).stream()
+ .filter(meta -> FINAL_USAGE.equals(meta.usage()))
+ .map(NameGenIndex.RuleSetMeta::title)
+ .distinct()
+ .sorted()
+ .toList();
+ }
+
+ /**
+ * Genders available for a given (category, title). Returned in the
+ * {@code [Female, Male, Other]} canonical order; entries missing from
+ * the data are simply absent.
+ *
+ * Reads index metadata only — no XML body parsed.
+ */
+ public List getGendersFor(String category, String title)
+ {
+ Set raw = backing.rulesetMetaFor(category).stream()
+ .filter(meta -> FINAL_USAGE.equals(meta.usage()))
+ .filter(meta -> meta.title().equals(title))
+ .flatMap(meta -> backing.gendersForRuleset(meta.id()).stream())
+ .collect(Collectors.toSet());
+
+ Set ordered = new LinkedHashSet<>();
+ Stream.of("Female", "Male", "Other").filter(raw::contains).forEach(ordered::add);
+ ordered.addAll(raw);
+ return List.copyOf(ordered);
+ }
+
+ /**
+ * Resolve a (category, title, gender) triple to a final-usage ruleset.
+ * Triggers parsing of the file containing the matched ruleset (and any
+ * files it transitively references via {@code GETLIST}/{@code GETRULE}).
+ *
+ * @return matching {@link RuleSet}, or {@code null} if no entry matches
+ */
+ public RuleSet getCatalog(String category, String title, String gender)
+ {
+ return backing.rulesetMetaFor(category).stream()
+ .filter(meta -> FINAL_USAGE.equals(meta.usage()))
+ .filter(meta -> meta.title().equals(title))
+ .filter(meta -> backing.isInGenderBucket(meta.id(), gender))
+ .findFirst()
+ .map(meta -> backing.ruleSet(meta.id()))
+ .orElse(null);
+ }
+
+ /**
+ * Pick a {@link Rule} from {@code catalog} by weighted random and
+ * generate a name from it.
+ */
+ public GeneratedName generate(RuleSet catalog)
+ {
+ Rule rule = catalog.pick();
+ if (rule == null)
+ {
+ return new GeneratedName("", "", "", null, List.of());
+ }
+ List parts = rule.generate();
+ return assemble(rule, parts);
+ }
+
+ /**
+ * Generate a name forcing a specific {@link Rule} (Structure override).
+ */
+ public GeneratedName generateWithRule(Rule forcedRule)
+ {
+ List parts = forcedRule.generate();
+ return assemble(forcedRule, parts);
+ }
+
+ /**
+ * Returns the {@link Rule} alternatives within {@code catalog}; used by
+ * the Advanced "Structure" picker.
+ */
+ public List getRulesFor(RuleSet catalog)
+ {
+ return catalog.rules();
+ }
+
+ /**
+ * Exposes a snapshot of all loaded data. Forces every XML file to be
+ * parsed (equivalent to the legacy eager load). Primarily for tests
+ * that want to inspect the full corpus.
+ */
+ public NameGenData getData()
+ {
+ // Parse every ruleset declared anywhere — that pulls in every
+ // referenced list/ruleset transitively, which between them touch
+ // every file.
+ for (String id : backing.rulesetMeta().keySet())
+ {
+ backing.ruleSet(id);
+ }
+ Map rulesets = new LinkedHashMap<>(backing.liveRulesets());
+
+ Map> categories = backing.rulesetIdsByCategory().entrySet().stream()
+ .collect(Collectors.toMap(
+ Map.Entry::getKey,
+ e -> e.getValue().stream()
+ .map(rulesets::get)
+ .filter(Objects::nonNull)
+ .toList(),
+ (a, b) -> b,
+ LinkedHashMap::new));
+ return new NameGenData(backing.liveLists(), rulesets, categories,
+ backing.unresolvedReferences());
+ }
+
+ private static GeneratedName assemble(Rule rule, List parts)
+ {
+ StringBuilder name = new StringBuilder();
+ StringBuilder meaning = new StringBuilder();
+ StringBuilder pron = new StringBuilder();
+ boolean anyMeaning = false;
+ boolean anyPron = false;
+ for (DataValue v : parts)
+ {
+ name.append(v.getValue());
+
+ String m = v.getSubValue("meaning");
+ meaning.append(m == null ? v.getValue() : m);
+ anyMeaning |= m != null;
+
+ String p = v.getSubValue("pronounciation");
+ pron.append(p == null ? v.getValue() : p);
+ anyPron |= p != null;
+ }
+ return new GeneratedName(name.toString(),
+ anyMeaning ? meaning.toString() : "",
+ anyPron ? pron.toString() : "",
+ rule, List.copyOf(parts));
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/NameList.java b/code/src/java/pcgen/core/namegen/NameList.java
new file mode 100644
index 00000000000..454a12245f5
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/NameList.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Immutable named bag of weighted values, picked from at name-generation
+ * time. Replaces the legacy {@code DDList}.
+ */
+public record NameList(String id, String title, List values)
+{
+ public NameList
+ {
+ values = List.copyOf(values);
+ }
+
+ /**
+ * Pick one value by weighted random. Zero-weight entries are skipped
+ * so authors can disable an entry without removing it.
+ *
+ * @return the chosen value, or {@code null} if the list has no
+ * positive-weight entries
+ */
+ public WeightedDataValue pick()
+ {
+ int total = values.stream().mapToInt(WeightedDataValue::getWeight).filter(w -> w > 0).sum();
+ if (total <= 0)
+ {
+ return null;
+ }
+ int roll = ThreadLocalRandom.current().nextInt(total) + 1;
+ int running = 0;
+ for (WeightedDataValue v : values)
+ {
+ int w = v.getWeight();
+ if (w <= 0)
+ {
+ continue;
+ }
+ running += w;
+ if (roll <= running)
+ {
+ return v;
+ }
+ }
+ return values.get(values.size() - 1);
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/Rule.java b/code/src/java/pcgen/core/namegen/Rule.java
new file mode 100644
index 00000000000..bcdb64974c8
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/Rule.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2003 (C) Devon Jones
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * One alternative within a {@link RuleSet}: a sequence of {@link RulePart}s
+ * that together produce a name. References inside the parts are linked at
+ * load time, so generation is a flat traversal — no map lookups, no
+ * runtime casts.
+ */
+public record Rule(int weight, String displayLabel, List parts)
+{
+ public Rule
+ {
+ parts = List.copyOf(parts);
+ }
+
+ /** Expand the rule into the value sequence consumed by the assembler. */
+ public List generate()
+ {
+ List out = new ArrayList<>();
+ for (RulePart part : parts)
+ {
+ out.addAll(part.generate());
+ }
+ return out;
+ }
+
+ /**
+ * Used by the Advanced "Structure" combo box, which renders rule
+ * alternatives like {@code "[Given] [Surname] "}.
+ */
+ @Override
+ public String toString()
+ {
+ return displayLabel;
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/RulePart.java b/code/src/java/pcgen/core/namegen/RulePart.java
new file mode 100644
index 00000000000..778e20af86e
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/RulePart.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * One element of a {@link Rule}: either a reference to a {@link NameList}
+ * or another {@link RuleSet}, or a literal separator. Resolved once at
+ * load time so name generation needs at most one map lookup per part.
+ */
+public sealed interface RulePart
+{
+ /** Materialise the part into one or more {@link DataValue}s. */
+ List generate();
+
+ /** Human-readable label for {@code Rule.toString()} (e.g. "[Given] "). */
+ String label();
+
+ /** A reference to a {@link NameList}: pick one weighted value. */
+ record ListRef(NameList list) implements RulePart
+ {
+ @Override
+ public List generate()
+ {
+ WeightedDataValue picked = list.pick();
+ return picked == null ? List.of() : List.of(picked);
+ }
+
+ @Override
+ public String label()
+ {
+ return "[" + list.title() + "] ";
+ }
+ }
+
+ /**
+ * A reference to a {@link RuleSet}, resolved through a shared map.
+ * Rulesets reference each other, so we can't hold the target record
+ * directly at construction time — the map is populated and frozen
+ * after the loader has built every {@code RuleSet}, then handed in.
+ */
+ record RuleSetRef(String targetId, String targetTitle,
+ Map rulesets) implements RulePart
+ {
+ @Override
+ public List generate()
+ {
+ RuleSet rs = rulesets.get(targetId);
+ if (rs == null)
+ {
+ return List.of();
+ }
+ Rule picked = rs.pick();
+ return picked == null ? List.of() : picked.generate();
+ }
+
+ @Override
+ public String label()
+ {
+ return "[" + targetTitle + "] ";
+ }
+ }
+
+ /** Literal separator emitted as-is into the generated name. */
+ enum Literal implements RulePart
+ {
+ SPACE(" "), HYPHEN("-"), CR("\n");
+
+ private final DataValue value;
+
+ Literal(String text)
+ {
+ this.value = new DataValue(text);
+ }
+
+ @Override
+ public List generate()
+ {
+ return List.of(value);
+ }
+
+ @Override
+ public String label()
+ {
+ return "";
+ }
+ }
+}
diff --git a/code/src/java/pcgen/core/namegen/RuleSet.java b/code/src/java/pcgen/core/namegen/RuleSet.java
new file mode 100644
index 00000000000..6147b6bfed3
--- /dev/null
+++ b/code/src/java/pcgen/core/namegen/RuleSet.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2003 (C) Devon Jones
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Immutable bag of {@link Rule} alternatives keyed by display title.
+ * The {@code usage} field carries the legacy {@code "final"} marker that
+ * tells the facade which rulesets the user picks directly versus the
+ * shared building-block rulesets ({@code "private"}).
+ */
+public record RuleSet(String id, String title, String usage, List rules)
+{
+ public RuleSet
+ {
+ rules = List.copyOf(rules);
+ }
+
+ /** Title is what the combo boxes show for a ruleset. */
+ @Override
+ public String toString()
+ {
+ return title;
+ }
+
+ /**
+ * Pick one rule by weighted random. Zero-weight entries are skipped.
+ *
+ * @return the chosen rule, or {@code null} if no rule has positive
+ * weight
+ */
+ public Rule pick()
+ {
+ int total = rules.stream().mapToInt(Rule::weight).filter(w -> w > 0).sum();
+ if (total <= 0)
+ {
+ return null;
+ }
+ int roll = ThreadLocalRandom.current().nextInt(total) + 1;
+ int running = 0;
+ for (Rule r : rules)
+ {
+ int w = r.weight();
+ if (w <= 0)
+ {
+ continue;
+ }
+ running += w;
+ if (roll <= running)
+ {
+ return r;
+ }
+ }
+ return rules.get(rules.size() - 1);
+ }
+}
diff --git a/code/src/java/pcgen/core/doomsdaybook/WeightedDataValue.java b/code/src/java/pcgen/core/namegen/WeightedDataValue.java
similarity index 97%
rename from code/src/java/pcgen/core/doomsdaybook/WeightedDataValue.java
rename to code/src/java/pcgen/core/namegen/WeightedDataValue.java
index ef0704a4150..9863b562871 100644
--- a/code/src/java/pcgen/core/doomsdaybook/WeightedDataValue.java
+++ b/code/src/java/pcgen/core/namegen/WeightedDataValue.java
@@ -15,7 +15,7 @@
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
-package pcgen.core.doomsdaybook;
+package pcgen.core.namegen;
/**
* {@code WeightedDataValue}.
diff --git a/code/src/java/pcgen/gui2/dialog/RandomNameDialog.java b/code/src/java/pcgen/gui2/dialog/RandomNameDialog.java
deleted file mode 100644
index 205d0b05538..00000000000
--- a/code/src/java/pcgen/gui2/dialog/RandomNameDialog.java
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * Copyright James Dempsey, 2010
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 2.1 of the License, or (at your option) any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public
- * License along with this library; if not, write to the Free Software
- * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
- */
-package pcgen.gui2.dialog;
-
-import java.awt.BorderLayout;
-import java.io.File;
-
-import javax.swing.JDialog;
-import javax.swing.JFrame;
-import javax.swing.WindowConstants;
-
-import pcgen.core.SettingsHandler;
-import pcgen.gui2.doomsdaybook.NameGenPanel;
-import pcgen.gui2.tools.Utility;
-import pcgen.gui3.GuiUtility;
-import pcgen.gui3.component.OKCloseButtonBar;
-import pcgen.system.LanguageBundle;
-
-import javafx.scene.control.ButtonBar;
-import org.apache.commons.lang3.StringUtils;
-
-/**
- * The Class {@code RandomNameDialog} is a dialog in which the user can
- * generate a random name for their character.
- */
-public final class RandomNameDialog extends JDialog
-{
- private final NameGenPanel nameGenPanel;
- private boolean cancelled;
-
- /**
- * Create a new Random Name Dialog
- * @param frame The parent frame. The dialog will be centred on this frame
- * @param gender The current gender of the character.
- */
- public RandomNameDialog(JFrame frame, String gender)
- {
- super(frame, LanguageBundle.getString("in_rndNameTitle"), true); //$NON-NLS-1$
- nameGenPanel = new NameGenPanel(new File(getDataDir()));
- nameGenPanel.setGender(gender);
- initUserInterface();
- pack();
- setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE);
- this.setLocationRelativeTo(frame);
- cancelled = false;
-
- Utility.installEscapeCloseOperation(this);
- }
-
- private void initUserInterface()
- {
- getContentPane().setLayout(new BorderLayout());
-
- getContentPane().add(nameGenPanel, BorderLayout.CENTER);
-
- ButtonBar buttonBar = new OKCloseButtonBar(
- evt -> okButtonActionPerformed(),
- evt -> cancelButtonActionPerformed()
- );
-
- getContentPane().add(GuiUtility.wrapParentAsJFXPanel(buttonBar), BorderLayout.PAGE_END);
- }
-
- private void okButtonActionPerformed()
- {
- setVisible(false);
- }
-
- private void cancelButtonActionPerformed()
- {
- cancelled = true;
- setVisible(false);
- }
-
- /**
- * @return The directory where the random name data is held
- */
- private String getDataDir()
- {
- String pluginDirectory = SettingsHandler.getGmgenPluginDir().toString();
-
- return pluginDirectory + File.separator + "Random Names";
- }
-
- /**
- * @return The name the user generated.
- */
- public String getChosenName()
- {
- if (cancelled)
- {
- return StringUtils.EMPTY;
- }
- return nameGenPanel.getChosenName();
- }
-
- /**
- * @return the gender
- */
- public String getGender()
- {
- if (cancelled)
- {
- return StringUtils.EMPTY;
- }
- return nameGenPanel.getGender();
- }
-
-}
diff --git a/code/src/java/pcgen/gui2/doomsdaybook/NameButton.java b/code/src/java/pcgen/gui2/doomsdaybook/NameButton.java
deleted file mode 100644
index 292e852bc14..00000000000
--- a/code/src/java/pcgen/gui2/doomsdaybook/NameButton.java
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright 2003 (C) Devon Jones
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 2.1 of the License, or (at your option) any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public
- * License along with this library; if not, write to the Free Software
- * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
- */
-package pcgen.gui2.doomsdaybook;
-
-import pcgen.core.doomsdaybook.DataElement;
-
-class NameButton extends javax.swing.JButton
-{
- private final DataElement element;
-
- /** Creates a new instance of NameButton
- * @param element
- */
- NameButton(DataElement element)
- {
- this.element = element;
- super.setText(element.getTitle());
- }
-
- /**
- * Get the data element for the name button
- * @return the data element for the name button
- */
- DataElement getDataElement()
- {
- return element;
- }
-}
diff --git a/code/src/java/pcgen/gui2/doomsdaybook/NameGenPanel.java b/code/src/java/pcgen/gui2/doomsdaybook/NameGenPanel.java
deleted file mode 100644
index e5a2b6d1c27..00000000000
--- a/code/src/java/pcgen/gui2/doomsdaybook/NameGenPanel.java
+++ /dev/null
@@ -1,1005 +0,0 @@
-/*
- * Copyright 2003 (C) Devon Jones
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 2.1 of the License, or (at your option) any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public
- * License along with this library; if not, write to the Free Software
- * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
- */
-package pcgen.gui2.doomsdaybook;
-
-import java.awt.BorderLayout;
-import java.awt.FlowLayout;
-import java.awt.Insets;
-import java.awt.datatransfer.Clipboard;
-import java.awt.datatransfer.StringSelection;
-import java.awt.event.ActionEvent;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.ListIterator;
-import java.util.Map;
-import java.util.Set;
-import java.util.Vector;
-
-import javax.swing.BoxLayout;
-import javax.swing.ComboBoxModel;
-import javax.swing.DefaultComboBoxModel;
-import javax.swing.JButton;
-import javax.swing.JCheckBox;
-import javax.swing.JComboBox;
-import javax.swing.JLabel;
-import javax.swing.JOptionPane;
-import javax.swing.JPanel;
-import javax.swing.JSeparator;
-import javax.swing.JTextField;
-
-import pcgen.core.doomsdaybook.CRRule;
-import pcgen.core.doomsdaybook.DataElement;
-import pcgen.core.doomsdaybook.DataElementComperator;
-import pcgen.core.doomsdaybook.DataValue;
-import pcgen.core.doomsdaybook.HyphenRule;
-import pcgen.core.doomsdaybook.Rule;
-import pcgen.core.doomsdaybook.RuleSet;
-import pcgen.core.doomsdaybook.SpaceRule;
-import pcgen.core.doomsdaybook.VariableHashMap;
-import pcgen.core.doomsdaybook.WeightedDataValue;
-import pcgen.gui2.tools.Icons;
-import pcgen.gui2.util.FontManipulation;
-import pcgen.system.LanguageBundle;
-import pcgen.util.Logging;
-
-import org.jdom2.DataConversionException;
-import org.jdom2.DocType;
-import org.jdom2.Document;
-import org.jdom2.Element;
-import org.jdom2.input.SAXBuilder;
-import org.xml.sax.EntityResolver;
-import org.xml.sax.InputSource;
-
-/**
- * Main panel of the random name generator.
- */
-@SuppressWarnings({"UseOfObsoleteCollectionType", "PMD.UseArrayListInsteadOfVector"})
-public class NameGenPanel extends JPanel
-{
- private final Map> categories = new HashMap<>();
- private JButton generateButton;
- private JButton jButton1;
- private JCheckBox chkStructure;
- private JComboBox cbCatalog;
- private JComboBox cbCategory;
- private JComboBox cbGender;
- private JComboBox cbStructure;
- private JLabel jLabel1;
- private JLabel jLabel2;
- private JLabel jLabel3;
-
- private JLabel jLabel4;
- private JLabel jLabel5;
- private JLabel jLabel6;
- private JLabel meaning;
- private JLabel pronounciation;
- private JPanel buttonPanel;
- private JPanel genCtrlPanel;
- private JPanel jPanel10;
- private JPanel jPanel11;
- private JPanel jPanel12;
- private JPanel jPanel13;
- private JPanel jPanel14;
- private JPanel nameDisplayPanel;
- private JPanel namePanel;
- private JPanel jPanel4;
- private JPanel nameSubInfoPanel;
- private JPanel nameActionPanel;
- private JPanel jPanel7;
- private JPanel jPanel8;
- private JPanel jPanel9;
- private JSeparator jSeparator1;
- private JSeparator jSeparator2;
- private JSeparator jSeparator3;
- private JSeparator jSeparator4;
- private JTextField name;
- private final VariableHashMap allVars = new VariableHashMap();
-
- private Rule lastRule = null;
-
- /** Creates new form NameGenPanel */
- public NameGenPanel()
- {
- this(new File("."));
- }
-
- /**
- * Constructs a NameGenPanel given a dataPath
- *
- * @param dataPath The path to the random name data files.
- */
- public NameGenPanel(File dataPath)
- {
- initComponents();
- loadData(dataPath);
- }
-
- /**
- * Generate a Rule
- * @return new Rule
- */
- public Rule generate()
- {
- try
- {
- Rule rule;
-
- if (chkStructure.isSelected())
- {
- RuleSet rs = (RuleSet) cbCatalog.getSelectedItem();
- rule = rs.getRule();
- }
- else
- {
- rule = (Rule) cbStructure.getSelectedItem();
- }
-
- List aName = rule.getData();
- setNameText(aName);
- setMeaningText(aName);
- setPronounciationText(aName);
-
- return rule;
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
-
- return null;
- }
- }
-
- private void setMeaningText(String meaning)
- {
- this.meaning.setText(meaning);
- }
-
- private void setMeaningText(Iterable data)
- {
- StringBuilder meaningBuffer = new StringBuilder();
-
- for (DataValue val : data)
- {
- String aMeaning = val.getSubValue("meaning"); //$NON-NLS-1$ // XML attribute no translation
-
- if (aMeaning == null)
- {
- aMeaning = val.getValue();
- }
-
- meaningBuffer.append(aMeaning);
- }
-
- setMeaningText(meaningBuffer.toString());
- }
-
- private void setNameText(String name)
- {
- this.name.setText(name);
- }
-
- private void setNameText(Iterable data)
- {
- StringBuilder nameBuffer = new StringBuilder();
-
- for (DataValue val : data)
- {
- nameBuffer.append(val.getValue());
- }
-
- setNameText(nameBuffer.toString());
- }
-
- private void setPronounciationText(String pronounciation)
- {
- this.pronounciation.setText(pronounciation);
- }
-
- private void setPronounciationText(Iterable data)
- {
- StringBuilder proBuffer = new StringBuilder();
-
- for (DataValue val : data)
- {
- String aPronounciation = val.getSubValue("pronounciation");
-
- if (aPronounciation == null)
- {
- aPronounciation = val.getValue();
- }
-
- proBuffer.append(aPronounciation);
- }
-
- setPronounciationText(proBuffer.toString());
- }
-
- private void NameButtonActionPerformed(ActionEvent evt)
- {
- try
- {
- NameButton nb = (NameButton) evt.getSource();
- DataElement element = nb.getDataElement();
- element.getData();
-
- Rule rule = this.lastRule;
-
- if (rule == null)
- {
- if (chkStructure.isSelected())
- {
- RuleSet rs = (RuleSet) cbCatalog.getSelectedItem();
- rule = rs.getLastRule();
- }
- else
- {
- rule = (Rule) cbStructure.getSelectedItem();
- }
-
- this.lastRule = rule;
- }
-
- List aName = rule.getLastData();
-
- setNameText(aName);
- setMeaningText(aName);
- setPronounciationText(aName);
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
- }
- }
-
- private void cbCatalogActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_cbCatalogActionPerformed
- loadStructureDD();
- this.clearButtons();
- }
-
- //GEN-LAST:event_cbCatalogActionPerformed
-
- private void cbStructureActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_cbStructureActionPerformed
- this.clearButtons();
- }
-
- //GEN-LAST:event_cbStructureActionPerformed
-
- private void cbCategoryActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_cbCategoryActionPerformed
- this.loadGenderDD();
- loadCatalogDD();
- loadStructureDD();
- this.clearButtons();
- }
-
- //GEN-LAST:event_cbCategoryActionPerformed
-
- private void cbGenderActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_cbGenderActionPerformed
- loadCatalogDD();
- loadStructureDD();
- this.clearButtons();
- }
-
- //GEN-LAST:event_cbGenderActionPerformed
-
- private void chkStructureActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_chkStructureActionPerformed
- loadStructureDD();
- }
-
- //GEN-LAST:event_chkStructureActionPerformed
-
- private void clearButtons()
- {
- buttonPanel.removeAll();
- buttonPanel.repaint();
- }
-
- private void displayButtons(Rule rule)
- {
- clearButtons();
-
- for (String key : rule)
- {
- try
- {
- DataElement ele = allVars.getDataElement(key);
-
- if (ele.getTitle() != null)
- {
- NameButton nb = new NameButton(ele);
- nb.addActionListener(this::NameButtonActionPerformed);
- buttonPanel.add(nb);
- }
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
- }
- }
-
- buttonPanel.repaint();
- }
-
- private void generateButtonActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_generateButtonActionPerformed
-
- try
- {
- this.lastRule = generate();
- displayButtons(this.lastRule);
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
- }
- }
-
- //GEN-LAST:event_generateButtonActionPerformed
-
- /**
- * This method is called from within the constructor to
- * initialize the form.
- */
- private void initComponents()
- {
- genCtrlPanel = new JPanel();
- jPanel4 = new JPanel();
- jPanel13 = new JPanel();
- jPanel10 = new JPanel();
- jLabel4 = new JLabel();
- cbCatalog = new JComboBox<>();
- jPanel8 = new JPanel();
- jLabel1 = new JLabel();
- cbCategory = new JComboBox<>();
- jPanel14 = new JPanel();
- jPanel11 = new JPanel();
- generateButton = new JButton();
- jPanel9 = new JPanel();
- jLabel5 = new JLabel();
- cbGender = new JComboBox<>();
- jPanel7 = new JPanel();
- jSeparator4 = new JSeparator();
- jPanel12 = new JPanel();
- jLabel6 = new JLabel();
- cbStructure = new JComboBox<>();
- chkStructure = new JCheckBox();
- buttonPanel = new JPanel();
- nameDisplayPanel = new JPanel();
- nameSubInfoPanel = new JPanel();
- jSeparator2 = new JSeparator();
- jLabel2 = new JLabel();
- meaning = new JLabel();
- jSeparator1 = new JSeparator();
- jLabel3 = new JLabel();
- pronounciation = new JLabel();
- jSeparator3 = new JSeparator();
- namePanel = new JPanel();
- name = new JTextField();
- nameActionPanel = new JPanel();
- jButton1 = new JButton();
-
- setLayout(new BorderLayout(0, 5));
-
- genCtrlPanel.setLayout(new BorderLayout());
-
- jPanel4.setLayout(new BoxLayout(jPanel4, BoxLayout.X_AXIS));
-
- jPanel13.setLayout(new BorderLayout());
-
- jPanel10.setLayout(new FlowLayout(FlowLayout.LEFT));
-
- jLabel4.setText(LanguageBundle.getString("in_rndNameCatalog")); //$NON-NLS-1$
- jPanel10.add(jLabel4);
-
- cbCatalog.addActionListener(this::cbCatalogActionPerformed);
-
- jPanel10.add(cbCatalog);
-
- jPanel13.add(jPanel10, BorderLayout.CENTER);
-
- jPanel8.setLayout(new FlowLayout(FlowLayout.LEFT));
-
- jLabel1.setText(LanguageBundle.getString("in_rndNameCategory")); //$NON-NLS-1$
- jPanel8.add(jLabel1);
-
- cbCategory.addActionListener(this::cbCategoryActionPerformed);
-
- jPanel8.add(cbCategory);
-
- jPanel13.add(jPanel8, BorderLayout.NORTH);
-
- jPanel4.add(jPanel13);
-
- jPanel14.setLayout(new BorderLayout());
-
- jPanel11.setLayout(new FlowLayout(FlowLayout.LEFT));
-
- generateButton.setText(LanguageBundle.getString("in_rndNameGenerate")); //$NON-NLS-1$
- generateButton.addActionListener(this::generateButtonActionPerformed);
-
- jPanel11.add(generateButton);
-
- jPanel14.add(jPanel11, BorderLayout.CENTER);
-
- jPanel9.setLayout(new FlowLayout(FlowLayout.LEFT));
-
- jLabel5.setText(LanguageBundle.getString("in_rndNameSex")); //$NON-NLS-1$
- jPanel9.add(jLabel5);
-
- cbGender.addActionListener(this::cbGenderActionPerformed);
-
- jPanel9.add(cbGender);
-
- jPanel14.add(jPanel9, BorderLayout.NORTH);
-
- jPanel4.add(jPanel14);
-
- genCtrlPanel.add(jPanel4, BorderLayout.NORTH);
-
- jPanel7.setLayout(new BorderLayout());
-
- jPanel7.add(jSeparator4, BorderLayout.NORTH);
-
- jPanel12.setLayout(new FlowLayout(FlowLayout.LEFT));
-
- jLabel6.setText(LanguageBundle.getString("in_rndNameStructure")); //$NON-NLS-1$
- jPanel12.add(jLabel6);
-
- cbStructure.setEnabled(false);
- cbStructure.addActionListener(this::cbStructureActionPerformed);
- jPanel12.add(cbStructure);
-
- chkStructure.setSelected(true);
- chkStructure.setText(LanguageBundle.getString("in_randomButton")); //$NON-NLS-1$
- chkStructure.addActionListener(this::chkStructureActionPerformed);
-
- jPanel12.add(chkStructure);
-
- jPanel7.add(jPanel12, BorderLayout.CENTER);
- jPanel7.add(new JSeparator(), BorderLayout.SOUTH);
-
- JPanel adjustNamePanel = new JPanel();
- adjustNamePanel.setLayout(new BorderLayout());
-
- JLabel adjNameLabel = new JLabel(LanguageBundle.getString("in_rndNameAdjust")); //$NON-NLS-1$
-
- adjustNamePanel.add(adjNameLabel, BorderLayout.NORTH);
-
- buttonPanel.setLayout(new FlowLayout(FlowLayout.LEFT));
- // CODE-2099 Component needed to have correct vertical space available.
- JLabel nb = new JLabel(" "); //$NON-NLS-1$
- buttonPanel.add(nb);
-
- adjustNamePanel.add(buttonPanel, BorderLayout.CENTER);
-
- add(adjustNamePanel, BorderLayout.SOUTH);
-
- genCtrlPanel.add(jPanel7, BorderLayout.CENTER);
-
- // Name display
- nameDisplayPanel.setLayout(new BorderLayout());
-
- nameSubInfoPanel.setLayout(new BoxLayout(nameSubInfoPanel, BoxLayout.Y_AXIS));
-
- nameSubInfoPanel.add(jSeparator2);
-
- jLabel2.setText(LanguageBundle.getString("in_rndNameMeaning")); //$NON-NLS-1$
- nameSubInfoPanel.add(jLabel2);
-
- meaning.setText(LanguageBundle.getString("in_rndNmDefault")); //$NON-NLS-1$
- nameSubInfoPanel.add(meaning);
-
- nameSubInfoPanel.add(jSeparator1);
-
- jLabel3.setText(LanguageBundle.getString("in_rndNmPronounciation")); //$NON-NLS-1$
- nameSubInfoPanel.add(jLabel3);
-
- pronounciation.setText("nAm");
- nameSubInfoPanel.add(pronounciation);
-
- nameSubInfoPanel.add(jSeparator3);
-
- nameDisplayPanel.add(nameSubInfoPanel, BorderLayout.SOUTH);
-
- JLabel nameTitleLabel = new JLabel(LanguageBundle.getString("in_sumName")); //$NON-NLS-1$
- JPanel nameTitlePanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
- nameTitlePanel.add(nameTitleLabel);
-
- JPanel topPanel = new JPanel();
- topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS));
-
- namePanel.setLayout(new BoxLayout(namePanel, BoxLayout.X_AXIS));
-
- FontManipulation.xxlarge(name);
- name.setText(LanguageBundle.getString("in_nameLabel")); //$NON-NLS-1$
- namePanel.add(name);
-
- nameActionPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
-
- jButton1.setIcon(Icons.Copy16.getImageIcon());
- jButton1.setAlignmentY(0.0F);
- jButton1.setIconTextGap(0);
- jButton1.setMargin(new Insets(2, 2, 2, 2));
- jButton1.addActionListener(this::jButton1ActionPerformed);
- nameActionPanel.add(jButton1);
-
- namePanel.add(nameActionPanel);
-
- topPanel.add(genCtrlPanel);
- topPanel.add(nameTitlePanel);
- topPanel.add(namePanel);
- topPanel.add(nameDisplayPanel);
- add(topPanel, BorderLayout.NORTH);
- }
-
- //GEN-END:initComponents
-
- private void jButton1ActionPerformed(ActionEvent evt)
- { //GEN-FIRST:event_jButton1ActionPerformed
- Clipboard cb = getToolkit().getSystemClipboard();
- StringSelection ss = new StringSelection(name.getText());
- cb.setContents(ss, ss);
- }
-
- //GEN-LAST:event_jButton1ActionPerformed
-
- private void loadCatalogDD()
- {
- try
- {
- String catKey = (String) cbCategory.getSelectedItem();
- String genderKey = (String) cbGender.getSelectedItem();
- RuleSet oldRS = (RuleSet) cbCatalog.getSelectedItem();
- String catalogKey = "";
-
- if (oldRS != null)
- {
- catalogKey = oldRS.getTitle();
- }
-
- List cats = categories.get(catKey);
- List genders = categories.get("Sex: " + genderKey);
- List join = new ArrayList<>(cats);
- join.retainAll(genders);
- join.sort(new DataElementComperator());
-
- Vector catalogs = new Vector<>();
- int oldSelected = -1;
- int n = 0;
-
- for (final RuleSet rs : join)
- {
- if (rs.getUsage().equals("final"))
- {
- catalogs.add(rs);
-
- if (rs.getTitle().equals(catalogKey))
- {
- oldSelected = n;
- }
-
- n++;
- }
- }
-
- ComboBoxModel catalogModel = new DefaultComboBoxModel<>(catalogs);
- cbCatalog.setModel(catalogModel);
- if (oldSelected >= 0)
- {
- cbCatalog.setSelectedIndex(oldSelected);
- }
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
- }
- }
-
- // Get a list of all the gender categories in the category map
- private Vector getGenderCategoryNames()
- {
- Vector genders = new Vector<>();
- Set keySet = categories.keySet();
-
- // Loop through the keys in the categories
- for (final String key : keySet)
- {
- // if the key starts with "Sex" then save it
- if (key.startsWith("Sex:"))
- {
- genders.add(key.substring(5));
- }
- }
-
- // Return all the found gender types
- return genders;
- }
-
- // Load the gender drop dowd
- private void loadGenderDD()
- {
- List genders = getGenderCategoryNames();
- Vector selectable = new Vector<>();
- String gender = (String) cbGender.getSelectedItem();
-
- // Get the selected category name
- String category = (String) cbCategory.getSelectedItem();
-
- // Get the set of rules for selected category
- List categoryRules = categories.get(category);
-
- // we need to determine if the selected category is supported by the
- // available genders
- // loop through the available genders
- for (String genderString : genders)
- {
- // Get the list of rules for the current gender
- List genderRules = categories.get("Sex: " + genderString);
-
- // now loop through all the rules from the selected category
- for (RuleSet categoryRule : categoryRules)
- {
- // if the category rule is in the list of gender rules
- // add the current gender to the selectable gender list
- // we can stop processing the list once we find a match
- if (genderRules.contains(categoryRule))
- {
- selectable.add(genderString);
- break;
- }
- }
- }
-
- // Sort the genders
- Collections.sort(selectable);
-
- // Create a new model for the combobox and set it
- cbGender.setModel(new DefaultComboBoxModel<>(selectable));
- if (gender != null && selectable.contains(gender))
- {
- cbGender.setSelectedItem(gender);
- }
- }
-
- private void loadCategory(Element category, RuleSet rs)
- {
- List cat = categories.get(category.getAttributeValue("title"));
- List thiscat;
-
- if (cat == null)
- {
- thiscat = new ArrayList<>();
- categories.put(category.getAttributeValue("title"), thiscat);
- }
- else
- {
- thiscat = cat;
- }
-
- thiscat.add(rs);
- }
-
- private void loadData(File path)
- {
- if (path.isDirectory())
- {
- File[] dataFiles = path.listFiles(new XMLFilter());
- SAXBuilder builder = new SAXBuilder();
- GeneratorDtdResolver resolver = new GeneratorDtdResolver(path);
- builder.setEntityResolver(resolver);
-
- for (File dataFile : dataFiles)
- {
- try
- {
- URL url = dataFile.toURI().toURL();
- Document nameSet = builder.build(url);
- DocType dt = nameSet.getDocType();
-
- if (dt.getElementName().equals("GENERATOR"))
- {
- loadFromDocument(nameSet);
- }
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
- JOptionPane.showMessageDialog(this, "XML Error with file " + dataFile.getName());
- }
- }
-
- loadDropdowns();
- }
- else
- {
- JOptionPane.showMessageDialog(this, "No data files in directory " + path.getPath());
- }
- }
-
- // Get a list of category names from the categories map
- private Vector getCategoryNames()
- {
- Vector cats = new Vector<>();
- Set keySet = categories.keySet();
-
- for (final String key : keySet)
- {
- // Ignore any category that starts with this
- if (key.startsWith("Sex:"))
- {
- continue;
- }
-
- cats.add(key);
- }
-
- // Sor the selected categories before returning it
- Collections.sort(cats);
-
- return cats;
- }
-
- private void loadDropdowns()
- {
- // This method now just loads the category dropdown from the list of
- // category names
- Vector cats = this.getCategoryNames();
- cbCategory.setModel(new DefaultComboBoxModel<>(cats));
-
- this.loadGenderDD();
- this.loadCatalogDD();
- }
-
- private void loadFromDocument(Document nameSet) throws DataConversionException
- {
- Element generator = nameSet.getRootElement();
- java.util.List> rulesets = generator.getChildren("RULESET");
- java.util.List> lists = generator.getChildren("LIST");
-
- for (final Object o : lists)
- {
- Element list = (Element) o;
- loadList(list);
- }
-
- for (final Object ruleset : rulesets)
- {
- Element ruleSet = (Element) ruleset;
- RuleSet rs = loadRuleSet(ruleSet);
- allVars.addDataElement(rs);
- }
- }
-
- private String loadList(Element list) throws DataConversionException
- {
- pcgen.core.doomsdaybook.DDList dataList = new pcgen.core.doomsdaybook.DDList(allVars,
- list.getAttributeValue("title"), list.getAttributeValue("id"));
- java.util.List> elements = list.getChildren();
-
- for (final Object element : elements)
- {
- Element child = (Element) element;
- String elementName = child.getName();
-
- if (elementName.equals("VALUE"))
- {
- WeightedDataValue dv =
- new WeightedDataValue(child.getText(), child.getAttribute("weight").getIntValue());
- List> subElements = child.getChildren("SUBVALUE");
-
- for (final Object subElement1 : subElements)
- {
- Element subElement = (Element) subElement1;
- dv.addSubValue(subElement.getAttributeValue("type"), subElement.getText());
- }
-
- dataList.add(dv);
- }
- }
-
- allVars.addDataElement(dataList);
-
- return dataList.getId();
- }
-
- private String loadRule(Element rule, String id) throws DataConversionException
- {
- Rule dataRule = new Rule(allVars, id, id, rule.getAttribute("weight").getIntValue());
- java.util.List> elements = rule.getChildren();
-
- for (final Object element : elements)
- {
- Element child = (Element) element;
- String elementName = child.getName();
-
- switch (elementName)
- {
- case "GETLIST" -> {
- String listId = child.getAttributeValue("idref");
- dataRule.add(listId);
- }
- case "SPACE" -> {
- SpaceRule sp = new SpaceRule();
- allVars.addDataElement(sp);
- dataRule.add(sp.getId());
- }
- case "HYPHEN" -> {
- HyphenRule hy = new HyphenRule();
- allVars.addDataElement(hy);
- dataRule.add(hy.getId());
- }
- case "CR" -> {
- CRRule cr = new CRRule();
- allVars.addDataElement(cr);
- dataRule.add(cr.getId());
- }
- case "GETRULE" -> {
- String ruleId = child.getAttributeValue("idref");
- dataRule.add(ruleId);
- }
- }
- }
-
- allVars.addDataElement(dataRule);
-
- return dataRule.getId();
- }
-
- private RuleSet loadRuleSet(Element ruleSet) throws DataConversionException
- {
- RuleSet rs = new RuleSet(allVars, ruleSet.getAttributeValue("title"), ruleSet.getAttributeValue("id"),
- ruleSet.getAttributeValue("usage"));
- java.util.List> elements = ruleSet.getChildren();
- ListIterator> elementsIterator = elements.listIterator();
- int num = 0;
-
- while (elementsIterator.hasNext())
- {
- Element child = (Element) elementsIterator.next();
- String elementName = child.getName();
-
- if (elementName.equals("CATEGORY"))
- {
- loadCategory(child, rs);
- }
- else if (elementName.equals("RULE"))
- {
- rs.add(loadRule(child, rs.getId() + num));
- }
-
- num++;
- }
-
- return rs;
- }
-
- private void loadStructureDD()
- {
- if (chkStructure.isSelected())
- {
- cbStructure.setModel(new DefaultComboBoxModel<>());
- cbStructure.setEnabled(false);
- }
- else
- {
- Vector struct = new Vector<>();
-
- for (String key : ((RuleSet) cbCatalog.getSelectedItem()))
- {
- try
- {
- struct.add(allVars.getDataElement(key));
- }
- catch (Exception e)
- {
- Logging.errorPrint(e.getMessage(), e);
- }
- }
-
- DefaultComboBoxModel structModel = new DefaultComboBoxModel<>(struct);
- cbStructure.setModel(structModel);
- cbStructure.setEnabled(true);
- }
- }
-
- /**
- * @return the generated name chosen by the user
- */
- public String getChosenName()
- {
- return name.getText();
- }
-
- /**
- * @return the gender
- */
- public String getGender()
- {
- return (String) cbGender.getSelectedItem();
- }
-
- /**
- * @param gender the gender to set
- */
- public void setGender(String gender)
- {
- if (gender != null && cbGender != null)
- {
- cbGender.setSelectedItem(gender);
- }
- }
-
- /**
- * The Class {@code GeneratorDtdResolver} is an EntityResolver implementation
- * for use with a SAX parser. It forces the generator.dtd to be read from a
- * known location.
- */
- public static class GeneratorDtdResolver implements EntityResolver
- {
-
- private final File parent;
-
- /**
- * Create a new instance of GeneratorDtdResolver to read the
- * generator.dtd from a specific directory.
- * @param parent The parent directory holding generator.dtd
- */
- GeneratorDtdResolver(File parent)
- {
- this.parent = parent;
-
- }
-
- @Override
- public InputSource resolveEntity(String publicId, String systemId)
- {
- if (systemId.endsWith("generator.dtd"))
- {
- // return a special input source
- InputStream dtdIn;
- try
- {
- dtdIn = new FileInputStream(new File(parent, "generator.dtd"));
- }
- catch (FileNotFoundException e)
- {
- Logging.errorPrint("GeneratorDtdResolver.resolveEntity failed", e);
- return null;
-
- }
- return new InputSource(dtdIn);
- }
- else
- {
- // use the default behaviour
- return null;
- }
- }
- }
-}
diff --git a/code/src/java/pcgen/gui2/doomsdaybook/XMLFilter.java b/code/src/java/pcgen/gui2/doomsdaybook/XMLFilter.java
deleted file mode 100644
index ed26642906e..00000000000
--- a/code/src/java/pcgen/gui2/doomsdaybook/XMLFilter.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright 2003 (C) Devon Jones
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 2.1 of the License, or (at your option) any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public
- * License along with this library; if not, write to the Free Software
- * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
- */
-package pcgen.gui2.doomsdaybook;
-
-import java.io.File;
-
-public class XMLFilter implements java.io.FilenameFilter
-{
-
- /**
- * Returns true if file matches *.xml
- *
- * @param file
- * @param str
- * @return true if filter matches *.xml
- */
- @Override
- public boolean accept(File file, String str)
- {
- return str.matches(".*\\.xml$");
- }
-}
diff --git a/code/src/java/pcgen/gui2/tabs/SummaryInfoTab.java b/code/src/java/pcgen/gui2/tabs/SummaryInfoTab.java
index 99efab00557..3b4eecba185 100644
--- a/code/src/java/pcgen/gui2/tabs/SummaryInfoTab.java
+++ b/code/src/java/pcgen/gui2/tabs/SummaryInfoTab.java
@@ -83,7 +83,7 @@
import pcgen.gui2.UIPropertyContext;
import pcgen.gui2.dialog.CharacterHPDialog;
import pcgen.gui2.dialog.KitSelectionDialog;
-import pcgen.gui2.dialog.RandomNameDialog;
+import pcgen.gui3.namegen.RandomNameDialog;
import pcgen.gui2.dialog.SinglePrefDialog;
import pcgen.gui2.prefs.CharacterStatsPanel;
import pcgen.gui2.tabs.models.CharacterComboBoxModel;
@@ -647,7 +647,7 @@ public ModelMap createModels(final CharacterFacade character)
models.put(ComboBoxModelHandler.class, new ComboBoxModelHandler(character));
models.put(RandomNameAction.class,
- new RandomNameAction(character, (JFrame) SwingUtilities.getWindowAncestor(this)));
+ new RandomNameAction(character));
models.put(ClassLevelTableModel.class, new ClassLevelTableModel(character, classLevelTable, classComboBox));
models.put(GenerateRollsAction.class, new GenerateRollsAction(character));
@@ -1172,22 +1172,22 @@ private static class RandomNameAction extends AbstractAction
{
private final CharacterFacade character;
- private final JFrame frame;
- RandomNameAction(CharacterFacade character, JFrame frame)
+ RandomNameAction(CharacterFacade character)
{
this.character = character;
- this.frame = frame;
}
@Override
public void actionPerformed(ActionEvent e)
{
+ java.awt.Window owner = (e.getSource() instanceof java.awt.Component c)
+ ? javax.swing.SwingUtilities.getWindowAncestor(c) : null;
String gender =
character.getGenderRef().get()
!= null ? character.getGenderRef().get().toString() : ""; //$NON-NLS-1$
- RandomNameDialog dialog = new RandomNameDialog(frame, gender);
- dialog.setVisible(true);
+ RandomNameDialog dialog = new RandomNameDialog(owner, gender);
+ dialog.showAndBlock();
String chosenName = dialog.getChosenName();
if (chosenName != null && !chosenName.isEmpty()
&& !chosenName.equals(LanguageBundle.getString("in_rndNmDefault"))) //$NON-NLS-1$
diff --git a/code/src/java/pcgen/gui3/JFXPanelFromResource.java b/code/src/java/pcgen/gui3/JFXPanelFromResource.java
index 0d471d77b89..ad2391afa56 100644
--- a/code/src/java/pcgen/gui3/JFXPanelFromResource.java
+++ b/code/src/java/pcgen/gui3/JFXPanelFromResource.java
@@ -106,7 +106,6 @@ public void showAndBlock(String title)
stage.setScene(getScene());
stage.sizeToScene();
stage.showAndWait();
- Logging.errorPrint("passed wait");
lock.completeAsync(() -> 0);
});
diff --git a/code/src/java/pcgen/gui3/application/DesktopHandler.java b/code/src/java/pcgen/gui3/application/DesktopHandler.java
index 26e2392054b..2a04933eda4 100644
--- a/code/src/java/pcgen/gui3/application/DesktopHandler.java
+++ b/code/src/java/pcgen/gui3/application/DesktopHandler.java
@@ -20,6 +20,7 @@
import java.awt.Desktop;
import java.awt.Desktop.Action;
+import java.awt.EventQueue;
import java.awt.desktop.AboutEvent;
import java.awt.desktop.PreferencesEvent;
import java.awt.desktop.QuitEvent;
@@ -93,7 +94,13 @@ private static class QuitHandler implements java.awt.desktop.QuitHandler
@Override
public void handleQuitRequestWith(final QuitEvent quitEvent, final QuitResponse quitResponse)
{
- PCGenUIManager.closePCGen();
+ // Cancel the system quit so macOS stops intercepting mouse events — without this, the save dialog appears,
+ // but its buttons are frozen because the OS is waiting for a quit response and routing clicks through the
+ // quit machinery. We drive the quit ourselves via closePCGen.
+ quitResponse.cancelQuit();
+
+ // Defer closePCGen to a fresh EDT turn so handleQuitRequestWith returns first.
+ EventQueue.invokeLater(PCGenUIManager::closePCGen);
}
}
}
diff --git a/code/src/java/pcgen/gui3/dialog/AboutDialogController.java b/code/src/java/pcgen/gui3/dialog/AboutDialogController.java
index 386050f7425..9095c139aa5 100644
--- a/code/src/java/pcgen/gui3/dialog/AboutDialogController.java
+++ b/code/src/java/pcgen/gui3/dialog/AboutDialogController.java
@@ -131,7 +131,6 @@ private void initText()
));
String s = LanguageBundle.getString("in_abt_lib_apache"); //$NON-NLS-1$
- s += LanguageBundle.getString("in_abt_lib_jdom"); //$NON-NLS-1$
librariesArea.setText(s);
String releaseDateStr = PCGenPropBundle.getReleaseDate();
diff --git a/code/src/java/pcgen/gui3/namegen/GenderSelection.java b/code/src/java/pcgen/gui3/namegen/GenderSelection.java
new file mode 100644
index 00000000000..0b40213aed3
--- /dev/null
+++ b/code/src/java/pcgen/gui3/namegen/GenderSelection.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.gui3.namegen;
+
+import java.util.List;
+
+/**
+ * Pure helpers for the random-name UI's gender-selection rules. Kept
+ * out of the FX controller so the logic can be exercised without
+ * spinning up a JavaFX runtime.
+ */
+final class GenderSelection
+{
+ private GenderSelection()
+ {
+ }
+
+ /**
+ * Pick a gender to select after the available list changes. Order:
+ * keep {@code previous} if still valid, then fall back to
+ * {@code preferred} (caller-supplied initial gender) if valid, then
+ * the first {@code available} entry. Returns the empty string when
+ * no gender is available.
+ */
+ static String chooseSticky(List available, String previous, String preferred)
+ {
+ if (available.isEmpty())
+ {
+ return "";
+ }
+ boolean previousStillValid = previous != null && available.contains(previous);
+ if (previousStillValid)
+ {
+ return previous;
+ }
+ boolean preferredStillValid = preferred != null && available.contains(preferred);
+ if (preferredStillValid)
+ {
+ return preferred;
+ }
+ return available.get(0);
+ }
+}
diff --git a/code/src/java/pcgen/gui3/namegen/RandomNameDialog.java b/code/src/java/pcgen/gui3/namegen/RandomNameDialog.java
new file mode 100644
index 00000000000..71d796b62bc
--- /dev/null
+++ b/code/src/java/pcgen/gui3/namegen/RandomNameDialog.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.gui3.namegen;
+
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.EventQueue;
+import java.awt.Window;
+import java.io.IOException;
+import java.net.URL;
+import java.util.Objects;
+import java.util.concurrent.CountDownLatch;
+
+import javax.swing.JDialog;
+import javax.swing.SwingUtilities;
+
+import pcgen.gui3.GuiAssertions;
+import pcgen.system.LanguageBundle;
+import pcgen.util.Logging;
+
+import javafx.application.Platform;
+import javafx.embed.swing.JFXPanel;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Scene;
+
+/**
+ * Modal random-name dialog. Presented to Swing callers as a synchronous
+ * call: construct, {@link #showAndBlock()}, then read back
+ * {@link #getChosenName()} / {@link #getGender()}.
+ *
+ * Hosts the FXML scene inside a Swing {@link JDialog} via
+ * {@link JFXPanel}. The Swing dialog gives us proper owner/modality
+ * semantics against the rest of the Swing UI; the FX side keeps the
+ * existing FXML, controller, and scene graph untouched.
+ */
+public final class RandomNameDialog
+{
+ private static final String FXML_RESOURCE = "RandomNamePanel.fxml";
+
+ private final Window owner;
+ private final String initialGender;
+ private RandomNamePanelController controller;
+
+ public RandomNameDialog(Window owner, String initialGender)
+ {
+ this.owner = owner;
+ this.initialGender = initialGender;
+ }
+
+ public RandomNameDialog(String initialGender)
+ {
+ this(null, initialGender);
+ }
+
+ /**
+ * Show the dialog and block until the user closes it. Must be called
+ * from a non-FX thread; the call hops to the EDT if needed because
+ * Swing modal dialogs require it.
+ */
+ public void showAndBlock()
+ {
+ GuiAssertions.assertIsNotJavaFXThread();
+ if (!EventQueue.isDispatchThread())
+ {
+ try
+ {
+ EventQueue.invokeAndWait(this::showAndBlock);
+ }
+ catch (InterruptedException _)
+ {
+ Thread.currentThread().interrupt();
+ }
+ catch (java.lang.reflect.InvocationTargetException e)
+ {
+ Logging.errorPrint("failed to show random-name dialog", e);
+ }
+ return;
+ }
+
+ JDialog dialog = new JDialog(owner, LanguageBundle.getString("in_rndNameTitle"),
+ Dialog.ModalityType.APPLICATION_MODAL);
+ JFXPanel fxPanel = new JFXPanel();
+ dialog.setContentPane(fxPanel);
+
+ CountDownLatch sceneReady = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try
+ {
+ FXMLLoader loader = new FXMLLoader();
+ URL location = RandomNameDialog.class.getResource(FXML_RESOURCE);
+ Objects.requireNonNull(location, FXML_RESOURCE);
+ loader.setLocation(location);
+ loader.setResources(LanguageBundle.getBundle());
+ Scene scene = loader.load();
+ controller = loader.getController();
+ assert controller != null;
+ if (initialGender != null && !initialGender.isEmpty())
+ {
+ controller.setInitialGender(initialGender);
+ }
+ controller.setCloseAction(() -> SwingUtilities.invokeLater(dialog::dispose));
+ fxPanel.setScene(scene);
+ // Read the FXML's preferred size from the scene root and
+ // pin both the JFXPanel's preferred size and the dialog's
+ // minimum size to it. Without this, JFXPanel reports a
+ // preferred size of 0 and pack() collapses the dialog.
+ javafx.scene.Parent root = scene.getRoot();
+ root.applyCss();
+ root.layout();
+ int prefW = (int) Math.ceil(root.prefWidth(-1));
+ int prefH = (int) Math.ceil(root.prefHeight(prefW));
+ SwingUtilities.invokeLater(() -> {
+ Dimension pref = new Dimension(prefW, prefH);
+ fxPanel.setPreferredSize(pref);
+ dialog.pack();
+ Dimension packed = dialog.getSize();
+ // Lock the height (user can't change it), let the
+ // width float above the design width.
+ dialog.setMinimumSize(new Dimension(packed.width, packed.height));
+ dialog.setMaximumSize(new Dimension(Integer.MAX_VALUE, packed.height));
+ dialog.setResizable(true);
+ dialog.setLocationRelativeTo(owner);
+ });
+ }
+ catch (IOException e)
+ {
+ Logging.errorPrint("failed to load random-name dialog FXML", e);
+ }
+ finally
+ {
+ sceneReady.countDown();
+ }
+ });
+ try
+ {
+ sceneReady.await();
+ }
+ catch (InterruptedException _)
+ {
+ Thread.currentThread().interrupt();
+ return;
+ }
+
+ dialog.setVisible(true);
+ }
+
+ public String getChosenName()
+ {
+ return controller == null ? "" : controller.getChosenName();
+ }
+
+ public String getGender()
+ {
+ return controller == null ? "" : controller.getGender();
+ }
+}
diff --git a/code/src/java/pcgen/gui3/namegen/RandomNamePanelController.java b/code/src/java/pcgen/gui3/namegen/RandomNamePanelController.java
new file mode 100644
index 00000000000..0b55be00161
--- /dev/null
+++ b/code/src/java/pcgen/gui3/namegen/RandomNamePanelController.java
@@ -0,0 +1,346 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.gui3.namegen;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+
+import pcgen.core.SettingsHandler;
+import pcgen.core.namegen.GeneratedName;
+import pcgen.core.namegen.NameGenerator;
+import pcgen.core.namegen.Rule;
+import pcgen.core.namegen.RuleSet;
+import pcgen.system.LanguageBundle;
+import pcgen.util.Logging;
+
+import javafx.collections.FXCollections;
+import javafx.event.ActionEvent;
+import javafx.fxml.FXML;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.TextField;
+import javafx.scene.control.Toggle;
+import javafx.scene.control.ToggleGroup;
+
+/**
+ * JavaFX controller behind the random-name dialog. Drives two cascading
+ * combo boxes (Category → Title) and a gender radio group with sticky
+ * fallback, then asks the headless {@link NameGenerator} for names.
+ */
+public final class RandomNamePanelController
+{
+ @FXML
+ private ComboBox categoryCombo;
+ @FXML
+ private ComboBox titleCombo;
+ @FXML
+ private ToggleGroup genderGroup;
+ @FXML
+ private RadioButton genderFemale;
+ @FXML
+ private RadioButton genderMale;
+ @FXML
+ private RadioButton genderOther;
+ @FXML
+ private TextField generatedNameLabel;
+ @FXML
+ private Label meaningLabel;
+ @FXML
+ private Label pronunciationLabel;
+ @FXML
+ private CheckBox randomStructureCheck;
+ @FXML
+ private ComboBox structureCombo;
+
+ private NameGenerator nameGenerator;
+ private String chosenName = "";
+ private String chosenGender = "";
+ private boolean cancelled = true;
+ private String preferredGender;
+ private Runnable closeAction;
+
+ @FXML
+ void initialize()
+ {
+ try
+ {
+ nameGenerator = new NameGenerator(new File(getDataDir()));
+ }
+ catch (IOException e)
+ {
+ Logging.errorPrint("failed to load random-name data", e);
+ generatedNameLabel.setText(LanguageBundle.getString("in_rndNmDefault"));
+ return;
+ }
+
+ categoryCombo.setItems(FXCollections.observableArrayList(nameGenerator.getCategories()));
+ categoryCombo.valueProperty().addListener((obs, old, val) -> onCategoryChanged(val));
+ titleCombo.valueProperty().addListener((obs, old, val) -> onTitleChanged(val));
+ genderGroup.selectedToggleProperty().addListener((obs, old, val) -> onGenderChanged(val));
+ randomStructureCheck.selectedProperty().addListener((obs, old, val) -> onRandomStructureChanged(val));
+ structureCombo.setDisable(true);
+ setOptionalLabel(meaningLabel, "in_rndNameMeaning", "", "");
+ setOptionalLabel(pronunciationLabel, "in_rndNmPronounciation", "", "");
+
+ if (!categoryCombo.getItems().isEmpty())
+ {
+ categoryCombo.getSelectionModel().selectFirst();
+ }
+ }
+
+ /**
+ * Called by {@link RandomNameDialog} before showing, to pre-select a
+ * gender if the character already has one. Must be invoked on the FX
+ * thread, after the FXML has loaded.
+ */
+ public void setInitialGender(String gender)
+ {
+ preferredGender = gender;
+ if (gender != null && !gender.isEmpty() && titleCombo.getValue() != null)
+ {
+ selectGender(gender);
+ }
+ }
+
+ /**
+ * Hook supplied by {@link RandomNameDialog} so OK/Cancel can dispose
+ * the hosting Swing dialog without the controller knowing about it.
+ */
+ public void setCloseAction(Runnable closeAction)
+ {
+ this.closeAction = closeAction;
+ }
+
+ private void onCategoryChanged(String category)
+ {
+ if (category == null)
+ {
+ titleCombo.setItems(FXCollections.emptyObservableList());
+ return;
+ }
+ List titles = nameGenerator.getTitlesFor(category);
+ titleCombo.setItems(FXCollections.observableArrayList(titles));
+ if (!titles.isEmpty())
+ {
+ titleCombo.getSelectionModel().selectFirst();
+ }
+ }
+
+ private void onTitleChanged(String title)
+ {
+ String category = categoryCombo.getValue();
+ if (category == null || title == null)
+ {
+ disableAllGenders();
+ return;
+ }
+ List available = nameGenerator.getGendersFor(category, title);
+ genderFemale.setDisable(!available.contains("Female"));
+ genderMale.setDisable(!available.contains("Male"));
+ genderOther.setDisable(!available.contains("Other"));
+
+ String previous = currentGender();
+ String target = chooseStickyGender(available, previous);
+ selectGender(target);
+ // If the sticky gender is unchanged, the toggle listener won't fire,
+ // so refresh the structure combo here too — the catalog is keyed on
+ // (category, title, gender) and title just changed.
+ refreshStructureCombo();
+ }
+
+ private void onGenderChanged(Toggle selected)
+ {
+ // Cleared selection happens transiently while we swap toggles —
+ // don't react until a button is actually selected.
+ if (selected == null)
+ {
+ return;
+ }
+ refreshStructureCombo();
+ clearOutput();
+ }
+
+ private void onRandomStructureChanged(boolean random)
+ {
+ structureCombo.setDisable(random);
+ }
+
+ private void refreshStructureCombo()
+ {
+ RuleSet catalog = currentCatalog();
+ if (catalog == null)
+ {
+ structureCombo.setItems(FXCollections.emptyObservableList());
+ return;
+ }
+ structureCombo.setItems(FXCollections.observableArrayList(nameGenerator.getRulesFor(catalog)));
+ if (!structureCombo.getItems().isEmpty())
+ {
+ structureCombo.getSelectionModel().selectFirst();
+ }
+ }
+
+ @FXML
+ void onGenerate(ActionEvent event)
+ {
+ RuleSet catalog = currentCatalog();
+ if (catalog == null)
+ {
+ return;
+ }
+ try
+ {
+ GeneratedName result = useForcedRule()
+ ? nameGenerator.generateWithRule(structureCombo.getValue())
+ : nameGenerator.generate(catalog);
+ generatedNameLabel.setText(result.name());
+ setOptionalLabel(meaningLabel, "in_rndNameMeaning", result.meaning(), result.name());
+ setOptionalLabel(pronunciationLabel, "in_rndNmPronounciation", result.pronunciation(), result.name());
+ }
+ catch (Exception e)
+ {
+ Logging.errorPrint("failed to generate random name", e);
+ generatedNameLabel.setText(LanguageBundle.getString("in_rndNmDefault"));
+ setOptionalLabel(meaningLabel, "in_rndNameMeaning", "", "");
+ setOptionalLabel(pronunciationLabel, "in_rndNmPronounciation", "", "");
+ }
+ }
+
+ private boolean useForcedRule()
+ {
+ return !randomStructureCheck.isSelected() && structureCombo.getValue() != null;
+ }
+
+ @FXML
+ void onOk(ActionEvent event)
+ {
+ String text = generatedNameLabel.getText();
+ String defaultLabel = LanguageBundle.getString("in_rndNmDefault");
+ // If the user clicks OK without ever generating, treat as cancel.
+ if (text == null || text.isEmpty() || text.equals(defaultLabel))
+ {
+ cancelled = true;
+ }
+ else
+ {
+ cancelled = false;
+ chosenName = text;
+ chosenGender = currentGender();
+ }
+ fireClose();
+ }
+
+ @FXML
+ void onCancel(ActionEvent event)
+ {
+ cancelled = true;
+ fireClose();
+ }
+
+ public String getChosenName()
+ {
+ return cancelled ? "" : chosenName;
+ }
+
+ public String getGender()
+ {
+ return cancelled ? "" : chosenGender;
+ }
+
+ private RuleSet currentCatalog()
+ {
+ String category = categoryCombo.getValue();
+ String title = titleCombo.getValue();
+ String gender = currentGender();
+ if (category == null || title == null || gender.isEmpty())
+ {
+ return null;
+ }
+ return nameGenerator.getCatalog(category, title, gender);
+ }
+
+ private String currentGender()
+ {
+ Toggle selected = genderGroup.getSelectedToggle();
+ if (selected instanceof RadioButton rb)
+ {
+ return rb.getText();
+ }
+ return "";
+ }
+
+ private String chooseStickyGender(List available, String previous)
+ {
+ return GenderSelection.chooseSticky(available, previous, preferredGender);
+ }
+
+ private void selectGender(String gender)
+ {
+ RadioButton target = switch (gender)
+ {
+ case "Female" -> genderFemale;
+ case "Male" -> genderMale;
+ case "Other" -> genderOther;
+ default -> null;
+ };
+ if (target != null && !target.isDisable())
+ {
+ target.setSelected(true);
+ }
+ else
+ {
+ genderGroup.selectToggle(null);
+ }
+ }
+
+ private void disableAllGenders()
+ {
+ genderFemale.setDisable(true);
+ genderMale.setDisable(true);
+ genderOther.setDisable(true);
+ genderGroup.selectToggle(null);
+ }
+
+ private void clearOutput()
+ {
+ generatedNameLabel.setText(LanguageBundle.getString("in_rndNmDefault"));
+ setOptionalLabel(meaningLabel, "in_rndNameMeaning", "", "");
+ setOptionalLabel(pronunciationLabel, "in_rndNmPronounciation", "", "");
+ }
+
+ private static void setOptionalLabel(Label label, String prefixKey, String value, String generatedName)
+ {
+ boolean hasValue = value != null && !value.isEmpty() && !value.equals(generatedName);
+ String shown = hasValue ? value : "-";
+ label.setText(LanguageBundle.getString(prefixKey) + " " + shown);
+ }
+
+ private void fireClose()
+ {
+ if (closeAction != null)
+ {
+ closeAction.run();
+ }
+ }
+
+ private static String getDataDir()
+ {
+ return Objects.requireNonNull(SettingsHandler.getGmgenPluginDir()).toString()
+ + File.separator + "Random Names";
+ }
+}
diff --git a/code/src/java/pcgen/system/Main.java b/code/src/java/pcgen/system/Main.java
index 5c321aea9d7..63c3e18f8d1 100644
--- a/code/src/java/pcgen/system/Main.java
+++ b/code/src/java/pcgen/system/Main.java
@@ -18,7 +18,6 @@
*/
package pcgen.system;
-import java.awt.Component;
import java.awt.Font;
import java.awt.FontFormatException;
import java.awt.GraphicsEnvironment;
@@ -41,8 +40,8 @@
import pcgen.gui2.PCGenUIManager;
import pcgen.gui2.UIPropertyContext;
import pcgen.gui2.converter.TokenConverter;
-import pcgen.gui2.dialog.RandomNameDialog;
import pcgen.gui3.JFXPanelFromResource;
+import pcgen.gui3.namegen.RandomNameDialog;
import pcgen.gui3.dialog.OptionsPathDialogController;
import pcgen.gui3.preloader.PCGenPreloader;
import pcgen.io.ExportHandler;
@@ -127,8 +126,8 @@ public static void main(String... args)
if (commandLineArguments.isStartNameGenerator())
{
- Component dialog = new RandomNameDialog(null, null);
- dialog.setVisible(true);
+ RandomNameDialog dialog = new RandomNameDialog(null);
+ dialog.showAndBlock();
GracefulExit.exit(0);
}
diff --git a/code/src/java/plugin/doomsdaybook/RandomNamePlugin.java b/code/src/java/plugin/doomsdaybook/RandomNamePlugin.java
deleted file mode 100644
index 28ccdec0fe8..00000000000
--- a/code/src/java/plugin/doomsdaybook/RandomNamePlugin.java
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * Copyright 2003 (C) Devon Jones
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 2.1 of the License, or (at your option) any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public
- * License along with this library; if not, write to the Free Software
- * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
- */
-package plugin.doomsdaybook;
-
-import java.awt.Component;
-import java.io.File;
-
-import javax.swing.JMenuItem;
-import javax.swing.JOptionPane;
-import javax.swing.JTabbedPane;
-
-import pcgen.core.SettingsHandler;
-import pcgen.gui2.doomsdaybook.NameGenPanel;
-import pcgen.gui2.tools.Utility;
-import pcgen.pluginmgr.InteractivePlugin;
-import pcgen.pluginmgr.PCGenMessage;
-import pcgen.pluginmgr.PCGenMessageHandler;
-import pcgen.pluginmgr.messages.FocusOrStateChangeOccurredMessage;
-import pcgen.system.LanguageBundle;
-
-public class RandomNamePlugin implements InteractivePlugin
-{
- /** Log name */
- private static final String LOG_NAME = "Random_Name_Generator";
-
- /** The plugin menu item in the tools menu. */
- private final JMenuItem nameToolsItem = new JMenuItem();
-
- /** The user interface that this class will be using. */
- private NameGenPanel theView;
-
- /** The English name of the plugin. */
- private static final String NAME = "Random Names"; //$NON-NLS-1$
- /** Key of plugin tab. */
- private static final String IN_NAME = "in_plugin_randomname_name"; //$NON-NLS-1$
- /** Mnemonic in menu for {@link #IN_NAME} */
- private static final String IN_NAME_MN = "in_mn_plugin_randomname_name"; //$NON-NLS-1$
-
- /**
- * Starts the plugin, registering itself with the {@code TabAddMessage}.
- */
- @Override
- public void start(PCGenMessageHandler mh)
- {
- theView = new NameGenPanel(getDataDirectory());
- initMenus();
- }
-
- @Override
- public void stop()
- {
- }
-
- @Override
- public int getPriority()
- {
- return SettingsHandler.getGMGenOption(RandomNamePlugin.LOG_NAME + ".LoadOrder", 80);
- }
-
- /**
- * Accessor for name
- * @return name
- */
- @Override
- public String getPluginName()
- {
- return RandomNamePlugin.NAME;
- }
-
- private static String getLocalizedName()
- {
- return LanguageBundle.getString(RandomNamePlugin.IN_NAME);
- }
-
- /**
- * Gets the view that this class is using.
- * @return the view.
- */
- public Component getView()
- {
- return theView;
- }
-
- /**
- * listens to messages from the GMGen system, and handles them as needed
- * @param message the source of the event from the system
- */
- @Override
- public void handleMessage(PCGenMessage message)
- {
- if (message instanceof FocusOrStateChangeOccurredMessage)
- {
- if (isActive())
- {
- nameToolsItem.setEnabled(false);
- }
- else
- {
- nameToolsItem.setEnabled(true);
- }
- }
- }
-
- /**
- * Returns true if this plugin is active
- * @return true if this plugin is active
- */
- public boolean isActive()
- {
- JTabbedPane tp = Utility.getTabbedPaneFor(theView);
- return (tp != null) && JOptionPane.getFrameForComponent(tp).isFocused()
- && tp.getSelectedComponent().equals(theView);
- }
-
- /**
- * Initialize the menus
- */
- private void initMenus()
- {
- nameToolsItem.setMnemonic(LanguageBundle.getMnemonic(RandomNamePlugin.IN_NAME_MN));
- nameToolsItem.setText(RandomNamePlugin.getLocalizedName());
- }
-
- @Override
- public File getDataDirectory()
- {
- return new File(SettingsHandler.getGmgenPluginDir(), RandomNamePlugin.NAME);
- }
-}
diff --git a/code/src/java/plugin/doomsdaybook/manifest.mf b/code/src/java/plugin/doomsdaybook/manifest.mf
deleted file mode 100644
index 24bce91775f..00000000000
--- a/code/src/java/plugin/doomsdaybook/manifest.mf
+++ /dev/null
@@ -1,4 +0,0 @@
-Manifest-Version: 1.2
-Class-Path: lib/jdom.jar lib/jaxp.jar
-Created-By: NetBeans IDE
-
diff --git a/code/src/resources/pcgen/gui3/namegen/RandomNamePanel.fxml b/code/src/resources/pcgen/gui3/namegen/RandomNamePanel.fxml
new file mode 100644
index 00000000000..130fe64a228
--- /dev/null
+++ b/code/src/resources/pcgen/gui3/namegen/RandomNamePanel.fxml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code/src/resources/pcgen/lang/LanguageBundle.properties b/code/src/resources/pcgen/lang/LanguageBundle.properties
index 0b9bde9d640..05cf38c2028 100644
--- a/code/src/resources/pcgen/lang/LanguageBundle.properties
+++ b/code/src/resources/pcgen/lang/LanguageBundle.properties
@@ -390,8 +390,6 @@ in_abt_java_version=Java Version:
in_abt_lib_apache=This product includes software developed by the Apache Software Foundation (http://www.apache.org/): Avalon, Batik, FOP. Copyright (C) 1999-2003 The Apache Software Foundation. All rights reserved.\n\n
-in_abt_lib_jdom=This product includes software developed by the JDOM Project (http://www.jdom.org/).\n\n
-
in_abt_libraries=Libraries
in_abt_awards=Awards
diff --git a/code/src/resources/pcgen/lang/LanguageBundle_es.properties b/code/src/resources/pcgen/lang/LanguageBundle_es.properties
index 6e5811665fb..69a6c0a34b3 100644
--- a/code/src/resources/pcgen/lang/LanguageBundle_es.properties
+++ b/code/src/resources/pcgen/lang/LanguageBundle_es.properties
@@ -390,8 +390,6 @@ in_abt_java_version=Versión de Java:
in_abt_lib_apache=Este producto incluye software desarrollado por Apache Software Foundation (http://www.apache.org/): Avalon, Batik, FOP, Xalan y Xerces. Copyright (C) 1999-2003 La Fundación Apache Software. Todos los derechos reservados.\n\n
-in_abt_lib_jdom=Este producto incluye software desarrollado por el Proyecto JDOM (http://www.jdom.org/).\n\n
-
in_abt_libraries=Bibliotecas
in_abt_awards=Premios
diff --git a/code/src/resources/pcgen/lang/LanguageBundle_fr.properties b/code/src/resources/pcgen/lang/LanguageBundle_fr.properties
index 4bf49996571..bf6a90b442a 100644
--- a/code/src/resources/pcgen/lang/LanguageBundle_fr.properties
+++ b/code/src/resources/pcgen/lang/LanguageBundle_fr.properties
@@ -369,8 +369,6 @@ in_abt_java_version=Version de Java :
in_abt_lib_apache=Ce produit contient des logiciels développés par The Apache Software Foundation (http://www.apache.org/) : Avalon, Batik, FOP, Xalan et Xerces. Copyright © 1999—2003 The Apache Software Foundation. Tous droits réservés.\n\n
-in_abt_lib_jdom=Ce produit contient du logiciel développé par JDOM Project (http://www.jdom.org/).\n\n
-
in_abt_libraries=Bibliothèques
in_abt_awards=Récompenses
diff --git a/code/src/resources/pcgen/lang/LanguageBundle_it.properties b/code/src/resources/pcgen/lang/LanguageBundle_it.properties
index 2d46dea2672..6eb287bb9b8 100644
--- a/code/src/resources/pcgen/lang/LanguageBundle_it.properties
+++ b/code/src/resources/pcgen/lang/LanguageBundle_it.properties
@@ -341,8 +341,6 @@ in_abt_java_version=Versione di Java:
in_abt_lib_apache=Questo prodotto include software sviluppato da Apache Software Foundation (http:#www.apache.org/): Avalon, Batik, FOP, Xalan and Xerces. Copyright (C) 1999-2003 The Apache Software Foundation. Tutti i diritti riservati.
-in_abt_lib_jdom=Questo prodotto include software sviluppato dal progetto JDOM (http:#www.jdom.org/).
-
in_abt_libraries=Librerie
in_abt_awards=Premi
diff --git a/code/src/testResources/pcgen/lang/cleaned.properties b/code/src/testResources/pcgen/lang/cleaned.properties
index b44578e4ee8..c652bd6f181 100644
--- a/code/src/testResources/pcgen/lang/cleaned.properties
+++ b/code/src/testResources/pcgen/lang/cleaned.properties
@@ -327,8 +327,6 @@ in_abt_java_version=Java Version:
in_abt_lib_apache=This product includes software developed by the Apache Software Foundation (http://www.apache.org/): Avalon, Batik, FOP. Copyright (C) 1999-2003 The Apache Software Foundation. All rights reserved.\n\n
-in_abt_lib_jdom=This product includes software developed by the JDOM Project (http://www.jdom.org/).\n\n
-
in_abt_libraries=Libraries
in_abt_awards=Awards
diff --git a/code/src/utest/pcgen/core/namegen/NameGenDataIntegrityTest.java b/code/src/utest/pcgen/core/namegen/NameGenDataIntegrityTest.java
new file mode 100644
index 00000000000..6fec44c3983
--- /dev/null
+++ b/code/src/utest/pcgen/core/namegen/NameGenDataIntegrityTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ */
+package pcgen.core.namegen;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.File;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.TestFactory;
+
+/**
+ * Per-ruleset smoke test against the bundled {@code plugins/Random Names}
+ * dataset. For every {@code usage="final"} ruleset declared in the data,
+ * we call {@link NameGenerator#generate(RuleSet)} a handful of times and
+ * assert the produced name is non-empty.
+ *
+ * This catches damage that {@link NameGenDataLoaderTest} does not:
+ * a file whose XML parses and whose cross-references resolve, but whose
+ * rules ultimately produce empty strings (all-zero weights, empty
+ * referenced lists, malformed rule structure).
+ */
+public class NameGenDataIntegrityTest
+{
+ private static final File DATA_DIR =
+ new File(System.getProperty("user.dir"), "plugins/Random Names");
+
+ private static final int GENERATIONS_PER_RULESET = 5;
+
+ @TestFactory
+ public Stream everyFinalRuleSetProducesNonEmptyName() throws Exception
+ {
+ // Force a full eager load so the @TestFactory body has access to
+ // every parsed ruleset without paying the lazy parse cost per test.
+ NameGenerator generator = new NameGenerator(DATA_DIR);
+ NameGenData data = generator.getData();
+ List finals = data.rulesets().values().stream()
+ .filter(rs -> "final".equals(rs.usage()))
+ .toList();
+ return finals.stream().map(rs -> DynamicTest.dynamicTest(
+ rs.id() + " (" + rs.title() + ")",
+ () -> assertGeneratesNonEmpty(generator, rs)));
+ }
+
+ private static void assertGeneratesNonEmpty(NameGenerator generator, RuleSet rs)
+ {
+ assertFalse(rs.rules().isEmpty(),
+ "ruleset " + rs.id() + " has no rules");
+ for (int i = 0; i < GENERATIONS_PER_RULESET; i++)
+ {
+ GeneratedName generated = generator.generate(rs);
+ assertNotNull(generated, "ruleset " + rs.id() + " produced null result");
+ assertFalse(generated.name().isEmpty(),
+ "ruleset " + rs.id() + " produced empty name on attempt " + i);
+ }
+ }
+}
diff --git a/code/src/utest/pcgen/core/namegen/NameGenDataLoaderTest.java b/code/src/utest/pcgen/core/namegen/NameGenDataLoaderTest.java
new file mode 100644
index 00000000000..703ae924e7c
--- /dev/null
+++ b/code/src/utest/pcgen/core/namegen/NameGenDataLoaderTest.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Smoke tests for {@link NameGenDataLoader} against the bundled
+ * {@code plugins/Random Names} dataset.
+ */
+public class NameGenDataLoaderTest
+{
+ private static final File DATA_DIR =
+ new File(System.getProperty("user.dir"), "plugins/Random Names");
+
+ @Test
+ public void loadsBundledDatasetWithoutThrowing() throws IOException
+ {
+ NameGenData data = NameGenDataLoader.load(DATA_DIR);
+ assertNotNull(data);
+ assertNotNull(data.lists());
+ assertFalse(data.categories().isEmpty(), "no categories were loaded");
+ }
+
+ @Test
+ public void categoriesIncludeSexBucketsAndAll() throws IOException
+ {
+ // Loader-level view: raw categories include the Sex: buckets and
+ // the All pseudo-category. The NameGenerator facade hides them;
+ // here we just verify the loader sees them.
+ NameGenData data = NameGenDataLoader.load(DATA_DIR);
+ assertTrue(data.categories().containsKey("All"),
+ "loader should expose the 'All' pseudo-category");
+ assertTrue(data.categories().keySet().stream().anyMatch(k -> k.startsWith("Sex:")),
+ "loader should expose at least one 'Sex:' bucket");
+ }
+
+ @Test
+ public void rejectsNonDirectory() throws IOException
+ {
+ File notADir = File.createTempFile("namegen", ".tmp");
+ notADir.deleteOnExit();
+ assertThrows(IOException.class, () -> NameGenDataLoader.load(notADir));
+ }
+
+ @Test
+ public void surfacesParseErrorsAsIoException(@TempDir Path tempDir) throws IOException
+ {
+ // generator.dtd must exist for the resolver, but the actual XML is broken.
+ Files.copy(new File(DATA_DIR, "generator.dtd").toPath(),
+ tempDir.resolve("generator.dtd"));
+ Files.writeString(tempDir.resolve("broken.xml"),
+ ""
+ + " ");
+ assertThrows(IOException.class, () -> NameGenDataLoader.load(tempDir.toFile()));
+ }
+
+ @Test
+ public void emptyDirectoryYieldsEmptyData(@TempDir Path tempDir) throws IOException
+ {
+ NameGenData data = NameGenDataLoader.load(tempDir.toFile());
+ assertTrue(data.categories().isEmpty());
+ }
+
+ @Test
+ public void valueWithoutWeightDefaultsToOne(@TempDir Path tempDir) throws IOException
+ {
+ // The DTD declares weight="1" as a default, but we use a
+ // non-validating parser, so a missing weight arrives as "" — the
+ // loader must treat that as 1 instead of throwing.
+ Files.copy(new File(DATA_DIR, "generator.dtd").toPath(),
+ tempDir.resolve("generator.dtd"));
+ Files.writeString(tempDir.resolve("noweight.xml"),
+ ""
+ + ""
+ + "Donn
"
+ + " ");
+ NameGenData data = NameGenDataLoader.load(tempDir.toFile());
+ assertNotNull(data);
+ }
+
+ @Test
+ public void rejectsExternalEntityExpansion(@TempDir Path tempDir) throws IOException
+ {
+ // Classic XXE payload: declare an external entity that points to
+ // a local secret file, then reference it. With external general
+ // entities disabled, the parser must NOT inline the file's
+ // contents into the resulting data — it should either refuse the
+ // document or leave the reference unresolved.
+ Files.copy(new File(DATA_DIR, "generator.dtd").toPath(),
+ tempDir.resolve("generator.dtd"));
+ Path secret = tempDir.resolve("secret.txt");
+ String marker = "TOP-SECRET-CANARY-12345";
+ Files.writeString(secret, marker);
+ String secretUri = secret.toUri().toString();
+ Files.writeString(tempDir.resolve("xxe.xml"),
+ ""
+ + ""
+ + "]>"
+ + ""
+ + "prefix-&leak;-suffix
"
+ + " ");
+ try
+ {
+ NameGenData data = NameGenDataLoader.load(tempDir.toFile());
+ // If the loader didn't throw, the parsed data must not contain
+ // the secret marker — otherwise XXE expanded successfully.
+ boolean leaked = data.lists().values().stream()
+ .flatMap(l -> l.values().stream())
+ .anyMatch(v -> v.getValue().contains(marker));
+ assertFalse(leaked, "external entity was expanded into the parsed data");
+ }
+ catch (IOException ignored)
+ {
+ // Refusing the document is also an acceptable outcome.
+ }
+ }
+
+ @Test
+ public void bundledDatasetHasNoUnresolvedReferences() throws IOException
+ {
+ // Each entry in unresolvedReferences() is a GETLIST/GETRULE in
+ // the data files pointing at a target id that doesn't exist —
+ // the legacy engine silently swallowed these at generation time;
+ // the new loader collects them so the data can be fixed.
+ // If this fails, the assertion message lists every broken ref.
+ NameGenData data = NameGenDataLoader.load(DATA_DIR);
+ String summary = data.unresolvedReferences().stream()
+ .map(u -> u.kind() + " " + u.targetId())
+ .distinct()
+ .sorted()
+ .collect(Collectors.joining("\n "));
+ assertTrue(data.unresolvedReferences().isEmpty(),
+ "bundled dataset has unresolved references:\n " + summary);
+ }
+
+ @Test
+ public void unresolvedReferencesAreCollected(@TempDir Path tempDir) throws IOException
+ {
+ // Verify the collection mechanism itself: a GETLIST pointing at a
+ // non-existent list and a GETRULE pointing at a non-existent
+ // ruleset should both surface in unresolvedReferences().
+ Files.copy(new File(DATA_DIR, "generator.dtd").toPath(),
+ tempDir.resolve("generator.dtd"));
+ Files.writeString(tempDir.resolve("dangling.xml"),
+ ""
+ + ""
+ + ""
+ + " "
+ + " "
+ + " ");
+ NameGenData data = NameGenDataLoader.load(tempDir.toFile());
+ assertEquals(2, data.unresolvedReferences().size());
+ assertTrue(data.unresolvedReferences().stream()
+ .anyMatch(u -> u.kind() == NameGenData.UnresolvedReference.Kind.GETLIST
+ && "missing-list".equals(u.targetId())));
+ assertTrue(data.unresolvedReferences().stream()
+ .anyMatch(u -> u.kind() == NameGenData.UnresolvedReference.Kind.GETRULE
+ && "missing-ruleset".equals(u.targetId())));
+ }
+}
diff --git a/code/src/utest/pcgen/core/namegen/NameGenLazyDataTest.java b/code/src/utest/pcgen/core/namegen/NameGenLazyDataTest.java
new file mode 100644
index 00000000000..a2e6e4c5ba5
--- /dev/null
+++ b/code/src/utest/pcgen/core/namegen/NameGenLazyDataTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ */
+package pcgen.core.namegen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for the lazy XML-loading path: verifies that the index pre-scan
+ * correctly identifies categories without parsing rule bodies, that
+ * selecting a (category, title, gender) triple forces parsing of just the
+ * relevant file plus its transitive references, and that the observable
+ * outputs match the eager loader's.
+ */
+public class NameGenLazyDataTest
+{
+ private static final File DATA_DIR =
+ new File(System.getProperty("user.dir"), "plugins/Random Names");
+
+ @Test
+ public void preScanFindsCategoriesWithoutParsingFiles() throws Exception
+ {
+ NameGenLazyData lazy = NameGenLazyData.open(DATA_DIR);
+ // open() does the StAX index scan but should never trigger a full
+ // DOM parse — parsedFiles must be empty.
+ assertTrue(lazy.parsedFiles().isEmpty(),
+ "open() must not parse any rule bodies");
+ assertFalse(lazy.categoryTitles().isEmpty(),
+ "pre-scan should expose at least one category");
+ }
+
+ @Test
+ public void selectingCategoryParsesOnlyOwningFiles() throws Exception
+ {
+ NameGenerator generator = new NameGenerator(DATA_DIR);
+ List categories = generator.getCategories();
+ assertFalse(categories.isEmpty());
+
+ // Reading category lists from the index should not have parsed
+ // anything yet — the lazy backend exposes its parse set via the
+ // internals; we observe through getCatalog instead.
+ String category = categories.getFirst();
+ List titles = generator.getTitlesFor(category);
+ assertFalse(titles.isEmpty());
+
+ String title = titles.getFirst();
+ List genders = generator.getGendersFor(category, title);
+ String gender = genders.isEmpty() ? "Male" : genders.getFirst();
+
+ RuleSet catalog = generator.getCatalog(category, title, gender);
+ assertNotNull(catalog, "expected a catalog for " + category + "/" + title + "/" + gender);
+ assertFalse(catalog.rules().isEmpty(),
+ "selected ruleset should be fully materialised");
+ }
+
+ @Test
+ public void lazyAndEagerProduceSameCategoryListing() throws Exception
+ {
+ NameGenData eager = NameGenDataLoader.load(DATA_DIR);
+ NameGenLazyData lazy = NameGenLazyData.open(DATA_DIR);
+ assertEquals(eager.categories().keySet(),
+ lazy.rulesetIdsByCategory().keySet(),
+ "category names must match between eager and lazy paths");
+ }
+
+ @Test
+ public void getDataMaterialisesEverything() throws Exception
+ {
+ // getData() is documented as forcing a full eager materialisation
+ // for tests. After calling it the rulesets() / lists() maps should
+ // be populated as if the eager loader had run.
+ NameGenerator generator = new NameGenerator(DATA_DIR);
+ NameGenData snapshot = generator.getData();
+ assertFalse(snapshot.lists().isEmpty(), "lists should be populated");
+ assertFalse(snapshot.rulesets().isEmpty(), "rulesets should be populated");
+ }
+}
diff --git a/code/src/utest/pcgen/core/namegen/NameGeneratorTest.java b/code/src/utest/pcgen/core/namegen/NameGeneratorTest.java
new file mode 100644
index 00000000000..9cd5d8e49d9
--- /dev/null
+++ b/code/src/utest/pcgen/core/namegen/NameGeneratorTest.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.core.namegen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Behavioural tests for {@link NameGenerator} driven by the bundled
+ * {@code plugins/Random Names} dataset.
+ */
+public class NameGeneratorTest
+{
+ private static final File DATA_DIR =
+ new File(System.getProperty("user.dir"), "plugins/Random Names");
+
+ private static NameGenerator generator;
+
+ @BeforeAll
+ public static void loadOnce() throws IOException
+ {
+ generator = new NameGenerator(DATA_DIR);
+ }
+
+ @Test
+ public void getCategoriesHidesInternalBuckets()
+ {
+ List categories = generator.getCategories();
+ assertFalse(categories.isEmpty());
+ assertTrue(categories.stream().noneMatch(c -> c.startsWith("Sex:")),
+ "Sex: buckets must not leak through the facade");
+ assertTrue(categories.stream().noneMatch("All"::equals),
+ "the 'All' pseudo-category must not leak through");
+ assertEquals(categories.stream().sorted().toList(), categories,
+ "categories must be sorted");
+ }
+
+ @Test
+ public void getTitlesForUnknownCategoryReturnsEmpty()
+ {
+ assertTrue(generator.getTitlesFor("definitely-not-a-category").isEmpty());
+ }
+
+ @Test
+ public void getTitlesForFantasyElvenIsNonEmptyAndSorted()
+ {
+ List titles = generator.getTitlesFor("Fantasy: Elven");
+ assertFalse(titles.isEmpty(), "Fantasy: Elven should have titles");
+ assertEquals(titles.stream().sorted().toList(), titles, "titles must be sorted");
+ }
+
+ @Test
+ public void getGendersForCanonicalOrder()
+ {
+ List genders = generator.getGendersFor("Fantasy: Elven", "Middle Earth Elf");
+ assertFalse(genders.isEmpty());
+ // Whatever subset shows up must appear in canonical order.
+ List canonical = List.of("Female", "Male", "Other");
+ List filteredCanonical = canonical.stream().filter(genders::contains).toList();
+ assertEquals(filteredCanonical, genders.subList(0, filteredCanonical.size()),
+ "canonical genders must come first in canonical order");
+ }
+
+ @Test
+ public void getCatalogResolvesKnownTriple()
+ {
+ RuleSet rs = generator.getCatalog("Fantasy: Elven", "Middle Earth Elf", "Male");
+ assertNotNull(rs, "Fantasy: Elven / Middle Earth Elf / Male should resolve");
+ assertEquals("final", rs.usage());
+ assertEquals("Middle Earth Elf", rs.title());
+ }
+
+ @Test
+ public void getCatalogReturnsNullForUnknownTriple()
+ {
+ assertNull(generator.getCatalog("Fantasy: Elven", "Middle Earth Elf", "Nonexistent"));
+ assertNull(generator.getCatalog("Fantasy: Elven", "no-such-title", "Male"));
+ assertNull(generator.getCatalog("no-such-category", "Middle Earth Elf", "Male"));
+ }
+
+ @Test
+ public void generateProducesNonEmptyNameRepeatedly() throws Exception
+ {
+ RuleSet rs = generator.getCatalog("Fantasy: Elven", "Middle Earth Elf", "Male");
+ assertNotNull(rs);
+ for (int i = 0; i < 100; i++)
+ {
+ GeneratedName generated = generator.generate(rs);
+ assertNotNull(generated.rule());
+ assertFalse(generated.name().isBlank(), "iteration " + i + " produced blank name");
+ }
+ }
+
+ @Test
+ public void generateWithRuleAlwaysUsesGivenRule() throws Exception
+ {
+ RuleSet rs = generator.getCatalog("Fantasy: Elven", "Middle Earth Elf", "Male");
+ List rules = generator.getRulesFor(rs);
+ assertFalse(rules.isEmpty());
+ Rule chosen = rules.get(0);
+ for (int i = 0; i < 20; i++)
+ {
+ GeneratedName generated = generator.generateWithRule(chosen);
+ assertEquals(chosen, generated.rule());
+ }
+ }
+
+ @Test
+ public void mixedContentValueExcludesSubvalueText() throws Exception
+ {
+ // In gaelic.xml, Donnbrown,
+ // brown-haired : the value text must be just
+ // "Donn", not "Donnbrown, brown-haired" — the DOM's
+ // getTextContent concatenates descendant text, but the data
+ // model stores the value separately from its subvalues.
+ NameList list = generator.getData().lists().get("gaelic-male-descriptive-byname");
+ assertNotNull(list, "expected to find list 'gaelic-male-descriptive-byname'");
+ Optional donn = list.values().stream()
+ .filter(v -> "Donn".equals(v.getValue()))
+ .findFirst();
+ assertTrue(donn.isPresent(), "expected to find 'Donn' value in list");
+ assertEquals("brown, brown-haired", donn.get().getSubValue("meaning"));
+ }
+}
diff --git a/code/src/utest/pcgen/gui3/namegen/GenderSelectionTest.java b/code/src/utest/pcgen/gui3/namegen/GenderSelectionTest.java
new file mode 100644
index 00000000000..cf56af2d374
--- /dev/null
+++ b/code/src/utest/pcgen/gui3/namegen/GenderSelectionTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.gui3.namegen;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class GenderSelectionTest
+{
+ @Test
+ void emptyAvailableYieldsEmptyString()
+ {
+ assertEquals("", GenderSelection.chooseSticky(List.of(), "Female", "Male"));
+ }
+
+ @Test
+ void previousWinsWhenStillValid()
+ {
+ assertEquals("Male",
+ GenderSelection.chooseSticky(List.of("Female", "Male"), "Male", "Female"));
+ }
+
+ @Test
+ void fallsBackToPreferredWhenPreviousMissing()
+ {
+ assertEquals("Female",
+ GenderSelection.chooseSticky(List.of("Female", "Other"), "Male", "Female"));
+ }
+
+ @Test
+ void fallsBackToPreferredWhenPreviousNull()
+ {
+ assertEquals("Other",
+ GenderSelection.chooseSticky(List.of("Other"), null, "Other"));
+ }
+
+ @Test
+ void fallsBackToFirstWhenNeitherValid()
+ {
+ assertEquals("Female",
+ GenderSelection.chooseSticky(List.of("Female", "Other"), "Male", "Male"));
+ }
+
+ @Test
+ void fallsBackToFirstWhenPreferredNull()
+ {
+ assertEquals("Female",
+ GenderSelection.chooseSticky(List.of("Female", "Male"), null, null));
+ }
+}
diff --git a/code/src/utest/pcgen/gui3/namegen/RandomNamePanelTest.java b/code/src/utest/pcgen/gui3/namegen/RandomNamePanelTest.java
new file mode 100644
index 00000000000..7d5a5de0349
--- /dev/null
+++ b/code/src/utest/pcgen/gui3/namegen/RandomNamePanelTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2026 Vest
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ */
+package pcgen.gui3.namegen;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+
+import pcgen.core.SettingsHandler;
+import pcgen.system.LanguageBundle;
+
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Scene;
+import javafx.stage.Stage;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.testfx.api.FxAssert;
+import org.testfx.api.FxRobotInterface;
+import org.testfx.framework.junit5.ApplicationExtension;
+import org.testfx.framework.junit5.Start;
+import org.testfx.matcher.base.NodeMatchers;
+
+/**
+ * Smoke test for the random-name FXML panel — mirrors {@code AboutDialogTest}.
+ * Loads the panel into a stage and asserts that the key controls render.
+ * Deeper logic is exercised in {@link GenderSelectionTest} and the engine
+ * tests under {@code pcgen.core.namegen}.
+ */
+@ExtendWith(ApplicationExtension.class)
+class RandomNamePanelTest
+{
+ @Start
+ private void start(Stage stage) throws IOException
+ {
+ // Controller's initialize() resolves the data dir through SettingsHandler.
+ SettingsHandler.setGmgenPluginDir(new File(System.getProperty("user.dir"), "plugins"));
+
+ FXMLLoader loader = new FXMLLoader();
+ URL resource = RandomNamePanelController.class.getResource("RandomNamePanel.fxml");
+ assert resource != null;
+ loader.setLocation(resource);
+ LanguageBundle.getString("");
+ loader.setResources(LanguageBundle.getBundle());
+ Scene scene = loader.load();
+ stage.setScene(scene);
+ stage.show();
+ }
+
+ @Test
+ void test_panel_renders_expected_controls(final FxRobotInterface robot)
+ {
+ FxAssert.verifyThat("#categoryCombo", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#titleCombo", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#genderFemale", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#genderMale", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#genderOther", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#advancedPane", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#randomStructureCheck", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#generateButton", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#okButton", NodeMatchers.isVisible());
+ FxAssert.verifyThat("#cancelButton", NodeMatchers.isVisible());
+ }
+}
diff --git a/installers/lobobrowser/cobra/0.98.4/cobra-0.98.4.jar b/installers/lobobrowser/cobra/0.98.4/cobra-0.98.4.jar
deleted file mode 100644
index 5c02e2ae614..00000000000
Binary files a/installers/lobobrowser/cobra/0.98.4/cobra-0.98.4.jar and /dev/null differ
diff --git a/installers/lobobrowser/cobra/0.98.4/cobra-0.98.4.pom b/installers/lobobrowser/cobra/0.98.4/cobra-0.98.4.pom
deleted file mode 100644
index db7c955b32f..00000000000
--- a/installers/lobobrowser/cobra/0.98.4/cobra-0.98.4.pom
+++ /dev/null
@@ -1,6 +0,0 @@
-
- 4.0.0
- lobobrowser
- cobra
- 0.98.4
-
\ No newline at end of file
diff --git a/installers/lobobrowser/cobra/0.98.4/cobra.iml b/installers/lobobrowser/cobra/0.98.4/cobra.iml
deleted file mode 100644
index 9e3449c9d8f..00000000000
--- a/installers/lobobrowser/cobra/0.98.4/cobra.iml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/plugins/Random Names/arabic.xml b/plugins/Random Names/arabic.xml
index be328e35a08..644fff07c67 100644
--- a/plugins/Random Names/arabic.xml
+++ b/plugins/Random Names/arabic.xml
@@ -19,12 +19,12 @@
-
+
-
+
@@ -51,7 +51,7 @@
-
+
@@ -59,7 +59,7 @@
-
+
@@ -76,12 +76,12 @@
-
+
-
+
@@ -117,11 +117,11 @@
-
+
-
+
diff --git a/plugins/Random Names/fantasy-tamriel.xml b/plugins/Random Names/fantasy-tamriel.xml
index fb87b6f2ea9..5ecaf0776c2 100644
--- a/plugins/Random Names/fantasy-tamriel.xml
+++ b/plugins/Random Names/fantasy-tamriel.xml
@@ -50,11 +50,11 @@
-
+
-
+
@@ -65,7 +65,7 @@
-
+
@@ -78,7 +78,7 @@
-
+
@@ -155,19 +155,19 @@
-
+
-
+
-
+
@@ -194,19 +194,19 @@
-
+
-
+
-
+
@@ -370,7 +370,7 @@
-
+
@@ -406,11 +406,11 @@
-
+
-
+
@@ -419,12 +419,12 @@
-
+
-
+
@@ -435,11 +435,11 @@
-
+
-
+
diff --git a/plugins/Random Names/fantasy.xml b/plugins/Random Names/fantasy.xml
index 5ae16b9a259..4fd4f75aa7a 100644
--- a/plugins/Random Names/fantasy.xml
+++ b/plugins/Random Names/fantasy.xml
@@ -92,22 +92,22 @@
-
+
-
+
-
+
-
+
-
+
-
+
@@ -118,10 +118,10 @@
-
+
-
+
@@ -142,7 +142,7 @@
-
+
@@ -159,10 +159,10 @@
-
+
-
+
@@ -174,7 +174,7 @@
-
+
@@ -203,8 +203,8 @@
-
-
+
+
@@ -221,10 +221,10 @@
-
+
-
-
+
+
@@ -238,7 +238,7 @@
-
+
@@ -252,7 +252,7 @@
-
+
diff --git a/plugins/Random Names/fantasy_craft.xml b/plugins/Random Names/fantasy_craft.xml
index 16c74afc902..c355b5f4d70 100644
--- a/plugins/Random Names/fantasy_craft.xml
+++ b/plugins/Random Names/fantasy_craft.xml
@@ -269,21 +269,21 @@
-
+
-
+
-
+
@@ -302,21 +302,21 @@
-
+
-
+
-
+
@@ -553,7 +553,7 @@
-
+
diff --git a/plugins/Random Names/french.xml b/plugins/Random Names/french.xml
index e94037e87f3..cfe425028db 100644
--- a/plugins/Random Names/french.xml
+++ b/plugins/Random Names/french.xml
@@ -9,12 +9,12 @@
-
+
-
+
@@ -25,12 +25,12 @@
-
+
-
+
diff --git a/plugins/Random Names/gaelic.xml b/plugins/Random Names/gaelic.xml
index ed2fe0262b5..3e7125be8c4 100644
--- a/plugins/Random Names/gaelic.xml
+++ b/plugins/Random Names/gaelic.xml
@@ -19,7 +19,7 @@
-
+
@@ -28,16 +28,16 @@
-
+
-
+
-
+
@@ -48,9 +48,9 @@
-
+
-
+
@@ -167,7 +167,7 @@
-
+
@@ -176,16 +176,16 @@
-
+
-
+
-
+
@@ -196,9 +196,9 @@
-
+
-
+
diff --git a/plugins/Random Names/gothic.xml b/plugins/Random Names/gothic.xml
index 15def7b055d..6b3e5d88375 100644
--- a/plugins/Random Names/gothic.xml
+++ b/plugins/Random Names/gothic.xml
@@ -6,21 +6,21 @@
-
+
-
+
-
+
-
+
-
+
@@ -37,14 +37,14 @@
-
+
-
+
-
+
diff --git a/plugins/Random Names/horror.xml b/plugins/Random Names/horror.xml
index 36d0060ae0c..a4a0b22cdf7 100644
--- a/plugins/Random Names/horror.xml
+++ b/plugins/Random Names/horror.xml
@@ -32,7 +32,7 @@
-
+
@@ -98,7 +98,7 @@
-
+
diff --git a/plugins/Random Names/pictish.xml b/plugins/Random Names/pictish.xml
index b99a819a23e..d414c852068 100644
--- a/plugins/Random Names/pictish.xml
+++ b/plugins/Random Names/pictish.xml
@@ -10,7 +10,7 @@
-
+
@@ -28,7 +28,7 @@
-
+
@@ -50,7 +50,7 @@
-
+
@@ -72,7 +72,7 @@
-
+
@@ -94,7 +94,7 @@
-
+
@@ -119,7 +119,7 @@
-
+
diff --git a/plugins/Random Names/russian.xml b/plugins/Random Names/russian.xml
index fbad7a6576e..8ffe65c90f9 100644
--- a/plugins/Random Names/russian.xml
+++ b/plugins/Random Names/russian.xml
@@ -52,7 +52,7 @@
-
+
@@ -68,7 +68,7 @@
-
+
@@ -84,7 +84,7 @@
-
+