fix(guild): implement alliance application toggle (#166)

Alliance applications were hardcoded to always-open. Add a `recruiting`
column to guild_alliances and handle OperateJoint actions 0x06 (Allow)
and 0x07 (Deny) confirmed via Wii U debug symbols. Only the parent
guild leader can toggle the setting, matching the existing disband guard.
This commit is contained in:
Houmgaor
2026-02-27 14:59:18 +01:00
parent fba8c2413c
commit d6938f2a27
9 changed files with 167 additions and 10 deletions

View File

@@ -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) | | `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_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: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: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) | | `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) 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` 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. 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 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 7. **Reverse-engineer MhfAddUdPoint fields** ([#168](https://github.com/Mezeporta/Erupe/issues/168)) — needs packet captures

View File

@@ -14,6 +14,8 @@ type OperateJointAction uint8
const ( const (
OPERATE_JOINT_DISBAND = 0x01 OPERATE_JOINT_DISBAND = 0x01
OPERATE_JOINT_LEAVE = 0x03 OPERATE_JOINT_LEAVE = 0x03
OPERATE_JOINT_ALLOW = 0x06
OPERATE_JOINT_DENY = 0x07
OPERATE_JOINT_KICK = 0x09 OPERATE_JOINT_KICK = 0x09
) )

View File

@@ -15,6 +15,7 @@ type GuildAlliance struct {
Name string `db:"name"` Name string `db:"name"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
TotalMembers uint16 TotalMembers uint16
Recruiting bool `db:"recruiting"`
ParentGuildID uint32 `db:"parent_id"` ParentGuildID uint32 `db:"parent_id"`
SubGuild1ID uint32 `db:"sub1_id"` SubGuild1ID uint32 `db:"sub1_id"`
@@ -75,6 +76,24 @@ func handleMsgMhfOperateJoint(s *Session, p mhfpacket.MHFPacket) {
) )
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4)) 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: case mhfpacket.OPERATE_JOINT_KICK:
if alliance.ParentGuild.LeaderCharID == s.charID { if alliance.ParentGuild.LeaderCharID == s.charID {
kickedGuildID := pkt.Data1.ReadUint32() kickedGuildID := pkt.Data1.ReadUint32()

View File

@@ -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 --- // --- handleMsgMhfInfoJoint tests ---
func TestInfoJoint_Success(t *testing.T) { func TestInfoJoint_Success(t *testing.T) {

View File

@@ -440,7 +440,7 @@ func handleMsgMhfEnumerateGuild(s *Session, p mhfpacket.MHFPacket) {
ps.Uint8(bf, alliance.Name, true) ps.Uint8(bf, alliance.Name, true)
ps.Uint8(bf, alliance.ParentGuild.LeaderName, true) ps.Uint8(bf, alliance.ParentGuild.LeaderName, true)
bf.WriteUint8(0x01) // Unk bf.WriteUint8(0x01) // Unk
bf.WriteBool(true) // TODO: Enable GuildAlliance applications bf.WriteBool(!alliance.Recruiting)
} }
} else { } else {
hasNextPage := false hasNextPage := false

View File

@@ -11,6 +11,7 @@ SELECT
ga.id, ga.id,
ga.name, ga.name,
created_at, created_at,
ga.recruiting,
parent_id, parent_id,
CASE CASE
WHEN sub1_id IS NULL THEN 0 WHEN sub1_id IS NULL THEN 0
@@ -79,6 +80,12 @@ func (r *GuildRepository) RemoveGuildFromAlliance(allianceID, guildID, subGuild1
return err 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. // scanAllianceWithGuilds scans an alliance row and populates its guild data.
func (r *GuildRepository) scanAllianceWithGuilds(rows *sqlx.Rows) (*GuildAlliance, error) { func (r *GuildRepository) scanAllianceWithGuilds(rows *sqlx.Rows) (*GuildAlliance, error) {
alliance := &GuildAlliance{} alliance := &GuildAlliance{}

View File

@@ -87,6 +87,7 @@ type GuildRepo interface {
CreateAlliance(name string, parentGuildID uint32) error CreateAlliance(name string, parentGuildID uint32) error
DeleteAlliance(allianceID uint32) error DeleteAlliance(allianceID uint32) error
RemoveGuildFromAlliance(allianceID, guildID, subGuild1ID, subGuild2ID uint32) error RemoveGuildFromAlliance(allianceID, guildID, subGuild1ID, subGuild2ID uint32) error
SetAllianceRecruiting(allianceID uint32, recruiting bool) error
ListAdventures(guildID uint32) ([]*GuildAdventure, error) ListAdventures(guildID uint32) ([]*GuildAdventure, error)
CreateAdventure(guildID, destination uint32, depart, returnTime int64) error CreateAdventure(guildID, destination uint32, depart, returnTime int64) error
CreateAdventureWithCharge(guildID, destination, charge uint32, depart, returnTime int64) error CreateAdventureWithCharge(guildID, destination, charge uint32, depart, returnTime int64) error

View File

@@ -304,13 +304,15 @@ type mockGuildRepo struct {
deletedPostID uint32 deletedPostID uint32
// Alliance // Alliance
alliance *GuildAlliance alliance *GuildAlliance
getAllianceErr error getAllianceErr error
createAllianceErr error createAllianceErr error
deleteAllianceErr error deleteAllianceErr error
removeAllyErr error removeAllyErr error
deletedAllianceID uint32 setAllianceRecruitErr error
removedAllyArgs []uint32 deletedAllianceID uint32
removedAllyArgs []uint32
allianceRecruitingSet *bool
// Cooking // Cooking
meals []*GuildMeal meals []*GuildMeal
@@ -454,6 +456,11 @@ func (m *mockGuildRepo) DeleteAlliance(id uint32) error {
return m.deleteAllianceErr 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 { func (m *mockGuildRepo) RemoveGuildFromAlliance(allyID, guildID, sub1, sub2 uint32) error {
m.removedAllyArgs = []uint32{allyID, guildID, sub1, sub2} m.removedAllyArgs = []uint32{allyID, guildID, sub1, sub2}
return m.removeAllyErr return m.removeAllyErr

View File

@@ -0,0 +1 @@
ALTER TABLE public.guild_alliances ADD COLUMN recruiting boolean NOT NULL DEFAULT true;