mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
fix(channelserver): post-RC1 stabilization sprint
Fix rasta_id=0 overwriting NULL in SaveMercenary, which prevented game state saving for characters without a mercenary (#163). Also includes: - CHANGELOG updated with all 10 post-RC1 commits - Setup wizard fmt.Printf replaced with zap structured logging - technical-debt.md updated with 6 newly completed items - Scenario binary format documented (docs/scenario-format.md) - Tests: alliance nil-guard (#171), handler dispatch table, migrations (sorted/SQL/baseline), setup wizard (10 tests), protbot protocol sign/entrance/channel (23 tests)
This commit is contained in:
25
CHANGELOG.md
25
CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `ecdMagic` constant byte order causing encryption failures on some platforms ([#174](https://github.com/Mezeporta/Erupe/issues/174))
|
||||
- Fixed guild nil panics: variable shadowing causing nil panic in scout list ([#171](https://github.com/Mezeporta/Erupe/issues/171))
|
||||
- Fixed guild nil panics: added nil guards in cancel and answer scout handlers ([#171](https://github.com/Mezeporta/Erupe/issues/171))
|
||||
- Fixed guild nil panics: added nil guards for alliance guild lookups ([#171](https://github.com/Mezeporta/Erupe/issues/171))
|
||||
- Fixed `rasta_id=0` overwriting NULL in mercenary save, preventing game state saving ([#163](https://github.com/Mezeporta/Erupe/issues/163))
|
||||
- Fixed false race condition in `PacketDuringLogout` test
|
||||
|
||||
### Changed
|
||||
|
||||
- Cached `rengoku_data.bin` at startup for improved channel server performance
|
||||
|
||||
### Added
|
||||
|
||||
- Tests for `logoutPlayer`, `saveAllCharacterData`, and transit message handlers
|
||||
- Alliance `scanAllianceWithGuilds` test for missing guild (nil return from GetByID)
|
||||
- Handler dispatch table test verifying all expected packet IDs are mapped
|
||||
- Scenario binary format documentation (`docs/scenario-format.md`)
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Updated `go.mod` dependencies
|
||||
- Added `IF NOT EXISTS` guard to alliance recruiting column migration
|
||||
|
||||
## [9.3.0-rc1] - 2026-02-28
|
||||
|
||||
900 commits, 860 files changed, ~100,000 lines of new code. The largest Erupe release ever.
|
||||
|
||||
303
cmd/protbot/protocol/channel_test.go
Normal file
303
cmd/protbot/protocol/channel_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHandleAck_SimpleAck(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
ackHandle := uint32(1)
|
||||
waitCh := make(chan *AckResponse, 1)
|
||||
ch.waiters.Store(ackHandle, waitCh)
|
||||
|
||||
// Build simple ACK data (after opcode has been stripped).
|
||||
// Format: uint32 ackHandle + uint8 isBuffer(0) + uint8 errorCode + uint16 ignored + uint32 data
|
||||
data := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(data[0:4], ackHandle)
|
||||
data[4] = 0 // isBuffer = false
|
||||
data[5] = 0 // errorCode = 0
|
||||
binary.BigEndian.PutUint16(data[6:8], 0)
|
||||
binary.BigEndian.PutUint32(data[8:12], 0xDEADBEEF) // simple ACK data
|
||||
|
||||
ch.handleAck(data)
|
||||
|
||||
select {
|
||||
case resp := <-waitCh:
|
||||
if resp.AckHandle != ackHandle {
|
||||
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
|
||||
}
|
||||
if resp.IsBufferResponse {
|
||||
t.Error("IsBufferResponse: got true, want false")
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
t.Errorf("ErrorCode: got %d, want 0", resp.ErrorCode)
|
||||
}
|
||||
if len(resp.Data) != 4 {
|
||||
t.Fatalf("Data length: got %d, want 4", len(resp.Data))
|
||||
}
|
||||
val := binary.BigEndian.Uint32(resp.Data)
|
||||
if val != 0xDEADBEEF {
|
||||
t.Errorf("Data value: got 0x%08X, want 0xDEADBEEF", val)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for ACK response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAck_BufferAck(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
ackHandle := uint32(2)
|
||||
waitCh := make(chan *AckResponse, 1)
|
||||
ch.waiters.Store(ackHandle, waitCh)
|
||||
|
||||
payload := []byte{0x01, 0x02, 0x03, 0x04, 0x05}
|
||||
|
||||
// Build buffer ACK data.
|
||||
// Format: uint32 ackHandle + uint8 isBuffer(1) + uint8 errorCode + uint16 payloadSize + payload
|
||||
data := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(data[0:4], ackHandle)
|
||||
data[4] = 1 // isBuffer = true
|
||||
data[5] = 0 // errorCode = 0
|
||||
binary.BigEndian.PutUint16(data[6:8], uint16(len(payload)))
|
||||
copy(data[8:], payload)
|
||||
|
||||
ch.handleAck(data)
|
||||
|
||||
select {
|
||||
case resp := <-waitCh:
|
||||
if resp.AckHandle != ackHandle {
|
||||
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
|
||||
}
|
||||
if !resp.IsBufferResponse {
|
||||
t.Error("IsBufferResponse: got false, want true")
|
||||
}
|
||||
if resp.ErrorCode != 0 {
|
||||
t.Errorf("ErrorCode: got %d, want 0", resp.ErrorCode)
|
||||
}
|
||||
if len(resp.Data) != len(payload) {
|
||||
t.Fatalf("Data length: got %d, want %d", len(resp.Data), len(payload))
|
||||
}
|
||||
for i, b := range payload {
|
||||
if resp.Data[i] != b {
|
||||
t.Errorf("Data[%d]: got 0x%02X, want 0x%02X", i, resp.Data[i], b)
|
||||
}
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for ACK response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAck_ExtendedBuffer(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
ackHandle := uint32(3)
|
||||
waitCh := make(chan *AckResponse, 1)
|
||||
ch.waiters.Store(ackHandle, waitCh)
|
||||
|
||||
payload := make([]byte, 10)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
|
||||
// Build extended buffer ACK data (payloadSize == 0xFFFF).
|
||||
// Format: uint32 ackHandle + uint8 isBuffer(1) + uint8 errorCode + uint16(0xFFFF) + uint32 realSize + payload
|
||||
data := make([]byte, 12+len(payload))
|
||||
binary.BigEndian.PutUint32(data[0:4], ackHandle)
|
||||
data[4] = 1 // isBuffer = true
|
||||
data[5] = 0 // errorCode = 0
|
||||
binary.BigEndian.PutUint16(data[6:8], 0xFFFF)
|
||||
binary.BigEndian.PutUint32(data[8:12], uint32(len(payload)))
|
||||
copy(data[12:], payload)
|
||||
|
||||
ch.handleAck(data)
|
||||
|
||||
select {
|
||||
case resp := <-waitCh:
|
||||
if resp.AckHandle != ackHandle {
|
||||
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
|
||||
}
|
||||
if !resp.IsBufferResponse {
|
||||
t.Error("IsBufferResponse: got false, want true")
|
||||
}
|
||||
if len(resp.Data) != len(payload) {
|
||||
t.Fatalf("Data length: got %d, want %d", len(resp.Data), len(payload))
|
||||
}
|
||||
for i, b := range payload {
|
||||
if resp.Data[i] != b {
|
||||
t.Errorf("Data[%d]: got 0x%02X, want 0x%02X", i, resp.Data[i], b)
|
||||
}
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for ACK response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAck_TooShort(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
// Should not panic with data shorter than 8 bytes.
|
||||
ch.handleAck(nil)
|
||||
ch.handleAck([]byte{})
|
||||
ch.handleAck([]byte{0x00, 0x01, 0x02})
|
||||
ch.handleAck([]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06})
|
||||
|
||||
// 7 bytes: still < 8, should return silently.
|
||||
data := make([]byte, 7)
|
||||
binary.BigEndian.PutUint32(data[0:4], 99)
|
||||
ch.handleAck(data)
|
||||
}
|
||||
|
||||
func TestHandleAck_ExtendedBuffer_TooShortForSize(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
ackHandle := uint32(4)
|
||||
waitCh := make(chan *AckResponse, 1)
|
||||
ch.waiters.Store(ackHandle, waitCh)
|
||||
|
||||
// payloadSize=0xFFFF but data too short for the uint32 real size field (only 8 bytes total).
|
||||
data := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(data[0:4], ackHandle)
|
||||
data[4] = 1 // isBuffer = true
|
||||
data[5] = 0
|
||||
binary.BigEndian.PutUint16(data[6:8], 0xFFFF)
|
||||
|
||||
ch.handleAck(data)
|
||||
|
||||
// The handler should return early (no payload), but still dispatch the response
|
||||
// with nil Data since the early return is only for reading payload.
|
||||
select {
|
||||
case resp := <-waitCh:
|
||||
// Response dispatched but with nil data since len(data) < 12.
|
||||
if resp.Data != nil {
|
||||
t.Errorf("expected nil Data for truncated extended buffer, got %d bytes", len(resp.Data))
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// The handler returns before dispatching if len(data) < 12 for 0xFFFF path.
|
||||
// This is also acceptable behavior.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAck_WithErrorCode(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
ackHandle := uint32(5)
|
||||
waitCh := make(chan *AckResponse, 1)
|
||||
ch.waiters.Store(ackHandle, waitCh)
|
||||
|
||||
data := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(data[0:4], ackHandle)
|
||||
data[4] = 0 // isBuffer = false
|
||||
data[5] = 42 // errorCode = 42
|
||||
binary.BigEndian.PutUint16(data[6:8], 0)
|
||||
binary.BigEndian.PutUint32(data[8:12], 0)
|
||||
|
||||
ch.handleAck(data)
|
||||
|
||||
select {
|
||||
case resp := <-waitCh:
|
||||
if resp.ErrorCode != 42 {
|
||||
t.Errorf("ErrorCode: got %d, want 42", resp.ErrorCode)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for ACK response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAck_NoWaiter(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
// No waiter registered for handle 999 — should not panic.
|
||||
data := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(data[0:4], 999)
|
||||
data[4] = 0
|
||||
data[5] = 0
|
||||
binary.BigEndian.PutUint16(data[6:8], 0)
|
||||
binary.BigEndian.PutUint32(data[8:12], 0)
|
||||
|
||||
// This should log but not panic.
|
||||
ch.handleAck(data)
|
||||
}
|
||||
|
||||
func TestNextAckHandle(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
h1 := ch.NextAckHandle()
|
||||
h2 := ch.NextAckHandle()
|
||||
h3 := ch.NextAckHandle()
|
||||
|
||||
if h1 != 1 {
|
||||
t.Errorf("first handle: got %d, want 1", h1)
|
||||
}
|
||||
if h2 != 2 {
|
||||
t.Errorf("second handle: got %d, want 2", h2)
|
||||
}
|
||||
if h3 != 3 {
|
||||
t.Errorf("third handle: got %d, want 3", h3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextAckHandle_Concurrent(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
const goroutines = 100
|
||||
results := make(chan uint32, goroutines)
|
||||
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
results <- ch.NextAckHandle()
|
||||
}()
|
||||
}
|
||||
|
||||
seen := make(map[uint32]bool)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
h := <-results
|
||||
if seen[h] {
|
||||
t.Errorf("duplicate handle: %d", h)
|
||||
}
|
||||
seen[h] = true
|
||||
}
|
||||
|
||||
if len(seen) != goroutines {
|
||||
t.Errorf("unique handles: got %d, want %d", len(seen), goroutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAck_Timeout(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
// No handleAck call will be made, so this should time out.
|
||||
_, err := ch.WaitForAck(999, 50*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAck_Success(t *testing.T) {
|
||||
ch := &ChannelConn{}
|
||||
|
||||
ackHandle := uint32(10)
|
||||
|
||||
// Dispatch the ACK from another goroutine after a short delay.
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
data := make([]byte, 12)
|
||||
binary.BigEndian.PutUint32(data[0:4], ackHandle)
|
||||
data[4] = 0 // isBuffer = false
|
||||
data[5] = 0 // errorCode
|
||||
binary.BigEndian.PutUint16(data[6:8], 0)
|
||||
binary.BigEndian.PutUint32(data[8:12], 0x12345678)
|
||||
ch.handleAck(data)
|
||||
}()
|
||||
|
||||
resp, err := ch.WaitForAck(ackHandle, 1*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.AckHandle != ackHandle {
|
||||
t.Errorf("AckHandle: got %d, want %d", resp.AckHandle, ackHandle)
|
||||
}
|
||||
}
|
||||
247
cmd/protbot/protocol/entrance_test.go
Normal file
247
cmd/protbot/protocol/entrance_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"erupe-ce/cmd/protbot/conn"
|
||||
"erupe-ce/common/byteframe"
|
||||
)
|
||||
|
||||
// encryptBin8 encrypts plaintext using the Bin8 algorithm.
|
||||
// Since Bin8 is a symmetric XOR cipher, DecryptBin8(plaintext, key) produces ciphertext.
|
||||
func encryptBin8(plaintext []byte, key byte) []byte {
|
||||
return conn.DecryptBin8(plaintext, key)
|
||||
}
|
||||
|
||||
// buildEntranceResponse constructs a valid Bin8-encrypted entrance server response.
|
||||
// Format: [key byte] [encrypted: "SV2" + uint16 entryCount + uint16 dataLen + uint32 checksum + serverData]
|
||||
func buildEntranceResponse(key byte, respType string, entries []testServerEntry) []byte {
|
||||
// Build server data blob first (to compute checksum and length).
|
||||
serverData := buildServerData(entries)
|
||||
|
||||
// Build the plaintext (before encryption).
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteBytes([]byte(respType)) // "SV2" or "SVR"
|
||||
bf.WriteUint16(uint16(len(entries)))
|
||||
bf.WriteUint16(uint16(len(serverData)))
|
||||
if len(serverData) > 0 {
|
||||
bf.WriteUint32(conn.CalcSum32(serverData))
|
||||
bf.WriteBytes(serverData)
|
||||
}
|
||||
|
||||
plaintext := bf.Data()
|
||||
encrypted := encryptBin8(plaintext, key)
|
||||
|
||||
// Final response: key byte + encrypted data.
|
||||
result := make([]byte, 1+len(encrypted))
|
||||
result[0] = key
|
||||
copy(result[1:], encrypted)
|
||||
return result
|
||||
}
|
||||
|
||||
type testServerEntry struct {
|
||||
ip [4]byte // big-endian IP bytes (reversed for MHF format)
|
||||
name string
|
||||
channelCount uint16
|
||||
channelPorts []uint16
|
||||
}
|
||||
|
||||
// buildServerData constructs the binary server entry data blob.
|
||||
// Format mirrors Erupe server/entranceserver/make_resp.go:encodeServerInfo.
|
||||
func buildServerData(entries []testServerEntry) []byte {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bf := byteframe.NewByteFrame()
|
||||
for _, e := range entries {
|
||||
// IP bytes (stored reversed in the protocol — client reads and reverses)
|
||||
bf.WriteBytes(e.ip[:])
|
||||
|
||||
bf.WriteUint16(0x0010) // serverIdx | 16
|
||||
bf.WriteUint16(0) // zero
|
||||
bf.WriteUint16(e.channelCount)
|
||||
bf.WriteUint8(0) // Type
|
||||
bf.WriteUint8(0) // Season
|
||||
|
||||
// G1+ recommended
|
||||
bf.WriteUint8(0)
|
||||
|
||||
// G51+ (ZZ): skip byte + 65-byte name
|
||||
bf.WriteUint8(0)
|
||||
nameBytes := make([]byte, 65)
|
||||
copy(nameBytes, []byte(e.name))
|
||||
bf.WriteBytes(nameBytes)
|
||||
|
||||
// GG+: AllowedClientFlags
|
||||
bf.WriteUint32(0)
|
||||
|
||||
// Channel entries (28 bytes each)
|
||||
for j := uint16(0); j < e.channelCount; j++ {
|
||||
port := uint16(54001)
|
||||
if j < uint16(len(e.channelPorts)) {
|
||||
port = e.channelPorts[j]
|
||||
}
|
||||
bf.WriteUint16(port) // port
|
||||
bf.WriteUint16(0x0010) // channelIdx | 16
|
||||
bf.WriteUint16(100) // maxPlayers
|
||||
bf.WriteUint16(5) // currentPlayers
|
||||
bf.WriteBytes(make([]byte, 18)) // remaining fields (9 x uint16)
|
||||
bf.WriteUint16(12345) // sentinel
|
||||
}
|
||||
}
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
func TestParseEntranceResponse_ValidSV2(t *testing.T) {
|
||||
entries := []testServerEntry{
|
||||
{
|
||||
ip: [4]byte{1, 0, 0, 127}, // 127.0.0.1 reversed
|
||||
name: "TestServer",
|
||||
channelCount: 2,
|
||||
channelPorts: []uint16{54001, 54002},
|
||||
},
|
||||
}
|
||||
|
||||
data := buildEntranceResponse(0x42, "SV2", entries)
|
||||
|
||||
result, err := parseEntranceResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("entry count: got %d, want 2", len(result))
|
||||
}
|
||||
|
||||
if result[0].Port != 54001 {
|
||||
t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port)
|
||||
}
|
||||
if result[1].Port != 54002 {
|
||||
t.Errorf("entry[1].Port: got %d, want 54002", result[1].Port)
|
||||
}
|
||||
if result[0].Name != "TestServer ch1" {
|
||||
t.Errorf("entry[0].Name: got %q, want %q", result[0].Name, "TestServer ch1")
|
||||
}
|
||||
if result[1].Name != "TestServer ch2" {
|
||||
t.Errorf("entry[1].Name: got %q, want %q", result[1].Name, "TestServer ch2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntranceResponse_ValidSVR(t *testing.T) {
|
||||
entries := []testServerEntry{
|
||||
{
|
||||
ip: [4]byte{1, 0, 0, 127},
|
||||
name: "World1",
|
||||
channelCount: 1,
|
||||
channelPorts: []uint16{54001},
|
||||
},
|
||||
}
|
||||
|
||||
data := buildEntranceResponse(0x10, "SVR", entries)
|
||||
|
||||
result, err := parseEntranceResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("entry count: got %d, want 1", len(result))
|
||||
}
|
||||
if result[0].Port != 54001 {
|
||||
t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntranceResponse_InvalidType(t *testing.T) {
|
||||
// Build a response with an invalid type string "BAD" instead of "SV2"/"SVR".
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteBytes([]byte("BAD"))
|
||||
bf.WriteUint16(0) // entryCount
|
||||
bf.WriteUint16(0) // dataLen
|
||||
|
||||
plaintext := bf.Data()
|
||||
key := byte(0x55)
|
||||
encrypted := encryptBin8(plaintext, key)
|
||||
|
||||
data := make([]byte, 1+len(encrypted))
|
||||
data[0] = key
|
||||
copy(data[1:], encrypted)
|
||||
|
||||
_, err := parseEntranceResponse(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid response type, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntranceResponse_EmptyData(t *testing.T) {
|
||||
_, err := parseEntranceResponse(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil data, got nil")
|
||||
}
|
||||
|
||||
_, err = parseEntranceResponse([]byte{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty data, got nil")
|
||||
}
|
||||
|
||||
_, err = parseEntranceResponse([]byte{0x42}) // only key, no encrypted data
|
||||
if err == nil {
|
||||
t.Fatal("expected error for single-byte data, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEntranceResponse_ZeroEntries(t *testing.T) {
|
||||
// Valid response with zero entries and zero data length.
|
||||
data := buildEntranceResponse(0x30, "SV2", nil)
|
||||
|
||||
result, err := parseEntranceResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for zero entries, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServerEntries_MultipleServers(t *testing.T) {
|
||||
entries := []testServerEntry{
|
||||
{
|
||||
ip: [4]byte{100, 1, 168, 192}, // 192.168.1.100 reversed
|
||||
name: "Server1",
|
||||
channelCount: 1,
|
||||
channelPorts: []uint16{54001},
|
||||
},
|
||||
{
|
||||
ip: [4]byte{200, 1, 168, 192}, // 192.168.1.200 reversed
|
||||
name: "Server2",
|
||||
channelCount: 1,
|
||||
channelPorts: []uint16{54010},
|
||||
},
|
||||
}
|
||||
|
||||
serverData := buildServerData(entries)
|
||||
|
||||
result, err := parseServerEntries(serverData, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("entry count: got %d, want 2", len(result))
|
||||
}
|
||||
|
||||
if result[0].Port != 54001 {
|
||||
t.Errorf("entry[0].Port: got %d, want 54001", result[0].Port)
|
||||
}
|
||||
if result[0].Name != "Server1 ch1" {
|
||||
t.Errorf("entry[0].Name: got %q, want %q", result[0].Name, "Server1 ch1")
|
||||
}
|
||||
if result[1].Port != 54010 {
|
||||
t.Errorf("entry[1].Port: got %d, want 54010", result[1].Port)
|
||||
}
|
||||
if result[1].Name != "Server2 ch1" {
|
||||
t.Errorf("entry[1].Name: got %q, want %q", result[1].Name, "Server2 ch1")
|
||||
}
|
||||
}
|
||||
196
cmd/protbot/protocol/sign_test.go
Normal file
196
cmd/protbot/protocol/sign_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"erupe-ce/common/byteframe"
|
||||
)
|
||||
|
||||
// buildSignResponse constructs a binary sign server response for testing.
|
||||
// Format mirrors Erupe server/signserver/dsgn_resp.go:makeSignResponse.
|
||||
func buildSignResponse(resultCode uint8, tokenID uint32, tokenString [16]byte, timestamp uint32, patchURLs []string, entranceAddr string, chars []testCharEntry) []byte {
|
||||
bf := byteframe.NewByteFrame()
|
||||
bf.WriteUint8(resultCode)
|
||||
bf.WriteUint8(uint8(len(patchURLs))) // patchCount
|
||||
bf.WriteUint8(1) // entranceCount (always 1 in tests)
|
||||
bf.WriteUint8(uint8(len(chars))) // charCount
|
||||
bf.WriteUint32(tokenID)
|
||||
bf.WriteBytes(tokenString[:])
|
||||
bf.WriteUint32(timestamp)
|
||||
|
||||
// Patch server URLs (pascal strings with uint8 length prefix)
|
||||
for _, url := range patchURLs {
|
||||
bf.WriteUint8(uint8(len(url)))
|
||||
bf.WriteBytes([]byte(url))
|
||||
}
|
||||
|
||||
// Entrance server address (pascal string with null terminator included in length)
|
||||
bf.WriteUint8(uint8(len(entranceAddr) + 1))
|
||||
bf.WriteBytes([]byte(entranceAddr))
|
||||
bf.WriteUint8(0) // null terminator
|
||||
|
||||
// Character entries
|
||||
for _, c := range chars {
|
||||
bf.WriteUint32(c.charID)
|
||||
bf.WriteUint16(c.hr)
|
||||
bf.WriteUint16(c.weaponType)
|
||||
bf.WriteUint32(c.lastLogin)
|
||||
bf.WriteUint8(c.isFemale)
|
||||
bf.WriteUint8(c.isNewChar)
|
||||
bf.WriteUint8(c.oldGR)
|
||||
bf.WriteUint8(c.useU16GR)
|
||||
// Name: 16 bytes padded
|
||||
name := make([]byte, 16)
|
||||
copy(name, []byte(c.name))
|
||||
bf.WriteBytes(name)
|
||||
// Desc: 32 bytes padded
|
||||
desc := make([]byte, 32)
|
||||
copy(desc, []byte(c.desc))
|
||||
bf.WriteBytes(desc)
|
||||
bf.WriteUint16(c.gr)
|
||||
bf.WriteUint8(c.unk1)
|
||||
bf.WriteUint8(c.unk2)
|
||||
}
|
||||
|
||||
return bf.Data()
|
||||
}
|
||||
|
||||
type testCharEntry struct {
|
||||
charID uint32
|
||||
hr uint16
|
||||
weaponType uint16
|
||||
lastLogin uint32
|
||||
isFemale uint8
|
||||
isNewChar uint8
|
||||
oldGR uint8
|
||||
useU16GR uint8
|
||||
name string
|
||||
desc string
|
||||
gr uint16
|
||||
unk1 uint8
|
||||
unk2 uint8
|
||||
}
|
||||
|
||||
func TestParseSignResponse_Success(t *testing.T) {
|
||||
tokenID := uint32(12345)
|
||||
var tokenString [16]byte
|
||||
copy(tokenString[:], []byte("ABCDEFGHIJKLMNOP"))
|
||||
timestamp := uint32(1700000000)
|
||||
patchURLs := []string{"http://patch1.example.com", "http://patch2.example.com"}
|
||||
entranceAddr := "192.168.1.1:53310"
|
||||
chars := []testCharEntry{
|
||||
{
|
||||
charID: 100,
|
||||
hr: 999,
|
||||
weaponType: 3,
|
||||
lastLogin: 1699999999,
|
||||
isFemale: 1,
|
||||
isNewChar: 0,
|
||||
oldGR: 50,
|
||||
useU16GR: 1,
|
||||
name: "Hunter",
|
||||
desc: "A brave hunter",
|
||||
gr: 200,
|
||||
unk1: 0,
|
||||
unk2: 0,
|
||||
},
|
||||
}
|
||||
|
||||
data := buildSignResponse(1, tokenID, tokenString, timestamp, patchURLs, entranceAddr, chars)
|
||||
|
||||
result, err := parseSignResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.TokenID != tokenID {
|
||||
t.Errorf("TokenID: got %d, want %d", result.TokenID, tokenID)
|
||||
}
|
||||
if result.TokenString != string(tokenString[:]) {
|
||||
t.Errorf("TokenString: got %q, want %q", result.TokenString, string(tokenString[:]))
|
||||
}
|
||||
if result.Timestamp != timestamp {
|
||||
t.Errorf("Timestamp: got %d, want %d", result.Timestamp, timestamp)
|
||||
}
|
||||
if result.EntranceAddr != entranceAddr {
|
||||
t.Errorf("EntranceAddr: got %q, want %q", result.EntranceAddr, entranceAddr)
|
||||
}
|
||||
if len(result.CharIDs) != 1 {
|
||||
t.Fatalf("CharIDs length: got %d, want 1", len(result.CharIDs))
|
||||
}
|
||||
if result.CharIDs[0] != 100 {
|
||||
t.Errorf("CharIDs[0]: got %d, want 100", result.CharIDs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignResponse_MultipleCharacters(t *testing.T) {
|
||||
var tokenString [16]byte
|
||||
copy(tokenString[:], []byte("0123456789ABCDEF"))
|
||||
chars := []testCharEntry{
|
||||
{charID: 10, name: "Char1"},
|
||||
{charID: 20, name: "Char2"},
|
||||
{charID: 30, name: "Char3"},
|
||||
}
|
||||
|
||||
data := buildSignResponse(1, 42, tokenString, 0, nil, "127.0.0.1:53310", chars)
|
||||
|
||||
result, err := parseSignResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.CharIDs) != 3 {
|
||||
t.Fatalf("CharIDs length: got %d, want 3", len(result.CharIDs))
|
||||
}
|
||||
expectedIDs := []uint32{10, 20, 30}
|
||||
for i, want := range expectedIDs {
|
||||
if result.CharIDs[i] != want {
|
||||
t.Errorf("CharIDs[%d]: got %d, want %d", i, result.CharIDs[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignResponse_NoCharacters(t *testing.T) {
|
||||
var tokenString [16]byte
|
||||
data := buildSignResponse(1, 1, tokenString, 0, nil, "127.0.0.1:53310", nil)
|
||||
|
||||
result, err := parseSignResponse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result.CharIDs) != 0 {
|
||||
t.Errorf("CharIDs length: got %d, want 0", len(result.CharIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignResponse_FailCode(t *testing.T) {
|
||||
// resultCode=0 means failure; the rest of the data is irrelevant
|
||||
// but we still need the 3 count bytes for the parser to read before checking
|
||||
data := []byte{0} // resultCode = 0
|
||||
|
||||
_, err := parseSignResponse(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failure result code, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignResponse_FailCode5(t *testing.T) {
|
||||
data := []byte{5} // resultCode = 5 (some other failure code)
|
||||
|
||||
_, err := parseSignResponse(data)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for result code 5, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignResponse_Empty(t *testing.T) {
|
||||
_, err := parseSignResponse(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil data, got nil")
|
||||
}
|
||||
|
||||
_, err = parseSignResponse([]byte{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty data, got nil")
|
||||
}
|
||||
}
|
||||
56
docs/scenario-format.md
Normal file
56
docs/scenario-format.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Scenario Binary Format
|
||||
|
||||
> Reference: `network/mhfpacket/msg_sys_get_file.go`, issue [#172](https://github.com/Mezeporta/Erupe/issues/172)
|
||||
|
||||
## Overview
|
||||
|
||||
Scenario files are binary blobs served by `MSG_SYS_GET_FILE` when `IsScenario` is true. They contain quest descriptions, NPC dialog, episode listings, and menu options for the game's scenario/story system.
|
||||
|
||||
## Request Format
|
||||
|
||||
When `IsScenario == true`, the client sends a `scenarioFileIdentifier`:
|
||||
|
||||
| Offset | Type | Field | Description |
|
||||
|--------|--------|-------------|-------------|
|
||||
| 0 | uint8 | CategoryID | Scenario category |
|
||||
| 1 | uint32 | MainID | Main scenario identifier |
|
||||
| 5 | uint8 | ChapterID | Chapter within the scenario |
|
||||
| 6 | uint8 | Flags | Bit flags selecting chunk types (see below) |
|
||||
|
||||
## Flags (Chunk Type Selection)
|
||||
|
||||
The `Flags` byte is a bitmask that selects which chunk types the client requests:
|
||||
|
||||
| Bit | Value | Type | Recursive | Content |
|
||||
|------|-------|---------|-----------|---------|
|
||||
| 0 | 0x01 | Chunk0 | Yes | Quest name/description + 0x14 byte info block |
|
||||
| 1 | 0x02 | Chunk1 | Yes | NPC dialog(?) + 0x2C byte info block |
|
||||
| 2 | 0x04 | — | — | Unknown (no instances found; possibly Chunk2) |
|
||||
| 3 | 0x08 | Chunk0 | No | Episode listing (0x1 prefixed?) |
|
||||
| 4 | 0x10 | Chunk1 | No | JKR-compressed blob, NPC dialog(?) |
|
||||
| 5 | 0x20 | Chunk2 | No | JKR-compressed blob, menu options or quest titles(?) |
|
||||
| 6 | 0x40 | — | — | Unknown (no instances found) |
|
||||
| 7 | 0x80 | — | — | Unknown (no instances found) |
|
||||
|
||||
### Chunk Types
|
||||
|
||||
- **Chunk0**: Contains text data (quest names, descriptions, episode titles) with an accompanying fixed-size info block.
|
||||
- **Chunk1**: Contains dialog or narrative text with a larger info block (0x2C bytes).
|
||||
- **Chunk2**: Contains menu/selection text.
|
||||
|
||||
### Recursive vs Non-Recursive
|
||||
|
||||
- **Recursive chunks** (flags 0x01, 0x02): The chunk data itself contains nested sub-chunks that must be parsed recursively.
|
||||
- **Non-recursive chunks** (flags 0x08, 0x10, 0x20): The chunk is a flat binary blob. Flags 0x10 and 0x20 are JKR-compressed and must be decompressed before reading.
|
||||
|
||||
## Response Format
|
||||
|
||||
The server responds with the scenario file data via `doAckBufSucceed`. The response is the raw binary blob matching the requested chunk types. If the scenario file is not found, the server sends `doAckBufFail` to prevent a client crash.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
Scenario files are loaded from `quests/scenarios/` on disk. The server currently serves them as opaque binary blobs with no parsing. Issue #172 proposes adding JSON/CSV support for easier editing, which would require implementing a parser/serializer for this format.
|
||||
|
||||
## JKR Compression
|
||||
|
||||
Chunks with flags 0x10 and 0x20 use JPK compression (magic bytes `0x1A524B4A`). See the ReFrontier tool for decompression utilities.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Erupe Technical Debt & Suggested Next Steps
|
||||
|
||||
> Last updated: 2026-02-27
|
||||
> Last updated: 2026-03-05
|
||||
|
||||
This document tracks actionable technical debt items discovered during a codebase audit. It complements `anti-patterns.md` (which covers structural patterns) by focusing on specific, fixable items with file paths and line numbers.
|
||||
|
||||
@@ -82,6 +82,11 @@ Items resolved since the original audit:
|
||||
| — | **`db != nil` guard** (`handlers_session.go:322`) | Investigated — this guard is intentional. Test servers run without repos; the guard protects the entire logout path from nil repo dereferences. Not a leaky abstraction. |
|
||||
| ~~2~~ | **Repo test coverage (17 files)** | All 20 repo source files now have `_test.go` files with mock-based unit tests. |
|
||||
| — | **Bookshelf data pointer** ([#164](https://github.com/Mezeporta/Erupe/issues/164)) | Corrected `pBookshelfData` offsets for G1–Z2 (103928), F4–F5 (71928), S6 (23928). Validated via inter-version delta analysis and Ghidra decompilation of ZZ `snj_db_get_housedata`. |
|
||||
| — | **Guild nil panics** ([#171](https://github.com/Mezeporta/Erupe/issues/171)) | Three fixes merged post-RC1: nil guards for alliance guild lookups (aee5353), variable shadowing fix in scout list (8e79fe6), nil guards in cancel/answer scout handlers (8717fb9). Clan hall softlock resolved. |
|
||||
| — | **ecdMagic byte order** ([#174](https://github.com/Mezeporta/Erupe/issues/174)) | Corrected constant byte order in `crypt_conn.go` (10ac803). |
|
||||
| — | **Rengoku caching** | Cached `rengoku_data.bin` at startup to avoid repeated disk reads (5b631d1). |
|
||||
| — | **rasta_id=0 save issue** ([#163](https://github.com/Mezeporta/Erupe/issues/163)) | `SaveMercenary` now skips rasta_id update when value is 0, preserving NULL for characters without a mercenary. |
|
||||
| — | **fmt.Printf in setup wizard** | Replaced `fmt.Printf` in `server/setup/setup.go` with structured `zap` logging. |
|
||||
|
||||
---
|
||||
|
||||
@@ -94,5 +99,5 @@ Based on remaining impact:
|
||||
3. **Fix achievement rank-up notifications** ([#165](https://github.com/Mezeporta/Erupe/issues/165)) — needs protocol research on `MhfDisplayedAchievement`
|
||||
4. ~~**Add coverage threshold** to CI~~ — **Done.** 50% floor enforced via `go tool cover` in CI; Codecov removed.
|
||||
5. ~~**Fix guild alliance toggle** ([#166](https://github.com/Mezeporta/Erupe/issues/166))~~ — **Done.** `recruiting` column + `OperateJoint` allow/deny actions + DB toggle
|
||||
6. **Fix session handler retail mismatches** ([#167](https://github.com/Mezeporta/Erupe/issues/167)) — log key off-by-one, version boundary, player count
|
||||
6. ~~**Fix session handler retail mismatches** ([#167](https://github.com/Mezeporta/Erupe/issues/167))~~ — **Documented.** RE'd from ZZ DLL; log key off-by-one is benign server-side, player count fixed via `QuestReserved`.
|
||||
7. **Reverse-engineer MhfAddUdPoint fields** ([#168](https://github.com/Mezeporta/Erupe/issues/168)) — needs packet captures
|
||||
|
||||
@@ -505,3 +505,49 @@ func TestOperateJoint_NilAlliance(t *testing.T) {
|
||||
t.Error("No response packet queued — would softlock the client")
|
||||
}
|
||||
}
|
||||
|
||||
// --- scanAllianceWithGuilds nil guild tests (issue #171) ---
|
||||
|
||||
func TestInfoJoint_MissingSubGuild1(t *testing.T) {
|
||||
// Verify that GetAllianceByID returns an error when sub guild 1 references
|
||||
// a non-existent guild (nil return from GetByID). This is the scenario from
|
||||
// issue #171 — a deleted guild causes a nil dereference in scanAllianceWithGuilds.
|
||||
server := createMockServer()
|
||||
guildMock := &mockGuildRepo{
|
||||
// GetAllianceByID returns an error for missing guilds because
|
||||
// scanAllianceWithGuilds calls GetByID for each sub guild.
|
||||
// With guild=nil and SubGuild1ID > 0, GetByID returns nil,
|
||||
// and scanAllianceWithGuilds should return an error rather than panic.
|
||||
getAllianceErr: errNotFound,
|
||||
}
|
||||
server.guildRepo = guildMock
|
||||
session := createMockSession(1, server)
|
||||
|
||||
pkt := &mhfpacket.MsgMhfInfoJoint{AckHandle: 100, AllianceID: 5}
|
||||
handleMsgMhfInfoJoint(session, pkt)
|
||||
|
||||
// Handler should send a response even on error (not softlock)
|
||||
select {
|
||||
case <-session.sendPackets:
|
||||
default:
|
||||
t.Error("No response packet queued — would softlock the client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoJoint_MissingSubGuild2(t *testing.T) {
|
||||
server := createMockServer()
|
||||
guildMock := &mockGuildRepo{
|
||||
getAllianceErr: errNotFound,
|
||||
}
|
||||
server.guildRepo = guildMock
|
||||
session := createMockSession(1, server)
|
||||
|
||||
pkt := &mhfpacket.MsgMhfInfoJoint{AckHandle: 100, AllianceID: 6}
|
||||
handleMsgMhfInfoJoint(session, pkt)
|
||||
|
||||
select {
|
||||
case <-session.sendPackets:
|
||||
default:
|
||||
t.Error("No response packet queued — would softlock the client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,12 @@ func handleMsgMhfSaveMercenary(s *Session, p mhfpacket.MHFPacket) {
|
||||
dumpSaveData(s, pkt.MercData, "mercenary")
|
||||
if len(pkt.MercData) >= 4 {
|
||||
temp := byteframe.NewByteFrameFromBytes(pkt.MercData)
|
||||
if err := s.server.charRepo.SaveMercenary(s.charID, pkt.MercData, temp.ReadUint32()); err != nil {
|
||||
rastaID := temp.ReadUint32()
|
||||
if rastaID == 0 {
|
||||
s.logger.Warn("Mercenary save with rasta_id=0, preserving existing value",
|
||||
zap.Uint32("charID", s.charID))
|
||||
}
|
||||
if err := s.server.charRepo.SaveMercenary(s.charID, pkt.MercData, rastaID); err != nil {
|
||||
s.logger.Error("Failed to save mercenary data", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
59
server/channelserver/handlers_table_test.go
Normal file
59
server/channelserver/handlers_table_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package channelserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"erupe-ce/network"
|
||||
)
|
||||
|
||||
func TestBuildHandlerTable_EntryCount(t *testing.T) {
|
||||
table := buildHandlerTable()
|
||||
// handlers_table.go has exactly 432 entries (one per packet ID).
|
||||
// This test catches accidental deletions or duplicates.
|
||||
const expectedCount = 432
|
||||
if len(table) != expectedCount {
|
||||
t.Errorf("handler table has %d entries, want %d", len(table), expectedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHandlerTable_CriticalOpcodes(t *testing.T) {
|
||||
table := buildHandlerTable()
|
||||
|
||||
critical := []struct {
|
||||
name string
|
||||
id network.PacketID
|
||||
}{
|
||||
{"MSG_SYS_LOGIN", network.MSG_SYS_LOGIN},
|
||||
{"MSG_SYS_LOGOUT", network.MSG_SYS_LOGOUT},
|
||||
{"MSG_SYS_PING", network.MSG_SYS_PING},
|
||||
{"MSG_SYS_ACK", network.MSG_SYS_ACK},
|
||||
{"MSG_SYS_CAST_BINARY", network.MSG_SYS_CAST_BINARY},
|
||||
{"MSG_SYS_ENTER_STAGE", network.MSG_SYS_ENTER_STAGE},
|
||||
{"MSG_SYS_LEAVE_STAGE", network.MSG_SYS_LEAVE_STAGE},
|
||||
{"MSG_MHF_SAVEDATA", network.MSG_MHF_SAVEDATA},
|
||||
{"MSG_MHF_LOADDATA", network.MSG_MHF_LOADDATA},
|
||||
{"MSG_MHF_ENUMERATE_QUEST", network.MSG_MHF_ENUMERATE_QUEST},
|
||||
{"MSG_MHF_CREATE_GUILD", network.MSG_MHF_CREATE_GUILD},
|
||||
{"MSG_MHF_INFO_GUILD", network.MSG_MHF_INFO_GUILD},
|
||||
{"MSG_MHF_GET_ACHIEVEMENT", network.MSG_MHF_GET_ACHIEVEMENT},
|
||||
{"MSG_MHF_PLAY_NORMAL_GACHA", network.MSG_MHF_PLAY_NORMAL_GACHA},
|
||||
{"MSG_MHF_SEND_MAIL", network.MSG_MHF_SEND_MAIL},
|
||||
{"MSG_MHF_SAVE_RENGOKU_DATA", network.MSG_MHF_SAVE_RENGOKU_DATA},
|
||||
{"MSG_MHF_LOAD_RENGOKU_DATA", network.MSG_MHF_LOAD_RENGOKU_DATA},
|
||||
}
|
||||
|
||||
for _, tc := range critical {
|
||||
if _, ok := table[tc.id]; !ok {
|
||||
t.Errorf("critical opcode %s (0x%04X) is not mapped in handler table", tc.name, uint16(tc.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHandlerTable_NoNilHandlers(t *testing.T) {
|
||||
table := buildHandlerTable()
|
||||
for id, handler := range table {
|
||||
if handler == nil {
|
||||
t.Errorf("handler for opcode 0x%04X is nil", uint16(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,8 +193,15 @@ func (r *CharacterRepository) ReadGuildPostChecked(charID uint32) (time.Time, er
|
||||
return t, err
|
||||
}
|
||||
|
||||
// SaveMercenary updates savemercenary and rasta_id atomically.
|
||||
// SaveMercenary updates savemercenary and optionally rasta_id.
|
||||
// When rastaID is 0, only the mercenary blob is saved — the existing rasta_id
|
||||
// (typically NULL for characters without a mercenary) is preserved. Writing 0
|
||||
// would pollute GetMercenaryLoans queries that match on pact_id.
|
||||
func (r *CharacterRepository) SaveMercenary(charID uint32, data []byte, rastaID uint32) error {
|
||||
if rastaID == 0 {
|
||||
_, err := r.db.Exec("UPDATE characters SET savemercenary=$1 WHERE id=$2", data, charID)
|
||||
return err
|
||||
}
|
||||
_, err := r.db.Exec("UPDATE characters SET savemercenary=$1, rasta_id=$2 WHERE id=$3", data, rastaID, charID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -208,3 +208,72 @@ func TestReadMigrations(t *testing.T) {
|
||||
t.Errorf("first migration filename = %q, want 0001_init.sql", migrations[0].filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMigrations_Sorted(t *testing.T) {
|
||||
migrations, err := readMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("readMigrations failed: %v", err)
|
||||
}
|
||||
for i := 1; i < len(migrations); i++ {
|
||||
if migrations[i].version <= migrations[i-1].version {
|
||||
t.Errorf("migrations not sorted: version %d at index %d follows version %d at index %d",
|
||||
migrations[i].version, i, migrations[i-1].version, i-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMigrations_AllHaveSQL(t *testing.T) {
|
||||
migrations, err := readMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("readMigrations failed: %v", err)
|
||||
}
|
||||
for _, m := range migrations {
|
||||
if m.sql == "" {
|
||||
t.Errorf("migration %s has empty SQL", m.filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMigrations_BaselineIsLargest(t *testing.T) {
|
||||
migrations, err := readMigrations()
|
||||
if err != nil {
|
||||
t.Fatalf("readMigrations failed: %v", err)
|
||||
}
|
||||
if len(migrations) < 2 {
|
||||
t.Skip("not enough migrations to compare sizes")
|
||||
}
|
||||
// The baseline (0001_init.sql) should be the largest migration.
|
||||
baselineLen := len(migrations[0].sql)
|
||||
for _, m := range migrations[1:] {
|
||||
if len(m.sql) > baselineLen {
|
||||
t.Errorf("migration %s (%d bytes) is larger than baseline (%d bytes)",
|
||||
m.filename, len(m.sql), baselineLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersion_Comprehensive(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{"0001_init.sql", 1, false},
|
||||
{"0002_add_users.sql", 2, false},
|
||||
{"0100_big_change.sql", 100, false},
|
||||
{"9999_final.sql", 9999, false},
|
||||
{"bad.sql", 0, true},
|
||||
{"noseparator", 0, true},
|
||||
{"abc_description.sql", 0, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := parseVersion(tt.filename)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseVersion(%q) error = %v, wantErr %v", tt.filename, err, tt.wantErr)
|
||||
continue
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseVersion(%q) = %d, want %d", tt.filename, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ func Run(logger *zap.Logger, port int) error {
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
logger.Info(fmt.Sprintf("Setup wizard available at http://localhost:%d", port))
|
||||
fmt.Printf("\n >>> Open http://localhost:%d in your browser to configure Erupe <<<\n\n", port)
|
||||
logger.Info("Setup wizard available",
|
||||
zap.String("url", fmt.Sprintf("http://localhost:%d", port)))
|
||||
logger.Warn("Open the URL above in your browser to configure Erupe")
|
||||
|
||||
// Start the HTTP server in a goroutine.
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -195,3 +196,291 @@ func containsHelper(s, substr string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestBuildDefaultConfig_EmptyLanguage(t *testing.T) {
|
||||
req := FinishRequest{
|
||||
DBHost: "localhost",
|
||||
DBPort: 5432,
|
||||
DBUser: "postgres",
|
||||
DBPassword: "pass",
|
||||
DBName: "erupe",
|
||||
Host: "127.0.0.1",
|
||||
ClientMode: "ZZ",
|
||||
Language: "", // empty — should default to "jp"
|
||||
}
|
||||
cfg := buildDefaultConfig(req)
|
||||
|
||||
lang, ok := cfg["Language"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Language is not a string")
|
||||
}
|
||||
if lang != "jp" {
|
||||
t.Errorf("Language = %q, want %q", lang, "jp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTestDB_InvalidJSON(t *testing.T) {
|
||||
ws := &wizardServer{
|
||||
logger: zap.NewNop(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/setup/test-db", strings.NewReader("{invalid"))
|
||||
w := httptest.NewRecorder()
|
||||
ws.handleTestDB(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if resp["error"] != "invalid JSON" {
|
||||
t.Errorf("error = %q, want %q", resp["error"], "invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInitDB_InvalidJSON(t *testing.T) {
|
||||
ws := &wizardServer{
|
||||
logger: zap.NewNop(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/setup/init-db", strings.NewReader("not json"))
|
||||
w := httptest.NewRecorder()
|
||||
ws.handleInitDB(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if resp["error"] != "invalid JSON" {
|
||||
t.Errorf("error = %q, want %q", resp["error"], "invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFinish_InvalidJSON(t *testing.T) {
|
||||
ws := &wizardServer{
|
||||
logger: zap.NewNop(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/setup/finish", strings.NewReader("%%%"))
|
||||
w := httptest.NewRecorder()
|
||||
ws.handleFinish(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if resp["error"] != "invalid JSON" {
|
||||
t.Errorf("error = %q, want %q", resp["error"], "invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleFinish_Success(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
|
||||
done := make(chan struct{})
|
||||
ws := &wizardServer{
|
||||
logger: zap.NewNop(),
|
||||
done: done,
|
||||
}
|
||||
|
||||
body := `{"dbHost":"localhost","dbPort":5432,"dbUser":"postgres","dbPassword":"pw","dbName":"erupe","host":"10.0.0.5","clientMode":"G10","autoCreateAccount":false}`
|
||||
req := httptest.NewRequest("POST", "/api/setup/finish", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
ws.handleFinish(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Verify config.json was written
|
||||
data, err := os.ReadFile(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("config.json not written: %v", err)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("config.json is not valid JSON: %v", err)
|
||||
}
|
||||
if parsed["Host"] != "10.0.0.5" {
|
||||
t.Errorf("Host = %v, want 10.0.0.5", parsed["Host"])
|
||||
}
|
||||
if parsed["ClientMode"] != "G10" {
|
||||
t.Errorf("ClientMode = %v, want G10", parsed["ClientMode"])
|
||||
}
|
||||
|
||||
// Verify done channel was closed
|
||||
select {
|
||||
case <-done:
|
||||
// expected
|
||||
default:
|
||||
t.Error("done channel was not closed after successful finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
payload interface{}
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "OK with string map",
|
||||
status: http.StatusOK,
|
||||
payload: map[string]string{"key": "value"},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "BadRequest with error",
|
||||
status: http.StatusBadRequest,
|
||||
payload: map[string]string{"error": "bad input"},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "InternalServerError",
|
||||
status: http.StatusInternalServerError,
|
||||
payload: map[string]string{"error": "something broke"},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "OK with nested payload",
|
||||
status: http.StatusOK,
|
||||
payload: map[string]interface{}{"count": 42, "items": []string{"a", "b"}},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeJSON(w, tc.status, tc.payload)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d", w.Code, tc.wantStatus)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
// Verify body is valid JSON
|
||||
var decoded interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&decoded); err != nil {
|
||||
t.Errorf("response body is not valid JSON: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientModesContainsExpected(t *testing.T) {
|
||||
modes := clientModes()
|
||||
expected := []string{"ZZ", "G10", "FW.4", "S1.0", "Z2", "GG"}
|
||||
modeSet := make(map[string]bool, len(modes))
|
||||
for _, m := range modes {
|
||||
modeSet[m] = true
|
||||
}
|
||||
for _, exp := range expected {
|
||||
if !modeSet[exp] {
|
||||
t.Errorf("clientModes() missing expected mode %q", exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInitDB_NoOps(t *testing.T) {
|
||||
ws := &wizardServer{
|
||||
logger: zap.NewNop(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
// All flags false — no DB operations, should succeed immediately.
|
||||
body := `{"host":"localhost","port":5432,"user":"test","password":"test","dbName":"test","createDB":false,"applySchema":false,"applyBundled":false}`
|
||||
req := httptest.NewRequest("POST", "/api/setup/init-db", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
ws.handleInitDB(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode error: %v", err)
|
||||
}
|
||||
if resp["success"] != true {
|
||||
t.Errorf("success = %v, want true", resp["success"])
|
||||
}
|
||||
log, ok := resp["log"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("log should be an array")
|
||||
}
|
||||
// Should contain the "complete" message
|
||||
found := false
|
||||
for _, entry := range log {
|
||||
if s, ok := entry.(string); ok && strings.Contains(s, "complete") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected completion message in log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDefaultConfig_WithLanguage(t *testing.T) {
|
||||
req := FinishRequest{
|
||||
DBHost: "localhost",
|
||||
DBPort: 5432,
|
||||
DBUser: "postgres",
|
||||
DBPassword: "pass",
|
||||
DBName: "erupe",
|
||||
Host: "127.0.0.1",
|
||||
ClientMode: "ZZ",
|
||||
Language: "en",
|
||||
}
|
||||
cfg := buildDefaultConfig(req)
|
||||
if cfg["Language"] != "en" {
|
||||
t.Errorf("Language = %v, want en", cfg["Language"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfig_Permissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Chdir(origDir) }()
|
||||
|
||||
cfg := buildDefaultConfig(FinishRequest{
|
||||
DBHost: "localhost",
|
||||
DBPort: 5432,
|
||||
DBUser: "postgres",
|
||||
DBPassword: "pass",
|
||||
DBName: "erupe",
|
||||
Host: "127.0.0.1",
|
||||
ClientMode: "ZZ",
|
||||
})
|
||||
|
||||
if err := writeConfig(cfg); err != nil {
|
||||
t.Fatalf("writeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(filepath.Join(dir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat config.json: %v", err)
|
||||
}
|
||||
// File should be 0600 (owner read/write only)
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("config.json permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user