mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-23 16:13:04 +01:00
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:
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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?"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user