panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user