mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 23:54:33 +01:00
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.
355 lines
11 KiB
Go
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
|
|
}
|