package dnfbridge import ( "bytes" "encoding/binary" "net" "testing" "time" dnfcompanion "longheng.io/server/internal/modules/dnf/companion" ) // companionOfflineTestConn is intentionally local to this test so the file can // be compiled without the large shared service_test.go harness. type companionOfflineTestConn struct { net.Conn write bytes.Buffer } func (c *companionOfflineTestConn) Write(data []byte) (int, error) { return c.write.Write(data) } func (c *companionOfflineTestConn) SetWriteDeadline(time.Time) error { return nil } func TestBuildCurrentDungeonCompanionOfflineBodyMatchesPresentZeroContract(t *testing.T) { body := buildCurrentDungeonCompanionOfflineBody(12345) if len(body) != currentDungeonCompanionProjectionHeaderSize { t.Fatalf("body length=%d want=%d", len(body), currentDungeonCompanionProjectionHeaderSize) } if got := binary.LittleEndian.Uint32(body[0:4]); got != currentDungeonCompanionProjectionMagic { t.Fatalf("magic=%#x want=%#x", got, currentDungeonCompanionProjectionMagic) } if body[4] != currentDungeonCompanionProjectionVersion { t.Fatalf("version=%d want=%d", body[4], currentDungeonCompanionProjectionVersion) } if body[5] != 0 { t.Fatalf("flags=%#x want 0 (present must be clear)", body[5]) } if got := binary.LittleEndian.Uint16(body[6:8]); got != 0 { t.Fatalf("source_character=%d want 0", got) } if got := binary.LittleEndian.Uint64(body[8:16]); got != 12345 { t.Fatalf("revision=%d want 12345", got) } for offset := 16; offset < len(body); offset++ { if body[offset] != 0 { t.Fatalf("byte %d=%#x want 0", offset, body[offset]) } } } func TestSendCurrentDungeonCompanionTeamOfflineIsIdempotent(t *testing.T) { connection := &companionOfflineTestConn{} session := &gameSession{conn: connection, connID: "companion-team-logout"} run := ¤tDungeonCompanionRun{ Ready: true, TeamMode: true, TeamLeaderID: 1, TeamSize: 2, Online: true, Sources: []currentDungeonCompanionSourceRun{ {Ready: true, SourceCharacterID: 2, Body: buildCurrentDungeonCompanionOfflineBody(77)}, {Ready: true, SourceCharacterID: 3, Body: buildCurrentDungeonCompanionOfflineBody(77)}, }, } runtime := &runtimeDungeonState{Companion: run} service := &Service{} service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "dungeon_return_town") if !run.LogoutSent || run.Online { t.Fatalf("run state after logout = sent:%v online:%v", run.LogoutSent, run.Online) } if run.OfflineAt.IsZero() || run.OfflineReason != "dungeon_return_town" { t.Fatalf("offline evidence = at:%v reason:%q", run.OfflineAt, run.OfflineReason) } first := connection.write.Len() if first == 0 { t.Fatal("logout packet was not sent") } // A second boundary must not replay the packet. service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "return_select_character") if connection.write.Len() != first { t.Fatalf("second logout wrote %d extra bytes", connection.write.Len()-first) } stream := connection.write.Bytes() headerSize := service.gameUpperHeaderSize() if len(stream) < headerSize { t.Fatalf("logout stream too short: %d", len(stream)) } total := int(binary.LittleEndian.Uint32(stream[3:7])) if total != len(stream) { t.Fatalf("logout packet total=%d stream=%d", total, len(stream)) } if msgID := binary.LittleEndian.Uint16(stream[1:3]); msgID != currentDungeonCompanionProjectionOpcode { t.Fatalf("msg id=%#x want=%#x", msgID, currentDungeonCompanionProjectionOpcode) } body := stream[headerSize:total] if len(body) != currentDungeonCompanionProjectionHeaderSize || body[5] != 0 { t.Fatalf("logout body length=%d flags=%d", len(body), body[5]) } } func TestMatchTeamAutoFillKeepsProjectionPipelinePerSource(t *testing.T) { // The matcher is pure; this guard pins the cap the bridge passes so a future // change cannot silently form a five-player dungeon party. if dnfcompanion.TeamMaxCompanions != 3 { t.Fatalf("TeamMaxCompanions=%d want 3", dnfcompanion.TeamMaxCompanions) } }