mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
server/migrations/sql/0004_alliance_recruiting.sql
Normal file
1
server/migrations/sql/0004_alliance_recruiting.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.guild_alliances ADD COLUMN recruiting boolean NOT NULL DEFAULT true;
|
||||
Reference in New Issue
Block a user