Files
Erupe/server/channelserver/svc_guild.go
Houmgaor dbbfb927f8 feat(guild): separate scout invitations into guild_invites table
Scout invitations were stored in guild_applications with type 'invited',
forcing the scout list response to use charID as the invitation ID — a
known hack that made CancelGuildScout semantically incorrect.

Introduce a dedicated guild_invites table (migration 0012) with a serial
PK. The scout list now returns real invite IDs and actual InvitedAt
timestamps. CancelGuildScout cancels by PK. AcceptInvite and DeclineInvite
operate on guild_invites while player-applied applications remain in
guild_applications unchanged.
2026-03-21 17:59:25 +01:00

355 lines
11 KiB
Go

package channelserver
import (
"errors"
"fmt"
"sort"
"go.uber.org/zap"
)
// GuildMemberAction is a domain enum for guild member operations.
type GuildMemberAction uint8
const (
GuildMemberActionAccept GuildMemberAction = iota + 1
GuildMemberActionReject
GuildMemberActionKick
)
// ErrUnauthorized is returned when the actor lacks permission for the operation.
var ErrUnauthorized = errors.New("unauthorized")
// ErrUnknownAction is returned for unrecognized guild member actions.
var ErrUnknownAction = errors.New("unknown guild member action")
// ErrNoEligibleLeader is returned when no member can accept leadership.
var ErrNoEligibleLeader = errors.New("no eligible leader")
// ErrAlreadyInvited is returned when a scout target already has a pending application.
var ErrAlreadyInvited = errors.New("already invited")
// ErrCannotRecruit is returned when the actor lacks recruit permission.
var ErrCannotRecruit = errors.New("cannot recruit")
// ErrApplicationMissing is returned when the expected guild application is not found.
var ErrApplicationMissing = errors.New("application missing")
// OperateMemberResult holds the outcome of a guild member operation.
type OperateMemberResult struct {
MailRecipientID uint32
Mail Mail
}
// DisbandResult holds the outcome of a guild disband operation.
type DisbandResult struct {
Success bool
}
// ResignResult holds the outcome of a leadership resignation.
type ResignResult struct {
NewLeaderCharID uint32
}
// LeaveResult holds the outcome of a guild leave operation.
type LeaveResult struct {
Success bool
}
// ScoutInviteStrings holds i18n strings needed for scout invitation mails.
type ScoutInviteStrings struct {
Title string
Body string // must contain %s for guild name
}
// AnswerScoutStrings holds i18n strings needed for scout answer mails.
type AnswerScoutStrings struct {
SuccessTitle string
SuccessBody string // %s for guild name
AcceptedTitle string
AcceptedBody string // %s for guild name
RejectedTitle string
RejectedBody string // %s for guild name
DeclinedTitle string
DeclinedBody string // %s for guild name
}
// AnswerScoutResult holds the outcome of answering a guild scout invitation.
type AnswerScoutResult struct {
GuildID uint32
Success bool
Mails []Mail
}
// GuildService encapsulates guild business logic, sitting between handlers and repos.
type GuildService struct {
guildRepo GuildRepo
mailSvc *MailService
charRepo CharacterRepo
logger *zap.Logger
}
// NewGuildService creates a new GuildService.
func NewGuildService(gr GuildRepo, ms *MailService, cr CharacterRepo, log *zap.Logger) *GuildService {
return &GuildService{
guildRepo: gr,
mailSvc: ms,
charRepo: cr,
logger: log,
}
}
// OperateMember performs a guild member management action (accept/reject/kick).
// The actor must be the guild leader or a sub-leader. On success, a notification
// mail is sent (best-effort) and the result is returned for protocol-level notification.
func (svc *GuildService) OperateMember(actorCharID, targetCharID uint32, action GuildMemberAction) (*OperateMemberResult, error) {
guild, err := svc.guildRepo.GetByCharID(targetCharID)
if err != nil || guild == nil {
return nil, fmt.Errorf("guild lookup for char %d: %w", targetCharID, err)
}
actorMember, err := svc.guildRepo.GetCharacterMembership(actorCharID)
if err != nil || (!actorMember.IsSubLeader() && guild.LeaderCharID != actorCharID) {
return nil, ErrUnauthorized
}
var mail Mail
switch action {
case GuildMemberActionAccept:
err = svc.guildRepo.AcceptApplication(guild.ID, targetCharID)
mail = Mail{
RecipientID: targetCharID,
Subject: "Accepted!",
Body: fmt.Sprintf("Your application to join 「%s」 was accepted.", guild.Name),
IsSystemMessage: true,
}
case GuildMemberActionReject:
err = svc.guildRepo.RejectApplication(guild.ID, targetCharID)
mail = Mail{
RecipientID: targetCharID,
Subject: "Rejected",
Body: fmt.Sprintf("Your application to join 「%s」 was rejected.", guild.Name),
IsSystemMessage: true,
}
case GuildMemberActionKick:
err = svc.guildRepo.RemoveCharacter(targetCharID)
mail = Mail{
RecipientID: targetCharID,
Subject: "Kicked",
Body: fmt.Sprintf("You were kicked from 「%s」.", guild.Name),
IsSystemMessage: true,
}
default:
return nil, ErrUnknownAction
}
if err != nil {
return nil, fmt.Errorf("guild member action %d: %w", action, err)
}
// Send mail best-effort
if mailErr := svc.mailSvc.SendSystem(mail.RecipientID, mail.Subject, mail.Body); mailErr != nil {
svc.logger.Warn("Failed to send guild member operation mail", zap.Error(mailErr))
}
return &OperateMemberResult{
MailRecipientID: targetCharID,
Mail: mail,
}, nil
}
// Disband disbands a guild. Only the guild leader may disband.
func (svc *GuildService) Disband(actorCharID, guildID uint32) (*DisbandResult, error) {
guild, err := svc.guildRepo.GetByID(guildID)
if err != nil {
return nil, fmt.Errorf("guild lookup: %w", err)
}
if guild.LeaderCharID != actorCharID {
svc.logger.Warn("Unauthorized guild disband attempt",
zap.Uint32("charID", actorCharID), zap.Uint32("guildID", guildID))
return &DisbandResult{Success: false}, nil
}
if err := svc.guildRepo.Disband(guildID); err != nil {
return &DisbandResult{Success: false}, nil
}
return &DisbandResult{Success: true}, nil
}
// ResignLeadership transfers guild leadership to the next eligible member.
// Members are sorted by order index; those with AvoidLeadership set are skipped.
func (svc *GuildService) ResignLeadership(actorCharID, guildID uint32) (*ResignResult, error) {
guild, err := svc.guildRepo.GetByID(guildID)
if err != nil {
return nil, fmt.Errorf("guild lookup: %w", err)
}
members, err := svc.guildRepo.GetMembers(guildID, false)
if err != nil {
return nil, fmt.Errorf("get members: %w", err)
}
sort.Slice(members, func(i, j int) bool {
return members[i].OrderIndex < members[j].OrderIndex
})
// Find current leader in sorted list (should be index 0)
var leaderIdx int
for i, m := range members {
if m.CharID == actorCharID {
leaderIdx = i
break
}
}
// Find first eligible successor (skip leader and anyone avoiding leadership)
var newLeaderIdx int
found := false
for i := 1; i < len(members); i++ {
if i == leaderIdx {
continue
}
if !members[i].AvoidLeadership {
newLeaderIdx = i
found = true
break
}
}
if !found {
return &ResignResult{NewLeaderCharID: 0}, nil
}
// Swap order indices
guild.LeaderCharID = members[newLeaderIdx].CharID
members[leaderIdx].OrderIndex, members[newLeaderIdx].OrderIndex =
members[newLeaderIdx].OrderIndex, 1
if err := svc.guildRepo.SaveMember(members[leaderIdx]); err != nil {
svc.logger.Error("Failed to save former leader member data", zap.Error(err))
}
if err := svc.guildRepo.SaveMember(members[newLeaderIdx]); err != nil {
svc.logger.Error("Failed to save new leader member data", zap.Error(err))
}
if err := svc.guildRepo.Save(guild); err != nil {
svc.logger.Error("Failed to save guild after leadership resign", zap.Error(err))
}
return &ResignResult{NewLeaderCharID: members[newLeaderIdx].CharID}, nil
}
// Leave removes a character from their guild. If the character is an applicant,
// their application is rejected; otherwise they are removed as a member.
// A withdrawal notification mail is sent on success.
func (svc *GuildService) Leave(charID, guildID uint32, isApplicant bool, guildName string) (*LeaveResult, error) {
if isApplicant {
if err := svc.guildRepo.RejectApplication(guildID, charID); err != nil {
return &LeaveResult{Success: false}, nil
}
} else {
if err := svc.guildRepo.RemoveCharacter(charID); err != nil {
return &LeaveResult{Success: false}, nil
}
}
// Best-effort withdrawal notification
if err := svc.mailSvc.SendSystem(charID, "Withdrawal",
fmt.Sprintf("You have withdrawn from 「%s」.", guildName)); err != nil {
svc.logger.Warn("Failed to send guild withdrawal notification", zap.Error(err))
}
return &LeaveResult{Success: true}, nil
}
// PostScout sends a guild scout invitation to a target character.
// The actor must have recruit permission. Returns ErrAlreadyInvited if the target
// already has a pending application.
func (svc *GuildService) PostScout(actorCharID, targetCharID uint32, strings ScoutInviteStrings) error {
actorMember, err := svc.guildRepo.GetCharacterMembership(actorCharID)
if err != nil {
return fmt.Errorf("actor membership lookup: %w", err)
}
if actorMember == nil || !actorMember.CanRecruit() {
return ErrCannotRecruit
}
guild, err := svc.guildRepo.GetByID(actorMember.GuildID)
if err != nil {
return fmt.Errorf("guild lookup: %w", err)
}
hasInvite, err := svc.guildRepo.HasInvite(guild.ID, targetCharID)
if err != nil {
return fmt.Errorf("check invite: %w", err)
}
if hasInvite {
return ErrAlreadyInvited
}
err = svc.guildRepo.CreateInviteWithMail(
guild.ID, targetCharID, actorCharID,
actorCharID, targetCharID,
strings.Title,
fmt.Sprintf(strings.Body, guild.Name))
if err != nil {
return fmt.Errorf("create scout application: %w", err)
}
return nil
}
// AnswerScout processes a character's response to a guild scout invitation.
// If accept is true, the character joins the guild; otherwise the invitation is rejected.
// Notification mails are sent to both the character and the leader.
func (svc *GuildService) AnswerScout(charID, leaderID uint32, accept bool, strings AnswerScoutStrings) (*AnswerScoutResult, error) {
guild, err := svc.guildRepo.GetByCharID(leaderID)
if err != nil {
return nil, fmt.Errorf("guild lookup for leader %d: %w", leaderID, err)
}
hasInvite, err := svc.guildRepo.HasInvite(guild.ID, charID)
if err != nil || !hasInvite {
return &AnswerScoutResult{
GuildID: guild.ID,
Success: false,
}, ErrApplicationMissing
}
var mails []Mail
if accept {
err = svc.guildRepo.AcceptInvite(guild.ID, charID)
mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.SuccessTitle, Body: fmt.Sprintf(strings.SuccessBody, guild.Name), IsSystemMessage: true},
{SenderID: charID, RecipientID: leaderID, Subject: strings.AcceptedTitle, Body: fmt.Sprintf(strings.AcceptedBody, guild.Name), IsSystemMessage: true},
}
} else {
err = svc.guildRepo.DeclineInvite(guild.ID, charID)
mails = []Mail{
{SenderID: 0, RecipientID: charID, Subject: strings.RejectedTitle, Body: fmt.Sprintf(strings.RejectedBody, guild.Name), IsSystemMessage: true},
{SenderID: charID, RecipientID: leaderID, Subject: strings.DeclinedTitle, Body: fmt.Sprintf(strings.DeclinedBody, guild.Name), IsSystemMessage: true},
}
}
if err != nil {
return &AnswerScoutResult{
GuildID: guild.ID,
Success: false,
}, nil
}
// Send mails best-effort
for _, m := range mails {
if mailErr := svc.mailSvc.SendSystem(m.RecipientID, m.Subject, m.Body); mailErr != nil {
svc.logger.Warn("Failed to send guild scout response mail", zap.Error(mailErr))
}
}
return &AnswerScoutResult{
GuildID: guild.ID,
Success: true,
Mails: mails,
}, nil
}