// EntityStore — synthetic game-phase packets in, entity state + interpolation out. #include "../src/net/entity_store.h" #include "../src/net/wire.h" #include #include #include using namespace mtnet; static int g_fail = 0; #define CHECK(c, msg) \ do { \ if (!(c)) { \ std::fprintf(stderr, "FAIL: %s\n", msg); \ ++g_fail; \ } \ } while (0) template static std::vector pkt(const T &s) { std::vector b(sizeof(T)); std::memcpy(b.data(), &s, sizeof(T)); return b; } int main() { EntityStore es; es.set_now(0); // --- main character spawn --- GCMainCharacter mc{}; mc.header = GC_MAIN_CHARACTER; mc.length = sizeof(mc); mc.vid = 1; mc.race = 4; std::strcpy(mc.name, "Hero"); mc.x = 1000; mc.y = 2000; mc.z = 5; auto b = pkt(mc); es.apply(GC_MAIN_CHARACTER, b.data(), (uint16_t)b.size()); CHECK(es.main_vid() == 1, "main vid set"); CHECK(es.get(1) && es.get(1)->is_main, "main entity is_main"); CHECK(es.get(1)->name == "Hero", "main name"); CHECK(es.get(1)->x == 1000 && es.get(1)->y == 2000, "main pos"); // --- another character appears --- GCCharacterAdd add{}; add.header = GC_CHARACTER_ADD; add.length = sizeof(add); add.vid = 2; add.race = 1; add.x = 500; add.y = 500; add.z = 0; add.angle = 90.0f; add.moving_speed = 120; add.attack_speed = 135; 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.size() == 2, "2 entities"); // change events: 1 spawn (main) + 1 spawn (entity 2) (+ MainSet dedupe tolerated) { auto ch = es.drain_changes(); int spawns = 0, mainsets = 0; for (auto &c : ch) { if (c.kind == EntityStore::ChangeKind::Spawn) { ++spawns; } if (c.kind == EntityStore::ChangeKind::MainSet) { ++mainsets; } } CHECK(spawns == 2, "2 spawn changes"); CHECK(es.drain_changes().empty(), "changes drained"); } // --- GC_CHAR_ADD_INFO fills name / level / guild / mount for entity 2 --- { GCCharAddInfo ai{}; ai.header = GC_CHAR_ADD_INFO; ai.length = sizeof(ai); ai.vid = 2; std::strcpy(ai.name, "Wolf"); ai.parts[0] = 111; ai.level = 12; ai.guild_id = 7; ai.alignment = -500; ai.pk_mode = 3; ai.mount_vnum = 20114; b = pkt(ai); es.apply(GC_CHAR_ADD_INFO, b.data(), (uint16_t)b.size()); CHECK(es.get(2)->name == "Wolf" && es.get(2)->level == 12, "add_info: name + level"); CHECK(es.get(2)->guild == 7 && es.get(2)->pk_mode == 3, "add_info: guild + pk_mode"); CHECK(es.get(2)->mount_vnum == 20114, "add_info: mount"); auto mc = es.drain_mount_changes(); CHECK(mc.size() == 1 && mc[0] == 2, "add_info: mount change emitted"); int infos = 0; for (auto &c : es.drain_changes()) { if (c.kind == EntityStore::ChangeKind::Info && c.vid == 2) { ++infos; } } CHECK(infos == 1, "add_info: Info change emitted for scene re-read"); } // --- GC_CHARACTER_UPDATE changes parts / mount for entity 2 --- { GCCharacterUpdate up{}; up.header = GC_CHARACTER_UPDATE; up.length = sizeof(up); up.vid = 2; up.parts[1] = 222; up.guild_id = 9; up.moving_speed = 140; up.attack_speed = 155; 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"); auto mc = es.drain_mount_changes(); CHECK(mc.size() == 1 && mc[0] == 2, "update: mount change emitted"); int uinfo = 0; for (auto &c : es.drain_changes()) { if (c.kind == EntityStore::ChangeKind::Info && c.vid == 2) { ++uinfo; } } CHECK(uinfo == 1, "update: parts changed -> Info change (costume/equip re-read)"); } // --- entity 2 gets a MOVE: (500,500) -> (1500,500) over 1000ms starting now(=0) --- GCMove mv{}; mv.header = GC_MOVE; mv.length = sizeof(mv); mv.vid = 2; mv.func = FUNC_MOVE; mv.rot = 0; mv.x = 1500; mv.y = 500; mv.time = 0; mv.duration = 1000; b = pkt(mv); es.set_now(0); es.apply(GC_MOVE, b.data(), (uint16_t)b.size()); CHECK(es.get(2)->moving, "entity 2 moving"); es.set_now(500); es.tick(); CHECK(std::abs(es.get(2)->x - 1000.0f) < 1.0f, "entity 2 halfway x"); CHECK(es.get(2)->moving, "still moving at t=0.5"); es.set_now(1000); es.tick(); CHECK(std::abs(es.get(2)->x - 1500.0f) < 0.01f, "entity 2 arrived x"); CHECK(!es.get(2)->moving, "stopped at t=1"); CHECK(es.get(2)->func == FUNC_WAIT, "func back to WAIT"); // --- a non-move func snaps --- GCMove atk{}; atk.header = GC_MOVE; atk.length = sizeof(atk); atk.vid = 2; atk.func = FUNC_ATTACK; atk.x = 1600; atk.y = 600; atk.duration = 0; b = pkt(atk); es.apply(GC_MOVE, b.data(), (uint16_t)b.size()); CHECK(!es.get(2)->moving && es.get(2)->x == 1600, "attack func snaps position"); CHECK(es.get(2)->func == FUNC_ATTACK, "func = ATTACK"); // --- movement metadata + batched correction packets --- { GCChangeSpeed speed{GC_CHANGE_SPEED, sizeof(GCChangeSpeed), 2, 175}; b = pkt(speed); es.apply(GC_CHANGE_SPEED, b.data(), (uint16_t)b.size()); GCWalkMode walk{GC_WALK_MODE, sizeof(GCWalkMode), 2, 1}; b = pkt(walk); es.apply(GC_WALK_MODE, b.data(), (uint16_t)b.size()); GCCharacterPosition pos{GC_CHARACTER_POSITION, sizeof(GCCharacterPosition), 2, 4}; b = pkt(pos); es.apply(GC_CHARACTER_POSITION, b.data(), (uint16_t)b.size()); CHECK(es.get(2)->moving_speed == 175 && es.get(2)->walk_mode == 1 && es.get(2)->position == 4, "movement: speed, walk mode and position state"); std::vector sync(sizeof(GCSyncPosition) + 2 * sizeof(GCSyncPositionElement)); GCSyncPosition sync_head{GC_SYNC_POSITION, (uint16_t)sync.size()}; GCSyncPositionElement sync_elems[2] = {{2, 2222, 3333}, {999, 1, 1}}; std::memcpy(sync.data(), &sync_head, sizeof(sync_head)); std::memcpy(sync.data() + sizeof(sync_head), sync_elems, sizeof(sync_elems)); 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"); } // --- chat (variable length) --- { const char *text = "hello world"; std::vector cb(sizeof(GCChat) + std::strlen(text) + 1); GCChat head{}; head.header = GC_CHAT; head.length = (uint16_t)cb.size(); head.type = 0; head.vid = 2; std::memcpy(cb.data(), &head, sizeof(head)); std::memcpy(cb.data() + sizeof(head), text, std::strlen(text) + 1); es.apply(GC_CHAT, cb.data(), (uint16_t)cb.size()); auto msgs = es.drain_chat(); CHECK(msgs.size() == 1, "1 chat msg"); CHECK(msgs.size() == 1 && msgs[0].vid == 2 && msgs[0].text == "hello world", "chat parsed"); } // --- whisper (GC_WHISPER, variable) --- { const char *text = "psst"; std::vector wb(sizeof(GCWhisper) + std::strlen(text) + 1); GCWhisper head{}; head.header = GC_WHISPER; head.length = (uint16_t)wb.size(); head.type = WHISPER_TYPE_CHAT; std::strcpy(head.from_name, "Alice"); std::memcpy(wb.data(), &head, sizeof(head)); std::memcpy(wb.data() + sizeof(head), text, std::strlen(text) + 1); es.apply(GC_WHISPER, wb.data(), (uint16_t)wb.size()); auto w = es.drain_chat(); CHECK(w.size() == 1 && w[0].type == CHAT_TYPE_WHISPER && w[0].from == "Alice" && w[0].text == "psst" && w[0].sub == WHISPER_TYPE_CHAT, "whisper parsed"); } // --- combat / status packets ------------------------------------------ // GC_PLAYER_POINTS: full stat block for the main char (vid 1) { GCPlayerPoints pts{}; pts.header = GC_PLAYER_POINTS; pts.length = sizeof(pts); pts.points[POINT_LEVEL] = 12; pts.points[POINT_HP] = 350; pts.points[POINT_MAX_HP] = 500; pts.points[POINT_SP] = 40; pts.points[POINT_MAX_SP] = 200; pts.points[POINT_GOLD] = 9999; pts.points[POINT_ATT_SPEED] = 150; pts.points[POINT_ENERGY] = 75; pts.points[POINT_ENERGY_END_TIME] = 123456; b = pkt(pts); es.apply(GC_PLAYER_POINTS, b.data(), (uint16_t)b.size()); CHECK(es.take_points_dirty(), "points dirty after GC_PLAYER_POINTS"); CHECK(!es.take_points_dirty(), "points dirty flag consumed"); CHECK(es.points().hp() == 350 && es.points().max_hp() == 500, "points hp/max_hp"); CHECK(es.points().level() == 12 && es.points().gold() == 9999, "points level/gold"); CHECK(es.points().energy() == 75 && es.points().energy_end_time() == 123456, "points energy + expiry"); CHECK(es.get(1)->hp == 350 && es.get(1)->max_hp == 500 && es.get(1)->attack_speed == 150, "main entity mirrors points + attack speed"); auto v = es.drain_vitals(); CHECK(v.size() == 1 && v[0] == 1, "vitals: main vid marked"); } // GC_PLAYER_POINT_CHANGE: entity 2's HP drops to 80 { GCPointChange pc{}; pc.header = GC_PLAYER_POINT_CHANGE; pc.length = sizeof(pc); pc.vid = 2; pc.type = POINT_HP; pc.amount = -20; pc.value = 80; 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 v = es.drain_vitals(); CHECK(v.size() == 1 && v[0] == 2, "vitals: entity 2 marked"); } // GC_DAMAGE_INFO: a crit for 137 over entity 2 { GCDamageInfo di{}; di.header = GC_DAMAGE_INFO; di.length = sizeof(di); di.vid = 2; di.flag = DAMAGE_NORMAL | DAMAGE_CRITICAL; di.damage = 137; b = pkt(di); es.apply(GC_DAMAGE_INFO, b.data(), (uint16_t)b.size()); auto d = es.drain_damage(); CHECK(d.size() == 1 && d[0].vid == 2 && d[0].amount == 137, "damage event"); CHECK(d.size() == 1 && (d[0].flag & DAMAGE_CRITICAL), "damage flag: crit"); CHECK(es.drain_damage().empty(), "damage drained"); } // GC_MOTION: entity 2 plays motion 3 at victim 1 { GCMotion mo{}; mo.header = GC_MOTION; mo.length = sizeof(mo); mo.vid = 2; mo.victim_vid = 1; mo.motion = 3; b = pkt(mo); es.apply(GC_MOTION, b.data(), (uint16_t)b.size()); auto ms = es.drain_motions(); CHECK(ms.size() == 1 && ms[0].vid == 2 && ms[0].victim_vid == 1 && ms[0].motion == 3, "motion event"); } // GC_DIG_MOTION: repeated mining animation toward a target. { GCDigMotion dig{GC_DIG_MOTION, sizeof(GCDigMotion), 2, 1, 3}; b = pkt(dig); es.apply(GC_DIG_MOTION, b.data(), (uint16_t)b.size()); auto ds = es.drain_dig_motions(); CHECK(ds.size() == 1 && ds[0].vid == 2 && ds[0].target_vid == 1 && ds[0].count == 3, "dig motion event"); } // GC_STUN then GC_DEAD for entity 2 { GCStun st{GC_STUN, sizeof(GCStun), 2}; b = pkt(st); es.apply(GC_STUN, b.data(), (uint16_t)b.size()); CHECK(es.get(2)->stunned, "entity 2 stunned"); GCDead dd{GC_DEAD, sizeof(GCDead), 2}; b = pkt(dd); es.apply(GC_DEAD, b.data(), (uint16_t)b.size()); CHECK(es.get(2)->dead && es.get(2)->hp == 0, "entity 2 dead, hp 0"); CHECK(!es.get(2)->moving && es.get(2)->func == FUNC_WAIT, "dead entity idle"); bool marked = false; for (uint32_t v : es.drain_vitals()) { if (v == 2) { marked = true; } } CHECK(marked, "vitals: entity 2 marked by stun/dead"); } // GC_TARGET_INFO: selected target 2 at 16% hp { GCTargetInfo ti{GC_TARGET_INFO, sizeof(GCTargetInfo), 2, 16}; b = pkt(ti); es.apply(GC_TARGET_INFO, b.data(), (uint16_t)b.size()); CHECK(es.target_vid() == 2 && es.target_hp_pct() == 16, "target info"); CHECK(es.take_target_dirty() && !es.take_target_dirty(), "target dirty flag"); } // --- items ----------------------------------------------------------- // GC_ITEM_SET: put a sword (vnum 19, x1) in inventory cell 5 with 2 attrs { GCItemSet s{}; s.header = GC_ITEM_SET; s.length = sizeof(s); s.pos = {WINDOW_INVENTORY, 5}; s.vnum = 19; s.count = 1; s.flags = 0x2; s.sockets[0] = 28960; s.attrs[0] = {5, 30}; // type 5, +30 s.attrs[1] = {7, 12}; b = pkt(s); es.apply(GC_ITEM_SET, b.data(), (uint16_t)b.size()); CHECK(es.inv_slot(5).vnum == 19 && es.inv_slot(5).count == 1, "item: inv cell 5 set"); CHECK(es.inv_slot(5).sockets[0] == 28960, "item: socket kept"); 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"); } // GC_ITEM_SET to EQUIPMENT wear slot 1 (body armor) { GCItemSet s{}; s.header = GC_ITEM_SET; s.length = sizeof(s); s.pos = {WINDOW_EQUIPMENT, 1}; s.vnum = 11209; s.count = 1; b = pkt(s); es.apply(GC_ITEM_SET, b.data(), (uint16_t)b.size()); CHECK(es.equip_slot(1).vnum == 11209, "item: equipment wear 1 set"); } // Costume / ring / belt positions arrive as the global equipment cells // (90 + wear), not as part of the old 11-slot packet array. { GCItemSet s{}; s.header = GC_ITEM_SET; s.length = sizeof(s); s.pos = {WINDOW_EQUIPMENT, (uint16_t)(EQUIPMENT_SLOT_START + 23)}; s.vnum = 18000; s.count = 1; b = pkt(s); es.apply(GC_ITEM_SET, b.data(), (uint16_t)b.size()); CHECK(es.equip_slot(23).vnum == 18000, "item: extended belt wear 23 set"); } { GCItemSet s{}; s.header = GC_ITEM_SET; s.length = sizeof(s); s.pos = {WINDOW_BELT, (uint16_t)(BELT_INVENTORY_SLOT_START + 15)}; s.vnum = 72700; s.count = 1; b = pkt(s); es.apply(GC_ITEM_SET, b.data(), (uint16_t)b.size()); CHECK(es.belt_slot(15).vnum == 72700, "item: belt inventory slot 15 set"); } // GC_ITEM_UPDATE: cell 5 count -> 3 { GCItemUpdate u{}; u.header = GC_ITEM_UPDATE; u.length = sizeof(u); u.cell = {WINDOW_INVENTORY, 5}; u.count = 3; 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"); } // GC_ITEM_DEL: clear cell 5 { GCItemDel dl{}; dl.header = GC_ITEM_DEL; dl.length = sizeof(dl); dl.pos = {WINDOW_INVENTORY, 5}; b = pkt(dl); es.apply(GC_ITEM_DEL, b.data(), (uint16_t)b.size()); CHECK(es.inv_slot(5).empty(), "item: cell 5 cleared"); } // GC_ITEM_GROUND_ADD / DEL { GCItemGroundAdd ga{}; ga.header = GC_ITEM_GROUND_ADD; ga.length = sizeof(ga); ga.vid = 5000; ga.vnum = 27; ga.x = 1234; ga.y = 5678; b = pkt(ga); es.apply(GC_ITEM_GROUND_ADD, b.data(), (uint16_t)b.size()); CHECK(es.ground(5000) && es.ground(5000)->vnum == 27, "item: ground add"); GCItemOwnership own{}; own.header = GC_ITEM_OWNERSHIP; own.length = sizeof(own); own.vid = 5000; std::strcpy(own.owner, "Tester"); b = pkt(own); es.apply(GC_ITEM_OWNERSHIP, b.data(), (uint16_t)b.size()); CHECK(es.ground(5000) && es.ground(5000)->owner == "Tester", "item: ground ownership"); auto gc = es.drain_ground(); CHECK(gc.size() == 2 && gc[0].vid == 5000 && gc[0].added && gc[1].added, "item: ground change (add + ownership update)"); GCItemGroundDel gd{GC_ITEM_GROUND_DEL, sizeof(GCItemGroundDel), 5000}; b = pkt(gd); es.apply(GC_ITEM_GROUND_DEL, b.data(), (uint16_t)b.size()); CHECK(es.ground(5000) == nullptr, "item: ground del"); auto gc2 = es.drain_ground(); CHECK(gc2.size() == 1 && !gc2[0].added, "item: ground change (del)"); } // GC_VIEW_EQUIP: an inspected player's legacy 11 equipment slots. { GCViewEquip view{}; view.header = GC_VIEW_EQUIP; view.length = sizeof(view); view.vid = 2; view.equips[4].vnum = 19; view.equips[4].count = 1; view.equips[4].sockets[0] = 17; view.equips[4].attrs[0] = {3, 42}; b = pkt(view); es.apply(GC_VIEW_EQUIP, b.data(), (uint16_t)b.size()); const ViewedEquipment *got = es.viewed_equipment(2); CHECK(got && got->items[4].vnum == 19 && got->items[4].sockets[0] == 17 && got->items[4].attrs[0].value == 42, "item: view equipment snapshot"); auto vc = es.drain_view_equipment_changes(); CHECK(vc.size() == 1 && vc[0] == 2, "item: view equipment change"); } // GC_PVP / GC_DUEL_START: relation state for target and name-color UI. { GCPvp pvp{GC_PVP, sizeof(GCPvp), 2, 9, 2}; b = pkt(pvp); es.apply(GC_PVP, b.data(), (uint16_t)b.size()); auto relations = es.pvp_relations(); CHECK(relations.size() == 1 && relations[0].src_vid == 2 && relations[0].dst_vid == 9 && relations[0].mode == 2, "pvp: fight relation stored"); auto pc = es.drain_pvp_changes(); CHECK(pc.size() == 1 && pc[0].mode == 2, "pvp: relation change emitted"); pvp.mode = 0; b = pkt(pvp); es.apply(GC_PVP, b.data(), (uint16_t)b.size()); CHECK(es.pvp_relations().empty(), "pvp: none removes relation"); GCDuelStart duel{GC_DUEL_START, sizeof(GCDuelStart)}; b = pkt(duel); es.apply(GC_DUEL_START, b.data(), (uint16_t)b.size()); CHECK(es.take_duel_started() && !es.take_duel_started(), "pvp: duel start one-shot flag"); } // GC_LOVER_INFO / GC_LOVE_POINT_UPDATE: partner name and affection meter. { GCLoverInfo lover{}; lover.header = GC_LOVER_INFO; lover.length = sizeof(lover); std::strcpy(lover.name, "Partner"); lover.love_point = 63; b = pkt(lover); es.apply(GC_LOVER_INFO, b.data(), (uint16_t)b.size()); CHECK(es.lover().valid && es.lover().name == "Partner" && es.lover().love_point == 63, "lover: info stored"); CHECK(es.take_lover_dirty() && !es.take_lover_dirty(), "lover: info dirty one-shot"); GCLovePointUpdate update{GC_LOVE_POINT_UPDATE, sizeof(GCLovePointUpdate), 88}; b = pkt(update); es.apply(GC_LOVE_POINT_UPDATE, b.data(), (uint16_t)b.size()); CHECK(es.lover().love_point == 88 && es.take_lover_dirty(), "lover: point update stored"); } // GC_ITEM_GET pickup notification { GCItemGet g{}; g.header = GC_ITEM_GET; g.length = sizeof(g); g.vnum = 1; g.count = 250; std::strcpy(g.from_name, "Ally"); b = pkt(g); es.apply(GC_ITEM_GET, b.data(), (uint16_t)b.size()); auto ev = es.drain_item_events(); CHECK(ev.size() == 1 && ev[0].kind == ItemEvent::Get && ev[0].vnum == 1 && ev[0].count == 250 && ev[0].from == "Ally", "item: GET event"); } // --- affects (GC_AFFECT_ADD / REMOVE) --- { GCAffectAdd aa{}; aa.header = GC_AFFECT_ADD; aa.length = sizeof(aa); aa.elem.type = 7001; // e.g. a skill vnum aa.elem.point_idx = 19; // some EPointTypes index aa.elem.apply_value = 25; aa.elem.duration = 300; b = pkt(aa); es.apply(GC_AFFECT_ADD, b.data(), (uint16_t)b.size()); CHECK(es.affects().size() == 1, "affect added"); CHECK(es.affects().size() == 1 && es.affects()[0].type == 7001 && es.affects()[0].value == 25 && es.affects()[0].duration == 300, "affect fields"); auto ac = es.drain_affects(); CHECK(ac.size() == 1 && ac[0].type == 7001 && ac[0].added, "affect change (add)"); GCAffectRemove ar{GC_AFFECT_REMOVE, sizeof(GCAffectRemove), 7001, 19}; b = pkt(ar); es.apply(GC_AFFECT_REMOVE, b.data(), (uint16_t)b.size()); CHECK(es.affects().empty(), "affect removed"); auto ac2 = es.drain_affects(); CHECK(ac2.size() == 1 && !ac2[0].added, "affect change (remove)"); } // --- effect cues (GC_SPECIFIC / SPECIAL EFFECT) --- { GCSpecificEffect se{}; se.header = GC_SPECIFIC_EFFECT; se.length = sizeof(se); se.vid = 2; std::strcpy(se.effect_file, "d:/ymir work/pc/warrior/effect/geompung_3_sword.mse"); b = pkt(se); es.apply(GC_SPECIFIC_EFFECT, b.data(), (uint16_t)b.size()); GCSpecialEffect sp{GC_SPECIAL_EFFECT, sizeof(GCSpecialEffect), 7, 1}; b = pkt(sp); es.apply(GC_SPECIAL_EFFECT, b.data(), (uint16_t)b.size()); auto cues = es.drain_effect_cues(); CHECK(cues.size() == 2, "2 effect cues"); CHECK(cues.size() == 2 && cues[0].vid == 2 && cues[0].file.find("geompung_3_sword.mse") != std::string::npos && cues[0].special == -1, "specific effect cue (file, special=-1)"); CHECK(cues.size() == 2 && cues[1].vid == 1 && cues[1].special == 7 && cues[1].file.empty(), "special effect cue (id 7, no file)"); } // --- skills (GC_SKILL_LEVEL / COOLTIME_END) + fly (GC_CREATE_FLY) --- { GCSkillLevel sl{}; sl.header = GC_SKILL_LEVEL; sl.length = sizeof(sl); sl.levels[1] = 5; sl.levels[16] = 3; b = pkt(sl); es.apply(GC_SKILL_LEVEL, b.data(), (uint16_t)b.size()); CHECK(es.skill_level(1) == 5 && es.skill_level(16) == 3, "skill levels set"); CHECK(es.skill_level(2) == 0, "unlearned skill = 0"); CHECK(es.skills_dirty() && !es.skills_dirty(), "skills dirty flag consumed once"); // GC_SKILL_LEVEL_NEW: {master,level,next_read}[255], 6B/record GCSkillLevelNew sn{}; sn.header = GC_SKILL_LEVEL_NEW; sn.length = sizeof(sn); sn.skills[7] = {3, 40, 123456}; // perfect master, lvl 40 sn.skills[9] = {1, 17, 0}; b = pkt(sn); es.apply(GC_SKILL_LEVEL_NEW, b.data(), (uint16_t)b.size()); CHECK(es.skill_level(7) == 40 && es.skill_master(7) == 3, "skill_level_new: lvl + master"); CHECK(es.skill_level(9) == 17 && es.skill_master(9) == 1, "skill_level_new: 2nd record"); CHECK(es.skills_dirty(), "skill_level_new: dirty"); // GC_QUICKSLOT_ADD / DEL / SWAP GCQuickSlotAdd qa0{}; qa0.header = GC_QUICKSLOT_ADD; qa0.length = sizeof(qa0); qa0.pos = 0; qa0.slot = {2, 7}; // skill 7 b = pkt(qa0); es.apply(GC_QUICKSLOT_ADD, b.data(), (uint16_t)b.size()); GCQuickSlotAdd qa1{}; qa1.header = GC_QUICKSLOT_ADD; qa1.length = sizeof(qa1); qa1.pos = 1; qa1.slot = {1, 12}; // item cell 12 b = pkt(qa1); es.apply(GC_QUICKSLOT_ADD, b.data(), (uint16_t)b.size()); CHECK(es.quickslot(0).type == 2 && es.quickslot(0).position == 7, "quickslot 0 = skill 7"); CHECK(es.quickslot(1).type == 1 && es.quickslot(1).position == 12, "quickslot 1 = item 12"); CHECK(es.quickslots_dirty() && !es.quickslots_dirty(), "quickslots dirty once"); GCQuickSlotSwap qsw{GC_QUICKSLOT_SWAP, sizeof(GCQuickSlotSwap), 0, 1}; b = pkt(qsw); es.apply(GC_QUICKSLOT_SWAP, b.data(), (uint16_t)b.size()); CHECK(es.quickslot(0).type == 1 && es.quickslot(1).type == 2, "quickslot swap"); GCQuickSlotDel qd{GC_QUICKSLOT_DEL, sizeof(GCQuickSlotDel), 1}; b = pkt(qd); es.apply(GC_QUICKSLOT_DEL, b.data(), (uint16_t)b.size()); CHECK(es.quickslot(1).type == 0, "quickslot del"); GCSkillCooltimeEnd ce{GC_SKILL_COOLTIME_END, sizeof(GCSkillCooltimeEnd), 1}; b = pkt(ce); es.apply(GC_SKILL_COOLTIME_END, b.data(), (uint16_t)b.size()); auto ends = es.drain_cooldown_ends(); CHECK(ends.size() == 1 && ends[0] == 1, "cooldown end for skill 1"); GCChangeSkillGroup group{GC_CHANGE_SKILL_GROUP, sizeof(GCChangeSkillGroup), 2}; b = pkt(group); es.apply(GC_CHANGE_SKILL_GROUP, b.data(), (uint16_t)b.size()); CHECK(es.skill_group() == 2 && es.skill_group_dirty() && !es.skill_group_dirty(), "skill group changed and dirty flag consumed"); CHECK(es.skills_dirty(), "skill group invalidates skill snapshot"); GCCreateFly cf{GC_CREATE_FLY, sizeof(GCCreateFly), 2, 1000, 2000}; b = pkt(cf); es.apply(GC_CREATE_FLY, b.data(), (uint16_t)b.size()); auto flies = es.drain_fly_cues(); CHECK(flies.size() == 1 && flies[0].start_vid == 1000 && flies[0].end_vid == 2000 && flies[0].type == 2, "fly cue parsed"); GCFlyTargeting target{GC_FLY_TARGETING, sizeof(GCFlyTargeting), 1, 2, 700, 800}; b = pkt(target); es.apply(GC_FLY_TARGETING, b.data(), (uint16_t)b.size()); CHECK(es.get(1)->fly_target_set && es.get(1)->fly_target_vid == 2 && es.get(1)->fly_target_x == 700 && es.get(1)->fly_target_y == 800, "fly target stored on shooter"); auto targets = es.drain_fly_target_cues(); CHECK(targets.size() == 1 && !targets[0].append && targets[0].shooter_vid == 1 && targets[0].target_vid == 2, "fly target cue parsed"); GCFlyTargeting append{GC_ADD_FLY_TARGETING, sizeof(GCFlyTargeting), 1, 0, 900, 1000}; b = pkt(append); es.apply(GC_ADD_FLY_TARGETING, b.data(), (uint16_t)b.size()); targets = es.drain_fly_target_cues(); CHECK(targets.size() == 1 && targets[0].append && targets[0].target_vid == 0 && targets[0].x == 900 && targets[0].y == 1000, "append fly target cue parsed"); CHECK(es.get(1)->fly_target_set && es.get(1)->fly_target_vid == 0 && es.get(1)->fly_target_x == 900 && es.get(1)->fly_target_y == 1000, "append fly target replaces fallback position"); } // --- quest / NPC (GC_SCRIPT / QUEST_CONFIRM / QUEST_INFO) --- { const char *script = "Hello traveller.[ENTER][QUESTION arg(\"Yes\") arg(\"No\")]"; std::vector sb(sizeof(GCScript) + std::strlen(script) + 1); GCScript sh{}; sh.header = GC_SCRIPT; sh.length = (uint16_t)sb.size(); sh.skin = 1; sh.src_size = (uint16_t)(std::strlen(script) + 1); std::memcpy(sb.data(), &sh, sizeof(sh)); std::memcpy(sb.data() + sizeof(sh), script, std::strlen(script) + 1); es.apply(GC_SCRIPT, sb.data(), (uint16_t)sb.size()); auto sc = es.drain_scripts(); CHECK(sc.size() == 1 && sc[0].skin == 1 && sc[0].text.find("[QUESTION") != std::string::npos, "GC_SCRIPT parsed (skin + text)"); GCQuestConfirm qc{}; qc.header = GC_QUEST_CONFIRM; qc.length = sizeof(qc); std::strcpy(qc.msg, "Accept the party invite?"); qc.timeout = 30; qc.request_pid = 4242; b = pkt(qc); es.apply(GC_QUEST_CONFIRM, b.data(), (uint16_t)b.size()); auto cf = es.drain_confirms(); CHECK(cf.size() == 1 && cf[0].msg == "Accept the party invite?" && cf[0].timeout == 30 && cf[0].request_pid == 4242, "GC_QUEST_CONFIRM parsed"); // GC_QUEST_INFO with TITLE + COUNTER_NAME + COUNTER_VALUE { std::vector qb; GCQuestInfo qh{}; qh.header = GC_QUEST_INFO; qh.index = 7; qh.flag = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE; qb.resize(sizeof(qh)); std::memcpy(qb.data(), &qh, sizeof(qh)); auto push_str = [&](const char *s) { qb.insert(qb.end(), s, s + std::strlen(s) + 1); }; auto push_i32 = [&](int32_t v) { const uint8_t *p = (const uint8_t *)&v; qb.insert(qb.end(), p, p + 4); }; push_str("Kill wolves"); push_str("Wolves"); push_i32(3); *(uint16_t *)(qb.data() + 2) = (uint16_t)qb.size(); es.apply(GC_QUEST_INFO, qb.data(), (uint16_t)qb.size()); CHECK(es.quest(7) != nullptr, "quest 7 recorded"); const QuestInfo *q = es.quest(7); CHECK(q && q->title == "Kill wolves" && q->counter_name == "Wolves" && 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"); } } // --- P8 party (GC_PARTY_ADD / UPDATE / LINK / PARAMETER / REMOVE / INVITE) --- { GCPartyInvite pi{GC_PARTY_INVITE, sizeof(GCPartyInvite), 555}; b = pkt(pi); es.apply(GC_PARTY_INVITE, b.data(), (uint16_t)b.size()); auto invs = es.drain_party_invites(); CHECK(invs.size() == 1 && invs[0] == 555, "party: invite queued"); GCPartyAdd pa{}; pa.header = GC_PARTY_ADD; pa.length = sizeof(pa); pa.pid = 100; std::strcpy(pa.name, "Ally"); b = pkt(pa); es.apply(GC_PARTY_ADD, b.data(), (uint16_t)b.size()); CHECK(es.in_party() && es.party_member(100) && es.party_member(100)->name == "Ally", "party: member added"); CHECK(es.party_dirty() && !es.party_dirty(), "party: dirty flag consumed once"); GCPartyUpdate pu{}; pu.header = GC_PARTY_UPDATE; pu.length = sizeof(pu); pu.pid = 100; pu.state = 1; // leader bit pu.percent_hp = 73; b = pkt(pu); es.apply(GC_PARTY_UPDATE, b.data(), (uint16_t)b.size()); CHECK(es.party_member(100)->leader() && es.party_member(100)->hp_pct == 73, "party: update leader + hp%"); GCPartyLink pl{GC_PARTY_LINK, sizeof(GCPartyLink), 100, 9001}; b = pkt(pl); es.apply(GC_PARTY_LINK, b.data(), (uint16_t)b.size()); CHECK(es.party_member(100)->vid == 9001, "party: link pid->vid"); GCPartyParameter pp{GC_PARTY_PARAMETER, sizeof(GCPartyParameter), 1}; b = pkt(pp); es.apply(GC_PARTY_PARAMETER, b.data(), (uint16_t)b.size()); CHECK(es.party_distribute_mode() == 1, "party: distribute mode"); GCPartyRemove pr{GC_PARTY_REMOVE, sizeof(GCPartyRemove), 100}; b = pkt(pr); es.apply(GC_PARTY_REMOVE, b.data(), (uint16_t)b.size()); CHECK(!es.in_party(), "party: member removed"); } // --- P8 messenger (GC_MESSENGER LIST / LOGIN / REMOVE_FRIEND) --- { std::vector mb; GCSubHeader mh{GC_MESSENGER, 0, MESSENGER_GC_LIST}; mb.resize(sizeof(mh)); std::memcpy(mb.data(), &mh, sizeof(mh)); auto push_friend = [&](uint8_t online, const char *nm) { mb.push_back(online); mb.push_back((uint8_t)std::strlen(nm)); mb.insert(mb.end(), nm, nm + std::strlen(nm)); }; push_friend(1, "Alice"); push_friend(0, "Bob"); *(uint16_t *)(mb.data() + 2) = (uint16_t)mb.size(); es.apply(GC_MESSENGER, mb.data(), (uint16_t)mb.size()); CHECK(es.friends().size() == 2, "messenger: 2 friends from LIST"); bool alice_online = false, bob_online = true; for (const auto &f : es.friends()) { if (f.name == "Alice") alice_online = f.online; if (f.name == "Bob") bob_online = f.online; } CHECK(alice_online && !bob_online, "messenger: online flags"); CHECK(es.friends_dirty() && !es.friends_dirty(), "messenger: dirty consumed once"); std::vector lb; GCSubHeader lh{GC_MESSENGER, 0, MESSENGER_GC_LOGIN}; lb.resize(sizeof(lh)); std::memcpy(lb.data(), &lh, sizeof(lh)); lb.push_back(3); lb.insert(lb.end(), {'B', 'o', 'b'}); *(uint16_t *)(lb.data() + 2) = (uint16_t)lb.size(); es.apply(GC_MESSENGER, lb.data(), (uint16_t)lb.size()); bool bob_now = false; for (const auto &f : es.friends()) { if (f.name == "Bob") bob_now = f.online; } CHECK(bob_now, "messenger: LOGIN flips Bob online"); const char *invite = "messenger_auth Alice"; std::vector invite_buf(sizeof(GCChat) + std::strlen(invite) + 1); GCChat invite_head{}; invite_head.header = GC_CHAT; invite_head.length = (uint16_t)invite_buf.size(); invite_head.type = CHAT_TYPE_COMMAND; std::memcpy(invite_buf.data(), &invite_head, sizeof(invite_head)); std::memcpy(invite_buf.data() + sizeof(invite_head), invite, std::strlen(invite) + 1); es.apply(GC_CHAT, invite_buf.data(), (uint16_t)invite_buf.size()); auto invites = es.drain_friend_invites(); CHECK(invites.size() == 1 && invites[0] == "Alice", "messenger: invitation command surfaced"); CHECK(es.drain_friend_invites().empty(), "messenger: invitation queue drained"); } // --- P8 NPC shop (GC_SHOP START / END + error) --- { std::vector shb; GCSubHeader sh{GC_SHOP, 0, SHOP_GC_START}; shb.resize(sizeof(sh)); std::memcpy(shb.data(), &sh, sizeof(sh)); uint32_t owner = 8080; const uint8_t *op = (const uint8_t *)&owner; shb.insert(shb.end(), op, op + 4); ShopItem si0{}; si0.vnum = 27; si0.price = 1500; si0.count = 1; const uint8_t *sp = (const uint8_t *)&si0; shb.insert(shb.end(), sp, sp + sizeof(si0)); *(uint16_t *)(shb.data() + 2) = (uint16_t)shb.size(); es.apply(GC_SHOP, shb.data(), (uint16_t)shb.size()); CHECK(es.shop_open() && es.shop_vid() == 8080, "shop: opened with owner vid"); CHECK(es.shop_items().size() == 1 && es.shop_items()[0].vnum == 27 && es.shop_items()[0].price == 1500, "shop: item row parsed"); GCSubHeader se{GC_SHOP, sizeof(GCSubHeader), SHOP_GC_NOT_ENOUGH_MONEY}; b = pkt(se); es.apply(GC_SHOP, b.data(), (uint16_t)b.size()); auto errs = es.drain_shop_errors(); CHECK(errs.size() == 1 && errs[0] == "NOT_ENOUGH_MONEY", "shop: error surfaced"); GCSubHeader sc{GC_SHOP, sizeof(GCSubHeader), SHOP_GC_END}; b = pkt(sc); es.apply(GC_SHOP, b.data(), (uint16_t)b.size()); CHECK(!es.shop_open() && es.shop_items().empty(), "shop: closed"); } // --- P8 NPC shop, SHOP_GC_START_EX (multi-shelf) --- { std::vector shb; GCSubHeader sh{GC_SHOP, 0, SHOP_GC_START_EX}; shb.resize(sizeof(sh)); std::memcpy(shb.data(), &sh, sizeof(sh)); uint32_t owner = 4242; const uint8_t *op = (const uint8_t *)&owner; shb.insert(shb.end(), op, op + 4); shb.push_back(2); // tab_count auto append_tab = [&](const char *name, uint8_t coin, std::initializer_list> rows) { ShopTabHead th{}; std::snprintf(th.name, sizeof(th.name), "%s", name); th.coin_type = coin; const uint8_t *tp = (const uint8_t *)&th; shb.insert(shb.end(), tp, tp + sizeof(th)); ShopItem grid[SHOP_HOST_ITEM_MAX_NUM] = {}; for (auto &r : rows) { grid[r.first] = r.second; } const uint8_t *gp = (const uint8_t *)grid; shb.insert(shb.end(), gp, gp + sizeof(grid)); }; ShopItem a{}; a.vnum = 101; a.price = 500; a.count = 1; ShopItem c{}; c.vnum = 202; c.price = 900; c.count = 3; append_tab("Weapons", 0, {{0, a}, {5, c}}); ShopItem d{}; d.vnum = 303; d.price = 12000; d.count = 1; append_tab("Armor", 1, {{3, d}}); *(uint16_t *)(shb.data() + 2) = (uint16_t)shb.size(); es.apply(GC_SHOP, shb.data(), (uint16_t)shb.size()); CHECK(es.shop_open() && es.shop_vid() == 4242, "shop_ex: opened with owner vid"); CHECK(es.shop_tabs().size() == 2, "shop_ex: two shelves"); CHECK(es.shop_tabs()[0].name == "Weapons" && es.shop_tabs()[0].coin_type == 0, "shop_ex: tab 0 name/coin"); CHECK(es.shop_tabs()[1].name == "Armor" && es.shop_tabs()[1].coin_type == 1, "shop_ex: tab 1 name/coin"); CHECK(es.shop_tabs()[0].items.size() == 2 && es.shop_tabs()[0].items[0].vnum == 101 && es.shop_tabs()[0].items[0].pos == 0 && es.shop_tabs()[0].items[1].vnum == 202 && es.shop_tabs()[0].items[1].pos == 5, "shop_ex: tab 0 rows keep slot index"); CHECK(es.shop_tabs()[1].items.size() == 1 && es.shop_tabs()[1].items[0].vnum == 303 && es.shop_tabs()[1].items[0].pos == 3, "shop_ex: tab 1 row"); CHECK(es.shop_items().size() == 2 && es.shop_items()[0].vnum == 101, "shop_ex: shop_items() mirrors tab 0"); GCSubHeader sc{GC_SHOP, sizeof(GCSubHeader), SHOP_GC_END}; b = pkt(sc); es.apply(GC_SHOP, b.data(), (uint16_t)b.size()); CHECK(!es.shop_open() && es.shop_tabs().empty() && es.shop_items().empty(), "shop_ex: closed clears shelves"); } // --- P8 exchange (GC_EXCHANGE START / ITEM_ADD / ELK_ADD / ACCEPT / END) --- { GCExchange xs{}; xs.header = GC_EXCHANGE; xs.length = sizeof(xs); xs.subheader = EXCHANGE_SUB_START; xs.arg1 = 4321; b = pkt(xs); es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size()); CHECK(es.exchange().active && es.exchange().partner_vid == 4321, "exchange: started"); GCExchange xi{}; xi.header = GC_EXCHANGE; xi.length = sizeof(xi); xi.subheader = EXCHANGE_SUB_ITEM_ADD; xi.is_me = 1; xi.arg1 = 19; // vnum xi.arg2.cell = 2; // exchange slot xi.arg3 = 1; // count b = pkt(xi); es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size()); CHECK(es.exchange().self_items[2].vnum == 19, "exchange: self item add"); GCExchange xe{}; xe.header = GC_EXCHANGE; xe.length = sizeof(xe); xe.subheader = EXCHANGE_SUB_ELK_ADD; xe.is_me = 0; xe.arg1 = 5000; b = pkt(xe); es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size()); CHECK(es.exchange().peer_gold == 5000, "exchange: peer gold"); GCExchange xa{}; xa.header = GC_EXCHANGE; xa.length = sizeof(xa); xa.subheader = EXCHANGE_SUB_ACCEPT; xa.is_me = 1; xa.arg1 = 1; b = pkt(xa); es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size()); CHECK(es.exchange().self_accept, "exchange: self accept"); GCExchange xd{}; xd.header = GC_EXCHANGE; xd.length = sizeof(xd); xd.subheader = EXCHANGE_GC_END; b = pkt(xd); es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size()); CHECK(!es.exchange().active, "exchange: ended / cleared"); } // --- P8 safebox (GC_SAFEBOX_SIZE / SET / DEL / MONEY_CHANGE) --- { GCSafeboxSize sz{GC_SAFEBOX_SIZE, sizeof(GCSafeboxSize), 2}; b = pkt(sz); es.apply(GC_SAFEBOX_SIZE, b.data(), (uint16_t)b.size()); CHECK(es.safebox_open() && es.safebox_size() == 2, "safebox: opened, size 2"); GCItemSet ss{}; ss.header = GC_SAFEBOX_SET; ss.length = sizeof(ss); ss.pos.window_type = WINDOW_SAFEBOX; ss.pos.cell = 4; ss.vnum = 27; ss.count = 5; b = pkt(ss); es.apply(GC_SAFEBOX_SET, b.data(), (uint16_t)b.size()); CHECK(es.safebox_slot(4).vnum == 27 && es.safebox_slot(4).count == 5, "safebox: item set"); GCSafeboxMoneyChange mc2{GC_SAFEBOX_MONEY_CHANGE, sizeof(GCSafeboxMoneyChange), 123456}; b = pkt(mc2); es.apply(GC_SAFEBOX_MONEY_CHANGE, b.data(), (uint16_t)b.size()); CHECK(es.safebox_gold() == 123456, "safebox: money change"); GCItemDel sd{}; sd.header = GC_SAFEBOX_DEL; sd.length = sizeof(sd); sd.pos.window_type = WINDOW_SAFEBOX; sd.pos.cell = 4; b = pkt(sd); es.apply(GC_SAFEBOX_DEL, b.data(), (uint16_t)b.size()); CHECK(es.safebox_slot(4).empty(), "safebox: item del"); CHECK(es.safebox_dirty(), "safebox: dirty flag set"); } // --- P9 world systems (GC_WARP / TIME / CHANNEL / NPC_POSITION / TARGET_* / MOUNT) --- { GCWarp wp{}; wp.header = GC_WARP; wp.length = sizeof(wp); wp.x = 12345; wp.y = 67890; wp.addr = 0; wp.port = 0; b = pkt(wp); es.apply(GC_WARP, b.data(), (uint16_t)b.size()); auto warps = es.drain_warps(); CHECK(warps.size() == 1 && warps[0].x == 12345 && warps[0].same_server(), "warp: queued, same-server"); GCTime tm{GC_TIME, sizeof(GCTime), 1735689600}; b = pkt(tm); es.apply(GC_TIME, b.data(), (uint16_t)b.size()); CHECK(es.server_time() == 1735689600 && es.take_time_dirty() && !es.take_time_dirty(), "time: set + dirty once"); GCChannel ch{GC_CHANNEL, sizeof(GCChannel), 4}; b = pkt(ch); es.apply(GC_CHANNEL, b.data(), (uint16_t)b.size()); CHECK(es.channel() == 4 && es.take_channel_dirty(), "channel: set"); // GC_NPC_POSITION with 2 entries { std::vector nb; GCNPCPosition nh{GC_NPC_POSITION, 0, 2}; nb.resize(sizeof(nh)); std::memcpy(nb.data(), &nh, sizeof(nh)); auto push_npc = [&](uint8_t type, uint32_t vnum, const char *nm, int32_t x, int32_t y) { NPCPositionEntry e{}; e.type = type; e.vnum = vnum; std::strncpy(e.name, nm, sizeof(e.name) - 1); e.x = x; e.y = y; const uint8_t *p = (const uint8_t *)&e; nb.insert(nb.end(), p, p + sizeof(e)); }; push_npc(1, 20001, "Blacksmith", 1000, 2000); push_npc(2, 30001, "Warp Gate", 3000, 4000); *(uint16_t *)(nb.data() + 2) = (uint16_t)nb.size(); es.apply(GC_NPC_POSITION, nb.data(), (uint16_t)nb.size()); CHECK(es.npc_marks().size() == 2 && es.npc_marks()[0].name == "Blacksmith" && es.npc_marks()[1].vnum == 30001, "npc_position: 2 marks parsed"); CHECK(es.take_npc_marks_dirty(), "npc_position: dirty"); } // world markers: create -> update -> delete GCTargetCreate tc{}; tc.header = GC_TARGET_CREATE; tc.length = sizeof(tc); tc.id = 7; std::strcpy(tc.name, "Go here"); tc.vid = 0; tc.type = CREATE_TARGET_TYPE_LOCATION; b = pkt(tc); es.apply(GC_TARGET_CREATE, b.data(), (uint16_t)b.size()); CHECK(es.markers().size() == 1 && es.markers()[0].name == "Go here", "marker: created"); GCTargetUpdate tu{GC_TARGET_UPDATE, sizeof(GCTargetUpdate), 7, 555, 666}; b = pkt(tu); es.apply(GC_TARGET_UPDATE, b.data(), (uint16_t)b.size()); CHECK(es.markers().size() == 1 && es.markers()[0].x == 555 && es.markers()[0].y == 666, "marker: updated position"); GCTargetDelete td{GC_TARGET_DELETE, sizeof(GCTargetDelete), 7}; b = pkt(td); es.apply(GC_TARGET_DELETE, b.data(), (uint16_t)b.size()); CHECK(es.markers().empty(), "marker: deleted"); // mount GCMount mo{}; mo.header = GC_MOUNT; mo.length = sizeof(mo); mo.vid = 2; mo.mount_vid = 20101; b = pkt(mo); es.apply(GC_MOUNT, b.data(), (uint16_t)b.size()); CHECK(es.get(2) && es.get(2)->mount_vnum == 20101, "mount: entity 2 mounted"); auto mc = es.drain_mount_changes(); CHECK(mc.size() == 1 && mc[0] == 2, "mount: change emitted"); } // --- guild (GC_GUILD INFO / LIST / GRADE) --- { // INFO std::vector gb(sizeof(GCSubHeader)); GCSubHeader gh{GC_GUILD, 0, GUILD_GC_INFO}; std::memcpy(gb.data(), &gh, sizeof(gh)); GuildInfoBody gi{}; gi.member_count = 3; gi.max_member_count = 32; gi.guild_id = 77; gi.master_pid = 100; gi.exp = 4200; gi.level = 12; std::strcpy(gi.name, "Dragons"); gi.gold = 999999; gi.has_land = 1; const uint8_t *gp = (const uint8_t *)&gi; gb.insert(gb.end(), gp, gp + sizeof(gi)); *(uint16_t *)(gb.data() + 2) = (uint16_t)gb.size(); es.apply(GC_GUILD, gb.data(), (uint16_t)gb.size()); CHECK(es.guild().in_guild && es.guild().name == "Dragons" && es.guild().level == 12, "guild: INFO parsed"); CHECK(es.guild().gold == 999999 && es.guild().max_member_count == 32, "guild: INFO gold + cap"); CHECK(es.guild_dirty() && !es.guild_dirty(), "guild: dirty consumed once"); // LIST — one member with name_flag=1 + name std::vector lb(sizeof(GCSubHeader)); GCSubHeader lh{GC_GUILD, 0, GUILD_GC_LIST}; std::memcpy(lb.data(), &lh, sizeof(lh)); GuildSubMember gm{}; gm.pid = 100; gm.grade = 1; gm.is_general = 1; gm.job = 0; gm.level = 90; gm.offer = 50000; gm.name_flag = 1; const uint8_t *mp = (const uint8_t *)&gm; lb.insert(lb.end(), mp, mp + sizeof(gm)); char nm[CHARACTER_NAME_MAX_LEN + 1] = "GuildMaster"; lb.insert(lb.end(), nm, nm + sizeof(nm)); *(uint16_t *)(lb.data() + 2) = (uint16_t)lb.size(); es.apply(GC_GUILD, lb.data(), (uint16_t)lb.size()); auto gms = es.guild_members(); CHECK(gms.size() == 1 && gms[0].name == "GuildMaster" && gms[0].level == 90 && gms[0].is_general && gms[0].offer == 50000, "guild: LIST member parsed"); // GRADE — [count][idx][GuildSubGrade] std::vector rb(sizeof(GCSubHeader)); GCSubHeader rh{GC_GUILD, 0, GUILD_GC_GRADE}; std::memcpy(rb.data(), &rh, sizeof(rh)); rb.push_back(1); // count rb.push_back(2); // index GuildSubGrade gg{}; std::strcpy(gg.grade_name, "Officer"); gg.auth_flag = 0x7; const uint8_t *ggp = (const uint8_t *)≫ rb.insert(rb.end(), ggp, ggp + sizeof(gg)); *(uint16_t *)(rb.data() + 2) = (uint16_t)rb.size(); es.apply(GC_GUILD, rb.data(), (uint16_t)rb.size()); CHECK(es.guild_grade(2).name == "Officer" && es.guild_grade(2).auth == 0x7, "guild: GRADE name + auth"); es.guild_dirty(); // clear // NAME — id -> name registry (needed to label war opponents) std::vector nb(sizeof(GCSubHeader)); GCSubHeader nh{GC_GUILD, 0, GUILD_GC_NAME}; std::memcpy(nb.data(), &nh, sizeof(nh)); GuildNameEntry ne1{}; ne1.id = 77; std::strncpy(ne1.name, "Dragons", sizeof(ne1.name)); GuildNameEntry ne2{}; ne2.id = 88; std::strncpy(ne2.name, "Tigers", sizeof(ne2.name)); const uint8_t *n1 = (const uint8_t *)&ne1; const uint8_t *n2 = (const uint8_t *)&ne2; nb.insert(nb.end(), n1, n1 + sizeof(ne1)); nb.insert(nb.end(), n2, n2 + sizeof(ne2)); *(uint16_t *)(nb.data() + 2) = (uint16_t)nb.size(); es.apply(GC_GUILD, nb.data(), (uint16_t)nb.size()); CHECK(es.guild_name(77) == "Dragons" && es.guild_name(88) == "Tigers", "guild: NAME registry"); // SKILL_INFO — {u8 point, u8 lvl[12], u16 gp, u16 max_gp} std::vector sb(sizeof(GCSubHeader)); GCSubHeader sh{GC_GUILD, 0, GUILD_GC_SKILL_INFO}; std::memcpy(sb.data(), &sh, sizeof(sh)); GuildSkillInfoBody gsk{}; gsk.skill_point = 5; gsk.skill_level[0] = 3; gsk.skill_level[11] = 7; gsk.guild_point = 1200; gsk.max_guild_point = 5000; const uint8_t *skp = (const uint8_t *)&gsk; sb.insert(sb.end(), skp, skp + sizeof(gsk)); *(uint16_t *)(sb.data() + 2) = (uint16_t)sb.size(); es.apply(GC_GUILD, sb.data(), (uint16_t)sb.size()); CHECK(es.guild_skill().valid && es.guild_skill().skill_point == 5 && es.guild_skill().levels[0] == 3 && es.guild_skill().levels[11] == 7 && es.guild_skill().guild_point == 1200 && es.guild_skill().max_guild_point == 5000, "guild: SKILL_INFO parsed"); CHECK(es.guild_skill_dirty() && !es.guild_skill_dirty(), "guild: skill dirty once"); // WAR — state transition to ON_WAR vs guild 88 std::vector wb(sizeof(GCSubHeader)); GCSubHeader wh{GC_GUILD, 0, GUILD_GC_WAR}; std::memcpy(wb.data(), &wh, sizeof(wh)); GCGuildWar gw{}; gw.guild_self = 77; gw.guild_opp = 88; gw.type = 1; gw.war_state = GUILD_WAR_ON_WAR; const uint8_t *gwp = (const uint8_t *)&gw; wb.insert(wb.end(), gwp, gwp + sizeof(gw)); *(uint16_t *)(wb.data() + 2) = (uint16_t)wb.size(); es.apply(GC_GUILD, wb.data(), (uint16_t)wb.size()); CHECK(es.guild_war().opp_guild_id == 88 && es.guild_war().state == GUILD_WAR_ON_WAR, "guild: WAR state"); { auto evs = es.drain_guild_war_events(); CHECK(evs.size() == 1 && evs[0].state == GUILD_WAR_ON_WAR && evs[0].opp_guild_id == 88, "guild: WAR event drained"); CHECK(es.drain_guild_war_events().empty(), "guild: WAR events cleared"); } // GUILD_WAR_LIST — {src,dst} pairs std::vector gl(sizeof(GCSubHeader)); GCSubHeader glh{GC_GUILD, 0, GUILD_GC_GUILD_WAR_LIST}; std::memcpy(gl.data(), &glh, sizeof(glh)); GuildWarPair p1{77, 88}; GuildWarPair p2{88, 77}; const uint8_t *pp1 = (const uint8_t *)&p1; const uint8_t *pp2 = (const uint8_t *)&p2; gl.insert(gl.end(), pp1, pp1 + sizeof(p1)); gl.insert(gl.end(), pp2, pp2 + sizeof(p2)); *(uint16_t *)(gl.data() + 2) = (uint16_t)gl.size(); es.apply(GC_GUILD, gl.data(), (uint16_t)gl.size()); CHECK(es.guild_wars().size() == 2, "guild: WAR_LIST 2 pairs"); // GUILD_WAR_END_LIST — drop one pair std::vector ge(sizeof(GCSubHeader)); GCSubHeader geh{GC_GUILD, 0, GUILD_GC_GUILD_WAR_END_LIST}; std::memcpy(ge.data(), &geh, sizeof(geh)); ge.insert(ge.end(), pp2, pp2 + sizeof(p2)); *(uint16_t *)(ge.data() + 2) = (uint16_t)ge.size(); es.apply(GC_GUILD, ge.data(), (uint16_t)ge.size()); CHECK(es.guild_wars().size() == 1 && es.guild_wars()[0].src == 77, "guild: WAR_END_LIST removed one"); // WAR_POINT — scoreboard delta std::vector pb(sizeof(GCSubHeader)); GCSubHeader ph{GC_GUILD, 0, GUILD_GC_WAR_POINT}; std::memcpy(pb.data(), &ph, sizeof(ph)); GuildWarPoint wp{77, 88, 25}; const uint8_t *wpp = (const uint8_t *)℘ pb.insert(pb.end(), wpp, wpp + sizeof(wp)); *(uint16_t *)(pb.data() + 2) = (uint16_t)pb.size(); es.apply(GC_GUILD, pb.data(), (uint16_t)pb.size()); { auto scs = es.drain_guild_war_scores(); CHECK(scs.size() == 1 && scs[0].gain_guild_id == 77 && scs[0].point == 25, "guild: WAR_POINT drained"); } } // --- refine (GC_REFINE_INFORMATION) --- { GCRefineInfo rf{}; rf.header = GC_REFINE_INFO; rf.length = sizeof(rf); rf.type = 1; rf.pos = 7; rf.table.src_vnum = 11209; rf.table.result_vnum = 11219; rf.table.material_count = 2; rf.table.cost = 200000; rf.table.prob = 45; rf.table.materials[0] = {30001, 3}; rf.table.materials[1] = {30002, 1}; b = pkt(rf); es.apply(GC_REFINE_INFO, b.data(), (uint16_t)b.size()); auto rcs = es.drain_refine_cues(); CHECK(rcs.size() == 1 && rcs[0].pos == 7 && rcs[0].src_vnum == 11209 && rcs[0].result_vnum == 11219 && rcs[0].prob == 45 && rcs[0].cost == 200000, "refine: info parsed"); CHECK(rcs.size() == 1 && rcs[0].material_count == 2 && rcs[0].materials[0].vnum == 30001 && rcs[0].materials[0].count == 3, "refine: materials parsed"); } // --- dragon-soul refine (GC_DRAGON_SOUL_REFINE) --- { GCDragonSoulRefine ds{}; ds.header = GC_DRAGON_SOUL_REFINE; ds.length = sizeof(ds); ds.sub_type = DS_SUB_OPEN; b = pkt(ds); es.apply(GC_DRAGON_SOUL_REFINE, b.data(), (uint16_t)b.size()); GCDragonSoulRefine ok{}; ok.header = GC_DRAGON_SOUL_REFINE; ok.length = sizeof(ok); ok.sub_type = DS_SUB_REFINE_SUCCEED; ok.pos = {WINDOW_DRAGON_SOUL, 4}; b = pkt(ok); es.apply(GC_DRAGON_SOUL_REFINE, b.data(), (uint16_t)b.size()); GCDragonSoulRefine bad{}; bad.header = GC_DRAGON_SOUL_REFINE; bad.length = sizeof(bad); bad.sub_type = DS_SUB_REFINE_FAIL_NOT_ENOUGH_MONEY; bad.pos = {WINDOW_DRAGON_SOUL, 4}; b = pkt(bad); es.apply(GC_DRAGON_SOUL_REFINE, b.data(), (uint16_t)b.size()); auto dcs = es.drain_ds_cues(); CHECK(dcs.size() == 3 && dcs[0].sub_type == DS_SUB_OPEN, "ds: open cue"); CHECK(dcs.size() == 3 && dcs[1].sub_type == DS_SUB_REFINE_SUCCEED && dcs[1].cell == 4 && dcs[1].window == WINDOW_DRAGON_SOUL, "ds: success cue carries cell"); CHECK(dcs.size() == 3 && dcs[2].sub_type == DS_SUB_REFINE_FAIL_NOT_ENOUGH_MONEY, "ds: fail cue"); CHECK(es.drain_ds_cues().empty(), "ds: drain clears"); } // --- item-mall (창고몰): GC_MALL_OPEN / SET / DEL --- { GCMallOpen mo{GC_MALL_OPEN, sizeof(GCMallOpen), 45}; b = pkt(mo); es.apply(GC_MALL_OPEN, b.data(), (uint16_t)b.size()); CHECK(es.mall_open() && es.mall_size() == 45, "mall: open size 45"); CHECK(es.mall_dirty(), "mall: dirty after open"); GCItemSet is{}; is.header = GC_MALL_SET; is.length = sizeof(is); is.pos = {WINDOW_MALL, 3}; is.vnum = 27993; is.count = 2; b = pkt(is); es.apply(GC_MALL_SET, b.data(), (uint16_t)b.size()); CHECK(es.mall_slot(3).vnum == 27993 && es.mall_slot(3).count == 2, "mall: slot 3 set"); GCItemDel id{}; id.header = GC_MALL_DEL; id.length = sizeof(id); id.pos = {WINDOW_MALL, 3}; b = pkt(id); es.apply(GC_MALL_DEL, b.data(), (uint16_t)b.size()); CHECK(es.mall_slot(3).empty(), "mall: slot 3 cleared"); } // --- cube (제작): GC_CHAT/COMMAND server-command bus --- { auto cmd = [&](const std::string &text) { std::vector buf(sizeof(GCChat) + text.size() + 1); GCChat h{}; h.header = GC_CHAT; h.length = (uint16_t)buf.size(); h.type = CHAT_TYPE_COMMAND; h.vid = 0; std::memcpy(buf.data(), &h, sizeof(h)); std::memcpy(buf.data() + sizeof(h), text.c_str(), text.size() + 1); es.apply(GC_CHAT, buf.data(), (uint16_t)buf.size()); }; size_t chat_before = es.drain_chat().size(); cmd("cube open 20383"); CHECK(es.cube().open && es.cube().npc_vnum == 20383, "cube: opened npc 20383"); cmd("cube r_list 20383 2 72723,1/50001,5"); CHECK(es.cube().results.size() == 2 && es.cube().results[0].vnum == 72723 && es.cube().results[1].count == 5, "cube: r_list parsed 2 results"); cmd("cube m_info 0 1 125,1|126,2&555,5/120000"); CHECK(es.cube().recipes.size() == 1, "cube: m_info made 1 recipe"); CHECK(es.cube().recipes[0].gold == 120000, "cube: recipe gold"); CHECK(es.cube().recipes[0].material_groups.size() == 2, "cube: 2 material groups"); CHECK(es.cube().recipes[0].material_groups[0].size() == 2 && es.cube().recipes[0].material_groups[0][1].vnum == 126, "cube: group 0 has 2 slots (v126)"); CHECK(es.cube().recipes[0].result_vnum == 72723, "cube: recipe linked to result 0"); cmd("cube info 5000 30001 3"); CHECK(es.cube().need_gold == 5000 && es.cube().need_item_vnum == 30001, "cube: info hint"); cmd("cube success 72723 1"); cmd("cube fail"); cmd("cube close"); { auto evs = es.drain_cube_events(); int opened = 0, info = 0, ok = 0, fail = 0, closed = 0; for (auto e : evs) { switch (e) { case EntityStore::CubeEvent::Opened: ++opened; break; case EntityStore::CubeEvent::InfoChanged: ++info; break; case EntityStore::CubeEvent::Success: ++ok; break; case EntityStore::CubeEvent::Fail: ++fail; break; case EntityStore::CubeEvent::Closed: ++closed; break; } } CHECK(opened == 1 && ok == 1 && fail == 1 && closed == 1 && info >= 3, "cube: event stream (open/3+info/success/fail/close)"); } CHECK(es.cube_last_success().vnum == 72723 && es.cube_last_success().count == 1, "cube: last success payload"); CHECK(!es.cube().open, "cube: closed"); CHECK(es.drain_chat().size() == chat_before, "cube: COMMAND lines never enter the chat log"); } // --- 40250 command-bus callbacks outside cube ------------------------- { auto cmd = [&](const std::string &text) { std::vector buf(sizeof(GCChat) + text.size() + 1); GCChat h{}; h.header = GC_CHAT; h.length = (uint16_t)buf.size(); h.type = CHAT_TYPE_COMMAND; std::memcpy(buf.data(), &h, sizeof(h)); std::memcpy(buf.data() + sizeof(h), text.c_str(), text.size() + 1); es.apply(GC_CHAT, buf.data(), (uint16_t)buf.size()); }; cmd("PartyRequestDenied"); cmd("ShowMeSafeboxPassword"); cmd("ShowMeMallPassword"); cmd("RefineSuceeded"); cmd("RefineFailed"); cmd("OpenPrivateShop"); cmd("MyShopPriceList 11209 250000"); cmd("setblockmode 37"); cmd("ObserverMode 1"); cmd("ObserverCount 3"); cmd("StoneDetect 9001 2 90"); cmd("StartStaminaConsume 10 77"); cmd("StopStaminaConsume 55"); cmd("sms"); cmd("combo 1"); cmd("mobile_auth"); cmd("gift"); cmd("kiss 1 2"); cmd("slap 1 2"); cmd("dance6 1"); cmd("joy 2"); 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; for (const auto &ev : events) { switch (ev.kind) { case ServerCommandEvent::PartyRequestDenied: ++denied; break; case ServerCommandEvent::SafeboxPasswordRequired: ++safe; break; case ServerCommandEvent::SafeboxWrongPassword: break; case ServerCommandEvent::MallPasswordRequired: ++mall; break; case ServerCommandEvent::RefineSucceeded: ++ok; break; case ServerCommandEvent::RefineFailed: ++fail; break; case ServerCommandEvent::PrivateShopOpenRequested: ++open; break; case ServerCommandEvent::MyShopPrice: price += ev.value == 11209 && ev.value2 == 250000; break; case ServerCommandEvent::BlockModeChanged: block += ev.value == 37; break; case ServerCommandEvent::ObserverModeChanged: observer += ev.value == 1 && es.observer_mode(); break; case ServerCommandEvent::ObserverCountChanged: observer_count += ev.value == 3 && es.observer_count() == 3; break; case ServerCommandEvent::StoneDetected: stone += ev.value == 9001 && ev.value2 == 2 && ev.value3 == 90.0f; break; case ServerCommandEvent::StaminaStarted: stamina_start += ev.value == 10 && ev.value2 == 77; break; case ServerCommandEvent::StaminaStopped: stamina_stop += ev.value == 0 && ev.value2 == 55; break; case ServerCommandEvent::MobileFlagChanged: mobile += ev.value == 1 && es.mobile_flag(); break; case ServerCommandEvent::MobileAuthRequired: ++mobile_auth; break; case ServerCommandEvent::ComboChanged: combo += ev.value == 1 && es.combo_skill_flag(); break; case ServerCommandEvent::GiftAvailable: ++gift; 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() && es.current_stamina() == 55, "40250 command bus: callbacks surfaced"); auto motions = es.drain_motions(); CHECK(motions.size() == 6 && motions[0].vid == 1 && motions[0].victim_vid == 2 && motions[0].motion == 308 && motions[1].vid == 2 && motions[1].motion == 308 && motions[2].motion == 320 && motions[3].motion == 316 && motions[4].motion == 330 && motions[5].motion == 349, "40250 command bus: emotions feed the GC_MOTION stream"); es.set_now(1000); cmd("StartStaminaConsume 10 77"); es.drain_server_commands(); es.set_now(1500); es.tick(); CHECK(es.stamina_consuming() && es.current_stamina() == 72 && es.points().stamina() == 72 && es.take_points_dirty(), "40250 command bus: stamina drains at server-command rate"); es.mut_safebox_wrong_password(); bool wrong_password = false; for (const auto &ev : es.drain_server_commands()) { wrong_password = wrong_password || ev.kind == ServerCommandEvent::SafeboxWrongPassword; } CHECK(wrong_password, "40250 command bus: wrong safebox password surfaced"); cmd("CloseSafebox"); cmd("CloseMall"); CHECK(!es.safebox_open() && !es.mall_open(), "40250 command bus: storage close callbacks update state"); } // --- despawn --- // --- guild land list + minimap observers --- { std::vector raw(sizeof(GCLandList) + sizeof(LandPacketElement)); GCLandList head{GC_LAND_LIST, (uint16_t)raw.size()}; LandPacketElement land{42, 100, 200, 300, 400, 77}; std::memcpy(raw.data(), &head, sizeof(head)); std::memcpy(raw.data() + sizeof(head), &land, sizeof(land)); es.apply(GC_LAND_LIST, raw.data(), (uint16_t)raw.size()); CHECK(es.land_areas().size() == 1 && es.land_areas()[0].guild_id == 77, "land list: one guild area"); CHECK(es.land_areas()[0].width == 300 && es.take_land_dirty(), "land list: dimensions + dirty"); GCObserverAdd oa{GC_OBSERVER_ADD, sizeof(GCObserverAdd), 9001, 12, 34}; b = pkt(oa); es.apply(GC_OBSERVER_ADD, b.data(), (uint16_t)b.size()); CHECK(es.observers().size() == 1 && es.observers()[0].x == 1200, "observer: add converts map units to cm"); GCObserverMove om{GC_OBSERVER_MOVE, sizeof(GCObserverMove), 9001, 15, 35}; b = pkt(om); es.apply(GC_OBSERVER_MOVE, b.data(), (uint16_t)b.size()); GCObserverRemove orp{GC_OBSERVER_REMOVE, sizeof(GCObserverRemove), 9001}; b = pkt(orp); es.apply(GC_OBSERVER_REMOVE, b.data(), (uint16_t)b.size()); auto oe = es.drain_observer_events(); CHECK(oe.size() == 3 && oe[0].kind == ObserverEvent::Add && oe[1].kind == ObserverEvent::Move && oe[2].kind == ObserverEvent::Remove, "observer: add/move/remove events"); CHECK(es.observers().empty(), "observer: remove clears state"); } // --- despawn --- GCCharacterDel del{GC_CHARACTER_DEL, sizeof(GCCharacterDel), 2}; b = pkt(del); es.apply(GC_CHARACTER_DEL, b.data(), (uint16_t)b.size()); CHECK(es.get(2) == nullptr, "entity 2 despawned"); CHECK(es.size() == 1, "1 entity left"); { auto ch = es.drain_changes(); bool despawn = false; for (auto &c : ch) { if (c.kind == EntityStore::ChangeKind::Despawn && c.vid == 2) { despawn = true; } } CHECK(despawn, "despawn change emitted"); } // --- CLIENT-GAP §2.1: EntityStore is the data layer. A far entity is a full // row here with a queued Spawn/dirty; no scene, no replay buffer. Scene- // node visibility (CHAR_STAGE_VIEW_BOUND cull / re-show) is asserted by // project/net_world_vis_test.gd, which drives net_world.gd directly. --- { EntityStore d; d.set_now(0); d.mut_spawn_main(1, 1, "hero", 0, 0, 0); d.drain_changes(); // GC_CHARACTER_ADD far outside any view bound -> still a full data row. d.mut_spawn(4242, 101, 2, "far-orc", 900000.0f, 0.0f, 0.0f, 0.0f, 300, 90); const Entity *orc = d.get(4242); CHECK(orc && orc->race == 101 && orc->attack_speed == 90 && orc->name == "far-orc", "§2.1 data layer: far GC_CHARACTER_ADD populates the entity row"); { auto ch = d.drain_changes(); bool spawn = false; for (auto &c : ch) { if (c.kind == EntityStore::ChangeKind::Spawn && c.vid == 4242) { spawn = true; } } CHECK(spawn, "§2.1 data layer: a Spawn change is queued for the far entity"); } // GC_CHARACTER_UPDATE for that same VID updates the row in place. d.mut_char_update(4242, nullptr, 40, 120, 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_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); d.mut_shop_sign(999999, "ghost shop"); CHECK(d.size() == n_before && d.get(999999) == nullptr, "§2.1 data layer: mutators for an unknown VID never spawn or buffer"); } // --- ClientVS22 loading reset: discard map-local state, retain no stale deltas --- es.mut_spawn(99, 101, 1, "reset-me", 10, 20, 0, 0); es.mut_ground_add(700, 27100, 12, 24, 0); es.mut_pvp(99, 1, 2); es.mut_target(99, 50); 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(), "map reset clears ground items and pvp state"); 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"); if (g_fail) { std::fprintf(stderr, "%d check(s) failed\n", g_fail); return 1; } std::printf("all checks passed\n"); return 0; }