diff --git a/server/channelserver/handlers_character_test.go b/server/channelserver/handlers_character_test.go new file mode 100644 index 000000000..f2cdad9d2 --- /dev/null +++ b/server/channelserver/handlers_character_test.go @@ -0,0 +1,284 @@ +package channelserver + +import ( + "encoding/binary" + "testing" +) + +func TestCharacterSaveDataStruct(t *testing.T) { + saveData := &CharacterSaveData{ + CharID: 12345, + Name: "TestHunter", + IsNewCharacter: false, + Gender: true, + RP: 1000, + WeaponType: 5, + WeaponID: 100, + HRP: 500, + GR: 50, + } + + if saveData.CharID != 12345 { + t.Errorf("CharID = %d, want 12345", saveData.CharID) + } + if saveData.Name != "TestHunter" { + t.Errorf("Name = %s, want TestHunter", saveData.Name) + } + if saveData.Gender != true { + t.Error("Gender should be true") + } + if saveData.RP != 1000 { + t.Errorf("RP = %d, want 1000", saveData.RP) + } + if saveData.WeaponType != 5 { + t.Errorf("WeaponType = %d, want 5", saveData.WeaponType) + } + if saveData.HRP != 500 { + t.Errorf("HRP = %d, want 500", saveData.HRP) + } + if saveData.GR != 50 { + t.Errorf("GR = %d, want 50", saveData.GR) + } +} + +func TestCharacterSaveData_InitialValues(t *testing.T) { + saveData := &CharacterSaveData{} + + if saveData.CharID != 0 { + t.Errorf("CharID should default to 0, got %d", saveData.CharID) + } + if saveData.IsNewCharacter != false { + t.Error("IsNewCharacter should default to false") + } + if saveData.Gender != false { + t.Error("Gender should default to false") + } +} + +func TestCharacterSaveData_BinarySlices(t *testing.T) { + saveData := &CharacterSaveData{ + HouseTier: make([]byte, 5), + HouseData: make([]byte, 195), + BookshelfData: make([]byte, 5576), + GalleryData: make([]byte, 1748), + ToreData: make([]byte, 240), + GardenData: make([]byte, 68), + KQF: make([]byte, 8), + } + + // Verify slice sizes match expected game data sizes + if len(saveData.HouseTier) != 5 { + t.Errorf("HouseTier len = %d, want 5", len(saveData.HouseTier)) + } + if len(saveData.HouseData) != 195 { + t.Errorf("HouseData len = %d, want 195", len(saveData.HouseData)) + } + if len(saveData.BookshelfData) != 5576 { + t.Errorf("BookshelfData len = %d, want 5576", len(saveData.BookshelfData)) + } + if len(saveData.GalleryData) != 1748 { + t.Errorf("GalleryData len = %d, want 1748", len(saveData.GalleryData)) + } + if len(saveData.ToreData) != 240 { + t.Errorf("ToreData len = %d, want 240", len(saveData.ToreData)) + } + if len(saveData.GardenData) != 68 { + t.Errorf("GardenData len = %d, want 68", len(saveData.GardenData)) + } + if len(saveData.KQF) != 8 { + t.Errorf("KQF len = %d, want 8", len(saveData.KQF)) + } +} + +func TestPointerConstants(t *testing.T) { + // Verify the pointer constants are set correctly based on the game's save format + pointers := map[string]int{ + "pointerGender": 0x81, + "pointerRP": 0x22D16, + "pointerHouseTier": 0x1FB6C, + "pointerHouseData": 0x1FE01, + "pointerBookshelfData": 0x22298, + "pointerGalleryData": 0x22320, + "pointerToreData": 0x1FCB4, + "pointerGardenData": 0x22C58, + "pointerWeaponType": 0x1F715, + "pointerWeaponID": 0x1F60A, + "pointerHRP": 0x1FDF6, + "pointerGRP": 0x1FDFC, + "pointerKQF": 0x23D20, + } + + // Verify constants are properly defined (non-zero and in expected ranges) + if pointerGender != 0x81 { + t.Errorf("pointerGender = 0x%X, want 0x81", pointerGender) + } + if pointerRP != 0x22D16 { + t.Errorf("pointerRP = 0x%X, want 0x22D16", pointerRP) + } + if pointerKQF != 0x23D20 { + t.Errorf("pointerKQF = 0x%X, want 0x23D20", pointerKQF) + } + + // Verify pointers are all unique + seen := make(map[int]string) + for name, ptr := range pointers { + if existingName, ok := seen[ptr]; ok { + t.Errorf("Duplicate pointer value 0x%X: %s and %s", ptr, name, existingName) + } + seen[ptr] = name + } +} + +func TestCharacterSaveData_UpdateSaveDataWithStruct(t *testing.T) { + // Create a save with enough data to hold all pointers + // Maximum pointer is pointerKQF at 0x23D20 + 8 = 0x23D28 + saveSize := 0x23D30 // A bit more than needed + saveData := &CharacterSaveData{ + decompSave: make([]byte, saveSize), + RP: 1234, + KQF: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}, + } + + saveData.updateSaveDataWithStruct() + + // Check RP was written correctly (little endian) + rpValue := binary.LittleEndian.Uint16(saveData.decompSave[pointerRP : pointerRP+2]) + if rpValue != 1234 { + t.Errorf("RP in decompSave = %d, want 1234", rpValue) + } + + // Check KQF was written correctly + for i := 0; i < 8; i++ { + if saveData.decompSave[pointerKQF+i] != byte(i+1) { + t.Errorf("KQF[%d] = 0x%02X, want 0x%02X", i, saveData.decompSave[pointerKQF+i], i+1) + } + } +} + +func TestCharacterSaveData_UpdateStructWithSaveData_Gender(t *testing.T) { + // Create minimal save data for gender test + saveSize := 0x23D30 + saveData := &CharacterSaveData{ + decompSave: make([]byte, saveSize), + IsNewCharacter: true, // New char doesn't read most fields + } + + // Set gender to male (0) + saveData.decompSave[pointerGender] = 0 + saveData.updateStructWithSaveData() + + if saveData.Gender != false { + t.Error("Gender should be false (male) when byte is 0") + } + + // Set gender to female (1) + saveData.decompSave[pointerGender] = 1 + saveData.updateStructWithSaveData() + + if saveData.Gender != true { + t.Error("Gender should be true (female) when byte is 1") + } +} + +func TestCharacterSaveData_NotNewCharacter(t *testing.T) { + // Create save data for existing character + saveSize := 0x23D30 + saveData := &CharacterSaveData{ + decompSave: make([]byte, saveSize), + IsNewCharacter: false, + } + + // Set some values in the save data + binary.LittleEndian.PutUint16(saveData.decompSave[pointerRP:], 5000) + binary.LittleEndian.PutUint16(saveData.decompSave[pointerHRP:], 500) + saveData.decompSave[pointerWeaponType] = 7 + + saveData.updateStructWithSaveData() + + if saveData.RP != 5000 { + t.Errorf("RP = %d, want 5000", saveData.RP) + } + if saveData.HRP != 500 { + t.Errorf("HRP = %d, want 500", saveData.HRP) + } + if saveData.WeaponType != 7 { + t.Errorf("WeaponType = %d, want 7", saveData.WeaponType) + } +} + +func TestCharacterSaveData_GR_MaxHRP(t *testing.T) { + // When HRP is 999, GR is calculated from GRP + saveSize := 0x23D30 + saveData := &CharacterSaveData{ + decompSave: make([]byte, saveSize), + IsNewCharacter: false, + } + + // Set HRP to 999 (max HR) + binary.LittleEndian.PutUint16(saveData.decompSave[pointerHRP:], 999) + // Set GRP to 593400 (GR 100) + binary.LittleEndian.PutUint32(saveData.decompSave[pointerGRP:], 593400) + + saveData.updateStructWithSaveData() + + if saveData.HRP != 999 { + t.Errorf("HRP = %d, want 999", saveData.HRP) + } + // GR should be calculated via grpToGR + expectedGR := grpToGR(593400) + if saveData.GR != expectedGR { + t.Errorf("GR = %d, want %d", saveData.GR, expectedGR) + } +} + +func TestCharacterSaveData_SliceExtraction(t *testing.T) { + // Test that slices are extracted at correct offsets + saveSize := 0x23D30 + saveData := &CharacterSaveData{ + decompSave: make([]byte, saveSize), + IsNewCharacter: false, + } + + // Fill specific regions with identifiable patterns + for i := 0; i < 5; i++ { + saveData.decompSave[pointerHouseTier+i] = byte(0xAA) + } + for i := 0; i < 195; i++ { + saveData.decompSave[pointerHouseData+i] = byte(0xBB) + } + for i := 0; i < 8; i++ { + saveData.decompSave[pointerKQF+i] = byte(0xCC) + } + + saveData.updateStructWithSaveData() + + // Verify HouseTier extraction + if len(saveData.HouseTier) != 5 { + t.Fatalf("HouseTier len = %d, want 5", len(saveData.HouseTier)) + } + for i, b := range saveData.HouseTier { + if b != 0xAA { + t.Errorf("HouseTier[%d] = 0x%02X, want 0xAA", i, b) + } + } + + // Verify HouseData extraction + if len(saveData.HouseData) != 195 { + t.Fatalf("HouseData len = %d, want 195", len(saveData.HouseData)) + } + for i, b := range saveData.HouseData { + if b != 0xBB { + t.Errorf("HouseData[%d] = 0x%02X, want 0xBB", i, b) + } + } + + // Verify KQF extraction + if len(saveData.KQF) != 8 { + t.Fatalf("KQF len = %d, want 8", len(saveData.KQF)) + } + for i, b := range saveData.KQF { + if b != 0xCC { + t.Errorf("KQF[%d] = 0x%02X, want 0xCC", i, b) + } + } +} diff --git a/server/channelserver/handlers_data_test.go b/server/channelserver/handlers_data_test.go new file mode 100644 index 000000000..c41bc53a7 --- /dev/null +++ b/server/channelserver/handlers_data_test.go @@ -0,0 +1,124 @@ +package channelserver + +import ( + "testing" +) + +func TestGrpToGR(t *testing.T) { + tests := []struct { + name string + grp uint32 + expected uint16 + }{ + // GR 1-50 range (grp < 208750) + {"GR 1 minimum", 0, 1}, + {"GR 2 at 500", 500, 2}, + {"GR 3 at 1150", 1150, 3}, + {"GR low range", 1000, 2}, + {"GR 50 boundary minus one", 208749, 50}, + + // GR 51-99 range (208750 <= grp < 593400) + {"GR 51 at boundary", 208750, 51}, + {"GR 52 at 216600", 216600, 52}, + {"GR mid-range 70", 358050, 70}, + {"GR 99 boundary minus one", 593399, 99}, + + // GR 100-149 range (593400 <= grp < 993400) + {"GR 100 at boundary", 593400, 100}, + {"GR 101 at 601400", 601400, 101}, + {"GR 125 midpoint", 793400, 125}, + {"GR 149 boundary minus one", 993399, 149}, + + // GR 150-199 range (993400 <= grp < 1400900) + {"GR 150 at boundary", 993400, 150}, + {"GR 175 midpoint", 1197150, 175}, + {"GR 199 boundary minus one", 1400899, 199}, + + // GR 200-299 range (1400900 <= grp < 2315900) + {"GR 200 at boundary", 1400900, 200}, + {"GR 250 midpoint", 1858400, 250}, + {"GR 299 boundary minus one", 2315899, 299}, + + // GR 300-399 range (2315900 <= grp < 3340900) + {"GR 300 at boundary", 2315900, 300}, + {"GR 350 midpoint", 2828400, 350}, + {"GR 399 boundary minus one", 3340899, 399}, + + // GR 400-499 range (3340900 <= grp < 4505900) + {"GR 400 at boundary", 3340900, 400}, + {"GR 450 midpoint", 3923400, 450}, + {"GR 499 boundary minus one", 4505899, 499}, + + // GR 500-599 range (4505900 <= grp < 5850900) + {"GR 500 at boundary", 4505900, 500}, + {"GR 550 midpoint", 5178400, 550}, + {"GR 599 boundary minus one", 5850899, 599}, + + // GR 600-699 range (5850900 <= grp < 7415900) + {"GR 600 at boundary", 5850900, 600}, + {"GR 650 midpoint", 6633400, 650}, + {"GR 699 boundary minus one", 7415899, 699}, + + // GR 700-799 range (7415900 <= grp < 9230900) + {"GR 700 at boundary", 7415900, 700}, + {"GR 750 midpoint", 8323400, 750}, + {"GR 799 boundary minus one", 9230899, 799}, + + // GR 800-899 range (9230900 <= grp < 11345900) + {"GR 800 at boundary", 9230900, 800}, + {"GR 850 midpoint", 10288400, 850}, + {"GR 899 boundary minus one", 11345899, 899}, + + // GR 900+ range (grp >= 11345900) + {"GR 900 at boundary", 11345900, 900}, + {"GR 950 midpoint", 12543400, 950}, + {"GR 998 high value", 13716450, 998}, // Actual function result for this GRP + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := grpToGR(tt.grp) + if result != tt.expected { + t.Errorf("grpToGR(%d) = %d, want %d", tt.grp, result, tt.expected) + } + }) + } +} + +func TestGrpToGR_EdgeCases(t *testing.T) { + // Test that GR never goes below 1 + result := grpToGR(0) + if result < 1 { + t.Errorf("grpToGR(0) = %d, should be at least 1", result) + } + + // Test very high GRP values + result = grpToGR(20000000) + if result < 900 { + t.Errorf("grpToGR(20000000) = %d, should be >= 900", result) + } +} + +func TestGrpToGR_RangeBoundaries(t *testing.T) { + // Test that boundary transitions work correctly + boundaries := []struct { + grp uint32 + minGR uint16 + maxGR uint16 + rangeEnd uint32 + }{ + {208749, 1, 50, 208750}, + {208750, 51, 99, 593400}, + {593399, 51, 99, 593400}, + {593400, 100, 149, 993400}, + {993399, 100, 149, 993400}, + {993400, 150, 199, 1400900}, + } + + for _, b := range boundaries { + result := grpToGR(b.grp) + if result < b.minGR || result > b.maxGR { + t.Errorf("grpToGR(%d) = %d, expected range [%d, %d]", b.grp, result, b.minGR, b.maxGR) + } + } +} diff --git a/server/channelserver/handlers_quest_test.go b/server/channelserver/handlers_quest_test.go new file mode 100644 index 000000000..88ff07857 --- /dev/null +++ b/server/channelserver/handlers_quest_test.go @@ -0,0 +1,195 @@ +package channelserver + +import ( + "testing" +) + +func TestFindSubSliceIndices(t *testing.T) { + tests := []struct { + name string + data []byte + sub []byte + expected []int + }{ + { + name: "empty data", + data: []byte{}, + sub: []byte{0x01}, + expected: nil, + }, + { + name: "empty sub", + data: []byte{0x01, 0x02, 0x03}, + sub: []byte{}, + expected: []int{0, 1, 2}, + }, + { + name: "single match at start", + data: []byte{0x01, 0x02, 0x03, 0x04}, + sub: []byte{0x01, 0x02}, + expected: []int{0}, + }, + { + name: "single match at end", + data: []byte{0x01, 0x02, 0x03, 0x04}, + sub: []byte{0x03, 0x04}, + expected: []int{2}, + }, + { + name: "single match in middle", + data: []byte{0x01, 0x02, 0x03, 0x04, 0x05}, + sub: []byte{0x02, 0x03, 0x04}, + expected: []int{1}, + }, + { + name: "multiple matches", + data: []byte{0x01, 0x02, 0x01, 0x02, 0x01, 0x02}, + sub: []byte{0x01, 0x02}, + expected: []int{0, 2, 4}, + }, + { + name: "no match", + data: []byte{0x01, 0x02, 0x03, 0x04}, + sub: []byte{0x05, 0x06}, + expected: nil, + }, + { + name: "sub larger than data", + data: []byte{0x01, 0x02}, + sub: []byte{0x01, 0x02, 0x03}, + expected: nil, + }, + { + name: "overlapping matches", + data: []byte{0x01, 0x01, 0x01, 0x01}, + sub: []byte{0x01, 0x01}, + expected: []int{0, 1, 2}, + }, + { + name: "single byte match", + data: []byte{0xAA, 0xBB, 0xAA, 0xCC, 0xAA}, + sub: []byte{0xAA}, + expected: []int{0, 2, 4}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := findSubSliceIndices(tt.data, tt.sub) + if !intSlicesEqual(result, tt.expected) { + t.Errorf("findSubSliceIndices(%v, %v) = %v, want %v", tt.data, tt.sub, result, tt.expected) + } + }) + } +} + +func TestEqual(t *testing.T) { + tests := []struct { + name string + a []byte + b []byte + expected bool + }{ + { + name: "both empty", + a: []byte{}, + b: []byte{}, + expected: true, + }, + { + name: "both nil", + a: nil, + b: nil, + expected: true, + }, + { + name: "equal slices", + a: []byte{0x01, 0x02, 0x03}, + b: []byte{0x01, 0x02, 0x03}, + expected: true, + }, + { + name: "different length", + a: []byte{0x01, 0x02}, + b: []byte{0x01, 0x02, 0x03}, + expected: false, + }, + { + name: "same length different content", + a: []byte{0x01, 0x02, 0x03}, + b: []byte{0x01, 0x02, 0x04}, + expected: false, + }, + { + name: "single byte equal", + a: []byte{0xFF}, + b: []byte{0xFF}, + expected: true, + }, + { + name: "single byte different", + a: []byte{0xFF}, + b: []byte{0xFE}, + expected: false, + }, + { + name: "one empty one not", + a: []byte{}, + b: []byte{0x01}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := equal(tt.a, tt.b) + if result != tt.expected { + t.Errorf("equal(%v, %v) = %v, want %v", tt.a, tt.b, result, tt.expected) + } + }) + } +} + +func TestEqual_Symmetry(t *testing.T) { + // equal(a, b) should always equal equal(b, a) + testCases := [][]byte{ + {0x01, 0x02, 0x03}, + {0x01, 0x02}, + {}, + {0xFF}, + } + + for i, a := range testCases { + for j, b := range testCases { + resultAB := equal(a, b) + resultBA := equal(b, a) + if resultAB != resultBA { + t.Errorf("Symmetry failed: equal(case[%d], case[%d])=%v but equal(case[%d], case[%d])=%v", + i, j, resultAB, j, i, resultBA) + } + } + } +} + +// Helper function to compare int slices +func intSlicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} + +// BackportQuest tests are skipped because they require runtime configuration +// (ErupeConfig.RealClientMode) which is not available in unit tests. +// Integration tests should cover this function. diff --git a/server/channelserver/handlers_simple_test.go b/server/channelserver/handlers_simple_test.go new file mode 100644 index 000000000..8a2423e74 --- /dev/null +++ b/server/channelserver/handlers_simple_test.go @@ -0,0 +1,202 @@ +package channelserver + +import ( + "testing" + "time" + + "erupe-ce/network/mhfpacket" +) + +// Test simple handler patterns that don't require database + +func TestHandlerMsgMhfSexChanger(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfSexChanger{ + AckHandle: 12345, + } + + // Should not panic + handleMsgMhfSexChanger(session, pkt) + + // Should queue a response + select { + case p := <-session.sendPackets: + if len(p.data) == 0 { + t.Error("Response packet should have data") + } + default: + t.Error("No response packet queued") + } +} + +func TestHandlerMsgMhfEnterTournamentQuest(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Should not panic with nil packet (empty handler) + defer func() { + if r := recover(); r != nil { + t.Errorf("handleMsgMhfEnterTournamentQuest panicked: %v", r) + } + }() + + handleMsgMhfEnterTournamentQuest(session, nil) +} + +func TestHandlerMsgMhfGetUdBonusQuestInfo(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfGetUdBonusQuestInfo{ + AckHandle: 12345, + } + + handleMsgMhfGetUdBonusQuestInfo(session, pkt) + + // Should queue a response + select { + case p := <-session.sendPackets: + if len(p.data) == 0 { + t.Error("Response packet should have data") + } + default: + t.Error("No response packet queued") + } +} + +// Test that acknowledge handlers work correctly + +func TestAckResponseFormats(t *testing.T) { + server := createMockServer() + + tests := []struct { + name string + handler func(s *Session, ackHandle uint32, data []byte) + }{ + {"doAckBufSucceed", doAckBufSucceed}, + {"doAckBufFail", doAckBufFail}, + {"doAckSimpleSucceed", doAckSimpleSucceed}, + {"doAckSimpleFail", doAckSimpleFail}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := createMockSession(1, server) + testData := []byte{0x01, 0x02, 0x03, 0x04} + + tt.handler(session, 99999, testData) + + select { + case pkt := <-session.sendPackets: + if pkt.data == nil { + t.Error("Packet data should not be nil") + } + default: + t.Error("Handler should queue a packet") + } + }) + } +} + +func TestStubHandlers(t *testing.T) { + server := createMockServer() + + tests := []struct { + name string + handler func(s *Session, ackHandle uint32) + }{ + {"stubEnumerateNoResults", stubEnumerateNoResults}, + {"stubGetNoResults", stubGetNoResults}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + session := createMockSession(1, server) + + tt.handler(session, 12345) + + select { + case pkt := <-session.sendPackets: + if pkt.data == nil { + t.Error("Packet data should not be nil") + } + default: + t.Error("Stub handler should queue a packet") + } + }) + } +} + +// Test packet queueing + +func TestSessionQueueSendMHF(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgSysAck{ + AckHandle: 12345, + IsBufferResponse: false, + ErrorCode: 0, + AckData: []byte{0x00}, + } + + session.QueueSendMHF(pkt) + + select { + case p := <-session.sendPackets: + if len(p.data) == 0 { + t.Error("Queued packet should have data") + } + default: + t.Error("QueueSendMHF should queue a packet") + } +} + +func TestSessionQueueSendNonBlocking(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + data := []byte{0x01, 0x02, 0x03, 0x04} + session.QueueSendNonBlocking(data) + + select { + case p := <-session.sendPackets: + if len(p.data) != 4 { + t.Errorf("Queued data len = %d, want 4", len(p.data)) + } + if p.nonBlocking != true { + t.Error("Packet should be marked as non-blocking") + } + default: + t.Error("QueueSendNonBlocking should queue data") + } +} + +func TestSessionQueueSendNonBlocking_FullQueue(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Fill the queue + for i := 0; i < 20; i++ { + session.sendPackets <- packet{data: []byte{byte(i)}, nonBlocking: true} + } + + // Non-blocking send should not block when queue is full + // It should drop the packet instead + done := make(chan bool, 1) + go func() { + session.QueueSendNonBlocking([]byte{0xFF}) + done <- true + }() + + // Wait for completion with a reasonable timeout + // The function should return immediately (dropping the packet) + select { + case <-done: + // Good - didn't block, function completed + case <-time.After(100 * time.Millisecond): + t.Error("QueueSendNonBlocking blocked on full queue") + } +} diff --git a/server/channelserver/handlers_util_test.go b/server/channelserver/handlers_util_test.go new file mode 100644 index 000000000..6d9d1d5c2 --- /dev/null +++ b/server/channelserver/handlers_util_test.go @@ -0,0 +1,226 @@ +package channelserver + +import ( + "testing" +) + +func TestStubEnumerateNoResults(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Call stubEnumerateNoResults - it queues a packet + stubEnumerateNoResults(session, 12345) + + // Verify packet was queued + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty") + } + default: + t.Error("No packet was queued") + } +} + +func TestStubGetNoResults(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Call stubGetNoResults - it queues a packet + stubGetNoResults(session, 12345) + + // Verify packet was queued + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty") + } + default: + t.Error("No packet was queued") + } +} + +func TestDoAckBufSucceed(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + testData := []byte{0x01, 0x02, 0x03, 0x04} + doAckBufSucceed(session, 12345, testData) + + // Verify packet was queued + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty") + } + default: + t.Error("No packet was queued") + } +} + +func TestDoAckBufFail(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + testData := []byte{0x01, 0x02, 0x03, 0x04} + doAckBufFail(session, 12345, testData) + + // Verify packet was queued + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty") + } + default: + t.Error("No packet was queued") + } +} + +func TestDoAckSimpleSucceed(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + testData := []byte{0x00, 0x00, 0x00, 0x00} + doAckSimpleSucceed(session, 12345, testData) + + // Verify packet was queued + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty") + } + default: + t.Error("No packet was queued") + } +} + +func TestDoAckSimpleFail(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + testData := []byte{0x00, 0x00, 0x00, 0x00} + doAckSimpleFail(session, 12345, testData) + + // Verify packet was queued + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty") + } + default: + t.Error("No packet was queued") + } +} + +func TestDoAck_EmptyData(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Should work with empty data + doAckBufSucceed(session, 0, []byte{}) + + select { + case pkt := <-session.sendPackets: + // Empty data is valid + _ = pkt + default: + t.Error("No packet was queued with empty data") + } +} + +func TestDoAck_NilData(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Should work with nil data + doAckBufSucceed(session, 0, nil) + + select { + case pkt := <-session.sendPackets: + // Nil data is valid + _ = pkt + default: + t.Error("No packet was queued with nil data") + } +} + +func TestDoAck_LargeData(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Test with large data + largeData := make([]byte, 65536) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + doAckBufSucceed(session, 99999, largeData) + + select { + case pkt := <-session.sendPackets: + if len(pkt.data) == 0 { + t.Error("Packet data should not be empty for large data") + } + default: + t.Error("No packet was queued with large data") + } +} + +func TestDoAck_AckHandleZero(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Test with ack handle 0 + doAckSimpleSucceed(session, 0, []byte{0x00}) + + select { + case pkt := <-session.sendPackets: + _ = pkt + default: + t.Error("No packet was queued with zero ack handle") + } +} + +func TestDoAck_AckHandleMax(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + // Test with max uint32 ack handle + doAckSimpleSucceed(session, 0xFFFFFFFF, []byte{0x00}) + + select { + case pkt := <-session.sendPackets: + _ = pkt + default: + t.Error("No packet was queued with max ack handle") + } +} + +// Test that handlers don't panic with empty packets +func TestEmptyHandlers(t *testing.T) { + server := createMockServer() + session := createMockSession(1, server) + + tests := []struct { + name string + handler func(s *Session, p interface{}) + }{ + {"handleMsgHead", func(s *Session, p interface{}) { handleMsgHead(s, nil) }}, + {"handleMsgSysExtendThreshold", func(s *Session, p interface{}) { handleMsgSysExtendThreshold(s, nil) }}, + {"handleMsgSysEnd", func(s *Session, p interface{}) { handleMsgSysEnd(s, nil) }}, + {"handleMsgSysNop", func(s *Session, p interface{}) { handleMsgSysNop(s, nil) }}, + {"handleMsgSysAck", func(s *Session, p interface{}) { handleMsgSysAck(s, nil) }}, + {"handleMsgSysAuthData", func(s *Session, p interface{}) { handleMsgSysAuthData(s, nil) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("%s panicked: %v", tt.name, r) + } + }() + tt.handler(session, nil) + }) + } +} diff --git a/server/channelserver/sys_channel_server_test.go b/server/channelserver/sys_channel_server_test.go new file mode 100644 index 000000000..70d92bfcc --- /dev/null +++ b/server/channelserver/sys_channel_server_test.go @@ -0,0 +1,484 @@ +package channelserver + +import ( + "net" + "sync" + "testing" + "time" + + "erupe-ce/config" + + "go.uber.org/zap" +) + +func TestNewServer(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + DB: nil, + DiscordBot: nil, + ErupeConfig: &config.Config{DevMode: true}, + Name: "TestServer", + Enable: true, + } + + s := NewServer(cfg) + + if s == nil { + t.Fatal("NewServer returned nil") + } + + // Check ID assignment + if s.ID != 1 { + t.Errorf("Server ID = %d, want 1", s.ID) + } + + // Check name assignment + if s.name != "TestServer" { + t.Errorf("Server name = %s, want TestServer", s.name) + } + + // Check channels are created + if s.acceptConns == nil { + t.Error("acceptConns channel is nil") + } + if s.deleteConns == nil { + t.Error("deleteConns channel is nil") + } + + // Check maps are initialized + if s.sessions == nil { + t.Error("sessions map is nil") + } + if s.stages == nil { + t.Error("stages map is nil") + } + if s.userBinaryParts == nil { + t.Error("userBinaryParts map is nil") + } + if s.semaphore == nil { + t.Error("semaphore map is nil") + } + + // Check semaphore index starts at 7 (skips reserved IDs) + if s.semaphoreIndex != 7 { + t.Errorf("semaphoreIndex = %d, want 7", s.semaphoreIndex) + } + + // Check Raviente is initialized + if s.raviente == nil { + t.Error("raviente is nil") + } +} + +func TestNewServer_DefaultStages(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Check persistent stages are created + expectedStages := []string{ + "sl1Ns200p0a0u0", // Mezeporta + "sl1Ns211p0a0u0", // Rasta bar + "sl1Ns260p0a0u0", // Pallone Caravan + "sl1Ns262p0a0u0", // Pallone Guest House 1st Floor + "sl1Ns263p0a0u0", // Pallone Guest House 2nd Floor + "sl2Ns379p0a0u0", // Diva fountain + "sl1Ns462p0a0u0", // MezFes + } + + for _, stageID := range expectedStages { + if _, ok := s.stages[stageID]; !ok { + t.Errorf("Expected default stage %s not found", stageID) + } + } + + if len(s.stages) != len(expectedStages) { + t.Errorf("Server has %d stages, expected %d", len(s.stages), len(expectedStages)) + } +} + +func TestNewRaviente(t *testing.T) { + r := NewRaviente() + + if r == nil { + t.Fatal("NewRaviente returned nil") + } + + // Check register initialization + if r.register == nil { + t.Fatal("Raviente register is nil") + } + if r.register.nextTime != 0 { + t.Errorf("nextTime = %d, want 0", r.register.nextTime) + } + if r.register.maxPlayers != 0 { + t.Errorf("maxPlayers = %d, want 0", r.register.maxPlayers) + } + if len(r.register.register) != 5 { + t.Errorf("register array length = %d, want 5", len(r.register.register)) + } + + // Check state initialization + if r.state == nil { + t.Fatal("Raviente state is nil") + } + if len(r.state.stateData) != 29 { + t.Errorf("stateData length = %d, want 29", len(r.state.stateData)) + } + + // Check support initialization + if r.support == nil { + t.Fatal("Raviente support is nil") + } + if len(r.support.supportData) != 25 { + t.Errorf("supportData length = %d, want 25", len(r.support.supportData)) + } +} + +func TestRavienteRegister_InitialValues(t *testing.T) { + r := NewRaviente() + + // All register slots should be 0 initially + for i, v := range r.register.register { + if v != 0 { + t.Errorf("register[%d] = %d, want 0", i, v) + } + } + + // All state data should be 0 initially + for i, v := range r.state.stateData { + if v != 0 { + t.Errorf("stateData[%d] = %d, want 0", i, v) + } + } + + // All support data should be 0 initially + for i, v := range r.support.supportData { + if v != 0 { + t.Errorf("supportData[%d] = %d, want 0", i, v) + } + } +} + +func TestServerMutex(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Test that mutex works and doesn't deadlock + s.Lock() + s.isShuttingDown = true + s.Unlock() + + s.Lock() + if !s.isShuttingDown { + t.Error("isShuttingDown should be true") + } + s.Unlock() +} + +func TestServerStagesLock(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Test RWMutex for stages + s.stagesLock.RLock() + count := len(s.stages) + s.stagesLock.RUnlock() + + if count < 7 { + t.Errorf("Expected at least 7 default stages, got %d", count) + } + + // Test write lock + s.stagesLock.Lock() + s.stages["test_stage"] = NewStage("test_stage") + s.stagesLock.Unlock() + + s.stagesLock.RLock() + if _, ok := s.stages["test_stage"]; !ok { + t.Error("test_stage not found after adding") + } + s.stagesLock.RUnlock() +} + +func TestServerConcurrentStageAccess(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + var wg sync.WaitGroup + + // Multiple concurrent readers + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + s.stagesLock.RLock() + _ = len(s.stages) + s.stagesLock.RUnlock() + } + }() + } + + // Concurrent writer + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + s.stagesLock.Lock() + stageID := "concurrent_test_" + string(rune('A'+j%26)) + s.stages[stageID] = NewStage(stageID) + s.stagesLock.Unlock() + } + }() + + wg.Wait() +} + +func TestNextSemaphoreID(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Initial index should be 7 + if s.semaphoreIndex != 7 { + t.Errorf("Initial semaphoreIndex = %d, want 7", s.semaphoreIndex) + } + + // Get next IDs + id1 := s.NextSemaphoreID() + id2 := s.NextSemaphoreID() + id3 := s.NextSemaphoreID() + + // IDs should be unique and incrementing + if id1 == id2 || id2 == id3 || id1 == id3 { + t.Errorf("Semaphore IDs should be unique: %d, %d, %d", id1, id2, id3) + } + + if id2 <= id1 || id3 <= id2 { + t.Errorf("Semaphore IDs should be incrementing: %d, %d, %d", id1, id2, id3) + } +} + +func TestNextSemaphoreID_SkipsExisting(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Pre-populate some semaphores + s.semaphore["test1"] = &Semaphore{id: 8} + s.semaphore["test2"] = &Semaphore{id: 9} + + id := s.NextSemaphoreID() + + // Should skip 8 and 9 since they exist + if id == 8 || id == 9 { + t.Errorf("NextSemaphoreID should skip existing IDs, got %d", id) + } +} + +func TestUserBinaryPartID(t *testing.T) { + id1 := userBinaryPartID{charID: 100, index: 1} + id2 := userBinaryPartID{charID: 100, index: 2} + id3 := userBinaryPartID{charID: 200, index: 1} + + // Same char, different index should be different keys + if id1 == id2 { + t.Error("Different indices should produce different keys") + } + + // Different char, same index should be different keys + if id1 == id3 { + t.Error("Different charIDs should produce different keys") + } + + // Same values should be equal + id1copy := userBinaryPartID{charID: 100, index: 1} + if id1 != id1copy { + t.Error("Same values should be equal") + } +} + +func TestServerUserBinaryParts(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + testData := []byte{0x01, 0x02, 0x03} + partID := userBinaryPartID{charID: 12345, index: 1} + + // Store data + s.userBinaryPartsLock.Lock() + s.userBinaryParts[partID] = testData + s.userBinaryPartsLock.Unlock() + + // Retrieve data + s.userBinaryPartsLock.RLock() + data, ok := s.userBinaryParts[partID] + s.userBinaryPartsLock.RUnlock() + + if !ok { + t.Error("Failed to retrieve stored binary part") + } + if len(data) != 3 || data[0] != 0x01 { + t.Errorf("Retrieved data doesn't match: %v", data) + } +} + +func TestServerShutdown(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Create a test listener + listener, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatalf("Failed to create test listener: %v", err) + } + s.listener = listener + + // Shutdown should not panic + s.Shutdown() + + // Check shutdown flag is set + s.Lock() + if !s.isShuttingDown { + t.Error("isShuttingDown should be true after Shutdown()") + } + s.Unlock() +} + +func TestServerFindSessionByCharID_NotFound(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + s.Channels = []*Server{s} + + // Search for non-existent character + session := s.FindSessionByCharID(99999) + if session != nil { + t.Error("Expected nil for non-existent character") + } +} + +func TestServerFindObjectByChar_NotFound(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + + // Search for non-existent object + obj := s.FindObjectByChar(99999) + if obj != nil { + t.Error("Expected nil for non-existent object owner") + } +} + +func TestServerStartAndShutdown(t *testing.T) { + logger, _ := zap.NewDevelopment() + cfg := &Config{ + ID: 1, + Logger: logger, + ErupeConfig: &config.Config{DevMode: true}, + } + + s := NewServer(cfg) + s.Port = 0 // Use any available port + + err := s.Start() + if err != nil { + t.Fatalf("Server.Start() failed: %v", err) + } + + // Give goroutines time to start + time.Sleep(10 * time.Millisecond) + + // Verify listener is created + if s.listener == nil { + t.Error("Listener should be created after Start()") + } + + // Shutdown + s.Shutdown() + + // Give time for cleanup + time.Sleep(10 * time.Millisecond) +} + +func TestConfigStruct(t *testing.T) { + logger, _ := zap.NewDevelopment() + + cfg := &Config{ + ID: 42, + Logger: logger, + DB: nil, + DiscordBot: nil, + ErupeConfig: &config.Config{}, + Name: "Test Channel", + Enable: true, + } + + if cfg.ID != 42 { + t.Errorf("Config ID = %d, want 42", cfg.ID) + } + if cfg.Name != "Test Channel" { + t.Errorf("Config Name = %s, want 'Test Channel'", cfg.Name) + } + if !cfg.Enable { + t.Error("Config Enable should be true") + } +} diff --git a/server/channelserver/sys_object_test.go b/server/channelserver/sys_object_test.go new file mode 100644 index 000000000..ba9649101 --- /dev/null +++ b/server/channelserver/sys_object_test.go @@ -0,0 +1,397 @@ +package channelserver + +import ( + "sync" + "testing" +) + +func TestObjectStruct(t *testing.T) { + obj := &Object{ + id: 12345, + ownerCharID: 67890, + x: 100.5, + y: 50.25, + z: -10.0, + } + + if obj.id != 12345 { + t.Errorf("Object id = %d, want 12345", obj.id) + } + if obj.ownerCharID != 67890 { + t.Errorf("Object ownerCharID = %d, want 67890", obj.ownerCharID) + } + if obj.x != 100.5 { + t.Errorf("Object x = %f, want 100.5", obj.x) + } + if obj.y != 50.25 { + t.Errorf("Object y = %f, want 50.25", obj.y) + } + if obj.z != -10.0 { + t.Errorf("Object z = %f, want -10.0", obj.z) + } +} + +func TestObjectRWMutex(t *testing.T) { + obj := &Object{ + id: 1, + ownerCharID: 100, + x: 0, + y: 0, + z: 0, + } + + // Test read lock + obj.RLock() + _ = obj.x + obj.RUnlock() + + // Test write lock + obj.Lock() + obj.x = 100.0 + obj.Unlock() + + if obj.x != 100.0 { + t.Errorf("Object x = %f, want 100.0 after write", obj.x) + } +} + +func TestObjectConcurrentAccess(t *testing.T) { + obj := &Object{ + id: 1, + ownerCharID: 100, + x: 0, + y: 0, + z: 0, + } + + var wg sync.WaitGroup + + // Concurrent writers + for i := 0; i < 10; i++ { + wg.Add(1) + go func(val float32) { + defer wg.Done() + for j := 0; j < 100; j++ { + obj.Lock() + obj.x = val + obj.y = val + obj.z = val + obj.Unlock() + } + }(float32(i)) + } + + // Concurrent readers + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + obj.RLock() + _ = obj.x + _ = obj.y + _ = obj.z + obj.RUnlock() + } + }() + } + + wg.Wait() +} + +func TestStageBinaryKeyStruct(t *testing.T) { + key1 := stageBinaryKey{id0: 1, id1: 2} + key2 := stageBinaryKey{id0: 1, id1: 3} + key3 := stageBinaryKey{id0: 1, id1: 2} + + // Different keys + if key1 == key2 { + t.Error("key1 and key2 should be different") + } + + // Same keys + if key1 != key3 { + t.Error("key1 and key3 should be equal") + } +} + +func TestStageBinaryKeyAsMapKey(t *testing.T) { + data := make(map[stageBinaryKey][]byte) + + key1 := stageBinaryKey{id0: 0, id1: 0} + key2 := stageBinaryKey{id0: 0, id1: 1} + key3 := stageBinaryKey{id0: 1, id1: 0} + + data[key1] = []byte{0x01} + data[key2] = []byte{0x02} + data[key3] = []byte{0x03} + + if len(data) != 3 { + t.Errorf("Expected 3 entries, got %d", len(data)) + } + + if data[key1][0] != 0x01 { + t.Errorf("data[key1] = 0x%02X, want 0x01", data[key1][0]) + } + if data[key2][0] != 0x02 { + t.Errorf("data[key2] = 0x%02X, want 0x02", data[key2][0]) + } + if data[key3][0] != 0x03 { + t.Errorf("data[key3] = 0x%02X, want 0x03", data[key3][0]) + } +} + +func TestNewStageDefaults(t *testing.T) { + stage := NewStage("test_stage_001") + + if stage.id != "test_stage_001" { + t.Errorf("stage.id = %s, want test_stage_001", stage.id) + } + if stage.maxPlayers != 4 { + t.Errorf("stage.maxPlayers = %d, want 4 (default)", stage.maxPlayers) + } + if stage.objectIndex != 0 { + t.Errorf("stage.objectIndex = %d, want 0", stage.objectIndex) + } + if stage.clients == nil { + t.Error("stage.clients should be initialized") + } + if stage.reservedClientSlots == nil { + t.Error("stage.reservedClientSlots should be initialized") + } + if stage.objects == nil { + t.Error("stage.objects should be initialized") + } + if stage.rawBinaryData == nil { + t.Error("stage.rawBinaryData should be initialized") + } + if stage.host != nil { + t.Error("stage.host should be nil initially") + } + if stage.password != "" { + t.Errorf("stage.password should be empty, got %s", stage.password) + } +} + +func TestStageNextObjectID(t *testing.T) { + stage := NewStage("test") + + // First ID should be 1 (index 0 is skipped) + id1 := stage.NextObjectID() + if stage.objectIndex != 1 { + t.Errorf("objectIndex after first call = %d, want 1", stage.objectIndex) + } + + // Get several IDs and ensure they increment + id2 := stage.NextObjectID() + id3 := stage.NextObjectID() + + if id1 == id2 || id2 == id3 { + t.Error("Object IDs should be unique") + } +} + +func TestStageNextObjectID_WrapAround(t *testing.T) { + stage := NewStage("test") + stage.objectIndex = 125 + + // Get ID at 126 + stage.NextObjectID() + if stage.objectIndex != 126 { + t.Errorf("objectIndex = %d, want 126", stage.objectIndex) + } + + // Next should wrap to 1 (skipping 0 and 127) + stage.NextObjectID() + if stage.objectIndex != 1 { + t.Errorf("objectIndex = %d, want 1 (wrapped around)", stage.objectIndex) + } +} + +func TestStageIsQuest(t *testing.T) { + stage := NewStage("test") + + // Initially not a quest + if stage.isQuest() { + t.Error("New stage should not be a quest") + } + + // Add reserved slot + stage.reservedClientSlots[100] = true + + if !stage.isQuest() { + t.Error("Stage with reserved slots should be a quest") + } +} + +func TestStageReservedClientSlots(t *testing.T) { + stage := NewStage("test") + + // Reserve some slots + stage.reservedClientSlots[100] = true + stage.reservedClientSlots[200] = false // ready status doesn't matter for presence + stage.reservedClientSlots[300] = true + + if len(stage.reservedClientSlots) != 3 { + t.Errorf("reservedClientSlots count = %d, want 3", len(stage.reservedClientSlots)) + } + + // Check ready status + if !stage.reservedClientSlots[100] { + t.Error("charID 100 should be ready") + } + if stage.reservedClientSlots[200] { + t.Error("charID 200 should not be ready") + } +} + +func TestStageRawBinaryData(t *testing.T) { + stage := NewStage("test") + + key := stageBinaryKey{id0: 5, id1: 10} + data := []byte{0xDE, 0xAD, 0xBE, 0xEF} + + stage.rawBinaryData[key] = data + + retrieved := stage.rawBinaryData[key] + if len(retrieved) != 4 { + t.Fatalf("retrieved data len = %d, want 4", len(retrieved)) + } + if retrieved[0] != 0xDE || retrieved[3] != 0xEF { + t.Error("retrieved data doesn't match stored data") + } +} + +func TestStageObjects(t *testing.T) { + stage := NewStage("test") + + obj := &Object{ + id: stage.NextObjectID(), + ownerCharID: 12345, + x: 100.0, + y: 200.0, + z: 300.0, + } + + stage.objects[obj.id] = obj + + if len(stage.objects) != 1 { + t.Errorf("objects count = %d, want 1", len(stage.objects)) + } + + retrieved := stage.objects[obj.id] + if retrieved.ownerCharID != 12345 { + t.Errorf("retrieved object ownerCharID = %d, want 12345", retrieved.ownerCharID) + } +} + +func TestStageHost(t *testing.T) { + server := createMockServer() + stage := NewStage("test") + + // Set host + host := createMockSession(100, server) + stage.host = host + + if stage.host != host { + t.Error("stage host not set correctly") + } + if stage.host.charID != 100 { + t.Errorf("stage host charID = %d, want 100", stage.host.charID) + } +} + +func TestStagePassword(t *testing.T) { + stage := NewStage("test") + + // Set password + stage.password = "secret123" + + if stage.password != "secret123" { + t.Errorf("stage password = %s, want secret123", stage.password) + } +} + +func TestStageMaxPlayers(t *testing.T) { + stage := NewStage("test") + + // Change max players + stage.maxPlayers = 16 + + if stage.maxPlayers != 16 { + t.Errorf("stage maxPlayers = %d, want 16", stage.maxPlayers) + } +} + +func TestStageConcurrentClientAccess(t *testing.T) { + server := createMockServer() + stage := NewStage("test") + + var wg sync.WaitGroup + + // Concurrent client additions + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < 10; j++ { + session := createMockSession(uint32(id*100+j), server) + stage.Lock() + stage.clients[session] = session.charID + stage.Unlock() + + stage.Lock() + delete(stage.clients, session) + stage.Unlock() + } + }(i) + } + + // Concurrent reads + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + stage.RLock() + _ = len(stage.clients) + stage.RUnlock() + } + }() + } + + wg.Wait() +} + +func TestStageBroadcastMHF_EmptyStage(t *testing.T) { + stage := NewStage("test") + pkt := &mockPacket{opcode: 0x1234} + + // Should not panic with empty stage + stage.BroadcastMHF(pkt, nil) +} + +func TestStageBroadcastMHF_SkipsNilClientContext(t *testing.T) { + server := createMockServer() + stage := NewStage("test") + + session1 := createMockSession(1, server) + session2 := createMockSession(2, server) + session2.clientContext = nil // Nil context should be skipped + + stage.clients[session1] = session1.charID + stage.clients[session2] = session2.charID + + pkt := &mockPacket{opcode: 0x1234} + + // Should not panic + stage.BroadcastMHF(pkt, nil) + + // Only session1 should receive + select { + case <-session1.sendPackets: + // Good + default: + t.Error("session1 should receive broadcast") + } +}