feat(guild): implement rookie and return guild assignment

New/returning players are now auto-assigned to temporary holding guilds
on MSG_MHF_ENTRY_ROOKIE_GUILD (pkt.Unk=0 → rookie guild, ≥1 → comeback
guild). Guilds are created on demand and capped at 60 members. Players
leave via the OperateGuildGraduateRookie/Return actions. The guild info
response now reports isReturnGuild from the DB instead of hardcoded false.
Migration 0014_return_guilds adds return_type to the guilds table.
This commit is contained in:
Houmgaor
2026-03-22 00:27:05 +01:00
parent 5fe1b22550
commit 5ee9a0e635
11 changed files with 150 additions and 20 deletions

View File

@@ -43,7 +43,8 @@ type Guild struct {
EventRP uint32 `db:"event_rp"`
RoomRP uint16 `db:"room_rp"`
RoomExpiry time.Time `db:"room_expiry"`
Comment string `db:"comment"`
Comment string `db:"comment"`
ReturnType uint8 `db:"return_type"`
PugiName1 string `db:"pugi_name_1"`
PugiName2 string `db:"pugi_name_2"`
PugiName3 string `db:"pugi_name_3"`

View File

@@ -372,7 +372,39 @@ func handleMsgMhfReadGuildcard(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfEntryRookieGuild(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfEntryRookieGuild)
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
// pkt.Unk==0: fresh rookie entering a rookie guild (return_type=1).
// pkt.Unk>=1: returning player entering a comeback/return guild (return_type=2).
returnType := uint8(1)
nameTemplate := s.server.i18n.guild.rookieGuildName
if pkt.Unk >= 1 {
returnType = 2
nameTemplate = s.server.i18n.guild.returnGuildName
}
guildID, err := s.server.guildRepo.FindOrCreateReturnGuild(returnType, nameTemplate)
if err != nil {
s.logger.Error("failed to find/create return guild",
zap.Uint32("charID", s.charID),
zap.Error(err),
)
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
if err := s.server.guildRepo.AddMember(guildID, s.charID); err != nil {
s.logger.Error("failed to add character to return guild",
zap.Uint32("charID", s.charID),
zap.Uint32("guildID", guildID),
zap.Error(err),
)
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf := byteframe.NewByteFrame()
bf.WriteUint32(guildID)
doAckSimpleSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfUpdateForceGuildRank(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented

View File

@@ -98,9 +98,9 @@ func handleMsgMhfInfoGuild(s *Session, p mhfpacket.MHFPacket) {
bf.WriteInt8(int8(FestivalColorCodes[guild.FestivalColor]))
bf.WriteUint32(guild.RankRP)
bf.WriteBytes(guildLeaderName)
bf.WriteUint32(0) // Unk
bf.WriteBool(false) // isReturnGuild
bf.WriteBool(false) // earnedSpecialHall
bf.WriteUint32(0) // Unk
bf.WriteBool(guild.ReturnType > 0) // isReturnGuild
bf.WriteBool(false) // earnedSpecialHall
bf.WriteUint8(2)
bf.WriteUint8(2)
bf.WriteUint32(guild.EventRP) // Skipped if last byte is <2?

View File

@@ -125,6 +125,13 @@ func handleMsgMhfOperateGuild(s *Session, p mhfpacket.MHFPacket) {
s.logger.Error("Failed to exchange guild event RP", zap.Error(err))
}
bf.WriteUint32(balance)
case mhfpacket.OperateGuildGraduateRookie, mhfpacket.OperateGuildGraduateReturn:
// Player graduates (leaves) a temporary return/rookie guild.
// No extra packet data — just remove and succeed.
isApplicant := characterGuildInfo != nil && characterGuildInfo.IsApplicant
if _, err := s.server.guildService.Leave(s.charID, guild.ID, isApplicant, guild.Name); err != nil {
s.logger.Error("Failed to graduate from return guild", zap.Error(err))
}
default:
s.logger.Error("unhandled operate guild action", zap.Uint8("action", uint8(pkt.Action)))
}

View File

@@ -923,23 +923,36 @@ func TestCheckMonthlyItem_UnknownType(t *testing.T) {
}
func TestHandleMsgMhfEntryRookieGuild(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfEntryRookieGuild{
AckHandle: 12345,
Unk: 42,
tests := []struct {
name string
unk uint32
}{
{"rookie (Unk=0)", 0},
{"comeback (Unk=1)", 1},
{"comeback with hr (Unk=2)", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := createMockServer()
server.guildRepo = &mockGuildRepo{}
session := createMockSession(1, server)
handleMsgMhfEntryRookieGuild(session, pkt)
pkt := &mhfpacket.MsgMhfEntryRookieGuild{
AckHandle: 12345,
Unk: tt.unk,
}
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("Response packet should have data")
}
default:
t.Error("No response packet queued")
handleMsgMhfEntryRookieGuild(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("Response packet should have data")
}
default:
t.Error("No response packet queued")
}
})
}
}

View File

@@ -35,6 +35,7 @@ SELECT
leader_id,
c.name AS leader_name,
comment,
return_type,
COALESCE(pugi_name_1, '') AS pugi_name_1,
COALESCE(pugi_name_2, '') AS pugi_name_2,
COALESCE(pugi_name_3, '') AS pugi_name_3,
@@ -196,6 +197,62 @@ func (r *GuildRepository) Create(leaderCharID uint32, guildName string) (int32,
return guildID, nil
}
// FindOrCreateReturnGuild finds an existing return guild of the given type with fewer
// than 60 members, or creates a new one. The name template receives the guild count+1
// as its single %d argument. Returns the guild ID.
func (r *GuildRepository) FindOrCreateReturnGuild(returnType uint8, nameTemplate string) (uint32, error) {
var guildID uint32
err := r.db.QueryRow(`
SELECT g.id FROM guilds g
WHERE g.return_type = $1
AND (SELECT COUNT(1) FROM guild_characters gc WHERE gc.guild_id = g.id) < 60
LIMIT 1
`, returnType).Scan(&guildID)
if err == nil {
return guildID, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, err
}
// No suitable guild — count existing ones and create a new one.
var count int
if err := r.db.QueryRow(
`SELECT COUNT(1) FROM guilds WHERE return_type = $1`, returnType,
).Scan(&count); err != nil {
return 0, err
}
tx, err := r.db.BeginTxx(context.Background(), nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
name := fmt.Sprintf(nameTemplate, count+1)
if err := tx.QueryRow(
`INSERT INTO guilds (name, leader_id, return_type, rank_rp) VALUES ($1, 0, $2, 1200) RETURNING id`,
name, returnType,
).Scan(&guildID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return guildID, nil
}
// AddMember inserts a character into a guild's member list.
func (r *GuildRepository) AddMember(guildID, charID uint32) error {
_, err := r.db.Exec(`
INSERT INTO guild_characters (guild_id, character_id, order_index)
VALUES ($1, $2, (SELECT COALESCE(MAX(order_index), 0) + 1 FROM guild_characters WHERE guild_id = $1))
ON CONFLICT (guild_id, character_id) DO NOTHING
`, guildID, charID)
return err
}
// Save persists guild metadata changes.
func (r *GuildRepository) Save(guild *Guild) error {
_, err := r.db.Exec(`

View File

@@ -126,6 +126,8 @@ type GuildRepo interface {
ListInvites(guildID uint32) ([]*GuildInvite, error)
RolloverDailyRP(guildID uint32, noon time.Time) error
AddWeeklyBonusUsers(guildID uint32, numUsers uint8) error
FindOrCreateReturnGuild(returnType uint8, nameTemplate string) (uint32, error)
AddMember(guildID, charID uint32) error
}
// UserRepo defines the contract for user account data access.

View File

@@ -613,6 +613,10 @@ func (m *mockGuildRepo) InsertKillLog(_ uint32, _ int, _ uint8, _ time.Time) err
func (m *mockGuildRepo) ListInvites(_ uint32) ([]*GuildInvite, error) { return nil, nil }
func (m *mockGuildRepo) RolloverDailyRP(_ uint32, _ time.Time) error { return nil }
func (m *mockGuildRepo) AddWeeklyBonusUsers(_ uint32, _ uint8) error { return nil }
func (m *mockGuildRepo) FindOrCreateReturnGuild(_ uint8, _ string) (uint32, error) {
return 1, nil
}
func (m *mockGuildRepo) AddMember(_, _ uint32) error { return nil }
// --- mockUserRepoForItems ---

View File

@@ -108,7 +108,9 @@ type i18n struct {
berserkSmall string
}
guild struct {
invite struct {
rookieGuildName string
returnGuildName string
invite struct {
title string
body string
success struct {
@@ -183,6 +185,9 @@ func getLangStrings(s *Server) i18n {
i.raviente.extremeLimited = "<大討伐:猛狂期【極】(制限付)>が開催されました!"
i.raviente.berserkSmall = "<大討伐:猛狂期(小数)>が開催されました!"
i.guild.rookieGuildName = "新米猟団%d"
i.guild.returnGuildName = "復帰猟団%d"
i.guild.invite.title = "猟団勧誘のご案内"
i.guild.invite.body = "猟団「%s」からの勧誘通知です。\n「勧誘に返答」より、返答を行ってください。"
@@ -272,6 +277,9 @@ func getLangStrings(s *Server) i18n {
i.raviente.extremeLimited = "<Great Slaying: Extreme (Limited)> is being held!"
i.raviente.berserkSmall = "<Great Slaying: Berserk (Small)> is being held!"
i.guild.rookieGuildName = "Rookie Clan %d"
i.guild.returnGuildName = "Return Clan %d"
i.guild.invite.title = "Invitation!"
i.guild.invite.body = "You have been invited to join\n「%s」\nDo you want to accept?"