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

@@ -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()

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 ---
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.ParentGuild.LeaderName, true)
bf.WriteUint8(0x01) // Unk
bf.WriteBool(true) // TODO: Enable GuildAlliance applications
bf.WriteBool(!alliance.Recruiting)
}
} else {
hasNextPage := false

View File

@@ -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{}

View File

@@ -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

View File

@@ -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