diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 4261d66d1..d15edfb40 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -28,7 +28,7 @@ These TODOs represent features that are visibly broken for players. |----------|-------|--------|---------| | `model_character.go:88,101,113` | `TODO: fix bookshelf data pointer` for G10-ZZ, F4-F5, and S6 versions | Wrong pointer corrupts character save reads for three game versions. Offset analysis shows all three are off by exactly 14810 vs the consistent delta pattern of other fields — but needs validation against actual save data. | [#164](https://github.com/Mezeporta/Erupe/issues/164) | | `handlers_achievement.go:117` | `TODO: Notify on rank increase` — always returns `false` | Achievement rank-up notifications are silently suppressed. Requires understanding what `MhfDisplayedAchievement` (currently an empty handler) sends to track "last displayed" state. | [#165](https://github.com/Mezeporta/Erupe/issues/165) | -| `handlers_guild_info.go:443` | `TODO: Enable GuildAlliance applications` — hardcoded `true` | Guild alliance applications are always open regardless of setting. Needs research into where the toggle originates. | [#166](https://github.com/Mezeporta/Erupe/issues/166) | +| ~~`handlers_guild_info.go:443`~~ | ~~`TODO: Enable GuildAlliance applications` — hardcoded `true`~~ | ~~Guild alliance applications are always open regardless of setting.~~ **Fixed.** Added `recruiting` column to `guild_alliances`, wired `OperateJoint` actions `0x06`/`0x07`, reads from DB. | [#166](https://github.com/Mezeporta/Erupe/issues/166) | | `handlers_session.go:410` | `TODO(Andoryuuta): log key index off-by-one` | Known off-by-one in log key indexing is unresolved | [#167](https://github.com/Mezeporta/Erupe/issues/167) | | `handlers_session.go:551` | `TODO: This case might be <=G2` | Uncertain version detection in switch case | [#167](https://github.com/Mezeporta/Erupe/issues/167) | | `handlers_session.go:714` | `TODO: Retail returned the number of clients in quests` | Player count reported to clients does not match retail behavior | [#167](https://github.com/Mezeporta/Erupe/issues/167) | @@ -92,6 +92,6 @@ Based on remaining impact: 2. **Fix bookshelf data pointer** ([#164](https://github.com/Mezeporta/Erupe/issues/164)) — corrupts saves for three game versions (needs save data validation) 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)) — needs research into where the setting originates +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 7. **Reverse-engineer MhfAddUdPoint fields** ([#168](https://github.com/Mezeporta/Erupe/issues/168)) — needs packet captures diff --git a/network/mhfpacket/msg_mhf_operate_joint.go b/network/mhfpacket/msg_mhf_operate_joint.go index d818ed8b5..b3ac6068c 100644 --- a/network/mhfpacket/msg_mhf_operate_joint.go +++ b/network/mhfpacket/msg_mhf_operate_joint.go @@ -14,6 +14,8 @@ type OperateJointAction uint8 const ( OPERATE_JOINT_DISBAND = 0x01 OPERATE_JOINT_LEAVE = 0x03 + OPERATE_JOINT_ALLOW = 0x06 + OPERATE_JOINT_DENY = 0x07 OPERATE_JOINT_KICK = 0x09 ) diff --git a/server/channelserver/handlers_guild_alliance.go b/server/channelserver/handlers_guild_alliance.go index a30f0330b..53c0db800 100644 --- a/server/channelserver/handlers_guild_alliance.go +++ b/server/channelserver/handlers_guild_alliance.go @@ -15,6 +15,7 @@ type GuildAlliance struct { Name string `db:"name"` CreatedAt time.Time `db:"created_at"` TotalMembers uint16 + Recruiting bool `db:"recruiting"` ParentGuildID uint32 `db:"parent_id"` SubGuild1ID uint32 `db:"sub1_id"` @@ -75,6 +76,24 @@ func handleMsgMhfOperateJoint(s *Session, p mhfpacket.MHFPacket) { ) doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) } + case mhfpacket.OPERATE_JOINT_ALLOW: + if guild.LeaderCharID == s.charID && alliance.ParentGuildID == guild.ID { + if err := s.server.guildRepo.SetAllianceRecruiting(alliance.ID, true); err != nil { + s.logger.Error("Failed to allow alliance applications", zap.Error(err)) + } + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) + } else { + doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) + } + case mhfpacket.OPERATE_JOINT_DENY: + if guild.LeaderCharID == s.charID && alliance.ParentGuildID == guild.ID { + if err := s.server.guildRepo.SetAllianceRecruiting(alliance.ID, false); err != nil { + s.logger.Error("Failed to deny alliance applications", zap.Error(err)) + } + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) + } else { + doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) + } case mhfpacket.OPERATE_JOINT_KICK: if alliance.ParentGuild.LeaderCharID == s.charID { kickedGuildID := pkt.Data1.ReadUint32() diff --git a/server/channelserver/handlers_guild_alliance_test.go b/server/channelserver/handlers_guild_alliance_test.go index 920c39928..0e483d6b8 100644 --- a/server/channelserver/handlers_guild_alliance_test.go +++ b/server/channelserver/handlers_guild_alliance_test.go @@ -238,6 +238,126 @@ func TestOperateJoint_Kick_NotOwner(t *testing.T) { } } +func TestOperateJoint_Allow_AsOwner(t *testing.T) { + server := createMockServer() + guildMock := &mockGuildRepo{ + alliance: &GuildAlliance{ + ID: 5, + ParentGuildID: 10, + }, + } + guildMock.guild = &Guild{ID: 10} + guildMock.guild.LeaderCharID = 1 + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfOperateJoint{ + AckHandle: 100, + AllianceID: 5, + GuildID: 10, + Action: mhfpacket.OPERATE_JOINT_ALLOW, + } + + handleMsgMhfOperateJoint(session, pkt) + + if guildMock.allianceRecruitingSet == nil || !*guildMock.allianceRecruitingSet { + t.Error("SetAllianceRecruiting should be called with true") + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestOperateJoint_Allow_NotOwner(t *testing.T) { + server := createMockServer() + guildMock := &mockGuildRepo{ + alliance: &GuildAlliance{ + ID: 5, + ParentGuildID: 99, // different guild + }, + } + guildMock.guild = &Guild{ID: 10} + guildMock.guild.LeaderCharID = 1 + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfOperateJoint{ + AckHandle: 100, + AllianceID: 5, + GuildID: 10, + Action: mhfpacket.OPERATE_JOINT_ALLOW, + } + + handleMsgMhfOperateJoint(session, pkt) + + if guildMock.allianceRecruitingSet != nil { + t.Error("Non-owner should not toggle alliance recruiting") + } +} + +func TestOperateJoint_Deny_AsOwner(t *testing.T) { + server := createMockServer() + guildMock := &mockGuildRepo{ + alliance: &GuildAlliance{ + ID: 5, + ParentGuildID: 10, + }, + } + guildMock.guild = &Guild{ID: 10} + guildMock.guild.LeaderCharID = 1 + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfOperateJoint{ + AckHandle: 100, + AllianceID: 5, + GuildID: 10, + Action: mhfpacket.OPERATE_JOINT_DENY, + } + + handleMsgMhfOperateJoint(session, pkt) + + if guildMock.allianceRecruitingSet == nil || *guildMock.allianceRecruitingSet { + t.Error("SetAllianceRecruiting should be called with false") + } + + select { + case <-session.sendPackets: + default: + t.Error("No response packet queued") + } +} + +func TestOperateJoint_Deny_NotOwner(t *testing.T) { + server := createMockServer() + guildMock := &mockGuildRepo{ + alliance: &GuildAlliance{ + ID: 5, + ParentGuildID: 99, + }, + } + guildMock.guild = &Guild{ID: 10} + guildMock.guild.LeaderCharID = 1 + server.guildRepo = guildMock + session := createMockSession(1, server) + + pkt := &mhfpacket.MsgMhfOperateJoint{ + AckHandle: 100, + AllianceID: 5, + GuildID: 10, + Action: mhfpacket.OPERATE_JOINT_DENY, + } + + handleMsgMhfOperateJoint(session, pkt) + + if guildMock.allianceRecruitingSet != nil { + t.Error("Non-owner should not toggle alliance recruiting") + } +} + // --- handleMsgMhfInfoJoint tests --- func TestInfoJoint_Success(t *testing.T) { diff --git a/server/channelserver/handlers_guild_info.go b/server/channelserver/handlers_guild_info.go index 01d5ef424..2153a2d08 100644 --- a/server/channelserver/handlers_guild_info.go +++ b/server/channelserver/handlers_guild_info.go @@ -440,7 +440,7 @@ func handleMsgMhfEnumerateGuild(s *Session, p mhfpacket.MHFPacket) { ps.Uint8(bf, alliance.Name, true) ps.Uint8(bf, alliance.ParentGuild.LeaderName, true) bf.WriteUint8(0x01) // Unk - bf.WriteBool(true) // TODO: Enable GuildAlliance applications + bf.WriteBool(!alliance.Recruiting) } } else { hasNextPage := false diff --git a/server/channelserver/repo_guild_alliance.go b/server/channelserver/repo_guild_alliance.go index 608356ae7..744394a07 100644 --- a/server/channelserver/repo_guild_alliance.go +++ b/server/channelserver/repo_guild_alliance.go @@ -11,6 +11,7 @@ SELECT ga.id, ga.name, created_at, +ga.recruiting, parent_id, CASE WHEN sub1_id IS NULL THEN 0 @@ -79,6 +80,12 @@ func (r *GuildRepository) RemoveGuildFromAlliance(allianceID, guildID, subGuild1 return err } +// SetAllianceRecruiting updates whether an alliance is accepting applications. +func (r *GuildRepository) SetAllianceRecruiting(allianceID uint32, recruiting bool) error { + _, err := r.db.Exec("UPDATE guild_alliances SET recruiting=$1 WHERE id=$2", recruiting, allianceID) + return err +} + // scanAllianceWithGuilds scans an alliance row and populates its guild data. func (r *GuildRepository) scanAllianceWithGuilds(rows *sqlx.Rows) (*GuildAlliance, error) { alliance := &GuildAlliance{} diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index b04cb824d..d0aee6fe1 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -87,6 +87,7 @@ type GuildRepo interface { CreateAlliance(name string, parentGuildID uint32) error DeleteAlliance(allianceID uint32) error RemoveGuildFromAlliance(allianceID, guildID, subGuild1ID, subGuild2ID uint32) error + SetAllianceRecruiting(allianceID uint32, recruiting bool) error ListAdventures(guildID uint32) ([]*GuildAdventure, error) CreateAdventure(guildID, destination uint32, depart, returnTime int64) error CreateAdventureWithCharge(guildID, destination, charge uint32, depart, returnTime int64) error diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 58f5cc568..fdd262e4a 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -304,13 +304,15 @@ type mockGuildRepo struct { deletedPostID uint32 // Alliance - alliance *GuildAlliance - getAllianceErr error - createAllianceErr error - deleteAllianceErr error - removeAllyErr error - deletedAllianceID uint32 - removedAllyArgs []uint32 + alliance *GuildAlliance + getAllianceErr error + createAllianceErr error + deleteAllianceErr error + removeAllyErr error + setAllianceRecruitErr error + deletedAllianceID uint32 + removedAllyArgs []uint32 + allianceRecruitingSet *bool // Cooking meals []*GuildMeal @@ -454,6 +456,11 @@ func (m *mockGuildRepo) DeleteAlliance(id uint32) error { return m.deleteAllianceErr } +func (m *mockGuildRepo) SetAllianceRecruiting(_ uint32, recruiting bool) error { + m.allianceRecruitingSet = &recruiting + return m.setAllianceRecruitErr +} + func (m *mockGuildRepo) RemoveGuildFromAlliance(allyID, guildID, sub1, sub2 uint32) error { m.removedAllyArgs = []uint32{allyID, guildID, sub1, sub2} return m.removeAllyErr diff --git a/server/migrations/sql/0004_alliance_recruiting.sql b/server/migrations/sql/0004_alliance_recruiting.sql new file mode 100644 index 000000000..47c2a40a2 --- /dev/null +++ b/server/migrations/sql/0004_alliance_recruiting.sql @@ -0,0 +1 @@ +ALTER TABLE public.guild_alliances ADD COLUMN recruiting boolean NOT NULL DEFAULT true;