fix: 装备属性面板避让逻辑 + 多项功能更新

- item_tooltip_view.gd: 新增 avoid_rect 属性,tooltip 与装备窗口重叠时自动推到左侧
- inventory_ui.gd: 悬停装备时传入窗口矩形作为避让区域
- 包含其他累积的功能开发和测试文件
This commit is contained in:
shen
2026-09-21 16:38:59 -07:00
parent 1522a8a1a1
commit c93894313a
5498 changed files with 4558004 additions and 1442 deletions
+350 -8
View File
@@ -57,15 +57,20 @@ int main() {
add.angle = 90.0f;
add.moving_speed = 120;
add.attack_speed = 135;
add.state_flag = 0x5a;
add.affect_flag[0] = 0x11223344;
add.affect_flag[1] = 0x55667788;
b = pkt(add);
es.apply(GC_CHARACTER_ADD, b.data(), (uint16_t)b.size());
CHECK(es.get(2) != nullptr, "entity 2 spawned");
CHECK(es.get(2)->x == 500 && es.get(2)->y == 500, "entity 2 pos");
CHECK(es.get(2)->moving_speed == 120 && es.get(2)->attack_speed == 135,
"entity 2 movement + attack speed");
CHECK(es.get(2)->state_flags == 0x5a &&
es.get(2)->affect_flags == 0x5566778811223344ULL,
"entity 2 state and affect flags from character add");
CHECK(es.size() == 2, "2 entities");
// change events: 1 spawn (main) + 1 spawn (entity 2) (+ MainSet dedupe tolerated)
// change events: 1 spawn (main) + 1 spawn (entity 2) + one main registration.
{
auto ch = es.drain_changes();
int spawns = 0, mainsets = 0;
@@ -78,9 +83,90 @@ int main() {
}
}
CHECK(spawns == 2, "2 spawn changes");
CHECK(mainsets == 1, "main character registration is emitted once");
CHECK(es.drain_changes().empty(), "changes drained");
}
// --- GC_CHARACTER_ADD2 carries the complete actor row in one packet ---
{
GCCharacterAdd2 add2{};
add2.header = GC_CHARACTER_ADD2;
add2.length = sizeof(add2);
add2.vid = 3;
std::strcpy(add2.name, "Add2Actor");
add2.angle = 27.0f;
add2.x = 700;
add2.y = 800;
add2.z = 9;
add2.type = 2;
add2.race = 101;
add2.parts[0] = 401;
add2.parts[1] = 402;
add2.moving_speed = 111;
add2.attack_speed = 122;
add2.state_flag = 0x6b;
add2.affect_flag[0] = 0x01020304;
add2.affect_flag[1] = 0xa0b0c0d0;
add2.empire = 2;
add2.guild = 17;
add2.alignment = -321;
add2.pk_mode = 4;
add2.mount_vnum = 20114;
es.apply(GC_CHARACTER_ADD2, pkt(add2).data(), (uint16_t)sizeof(add2));
const Entity *e = es.get(3);
CHECK(e && e->name == "Add2Actor" && e->race == 101 && e->ch_type == 2 &&
e->x == 700.f && e->y == 800.f && e->angle == 27.f,
"add2: identity and transform copied");
CHECK(e && e->parts[0] == 401 && e->parts[1] == 402 && e->moving_speed == 111 &&
e->attack_speed == 122 && e->state_flags == 0x6b &&
e->affect_flags == 0xa0b0c0d001020304ULL,
"add2: equipment, speeds, state and affect copied");
CHECK(e && e->empire == 2 && e->guild == 17 && e->alignment == -321 &&
e->pk_mode == 4 && e->mount_vnum == 20114,
"add2: empire, guild, alignment, pk mode and mount copied");
int add2_spawns = 0;
for (const auto &c : es.drain_changes()) {
if (c.kind == EntityStore::ChangeKind::Spawn && c.vid == 3) {
++add2_spawns;
}
}
CHECK(add2_spawns == 1, "add2: one complete spawn change");
es.mut_despawn(3);
es.drain_changes();
}
// Invisible races are filtered by the 40250 IsInvisibleRace gate.
{
GCCharacterAdd2 hidden{};
hidden.header = GC_CHARACTER_ADD2;
hidden.length = sizeof(hidden);
hidden.vid = 4;
hidden.race = 20025;
es.apply(GC_CHARACTER_ADD2, pkt(hidden).data(), (uint16_t)sizeof(hidden));
CHECK(es.get(4) == nullptr, "add2: invisible race is not spawned");
}
// Add2 was drained above; no stale change may leak into the next packet.
{
auto ch = es.drain_changes();
CHECK(ch.empty() && es.drain_changes().empty(), "changes drained");
}
// Re-announcing the existing main VID must produce one MainSet edge, not a
// duplicate pair. This mirrors NetworkActorManager::SetMainActorVID /
// __AppendCharacterManagerActor and prevents duplicate model/UI rebuilds.
es.mut_spawn_main(1, 4, "Hero", 1000, 2000, 5);
{
auto ch = es.drain_changes();
int mainsets = 0;
for (auto &c : ch) {
if (c.kind == EntityStore::ChangeKind::MainSet && c.vid == 1) {
++mainsets;
}
}
CHECK(mainsets == 1, "existing main re-registration emits one MainSet");
}
// --- GC_CHAR_ADD_INFO fills name / level / guild / mount for entity 2 ---
{
GCCharAddInfo ai{};
@@ -120,13 +206,19 @@ int main() {
up.guild_id = 9;
up.moving_speed = 140;
up.attack_speed = 155;
up.mount_vnum = 0; // dismount
up.state_flag = 0x7c;
up.affect_flag[0] = 0x0badcafe;
up.affect_flag[1] = 0x10203040;
up.mount_vnum = 0; // dismount
b = pkt(up);
es.apply(GC_CHARACTER_UPDATE, b.data(), (uint16_t)b.size());
CHECK(es.get(2)->parts[1] == 222 && es.get(2)->guild == 9 &&
es.get(2)->moving_speed == 140 && es.get(2)->attack_speed == 155,
"update: parts + guild + speeds");
CHECK(es.get(2)->mount_vnum == 0, "update: dismounted");
CHECK(es.get(2)->mount_vnum == 0, "update: dismounted");
CHECK(es.get(2)->state_flags == 0x7c &&
es.get(2)->affect_flags == 0x102030400badcafeULL,
"update: state and affect flags copied");
auto mc = es.drain_mount_changes();
CHECK(mc.size() == 1 && mc[0] == 2, "update: mount change emitted");
int uinfo = 0;
@@ -136,6 +228,38 @@ int main() {
}
}
CHECK(uinfo == 1, "update: parts changed -> Info change (costume/equip re-read)");
// A state/affect-only update still has one coherent UpdateActor/Info edge;
// it must not require an equipment change to reach the scene bridge.
GCCharacterUpdate state_only = up;
state_only.state_flag = 0x7d;
state_only.affect_flag[0] = 0xabcdef01;
state_only.affect_flag[1] = 0x23456789;
es.apply(GC_CHARACTER_UPDATE, pkt(state_only).data(), (uint16_t)sizeof(state_only));
CHECK(es.get(2)->state_flags == 0x7d &&
es.get(2)->affect_flags == 0x23456789abcdef01ULL,
"update: affect-only state reaches entity row");
int state_infos = 0;
for (const auto &c : es.drain_changes()) {
if (c.kind == EntityStore::ChangeKind::Info && c.vid == 2) {
++state_infos;
}
}
CHECK(state_infos == 1, "update: state/affect-only change emits one Info");
}
// Additional-info must complete a known/staged actor; it must not create an
// unknown half-entity when the preceding add was lost or reordered.
{
GCCharAddInfo unknown{};
unknown.header = GC_CHAR_ADD_INFO;
unknown.length = sizeof(unknown);
unknown.vid = 9999;
std::strcpy(unknown.name, "orphan");
const size_t before = es.size();
es.apply(GC_CHAR_ADD_INFO, pkt(unknown).data(), (uint16_t)sizeof(unknown));
CHECK(es.size() == before && es.get(9999) == nullptr,
"add_info: unknown VID is ignored without creating a partial entity");
}
// --- entity 2 gets a MOVE: (500,500) -> (1500,500) over 1000ms starting now(=0) ---
@@ -221,6 +345,15 @@ int main() {
es.apply(GC_SYNC_POSITION, sync.data(), (uint16_t)sync.size());
CHECK(es.get(2)->x == 2222 && es.get(2)->y == 3333 && !es.get(2)->moving,
"movement: sync position snaps known entity");
CHECK(es.get(2)->sync_push_serial == 1 && es.get(2)->sync_push_from_x == 1700.0f
&& es.get(2)->sync_push_from_y == 500.0f && es.get(2)->sync_push_large
&& !es.get(2)->sync_push_skill_active,
"movement: sync position records TEMP_Push source and large correction");
es.mut_network_state_gate(2, true, true, false, true);
es.mut_snap_position(2, 2222.0f, 3333.0f);
CHECK(es.get(2)->sync_push_serial == 2 && !es.get(2)->sync_push_large
&& es.get(2)->sync_push_skill_active,
"movement: zero correction keeps skill-active TEMP_Push suppression state");
}
// --- chat (variable length) ---
@@ -299,8 +432,63 @@ int main() {
b = pkt(pc);
es.apply(GC_PLAYER_POINT_CHANGE, b.data(), (uint16_t)b.size());
CHECK(es.get(2)->hp == 80, "entity 2 hp from point_change");
auto point_events = es.drain_point_changes();
CHECK(point_events.size() == 1 && point_events[0].vid == 2
&& point_events[0].type == POINT_HP && point_events[0].value == 80
&& point_events[0].amount == -20,
"typed point_change event keeps vid/type/value/amount");
auto v = es.drain_vitals();
CHECK(v.size() == 1 && v[0] == 2, "vitals: entity 2 marked");
// A positive HP update is the 40250 revive boundary: clear the dead
// state so a later death can emit entity_dead again.
GCPointChange revive = pc;
revive.amount = 20;
revive.value = 100;
b = pkt(revive);
es.apply(GC_PLAYER_POINT_CHANGE, b.data(), (uint16_t)b.size());
CHECK(es.get(2)->hp == 100 && !es.get(2)->dead,
"positive HP clears entity dead state");
CHECK(es.drain_point_changes().size() == 1, "revive point_change event emitted");
CHECK(es.drain_vitals().size() == 1, "revive marks vitals for consumers");
}
// 40250 RecvPointChange ordering / branches: the typed event is available
// before the aggregate snapshot, positive EXP/GOLD amounts survive, and
// zero energy clears POINT_ENERGY_END_TIME.
{
GCPointChange exp{};
exp.header = GC_PLAYER_POINT_CHANGE;
exp.length = sizeof(exp);
exp.vid = 1;
exp.type = POINT_EXP;
exp.amount = 321;
exp.value = 4321;
b = pkt(exp);
es.apply(GC_PLAYER_POINT_CHANGE, b.data(), (uint16_t)b.size());
GCPointChange gold = exp;
gold.type = POINT_GOLD;
gold.amount = 777;
gold.value = 10776;
b = pkt(gold);
es.apply(GC_PLAYER_POINT_CHANGE, b.data(), (uint16_t)b.size());
GCPointChange energy = exp;
energy.type = POINT_ENERGY;
energy.amount = 0;
energy.value = 0;
b = pkt(energy);
es.apply(GC_PLAYER_POINT_CHANGE, b.data(), (uint16_t)b.size());
const auto points = es.drain_point_changes();
CHECK(points.size() == 3, "three typed point changes drained in packet order");
CHECK(points.size() == 3 && points[0].type == POINT_EXP && points[0].amount == 321
&& points[1].type == POINT_GOLD && points[1].amount == 777
&& points[2].type == POINT_ENERGY,
"typed point changes preserve EXP/GOLD/ENERGY order");
CHECK(es.take_exp_gain() == 321, "positive EXP amount remains immediate gain");
CHECK(es.points().energy() == 0 && es.points().energy_end_time() == 0,
"zero energy clears energy effect end time");
CHECK(es.take_points_dirty(), "point changes mark aggregate points dirty");
CHECK(!es.take_points_dirty(), "aggregate points dirty flag consumed");
}
// GC_DAMAGE_INFO: a crit for 137 over entity 2
@@ -385,6 +573,7 @@ int main() {
s.vnum = 19;
s.count = 1;
s.flags = 0x2;
s.highlight = 1;
s.sockets[0] = 28960;
s.attrs[0] = {5, 30}; // type 5, +30
s.attrs[1] = {7, 12};
@@ -395,6 +584,9 @@ int main() {
CHECK(es.inv_slot(5).attrs[1].type == 7 && es.inv_slot(5).attrs[1].value == 12, "item: attr kept");
auto ic = es.drain_inv();
CHECK(ic.size() == 1 && ic[0].window == WINDOW_INVENTORY && ic[0].cell == 5, "item: inv change emitted");
auto highlights = es.drain_item_highlights();
CHECK(highlights.size() == 1 && highlights[0].window == WINDOW_INVENTORY &&
highlights[0].cell == 5, "item: Set2 highlight emitted separately from inventory change");
}
// GC_ITEM_SET to EQUIPMENT wear slot 1 (body armor)
@@ -437,14 +629,22 @@ int main() {
// GC_ITEM_UPDATE: cell 5 count -> 3
{
es.drain_inv(); // discard the preceding equipment/belt Set notifications
GCItemUpdate u{};
u.header = GC_ITEM_UPDATE;
u.length = sizeof(u);
u.cell = {WINDOW_INVENTORY, 5};
u.count = 3;
u.sockets[1] = 29001;
u.attrs[0] = {8, 22};
b = pkt(u);
es.apply(GC_ITEM_UPDATE, b.data(), (uint16_t)b.size());
CHECK(es.inv_slot(5).count == 3 && es.inv_slot(5).vnum == 19, "item: update count, vnum kept");
auto update_changes = es.drain_inv();
CHECK(update_changes.size() == 2 && update_changes[0].item.count == 3 &&
update_changes[0].item.sockets[1] == 0 && update_changes[0].item.attrs[0].value == 30 &&
update_changes[1].item.sockets[1] == 29001 && update_changes[1].item.attrs[0].value == 22,
"item: update emits count refresh before final socket/attribute refresh");
}
// GC_ITEM_DEL: clear cell 5
@@ -456,6 +656,47 @@ int main() {
b = pkt(dl);
es.apply(GC_ITEM_DEL, b.data(), (uint16_t)b.size());
CHECK(es.inv_slot(5).empty(), "item: cell 5 cleared");
es.drain_inv(); // discard the delete notification before the next packet
// ClientVS22 SetItemCount/SetItemMetinSocket/SetItemAttribute update a
// valid slot even when its vnum is currently zero; the packet is not
// rejected merely because the slot is empty.
GCItemUpdate empty_update{};
empty_update.header = GC_ITEM_UPDATE;
empty_update.length = sizeof(empty_update);
empty_update.cell = {WINDOW_INVENTORY, 6};
empty_update.count = 4;
empty_update.sockets[1] = 29000;
empty_update.attrs[0] = {9, 17};
b = pkt(empty_update);
es.apply(GC_ITEM_UPDATE, b.data(), (uint16_t)b.size());
CHECK(es.inv_slot(6).empty() && es.inv_slot(6).count == 4 &&
es.inv_slot(6).sockets[1] == 29000 && es.inv_slot(6).attrs[0].value == 17,
"item: update valid empty cell keeps packet state");
auto empty_update_changes = es.drain_inv();
CHECK(empty_update_changes.size() == 2 && empty_update_changes[0].item.count == 4 &&
empty_update_changes[0].item.sockets[1] == 0 &&
empty_update_changes[1].item.sockets[1] == 29000 &&
empty_update_changes[1].item.attrs[0].value == 17,
"item: empty-cell update preserves both refresh snapshots");
}
// GC_ITEM_GROUND_ADD / DEL
// CPythonPlayer::SetItemData rejects an unknown ItemManager vnum before
// touching the slot, and wiring the validator later also purges an item
// accepted during the loading burst before item_proto was available.
{
EntityStore guarded;
guarded.set_item_vnum_validator([](uint32_t vnum) { return vnum == 19; });
guarded.mut_item_set(WINDOW_INVENTORY, 0, 999999, 1, 0, 0, nullptr, nullptr, true);
CHECK(guarded.inv_slot(0).empty() && guarded.drain_inv().empty(),
"item: unknown vnum rejected without change/highlight");
guarded.mut_item_set(WINDOW_INVENTORY, 0, 19, 1, 0, 0, nullptr, nullptr);
CHECK(guarded.inv_slot(0).vnum == 19, "item: known vnum accepted by validator");
guarded.set_item_vnum_validator([](uint32_t) { return false; });
CHECK(guarded.inv_slot(0).empty(), "item: late validator purges pre-existing unknown vnum");
CHECK(guarded.drain_inv().size() == 2,
"item: late validator emits accepted set and purge snapshots");
}
// GC_ITEM_GROUND_ADD / DEL
@@ -760,6 +1001,65 @@ int main() {
&& q->counter_value == 3, "quest info fields (title/counter)");
auto qch = es.drain_quest_changes();
CHECK(qch.size() == 1 && qch[0] == 7, "quest change emitted");
// 40250 RecvQuestInfoPacket treats QUEST_SEND_IS_BEGIN + false as
// QUEST_PACKET_TYPE_END and calls DeleteQuestInstance(index). The
// pre-fix EntityStore kept the row with begin=false, so this assertion
// deliberately fails until the delete branch is implemented.
std::vector<uint8_t> qe(sizeof(GCQuestInfo) + 1, 0);
GCQuestInfo end{};
end.header = GC_QUEST_INFO;
end.length = (uint16_t)qe.size();
end.index = 7;
end.flag = QUEST_SEND_IS_BEGIN;
std::memcpy(qe.data(), &end, sizeof(end));
es.apply(GC_QUEST_INFO, qe.data(), (uint16_t)qe.size());
CHECK(es.quest(7) == nullptr, "quest end deletes the quest instance");
auto end_changes = es.drain_quest_changes();
CHECK(end_changes.size() == 1 && end_changes[0] == 7,
"quest end emits one removal refresh");
// CPythonQuest keeps QuestInstance objects in insertion order. The
// pre-fix EntityStore exposed unordered_map iteration order instead,
// which made the quest-log panel reorder entries between updates.
es.mut_quest_info(20, QUEST_SEND_IS_BEGIN, true, "Q20", "", 0, "", 0, "");
es.mut_quest_info(5, QUEST_SEND_IS_BEGIN, true, "Q5", "", 0, "", 0, "");
es.mut_quest_info(10, QUEST_SEND_IS_BEGIN, true, "Q10", "", 0, "", 0, "");
auto quest_order = es.quest_indices();
CHECK(quest_order.size() == 3 && quest_order[0] == 20 && quest_order[1] == 5
&& quest_order[2] == 10, "quest indices preserve CPythonQuest vector order");
// A regular update keeps its position; a new BEGIN replaces the old
// instance and moves that quest to the end, matching RegisterQuestInstance.
es.mut_quest_info(5, QUEST_SEND_TITLE, false, "Q5 updated", "", 0, "", 0, "");
quest_order = es.quest_indices();
CHECK(quest_order.size() == 3 && quest_order[0] == 20 && quest_order[1] == 5
&& quest_order[2] == 10, "quest update preserves insertion position");
const QuestInfo *updated_quest = es.quest(5);
CHECK(updated_quest && updated_quest->begin && updated_quest->title == "Q5 updated",
"quest UPDATE keeps the instance active");
es.mut_quest_info(20, QUEST_SEND_IS_BEGIN, true, "Q20 restarted", "", 0, "", 0, "");
quest_order = es.quest_indices();
CHECK(quest_order.size() == 3 && quest_order[0] == 5 && quest_order[1] == 10
&& quest_order[2] == 20, "quest BEGIN replaces and appends instance");
// CPythonQuest starts a timer on BEGIN/MakeQuest and restarts it only
// when QUEST_SEND_CLOCK_VALUE is received; an unrelated UPDATE must not
// reset the countdown while the quest window is closed.
es.set_now(1000);
es.mut_quest_info(30, QUEST_SEND_IS_BEGIN | QUEST_SEND_CLOCK_VALUE, true,
"Timed quest", "Time", 65, "", 0, "");
const QuestInfo *timed = es.quest(30);
CHECK(timed && timed->clock_start_ms == 1000, "quest BEGIN records clock start");
es.set_now(2000);
es.mut_quest_info(30, QUEST_SEND_TITLE, false, "Timed quest updated", "", 0, "", 0, "");
timed = es.quest(30);
CHECK(timed && timed->clock_start_ms == 1000, "quest UPDATE preserves clock start");
es.set_now(3000);
es.mut_quest_info(30, QUEST_SEND_CLOCK_VALUE, false, "", "", 40, "", 0, "");
timed = es.quest(30);
CHECK(timed && timed->clock_start_ms == 3000 && timed->clock_value == 40,
"quest clock-value UPDATE restarts clock");
}
}
@@ -1075,6 +1375,19 @@ int main() {
CHECK(warps.size() == 1 && warps[0].x == 12345 && warps[0].same_server(),
"warp: queued, same-server");
// Reconnect handling consumes one replacement transport at a time. A
// later warp must remain queued if the caller reconnects after the first.
WarpCue first{100, 200, 0x0100007f, 13001, true};
WarpCue second{300, 400, 0x0100007f, 13002, true};
es.mut_warp(first.x, first.y, first.addr, first.port, first.reconnect);
es.mut_warp(second.x, second.y, second.addr, second.port, second.reconnect);
WarpCue next{};
CHECK(es.take_next_warp(next) && next.x == first.x && next.port == first.port,
"warp queue: first reconnect consumed in order");
CHECK(es.take_next_warp(next) && next.x == second.x && next.port == second.port,
"warp queue: second reconnect preserved");
CHECK(!es.take_next_warp(next), "warp queue: empty after ordered consumption");
GCTime tm{GC_TIME, sizeof(GCTime), 1735689600};
b = pkt(tm);
es.apply(GC_TIME, b.data(), (uint16_t)b.size());
@@ -1486,6 +1799,9 @@ int main() {
cmd("combo 1");
cmd("mobile_auth");
cmd("gift");
cmd("xmas_snow 1");
cmd("xmas_snow 0");
cmd("quit");
cmd("kiss 1 2");
cmd("slap 1 2");
cmd("dance6 1");
@@ -1493,7 +1809,9 @@ int main() {
auto events = es.drain_server_commands();
int denied = 0, safe = 0, mall = 0, ok = 0, fail = 0, open = 0, price = 0, block = 0;
int observer = 0, observer_count = 0, stone = 0, stamina_start = 0, stamina_stop = 0;
int mobile = 0, mobile_auth = 0, combo = 0, gift = 0;
int mobile = 0, mobile_auth = 0, combo = 0, gift = 0, weather = 0;
int snow_on = 0, snow_off = 0;
int server_quit = 0;
for (const auto &ev : events) {
switch (ev.kind) {
case ServerCommandEvent::PartyRequestDenied: ++denied; break;
@@ -1536,12 +1854,28 @@ int main() {
case ServerCommandEvent::GiftAvailable:
++gift;
break;
case ServerCommandEvent::WeatherChanged:
++weather;
if (ev.value != 0) ++snow_on;
else ++snow_off;
break;
case ServerCommandEvent::ServerQuitRequested:
++server_quit;
break;
case ServerCommandEvent::LoverLogin:
case ServerCommandEvent::LoverLogout:
case ServerCommandEvent::LoverNear:
case ServerCommandEvent::LoverFar:
case ServerCommandEvent::LoverDivorce:
break;
}
}
CHECK(denied == 1 && safe == 1 && mall == 1 && ok == 1 && fail == 1 && open == 1
&& price == 1 && block == 1 && observer == 1 && observer_count == 1
&& stone == 1 && stamina_start == 1 && stamina_stop == 1 && mobile == 1
&& mobile_auth == 1 && combo == 1 && gift == 1 && !es.stamina_consuming()
&& mobile_auth == 1 && combo == 1 && gift == 1 && weather == 2
&& snow_on == 1 && snow_off == 1 && server_quit == 1
&& !es.stamina_consuming()
&& es.current_stamina() == 55,
"40250 command bus: callbacks surfaced");
auto motions = es.drain_motions();
@@ -1646,14 +1980,14 @@ int main() {
}
// GC_CHARACTER_UPDATE for that same VID updates the row in place.
d.mut_char_update(4242, nullptr, 40, 120, 7, -3, 1, 0);
d.mut_char_update(4242, nullptr, 40, 120, 0, 0, 7, -3, 1, 0);
orc = d.get(4242);
CHECK(orc && orc->attack_speed == 120 && orc->moving_speed == 40 && orc->guild == 7,
"§2.1 data layer: UPDATE mutates the existing row");
// Unknown VID: no create, no replay buffer, no size growth.
const size_t n_before = d.size();
d.mut_char_update(999999, nullptr, 10, 10, 0, 0, 0, 0);
d.mut_char_update(999999, nullptr, 10, 10, 0, 0, 0, 0, 0, 0);
d.mut_move(999999, 90.0f, 1, 5.0f, 5.0f, 200);
d.mut_mount(999999, 20101);
d.mut_char_info(999999, "ghost", nullptr, 1, 0, 5, 0, 0, 0);
@@ -1667,6 +2001,10 @@ int main() {
es.mut_ground_add(700, 27100, 12, 24, 0);
es.mut_pvp(99, 1, 2);
es.mut_target(99, 50);
es.mut_fly(2, 99, 1);
es.mut_fly_target(1, 99, 120, 240, false);
es.mut_special_effect(99, 7);
es.mut_set_point(POINT_LEVEL, 99, 99, 0);
es.reset_for_map_change();
CHECK(es.size() == 0 && es.main_vid() == 0, "map reset clears network entities");
CHECK(es.ground_vids().empty() && es.pvp_relations().empty(),
@@ -1674,6 +2012,10 @@ int main() {
CHECK(es.target_vid() == 0 && es.take_target_dirty(), "map reset clears target state");
CHECK(es.drain_changes().empty() && es.drain_ground().empty(),
"map reset drops stale entity and ground deltas");
CHECK(es.drain_fly_cues().empty() && es.drain_fly_target_cues().empty()
&& es.drain_effect_cues().empty(),
"map reset drops stale projectile, targeting, and effect cues");
CHECK(es.drain_point_changes().empty(), "map reset drops stale point-change events");
if (g_fail) {
std::fprintf(stderr, "%d check(s) failed\n", g_fail);