diff --git a/CHANGELOG.md b/CHANGELOG.md index 698d9ce2a..d945bb6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Return/Rookie Guild system: new players are automatically placed in a temporary rookie guild (`return_type=1`) and returning players in a comeback guild (`return_type=2`) via `MSG_MHF_ENTRY_ROOKIE_GUILD`. Players graduate (leave) via `OperateGuildGraduateRookie`/`OperateGuildGraduateReturn`. Guild info response now reports `isReturnGuild` correctly. Database migration `0014_return_guilds` adds `return_type` to the `guilds` table. - `saveutil` admin CLI (`cmd/saveutil/`): `import`, `export`, `grant-import`, and `revoke-import` commands for transferring character save data between server instances without touching the database manually. - `POST /v2/characters/{id}/import` API endpoint: player-facing save import gated behind a one-time admin-granted token (generated by `saveutil grant-import`). Token expires after a configurable TTL (default 24 h). - Database migration `0013_save_transfer`: adds `savedata_import_token` and `savedata_import_token_expiry` columns to the `characters` table. diff --git a/server/channelserver/guild_model.go b/server/channelserver/guild_model.go index d6bbab2ea..f86966d21 100644 --- a/server/channelserver/guild_model.go +++ b/server/channelserver/guild_model.go @@ -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"` diff --git a/server/channelserver/handlers_guild.go b/server/channelserver/handlers_guild.go index 11b060d41..0e5b5d132 100644 --- a/server/channelserver/handlers_guild.go +++ b/server/channelserver/handlers_guild.go @@ -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 diff --git a/server/channelserver/handlers_guild_info.go b/server/channelserver/handlers_guild_info.go index dc7dc8182..4542e00f9 100644 --- a/server/channelserver/handlers_guild_info.go +++ b/server/channelserver/handlers_guild_info.go @@ -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? diff --git a/server/channelserver/handlers_guild_ops.go b/server/channelserver/handlers_guild_ops.go index 4fa1ce6ed..faff03246 100644 --- a/server/channelserver/handlers_guild_ops.go +++ b/server/channelserver/handlers_guild_ops.go @@ -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))) } diff --git a/server/channelserver/handlers_guild_test.go b/server/channelserver/handlers_guild_test.go index 073a4ddc0..4b16ceacd 100644 --- a/server/channelserver/handlers_guild_test.go +++ b/server/channelserver/handlers_guild_test.go @@ -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") + } + }) } } diff --git a/server/channelserver/repo_guild.go b/server/channelserver/repo_guild.go index ae17645f9..09d30d85e 100644 --- a/server/channelserver/repo_guild.go +++ b/server/channelserver/repo_guild.go @@ -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(` diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 4d5595673..827fe5787 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -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. diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 9e03a67c8..cee03a997 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -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 --- diff --git a/server/channelserver/sys_language.go b/server/channelserver/sys_language.go index c51c850ba..50b636909 100644 --- a/server/channelserver/sys_language.go +++ b/server/channelserver/sys_language.go @@ -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 = " is being held!" i.raviente.berserkSmall = " 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?" diff --git a/server/migrations/sql/0014_return_guilds.sql b/server/migrations/sql/0014_return_guilds.sql new file mode 100644 index 000000000..417958e96 --- /dev/null +++ b/server/migrations/sql/0014_return_guilds.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE public.guilds ADD COLUMN IF NOT EXISTS return_type SMALLINT NOT NULL DEFAULT 0; + +COMMIT;