Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.
Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:
- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
(§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).
Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
1613 lines
58 KiB
C++
1613 lines
58 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");
|
|
|
|
// --- 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<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
|
|
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<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 *)≫
|
|
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 *)℘
|
|
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");
|
|
}
|
|
|
|
// --- 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;
|
|
}
|