panel public release

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:13:02 -07:00
commit 8a94ffd58f
2165 changed files with 301493 additions and 0 deletions
+490
View File
@@ -0,0 +1,490 @@
// Package dayzxml merges mod XML snippets into a running DayZ server's
// Central Economy mission XMLs (types.xml, events.xml, cfgspawnabletypes.xml,
// cfgeventspawns.xml, cfgrandompresets.xml, messages.xml).
//
// Why this exists:
//
// Mods almost always ship "snippet" XMLs — a types.xml with just the mod's new
// items, or an events.xml with the mod's new dynamic events. Operators are
// expected to merge these into their base mission files. The canonical
// approach Bohemia documents is to register each mod's folder via
// cfgeconomycore.xml and let the engine merge at load time, but most community
// operators still pre-merge into a single file per kind to make debugging,
// diffing, and rolling back easier. That's what this package does.
//
// Merge semantics (per Bohemia wiki "DayZ:Central Economy mission files modding"):
//
// - types.xml, cfgspawnabletypes.xml, cfgeventspawns.xml, cfgrandompresets.xml
// → replace existing entry by `name` attribute; new names appended.
// - events.xml
// → replace the entry's scalar children; APPEND new <child> nodes (events'
// children list is append-only in engine merge behavior — there's no
// "remove a child" via merge; operators must redefine the entire event).
// - messages.xml
// → append-only; messages don't have a unique key and there's no
// replace-by-id semantic in the engine.
//
// We use github.com/beevik/etree rather than Go's encoding/xml so element
// order, attribute order, and comments are preserved verbatim. DayZ's
// engine doesn't care about order, but operators diff these files by hand
// and expect them to stay stable across panel edits.
package dayzxml
import (
"bytes"
"fmt"
"sort"
"strings"
"github.com/beevik/etree"
)
// FileKind identifies a DayZ CE XML file type whose merge semantics we know.
type FileKind string
const (
KindTypes FileKind = "types" // types.xml
KindSpawnableTypes FileKind = "spawnabletypes" // cfgspawnabletypes.xml
KindEvents FileKind = "events" // events.xml
KindEventSpawns FileKind = "eventposdef" // cfgeventspawns.xml
KindRandomPresets FileKind = "randompresets" // cfgrandompresets.xml
KindMessages FileKind = "messages" // messages.xml
)
// MergeMode controls what happens when a snippet entry collides with an existing one.
type MergeMode int
const (
// ReplaceByName overwrites the existing entry wholesale.
ReplaceByName MergeMode = iota
// AppendOnly ignores the existing entry and just appends the snippet's copy.
// Used for files without a unique key (messages.xml).
AppendOnly
// EventsMerge replaces the entry's scalar attributes + non-child descendants,
// and APPENDS <child> nodes from the snippet that don't already exist in
// base (matched by `type=` attribute). Matches engine behavior for events.xml.
EventsMerge
)
// Spec describes how to identify and merge entries in one file kind.
type Spec struct {
RootTag string // expected root element tag ("types", "events", ...)
EntryTag string // entry element tag ("type", "event"); empty = heterogeneous
KeyAttr string // attribute that identifies an entry ("name"); empty = no key
Mode MergeMode // how to resolve collisions
EntryTags []string // for heterogeneous files (randompresets: "cargo"/"attachments")
}
// defaultSpecs holds the canonical spec per kind. Callers should treat
// these as read-only.
var defaultSpecs = map[FileKind]Spec{
KindTypes: {RootTag: "types", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
KindSpawnableTypes: {RootTag: "spawnabletypes", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName},
KindEvents: {RootTag: "events", EntryTag: "event", KeyAttr: "name", Mode: EventsMerge},
KindEventSpawns: {RootTag: "eventposdef", EntryTag: "event", KeyAttr: "name", Mode: ReplaceByName},
KindRandomPresets: {RootTag: "randompresets", KeyAttr: "name", Mode: ReplaceByName, EntryTags: []string{"cargo", "attachments"}},
KindMessages: {RootTag: "messages", EntryTag: "message", Mode: AppendOnly},
}
// SpecFor returns the default Spec for a file kind, or zero + false if unknown.
func SpecFor(k FileKind) (Spec, bool) {
s, ok := defaultSpecs[k]
return s, ok
}
// SupportedKinds returns all FileKinds this package knows how to merge, in
// a stable order suitable for UI lists.
func SupportedKinds() []FileKind {
return []FileKind{
KindTypes, KindSpawnableTypes, KindEvents, KindEventSpawns,
KindRandomPresets, KindMessages,
}
}
// Plan is the outcome of a (possibly dry-run) merge.
type Plan struct {
Kind FileKind `json:"kind"`
Added []string `json:"added"` // new entry names appended
Replaced []string `json:"replaced"` // existing names overwritten
Unchanged []string `json:"unchanged"` // snippet entries byte-identical to base
AppendedChildren map[string][]string `json:"appended_children,omitempty"` // event name → child types added
Conflicts []string `json:"conflicts,omitempty"` // snippet has same key twice; first wins
Warnings []string `json:"warnings,omitempty"` // non-fatal (BOM stripped, EOL adjusted, etc.)
}
// Result is returned by Merge. Bytes is the merged XML (empty in dry-run mode).
type Result struct {
Plan Plan
Bytes []byte
}
// DetectKind classifies a raw snippet by its root element tag. Returns
// the matching FileKind or an error if the root doesn't match any known spec.
func DetectKind(snippet []byte) (FileKind, error) {
doc := etree.NewDocument()
if err := doc.ReadFromBytes(stripBOM(snippet)); err != nil {
return "", fmt.Errorf("parse snippet: %w", err)
}
root := doc.Root()
if root == nil {
return "", fmt.Errorf("snippet has no root element")
}
for k, s := range defaultSpecs {
if s.RootTag == root.Tag {
return k, nil
}
}
return "", fmt.Errorf("unknown root element %q — not a supported DayZ CE XML", root.Tag)
}
// Merge applies the snippet to the base file. If dryRun is true, only the
// Plan is populated (Bytes is nil). Otherwise Bytes contains the merged
// XML, ready to write.
func Merge(kind FileKind, base, snippet []byte, dryRun bool) (*Result, error) {
spec, ok := defaultSpecs[kind]
if !ok {
return nil, fmt.Errorf("unsupported file kind %q", kind)
}
baseDoc, warnings, err := parseDoc(base, "base")
if err != nil {
return nil, err
}
snipDoc, snipWarnings, err := parseDoc(snippet, "snippet")
if err != nil {
return nil, err
}
warnings = append(warnings, snipWarnings...)
baseRoot := baseDoc.Root()
if baseRoot == nil || baseRoot.Tag != spec.RootTag {
return nil, fmt.Errorf("base is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(baseDoc))
}
snipRoot := snipDoc.Root()
if snipRoot == nil || snipRoot.Tag != spec.RootTag {
return nil, fmt.Errorf("snippet is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(snipDoc))
}
plan := Plan{
Kind: kind,
AppendedChildren: map[string][]string{},
}
// Index base entries by key, preserving insertion order.
baseIndex, baseOrder := indexEntries(baseRoot, spec)
// Walk the snippet in order.
seenInSnippet := map[string]bool{}
for _, child := range snipRoot.ChildElements() {
if !matchesEntry(child, spec) {
continue
}
key := entryKey(child, spec)
if spec.Mode == AppendOnly || key == "" {
// No dedup; append unconditionally.
baseRoot.AddChild(child.Copy())
plan.Added = append(plan.Added, fmt.Sprintf("%s#%d", child.Tag, len(plan.Added)+1))
continue
}
if seenInSnippet[key] {
plan.Conflicts = append(plan.Conflicts, key)
continue
}
seenInSnippet[key] = true
existing, exists := baseIndex[key]
if !exists {
baseRoot.AddChild(child.Copy())
plan.Added = append(plan.Added, key)
continue
}
// Collision — resolve per mode.
switch spec.Mode {
case ReplaceByName:
if elementsEqualish(existing, child) {
plan.Unchanged = append(plan.Unchanged, key)
continue
}
replaceElement(baseRoot, existing, child)
baseIndex[key] = child.Copy()
plan.Replaced = append(plan.Replaced, key)
case EventsMerge:
appended, changed := mergeEventChildren(existing, child)
if !changed {
plan.Unchanged = append(plan.Unchanged, key)
continue
}
plan.Replaced = append(plan.Replaced, key)
if len(appended) > 0 {
plan.AppendedChildren[key] = appended
}
}
_ = baseOrder // preserved via baseRoot ordering
}
// Stable-sort output slices for deterministic UI.
sort.Strings(plan.Added)
sort.Strings(plan.Replaced)
sort.Strings(plan.Unchanged)
sort.Strings(plan.Conflicts)
plan.Warnings = append(plan.Warnings, warnings...)
res := &Result{Plan: plan}
if dryRun {
return res, nil
}
baseDoc.Indent(4)
out, err := baseDoc.WriteToBytes()
if err != nil {
return nil, fmt.Errorf("render merged xml: %w", err)
}
// Preserve the base file's line-ending convention.
if bytes.Contains(base, []byte("\r\n")) && !bytes.Contains(out, []byte("\r\n")) {
out = bytes.ReplaceAll(out, []byte("\n"), []byte("\r\n"))
}
res.Bytes = out
return res, nil
}
// ---------- helpers ----------
func parseDoc(raw []byte, label string) (*etree.Document, []string, error) {
warnings := []string{}
clean, bomStripped := stripBOMWithFlag(raw)
if bomStripped {
warnings = append(warnings, label+": stripped UTF-8 BOM")
}
doc := etree.NewDocument()
if err := doc.ReadFromBytes(clean); err != nil {
return nil, warnings, fmt.Errorf("%s: parse: %w", label, err)
}
return doc, warnings, nil
}
func safeRootTag(d *etree.Document) string {
if r := d.Root(); r != nil {
return r.Tag
}
return "(no-root)"
}
// matchesEntry reports whether child is one of the entry tags this spec considers.
func matchesEntry(child *etree.Element, spec Spec) bool {
if len(spec.EntryTags) > 0 {
for _, t := range spec.EntryTags {
if child.Tag == t {
return true
}
}
return false
}
return spec.EntryTag != "" && child.Tag == spec.EntryTag
}
// entryKey returns the merge key for a child element (empty string if no key).
func entryKey(child *etree.Element, spec Spec) string {
if spec.KeyAttr == "" {
return ""
}
return child.SelectAttrValue(spec.KeyAttr, "")
}
// indexEntries scans root and builds a name→element map of mergeable entries.
// Also returns the insertion-order list (useful for deterministic diffs).
func indexEntries(root *etree.Element, spec Spec) (map[string]*etree.Element, []string) {
m := make(map[string]*etree.Element)
order := make([]string, 0, 64)
for _, child := range root.ChildElements() {
if !matchesEntry(child, spec) {
continue
}
k := entryKey(child, spec)
if k == "" {
continue
}
if _, dup := m[k]; !dup {
order = append(order, k)
}
m[k] = child
}
return m, order
}
// replaceElement swaps old in-place with a copy of repl, preserving sibling order.
func replaceElement(parent, old, repl *etree.Element) {
newEl := repl.Copy()
// etree doesn't have an "insert at index"; replicate by collecting children
// around `old`, removing all, and re-adding.
children := parent.ChildElements()
idx := -1
for i, c := range children {
if c == old {
idx = i
break
}
}
if idx < 0 {
parent.AddChild(newEl)
return
}
// Remove old and rebuild tail ordering.
parent.RemoveChild(old)
// If we removed from the end, AddChild appends at the end — correct.
// Otherwise we need to move the trailing children back after newEl. etree's
// AddChild appends, so we re-add newEl then move the original tail past it.
tail := children[idx+1:]
for _, t := range tail {
parent.RemoveChild(t)
}
parent.AddChild(newEl)
for _, t := range tail {
parent.AddChild(t)
}
}
// elementsEqualish is a lenient equality check: same tag, same attrs
// (set-compare, order-insensitive), same text content, same child-element
// tree by signature dump.
func elementsEqualish(a, b *etree.Element) bool {
if a.Tag != b.Tag {
return false
}
if strings.TrimSpace(a.Text()) != strings.TrimSpace(b.Text()) {
return false
}
if len(a.Attr) != len(b.Attr) {
return false
}
aAttrs := attrMap(a)
bAttrs := attrMap(b)
for k, v := range aAttrs {
if bAttrs[k] != v {
return false
}
}
return childrenSignature(a) == childrenSignature(b)
}
func attrMap(e *etree.Element) map[string]string {
m := make(map[string]string, len(e.Attr))
for _, a := range e.Attr {
m[a.Key] = a.Value
}
return m
}
func childrenSignature(e *etree.Element) string {
var b strings.Builder
for _, c := range e.ChildElements() {
b.WriteString(c.Tag)
b.WriteByte('{')
keys := make([]string, 0, len(c.Attr))
am := attrMap(c)
for k := range am {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(k)
b.WriteByte('=')
b.WriteString(am[k])
b.WriteByte(' ')
}
b.WriteString(childrenSignature(c))
b.WriteByte('}')
t := strings.TrimSpace(c.Text())
if t != "" {
b.WriteString("#")
b.WriteString(t)
}
}
return b.String()
}
// mergeEventChildren implements events.xml's special semantics: replace the
// scalar attrs + non-children descendants of `base` with those of `snippet`,
// then append any `<child type="...">` from snippet whose `type` isn't
// already present in base. Returns (appended child types, changed?).
func mergeEventChildren(base, snippet *etree.Element) ([]string, bool) {
changed := false
// 1. Overwrite attributes on the event element.
if !sameAttrs(base, snippet) {
for _, a := range base.Attr {
base.RemoveAttr(a.Key)
}
for _, a := range snippet.Attr {
base.CreateAttr(a.Key, a.Value)
}
changed = true
}
// 2. Replace scalar direct children (non-<children>) wholesale.
for _, sChild := range snippet.ChildElements() {
if sChild.Tag == "children" {
continue
}
// find matching scalar in base (by tag)
if bChild := base.SelectElement(sChild.Tag); bChild != nil {
if !elementsEqualish(bChild, sChild) {
base.RemoveChild(bChild)
base.AddChild(sChild.Copy())
changed = true
}
} else {
base.AddChild(sChild.Copy())
changed = true
}
}
// 3. Append new <child type=...> entries from snippet's <children>.
var appended []string
sChildren := snippet.SelectElement("children")
if sChildren == nil {
return appended, changed
}
bChildren := base.SelectElement("children")
if bChildren == nil {
bChildren = base.CreateElement("children")
changed = true
}
existingTypes := map[string]bool{}
for _, c := range bChildren.ChildElements() {
if t := c.SelectAttrValue("type", ""); t != "" {
existingTypes[t] = true
}
}
for _, sChildEl := range sChildren.ChildElements() {
t := sChildEl.SelectAttrValue("type", "")
if t == "" || existingTypes[t] {
continue
}
bChildren.AddChild(sChildEl.Copy())
existingTypes[t] = true
appended = append(appended, t)
changed = true
}
return appended, changed
}
func sameAttrs(a, b *etree.Element) bool {
if len(a.Attr) != len(b.Attr) {
return false
}
am := attrMap(a)
for _, at := range b.Attr {
if am[at.Key] != at.Value {
return false
}
}
return true
}
// stripBOM returns input with a leading UTF-8 BOM removed (if present).
func stripBOM(b []byte) []byte {
out, _ := stripBOMWithFlag(b)
return out
}
func stripBOMWithFlag(b []byte) ([]byte, bool) {
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
return b[3:], true
}
return b, false
}
+170
View File
@@ -0,0 +1,170 @@
package dayzxml
import (
"strings"
"testing"
)
func TestDetectKind(t *testing.T) {
cases := []struct {
in string
want FileKind
}{
{`<types><type name="X"/></types>`, KindTypes},
{`<events><event name="X"/></events>`, KindEvents},
{`<spawnabletypes><type name="X"/></spawnabletypes>`, KindSpawnableTypes},
{`<messages><message/></messages>`, KindMessages},
{`<randompresets><cargo name="X"/></randompresets>`, KindRandomPresets},
{`<eventposdef><event name="X"/></eventposdef>`, KindEventSpawns},
}
for _, c := range cases {
got, err := DetectKind([]byte(c.in))
if err != nil {
t.Errorf("DetectKind(%q) err: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("DetectKind(%q) = %v, want %v", c.in, got, c.want)
}
}
if _, err := DetectKind([]byte(`<foo/>`)); err == nil {
t.Error("expected error for unknown root")
}
}
func TestMergeTypes_AddAndReplace(t *testing.T) {
base := []byte(`<types>
<type name="AKM"><nominal>5</nominal><min>2</min></type>
<type name="M4A1"><nominal>3</nominal><min>1</min></type>
</types>`)
snippet := []byte(`<types>
<type name="AKM"><nominal>8</nominal><min>4</min></type>
<type name="NewItem"><nominal>1</nominal><min>1</min></type>
</types>`)
res, err := Merge(KindTypes, base, snippet, false)
if err != nil {
t.Fatal(err)
}
if len(res.Plan.Added) != 1 || res.Plan.Added[0] != "NewItem" {
t.Errorf("Added = %v, want [NewItem]", res.Plan.Added)
}
if len(res.Plan.Replaced) != 1 || res.Plan.Replaced[0] != "AKM" {
t.Errorf("Replaced = %v, want [AKM]", res.Plan.Replaced)
}
out := string(res.Bytes)
if !strings.Contains(out, `name="NewItem"`) {
t.Error("merged output missing NewItem")
}
if !strings.Contains(out, `<nominal>8</nominal>`) {
t.Error("merged output missing updated AKM nominal value")
}
if !strings.Contains(out, `name="M4A1"`) {
t.Error("merged output dropped untouched M4A1 entry")
}
}
func TestMergeTypes_Unchanged(t *testing.T) {
xml := []byte(`<types>
<type name="AKM"><nominal>5</nominal></type>
</types>`)
res, err := Merge(KindTypes, xml, xml, true)
if err != nil {
t.Fatal(err)
}
if len(res.Plan.Unchanged) != 1 || res.Plan.Unchanged[0] != "AKM" {
t.Errorf("Unchanged = %v, want [AKM]", res.Plan.Unchanged)
}
if len(res.Plan.Replaced) != 0 {
t.Errorf("Replaced = %v, want []", res.Plan.Replaced)
}
}
func TestMergeEvents_AppendsChildren(t *testing.T) {
base := []byte(`<events>
<event name="VehicleSpawn">
<nominal>3</nominal>
<children>
<child type="UAZ_Base" min="1" max="1"/>
</children>
</event>
</events>`)
snippet := []byte(`<events>
<event name="VehicleSpawn">
<nominal>5</nominal>
<children>
<child type="UAZ_Mod1" min="1" max="1"/>
<child type="UAZ_Base" min="1" max="1"/>
</children>
</event>
</events>`)
res, err := Merge(KindEvents, base, snippet, false)
if err != nil {
t.Fatal(err)
}
out := string(res.Bytes)
// nominal overridden
if !strings.Contains(out, `<nominal>5</nominal>`) {
t.Error("event scalar child <nominal> not overridden")
}
// new child appended, existing child not duplicated
if strings.Count(out, `type="UAZ_Base"`) != 1 {
t.Errorf("existing child duplicated; output:\n%s", out)
}
if !strings.Contains(out, `type="UAZ_Mod1"`) {
t.Error("new child not appended")
}
if got := res.Plan.AppendedChildren["VehicleSpawn"]; len(got) != 1 || got[0] != "UAZ_Mod1" {
t.Errorf("AppendedChildren[VehicleSpawn] = %v, want [UAZ_Mod1]", got)
}
}
func TestMergeMessages_AppendOnly(t *testing.T) {
base := []byte(`<messages>
<message><id>1</id><text>Welcome</text></message>
</messages>`)
snippet := []byte(`<messages>
<message><id>2</id><text>Restart in 10m</text></message>
</messages>`)
res, err := Merge(KindMessages, base, snippet, false)
if err != nil {
t.Fatal(err)
}
if len(res.Plan.Added) != 1 {
t.Errorf("Added count = %d, want 1", len(res.Plan.Added))
}
if !strings.Contains(string(res.Bytes), "Restart in 10m") {
t.Error("appended message not present")
}
}
func TestStripBOM(t *testing.T) {
bom := []byte{0xEF, 0xBB, 0xBF}
raw := append(bom, []byte(`<types><type name="X"/></types>`)...)
k, err := DetectKind(raw)
if err != nil {
t.Fatal(err)
}
if k != KindTypes {
t.Errorf("kind = %v, want %v", k, KindTypes)
}
}
func TestRootMismatch(t *testing.T) {
base := []byte(`<types></types>`)
snippet := []byte(`<events></events>`)
if _, err := Merge(KindTypes, base, snippet, true); err == nil {
t.Error("expected error when snippet root doesn't match")
}
}
func TestPreserveCRLF(t *testing.T) {
base := []byte("<types>\r\n <type name=\"AKM\"><nominal>5</nominal></type>\r\n</types>")
snippet := []byte(`<types><type name="NEW"><nominal>1</nominal></type></types>`)
res, err := Merge(KindTypes, base, snippet, false)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(res.Bytes), "\r\n") {
t.Error("output dropped CRLF line endings; base had CRLF")
}
}