Files

1685 lines
61 KiB
C++

// EntityStore — synthetic game-phase packets in, entity state + interpolation out.
#include "../src/net/entity_store.h"
#include "../src/net/wire.h"
#include <cstdio>
#include <cstring>
#include <vector>
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 <class T>
static std::vector<uint8_t> pkt(const T &s) {
std::vector<uint8_t> 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");
// --- §3.2 state queue: a near attack func (dir_len < 50) snaps and acts ---
// entity 2 is at (1500,500); target (1530,500) -> dir_len 30 < 50.
GCMove atk{};
atk.header = GC_MOVE;
atk.length = sizeof(atk);
atk.vid = 2;
atk.func = FUNC_ATTACK;
atk.x = 1530;
atk.y = 500;
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 == 1530, "near attack func snaps position");
CHECK(es.get(2)->func == FUNC_ATTACK, "func = ATTACK");
// --- §3.2 state queue: a far attack func (dir_len >= 50) walks, then attacks
// on arrival (reference m_kMovAfterFunc). ---
GCMove fatk{};
fatk.header = GC_MOVE;
fatk.length = sizeof(fatk);
fatk.vid = 2;
fatk.func = FUNC_ATTACK;
fatk.x = 1700; // from (1530,500): dir_len 170 >= 50
fatk.y = 500;
fatk.duration = 0;
b = pkt(fatk);
es.set_now(2000);
es.apply(GC_MOVE, b.data(), (uint16_t)b.size());
CHECK(es.get(2)->moving && es.get(2)->func == FUNC_MOVE, "far attack func walks first");
es.set_now(20000); // well past arrival
es.tick();
CHECK(!es.get(2)->moving && std::abs(es.get(2)->x - 1700.0f) < 0.01f,
"far attack func: arrived at Dst");
CHECK(es.get(2)->func == FUNC_ATTACK, "far attack func: attacks on arrival");
// --- 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<std::pair<int, ShopItem>> 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
xi.sockets[0] = 7001;
xi.sockets[2] = -1;
xi.attrs[0] = {5, 42};
b = pkt(xi);
es.apply(GC_EXCHANGE, b.data(), (uint16_t)b.size());
CHECK(es.exchange().self_items[2].vnum == 19 &&
es.exchange().self_items[2].sockets[0] == 7001 &&
es.exchange().self_items[2].sockets[2] == -1 &&
es.exchange().self_items[2].attrs[0].type == 5 &&
es.exchange().self_items[2].attrs[0].value == 42,
"exchange: self item add keeps sockets/attrs");
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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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 *)&gg;
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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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 *)&wp;
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<uint8_t> 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<uint8_t> 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<uint8_t> 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;
}