using ACE.Database.Models.World;
using System.Globalization;
namespace Aeshnidae.DevKit;
///
/// "What did I change?" - a weenie or a landblock in this server's world schema
/// compared with the same one in the base schema (the untouched copy of live that
/// staging was cloned from).
///
/// Rows are compared by CONTENT, never by id. A weenie that was exported, edited and
/// re-imported has been deleted and re-inserted, so every autoincrement id under it is
/// new even where nothing changed; comparing ids would call every row modified. The
/// emote tables are the awkward case - actions hang off their emote's id - so each
/// emote is folded together with its actions into one composite before comparing.
///
/// Output is lines in the same vocabulary as /winfo, prefixed
/// ~ changed - removed + added
///
internal static class Diff
{
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
public sealed record Result(string Subject, bool InBase, bool InWorld, List Lines)
{
public bool Changed => Lines.Count > 0;
}
/// Tables whose rows are (type, value) pairs; rendered as property changes.
private static readonly Dictionary PropertyTables = new()
{
["weenie_properties_int"] = "int",
["weenie_properties_int64"] = "int64",
["weenie_properties_bool"] = "bool",
["weenie_properties_float"] = "float",
["weenie_properties_string"] = "string",
["weenie_properties_d_i_d"] = "d_i_d",
["weenie_properties_i_i_d"] = "i_i_d",
};
private static readonly string[] IgnoredColumns = { "id", "object_Id", "last_Modified" };
// ---- weenie -----------------------------------------------------------------
public static Result Weenie(uint wcid)
{
var world = Db.SafeIdent(Db.WorldSchema);
var baseS = Db.SafeIdent(Mod.Settings.BaseSchema);
var lines = new List();
var headBase = Db.Query($"SELECT class_Name, type FROM `{baseS}`.weenie WHERE class_Id = @w", ("@w", wcid)).FirstOrDefault();
var headWorld = Db.Query($"SELECT class_Name, type FROM `{world}`.weenie WHERE class_Id = @w", ("@w", wcid)).FirstOrDefault();
var subject = $"{wcid} {Names.Weenie(wcid)}";
if (headBase is null && headWorld is null)
return new Result(subject, false, false, lines);
if (headBase is null)
{
lines.Add($"+ weenie {wcid} is NEW - not in live");
}
else if (headWorld is null)
{
lines.Add($"- weenie {wcid} DELETED - exists in live");
return new Result(subject, true, false, lines);
}
else
{
if (!Equals(headBase["class_Name"], headWorld["class_Name"]))
lines.Add($"~ class name: {headBase["class_Name"]} -> {headWorld["class_Name"]}");
if (Convert.ToUInt32(headBase["type"]) != Convert.ToUInt32(headWorld["type"]))
lines.Add($"~ weenie type: {Format.EnumName(Convert.ToUInt32(headBase["type"]))} -> {Format.EnumName(Convert.ToUInt32(headWorld["type"]))}");
}
var tables = Db.Query(
"SELECT table_name FROM information_schema.tables WHERE table_schema = @s AND table_name LIKE 'weenie\\_properties\\_%' ORDER BY table_name",
("@s", world))
.Select(r => (string)r["table_name"]!)
.Where(t => t != "weenie_properties_emote_action")
.ToList();
foreach (var table in tables)
{
if (table == "weenie_properties_emote")
{
DiffEmotes(lines, baseS, world, wcid);
continue;
}
var cols = Db.Columns(world, table, IgnoredColumns);
if (cols.Count == 0) continue;
var colList = string.Join(", ", cols.Select(c => $"`{c}`"));
var baseRows = headBase is null ? new List() : Db.Query($"SELECT {colList} FROM `{baseS}`.`{table}` WHERE object_Id = @w", ("@w", wcid));
var worldRows = Db.Query($"SELECT {colList} FROM `{world}`.`{table}` WHERE object_Id = @w", ("@w", wcid));
var (removed, added) = Compare(baseRows, worldRows, cols);
if (removed.Count == 0 && added.Count == 0) continue;
if (PropertyTables.TryGetValue(table, out var kind))
RenderProperties(lines, kind, removed, added);
else
RenderRows(lines, table, removed, added);
}
return new Result(subject, headBase is not null, true, lines);
}
private static void DiffEmotes(List lines, string baseS, string world, uint wcid)
{
var baseEmotes = LoadEmotes(baseS, wcid);
var worldEmotes = LoadEmotes(world, wcid);
// Composite key: the emote's own columns plus each action's columns in order.
var baseKeys = baseEmotes.Select(e => (key: e.key, emote: e.emote)).ToList();
var worldKeys = worldEmotes.Select(e => (key: e.key, emote: e.emote)).ToList();
var removed = MultisetExcept(baseKeys, worldKeys, x => x.key);
var added = MultisetExcept(worldKeys, baseKeys, x => x.key);
// Pair a removed and an added emote with the same trigger so it reads as a change.
foreach (var r in removed.ToList())
{
var header = Format.EmoteHeader(r.emote);
var match = added.FirstOrDefault(a => Format.EmoteHeader(a.emote) == header);
if (match.emote is null) continue;
lines.Add($"~ {header}:");
lines.Add($" was: {string.Join(" ; ", r.emote.WeeniePropertiesEmoteAction.OrderBy(a => a.Order).Select(Format.Action))}");
lines.Add($" now: {string.Join(" ; ", match.emote.WeeniePropertiesEmoteAction.OrderBy(a => a.Order).Select(Format.Action))}");
removed.Remove(r);
added.Remove(match);
}
foreach (var r in removed) lines.Add($"- emote {Format.Emote(r.emote)}");
foreach (var a in added) lines.Add($"+ emote {Format.Emote(a.emote)}");
}
private static List<(string key, WeeniePropertiesEmote emote)> LoadEmotes(string schema, uint wcid)
{
var result = new List<(string, WeeniePropertiesEmote)>();
var emoteCols = Db.Columns(schema, "weenie_properties_emote", IgnoredColumns);
var actionCols = Db.Columns(schema, "weenie_properties_emote_action", "id", "emote_Id");
var emotes = Db.Query($"SELECT id, {string.Join(", ", emoteCols.Select(c => $"`{c}`"))} FROM `{schema}`.weenie_properties_emote WHERE object_Id = @w ORDER BY id", ("@w", wcid));
if (emotes.Count == 0) return result;
var ids = string.Join(",", emotes.Select(e => Convert.ToUInt32(e["id"]).ToString(Inv)));
var actions = Db.Query($"SELECT emote_Id, {string.Join(", ", actionCols.Select(c => $"`{c}`"))} FROM `{schema}`.weenie_properties_emote_action WHERE emote_Id IN ({ids}) ORDER BY emote_Id, `order`")
.GroupBy(a => Convert.ToUInt32(a["emote_Id"]))
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var row in emotes)
{
var id = Convert.ToUInt32(row["id"]);
actions.TryGetValue(id, out var acts);
acts ??= new List();
var e = new WeeniePropertiesEmote
{
ObjectId = wcid,
Category = Convert.ToUInt32(row["category"]),
Probability = Convert.ToSingle(row["probability"]),
WeenieClassId = U(row["weenie_Class_Id"]),
Style = U(row["style"]),
Substyle = U(row["substyle"]),
Quest = row["quest"] as string,
VendorType = I(row["vendor_Type"]),
MinHealth = F(row["min_Health"]),
MaxHealth = F(row["max_Health"]),
};
foreach (var a in acts)
{
e.WeeniePropertiesEmoteAction.Add(new WeeniePropertiesEmoteAction
{
Order = Convert.ToUInt32(a["order"]),
Type = Convert.ToUInt32(a["type"]),
Delay = Convert.ToSingle(a["delay"]),
Extent = Convert.ToSingle(a["extent"]),
Motion = U(a["motion"]),
Message = a["message"] as string,
TestString = a["test_String"] as string,
Min = I(a["min"]),
Max = I(a["max"]),
Min64 = L(a["min_64"]),
Max64 = L(a["max_64"]),
MinDbl = D(a["min_Dbl"]),
MaxDbl = D(a["max_Dbl"]),
Stat = I(a["stat"]),
Display = a["display"] is null ? null : Convert.ToBoolean(a["display"]),
Amount = I(a["amount"]),
Amount64 = L(a["amount_64"]),
HeroXP64 = L(a["hero_X_P_64"]),
Percent = D(a["percent"]),
SpellId = I(a["spell_Id"]),
WealthRating = I(a["wealth_Rating"]),
TreasureClass = I(a["treasure_Class"]),
TreasureType = I(a["treasure_Type"]),
PScript = I(a["p_Script"]),
Sound = I(a["sound"]),
DestinationType = a["destination_Type"] is null ? null : Convert.ToSByte(a["destination_Type"]),
WeenieClassId = U(a["weenie_Class_Id"]),
StackSize = I(a["stack_Size"]),
Palette = I(a["palette"]),
Shade = F(a["shade"]),
TryToBond = a["try_To_Bond"] is null ? null : Convert.ToBoolean(a["try_To_Bond"]),
ObjCellId = U(a["obj_Cell_Id"]),
OriginX = F(a["origin_X"]),
OriginY = F(a["origin_Y"]),
OriginZ = F(a["origin_Z"]),
AnglesW = F(a["angles_W"]),
AnglesX = F(a["angles_X"]),
AnglesY = F(a["angles_Y"]),
AnglesZ = F(a["angles_Z"]),
});
}
var key = Normalize(row, emoteCols) + "||" + string.Join("|", acts.Select(a => Normalize(a, actionCols)));
result.Add((key, e));
}
return result;
}
// ---- landblock ------------------------------------------------------------------
public static Result Landblock(ushort landblock)
{
var world = Db.SafeIdent(Db.WorldSchema);
var baseS = Db.SafeIdent(Mod.Settings.BaseSchema);
var lines = new List();
var baseRows = LoadInstances(baseS, landblock);
var worldRows = LoadInstances(world, landblock);
var subject = $"landblock {landblock:X4}";
if (baseRows.Count == 0 && worldRows.Count == 0)
return new Result(subject, false, false, lines);
foreach (var (guid, b) in baseRows)
{
if (!worldRows.TryGetValue(guid, out var w))
{
lines.Add($"- {Format.Instance(b)}");
continue;
}
var changes = new List();
if (b.WeenieClassId != w.WeenieClassId) changes.Add($"weenie <{Names.WeenieRef(b.WeenieClassId)}> -> <{Names.WeenieRef(w.WeenieClassId)}>");
if (b.ObjCellId != w.ObjCellId) changes.Add($"cell 0x{b.ObjCellId:X8} -> 0x{w.ObjCellId:X8}");
if (!Near(b.OriginX, w.OriginX) || !Near(b.OriginY, w.OriginY) || !Near(b.OriginZ, w.OriginZ))
changes.Add($"moved ({b.OriginX:0.###}, {b.OriginY:0.###}, {b.OriginZ:0.###}) -> ({w.OriginX:0.###}, {w.OriginY:0.###}, {w.OriginZ:0.###})");
if (!Near(b.AnglesW, w.AnglesW) || !Near(b.AnglesX, w.AnglesX) || !Near(b.AnglesY, w.AnglesY) || !Near(b.AnglesZ, w.AnglesZ))
changes.Add($"rotated ({b.AnglesW:0.###}, {b.AnglesX:0.###}, {b.AnglesY:0.###}, {b.AnglesZ:0.###}) -> ({w.AnglesW:0.###}, {w.AnglesX:0.###}, {w.AnglesY:0.###}, {w.AnglesZ:0.###})");
if (b.IsLinkChild != w.IsLinkChild) changes.Add($"link child {b.IsLinkChild} -> {w.IsLinkChild}");
if (changes.Count > 0)
lines.Add($"~ 0x{guid:X8} <{Names.WeenieRef(w.WeenieClassId)}>: {string.Join("; ", changes)}");
}
foreach (var (guid, w) in worldRows)
if (!baseRows.ContainsKey(guid))
lines.Add($"+ {Format.Instance(w)}");
var baseLinks = LoadLinks(baseS, landblock);
var worldLinks = LoadLinks(world, landblock);
foreach (var l in baseLinks.Except(worldLinks)) lines.Add($"- link 0x{l.parent:X8} -> 0x{l.child:X8}");
foreach (var l in worldLinks.Except(baseLinks)) lines.Add($"+ link 0x{l.parent:X8} -> 0x{l.child:X8}");
return new Result(subject, baseRows.Count > 0, worldRows.Count > 0, lines);
}
private static Dictionary LoadInstances(string schema, ushort landblock) =>
Db.Query($"SELECT * FROM `{schema}`.landblock_instance WHERE landblock = @lb", ("@lb", (int)landblock))
.Select(r => new LandblockInstance
{
Guid = Convert.ToUInt32(r["guid"]),
Landblock = landblock,
WeenieClassId = Convert.ToUInt32(r["weenie_Class_Id"]),
ObjCellId = Convert.ToUInt32(r["obj_Cell_Id"]),
OriginX = Convert.ToSingle(r["origin_X"]),
OriginY = Convert.ToSingle(r["origin_Y"]),
OriginZ = Convert.ToSingle(r["origin_Z"]),
AnglesW = Convert.ToSingle(r["angles_W"]),
AnglesX = Convert.ToSingle(r["angles_X"]),
AnglesY = Convert.ToSingle(r["angles_Y"]),
AnglesZ = Convert.ToSingle(r["angles_Z"]),
IsLinkChild = Convert.ToBoolean(r["is_Link_Child"]),
})
.ToDictionary(i => i.Guid, i => i);
private static HashSet<(uint parent, uint child)> LoadLinks(string schema, ushort landblock) =>
Db.Query($"SELECT l.parent_GUID, l.child_GUID FROM `{schema}`.landblock_instance_link l JOIN `{schema}`.landblock_instance i ON i.guid = l.parent_GUID WHERE i.landblock = @lb", ("@lb", (int)landblock))
.Select(r => (Convert.ToUInt32(r["parent_GUID"]), Convert.ToUInt32(r["child_GUID"])))
.ToHashSet();
// ---- comparing and rendering ----------------------------------------------------------
private static (List removed, List added) Compare(List baseRows, List worldRows, List cols)
{
var baseKeyed = baseRows.Select(r => (key: Normalize(r, cols), row: r)).ToList();
var worldKeyed = worldRows.Select(r => (key: Normalize(r, cols), row: r)).ToList();
return (MultisetExcept(baseKeyed, worldKeyed, x => x.key).Select(x => x.row).ToList(),
MultisetExcept(worldKeyed, baseKeyed, x => x.key).Select(x => x.row).ToList());
}
/// Elements of a whose key is not matched, one for one, by an element of b.
private static List MultisetExcept(List a, List b, Func keyOf)
{
var counts = new Dictionary();
foreach (var x in b)
counts[keyOf(x)] = counts.GetValueOrDefault(keyOf(x)) + 1;
var result = new List();
foreach (var x in a)
{
var key = keyOf(x);
if (counts.GetValueOrDefault(key) > 0)
counts[key]--;
else
result.Add(x);
}
return result;
}
/// A row as one string, column order fixed, floats rounded so 1.0 and 1.00001 agree.
private static string Normalize(Db.Row row, List cols)
{
var sb = new StringBuilder();
foreach (var c in cols)
{
sb.Append(c).Append('=');
var v = row[c];
sb.Append(v switch
{
null => "NULL",
float f => f.ToString("0.#####", Inv),
double d => d.ToString("0.#####", Inv),
bool b => b ? "1" : "0",
byte[] bytes => Convert.ToHexString(bytes),
_ => Convert.ToString(v, Inv),
});
sb.Append('');
}
return sb.ToString();
}
private static void RenderProperties(List lines, string kind, List removed, List added)
{
var addedByType = added.ToDictionary(r => Convert.ToUInt32(r["type"]), r => r);
foreach (var r in removed)
{
var type = Convert.ToUInt32(r["type"]);
var name = Format.PropertyName(kind, type);
if (addedByType.Remove(type, out var a))
lines.Add($"~ {kind} {name}: {Format.PropertyValue(kind, type, r["value"])} -> {Format.PropertyValue(kind, type, a["value"])}");
else
lines.Add($"- {kind} {name} = {Format.PropertyValue(kind, type, r["value"])}");
}
foreach (var (type, a) in addedByType)
lines.Add($"+ {kind} {Format.PropertyName(kind, type)} = {Format.PropertyValue(kind, type, a["value"])}");
}
private static void RenderRows(List lines, string table, List removed, List added)
{
var label = table.Replace("weenie_properties_", "");
foreach (var r in removed) lines.Add($"- {label} {Describe(table, r)}");
foreach (var a in added) lines.Add($"+ {label} {Describe(table, a)}");
}
/// Rows of the tables /winfo knows how to read get its wording; the rest print their columns.
private static string Describe(string table, Db.Row r)
{
try
{
switch (table)
{
case "weenie_properties_create_list":
return Format.CreateList(new WeeniePropertiesCreateList
{
DestinationType = Convert.ToSByte(r["destination_Type"]),
WeenieClassId = Convert.ToUInt32(r["weenie_Class_Id"]),
StackSize = Convert.ToInt32(r["stack_Size"]),
Palette = Convert.ToSByte(r["palette"]),
Shade = Convert.ToSingle(r["shade"]),
TryToBond = Convert.ToBoolean(r["try_To_Bond"]),
});
case "weenie_properties_generator":
return Format.Generator(new WeeniePropertiesGenerator
{
Probability = Convert.ToSingle(r["probability"]),
WeenieClassId = Convert.ToUInt32(r["weenie_Class_Id"]),
Delay = F(r["delay"]),
InitCreate = Convert.ToInt32(r["init_Create"]),
MaxCreate = Convert.ToInt32(r["max_Create"]),
WhenCreate = Convert.ToUInt32(r["when_Create"]),
WhereCreate = Convert.ToUInt32(r["where_Create"]),
StackSize = I(r["stack_Size"]),
PaletteId = U(r["palette_Id"]),
Shade = F(r["shade"]),
ObjCellId = U(r["obj_Cell_Id"]),
OriginX = F(r["origin_X"]),
OriginY = F(r["origin_Y"]),
OriginZ = F(r["origin_Z"]),
});
case "weenie_properties_spell_book":
return Format.SpellBook(new WeeniePropertiesSpellBook
{
Spell = Convert.ToInt32(r["spell"]),
Probability = Convert.ToSingle(r["probability"]),
});
case "weenie_properties_skill":
return $"{Format.EnumName(Convert.ToUInt32(r["type"]))} {r["init_Level"]}+{r["level_From_P_P"]} ({Format.EnumName(Convert.ToUInt32(r["s_a_c"]))})";
case "weenie_properties_attribute":
return $"{Format.EnumName(Convert.ToUInt32(r["type"]))} {r["init_Level"]}+{r["level_From_C_P"]}";
case "weenie_properties_attribute_2nd":
return $"{Format.EnumName(Convert.ToUInt32(r["type"]))} {r["init_Level"]}+{r["level_From_C_P"]} cur {r["current_Level"]}";
case "weenie_properties_position":
return $"{Format.EnumName(Convert.ToUInt32(r["position_Type"]))} 0x{Convert.ToUInt32(r["obj_Cell_Id"]):X8} ({Convert.ToSingle(r["origin_X"]):0.##}, {Convert.ToSingle(r["origin_Y"]):0.##}, {Convert.ToSingle(r["origin_Z"]):0.##})";
}
}
catch
{
// A column this build does not know - fall through to the generic form.
}
return string.Join(" ", r.Select(kv => $"{kv.Key}={(kv.Value is null ? "NULL" : Convert.ToString(kv.Value, Inv))}"));
}
private static bool Near(float a, float b) => Math.Abs(a - b) < 0.0005f;
private static uint? U(object? v) => v is null ? null : Convert.ToUInt32(v);
private static int? I(object? v) => v is null ? null : Convert.ToInt32(v);
private static long? L(object? v) => v is null ? null : Convert.ToInt64(v);
private static float? F(object? v) => v is null ? null : Convert.ToSingle(v);
private static double? D(object? v) => v is null ? null : Convert.ToDouble(v);
}