DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,212 @@
|
||||
package unitedfriend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const MaximumFriends = 100
|
||||
|
||||
// UnknownCharacterID is the current client's op290 "target id not known"
|
||||
// sentinel. The friend window adds by typed name and has no actor to read an
|
||||
// id from, so it fills 0xffff; only a scene target (right-click a nearby
|
||||
// player) carries a real character id. Comparing the sentinel against the
|
||||
// resolved record's id rejected every add made from the friend window.
|
||||
const UnknownCharacterID = uint16(0xffff)
|
||||
|
||||
var (
|
||||
ErrRepositoryUnavailable = errors.New("united friend repository is unavailable")
|
||||
ErrCharacterNotFound = errors.New("united friend character was not found")
|
||||
ErrSelf = errors.New("cannot add self as united friend")
|
||||
ErrFull = errors.New("united friend list is full")
|
||||
ErrRelationNotFound = errors.New("united friend relation was not found")
|
||||
)
|
||||
|
||||
type Owner struct {
|
||||
mu sync.Mutex
|
||||
friends repository.UnitedFriendRepository
|
||||
characters repository.CharacterRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type Friend struct {
|
||||
Character repository.CharacterRecord
|
||||
}
|
||||
|
||||
func NewOwner(group repository.Group, now func() time.Time) (*Owner, error) {
|
||||
if group.UnitedFriend == nil || group.Character == nil {
|
||||
return nil, ErrRepositoryUnavailable
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Owner{friends: group.UnitedFriend, characters: group.Character, now: now}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) List(ctx context.Context, ownerName string) ([]Friend, error) {
|
||||
record, found, err := o.friends.Load(ctx, strings.TrimSpace(ownerName))
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Friend, 0, len(record.FriendNames))
|
||||
for _, name := range record.FriendNames {
|
||||
character, found, err := o.characterByName(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
out = append(out, Friend{Character: character})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Add(ctx context.Context, ownerName string, ownerCharacterID uint16, targetName string, targetCharacterID uint16) (Friend, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
ownerName = strings.TrimSpace(ownerName)
|
||||
targetName = strings.TrimSpace(targetName)
|
||||
target, found, err := o.characterByName(ctx, targetName)
|
||||
if err != nil {
|
||||
return Friend{}, err
|
||||
}
|
||||
if !found {
|
||||
return Friend{}, ErrCharacterNotFound
|
||||
}
|
||||
resolvedID := numericCharacterID(target.CharacterID)
|
||||
if targetCharacterID != 0 && targetCharacterID != UnknownCharacterID && resolvedID != targetCharacterID {
|
||||
return Friend{}, ErrCharacterNotFound
|
||||
}
|
||||
if targetName == ownerName || (ownerCharacterID != 0 && ownerCharacterID == resolvedID) {
|
||||
return Friend{}, ErrSelf
|
||||
}
|
||||
record, found, err := o.friends.Load(ctx, ownerName)
|
||||
if err != nil {
|
||||
return Friend{}, err
|
||||
}
|
||||
if !found {
|
||||
record = repository.UnitedFriendRecord{OwnerName: ownerName}
|
||||
}
|
||||
if slices.Contains(record.FriendNames, targetName) {
|
||||
return Friend{Character: target}, nil
|
||||
}
|
||||
if len(record.FriendNames) >= MaximumFriends {
|
||||
return Friend{}, ErrFull
|
||||
}
|
||||
record.FriendNames = append(record.FriendNames, targetName)
|
||||
if record.FriendSince == nil {
|
||||
record.FriendSince = make(map[string]time.Time, len(record.FriendNames))
|
||||
}
|
||||
// 记录这条关系**建立**的时间:商城赠送要求"互为好友满 7 天"。
|
||||
// 注意不能覆盖已存在的时间(重复 Add 已在上面 return)。
|
||||
now := o.now().UTC()
|
||||
if _, exists := record.FriendSince[targetName]; !exists {
|
||||
record.FriendSince[targetName] = now
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
if err := o.friends.Save(ctx, record); err != nil {
|
||||
return Friend{}, err
|
||||
}
|
||||
return Friend{Character: target}, nil
|
||||
}
|
||||
|
||||
// FriendshipSince 返回 ownerName→friendName 这条关系建立了多久,以及这个时间是否可信。
|
||||
//
|
||||
// trusted=false 表示这条关系早于 friend_since 字段上线(老数据):调用方必须按
|
||||
// "已满足 7 天"处理,否则新规则会追溯封锁所有老好友。
|
||||
func (o *Owner) FriendshipSince(ctx context.Context, ownerName, friendName string) (time.Time, bool, bool, error) {
|
||||
record, found, err := o.friends.Load(ctx, strings.TrimSpace(ownerName))
|
||||
if err != nil || !found {
|
||||
return time.Time{}, false, found, err
|
||||
}
|
||||
if !slices.Contains(record.FriendNames, strings.TrimSpace(friendName)) {
|
||||
return time.Time{}, false, false, nil
|
||||
}
|
||||
at, trusted := repository.UnitedFriendSince(record, friendName)
|
||||
return at, trusted, true, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Remove(ctx context.Context, ownerName, targetName string) error {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
ownerName = strings.TrimSpace(ownerName)
|
||||
targetName = strings.TrimSpace(targetName)
|
||||
record, found, err := o.friends.Load(ctx, ownerName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrRelationNotFound
|
||||
}
|
||||
index := slices.Index(record.FriendNames, targetName)
|
||||
if index < 0 {
|
||||
return ErrRelationNotFound
|
||||
}
|
||||
record.FriendNames = append(record.FriendNames[:index], record.FriendNames[index+1:]...)
|
||||
record.UpdatedAt = o.now().UTC()
|
||||
return o.friends.Save(ctx, record)
|
||||
}
|
||||
|
||||
func (o *Owner) Observers(ctx context.Context, friendName string) ([]string, error) {
|
||||
return o.friends.ListObservers(ctx, strings.TrimSpace(friendName))
|
||||
}
|
||||
|
||||
// DeleteCharacter removes both outgoing and incoming one-way edges and
|
||||
// returns the owners whose visible list must be refreshed.
|
||||
func (o *Owner) DeleteCharacter(ctx context.Context, name string) ([]string, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
name = strings.TrimSpace(name)
|
||||
observers, err := o.friends.ListObservers(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ownerName := range observers {
|
||||
record, found, err := o.friends.Load(ctx, ownerName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
record.FriendNames = slices.DeleteFunc(record.FriendNames, func(value string) bool { return value == name })
|
||||
record.UpdatedAt = o.now().UTC()
|
||||
if err := o.friends.Save(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := o.friends.Save(ctx, repository.UnitedFriendRecord{OwnerName: name, UpdatedAt: o.now().UTC()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return observers, nil
|
||||
}
|
||||
|
||||
func (o *Owner) characterByName(ctx context.Context, name string) (repository.CharacterRecord, bool, error) {
|
||||
id, found, err := o.characters.FindIDByName(ctx, name)
|
||||
if err != nil || !found {
|
||||
return repository.CharacterRecord{}, found, err
|
||||
}
|
||||
record, found, err := o.characters.Load(ctx, id)
|
||||
if err != nil || !found {
|
||||
return repository.CharacterRecord{}, found, err
|
||||
}
|
||||
if record.Stats["delete_flag"] != 0 || record.Name != name {
|
||||
return repository.CharacterRecord{}, false, nil
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func numericCharacterID(value string) uint16 {
|
||||
var id uint64
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &id); err != nil || id > 0xffff {
|
||||
return 0
|
||||
}
|
||||
return uint16(id)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package unitedfriend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerKeepsOneWayCaseSensitiveRelations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
group := memory.NewMemoryGroup()
|
||||
for _, character := range []repository.CharacterRecord{
|
||||
{CharacterID: "1", AccountID: "a", Name: "Alice", Level: 90},
|
||||
{CharacterID: "2", AccountID: "b", Name: "Bob", Level: 80},
|
||||
{CharacterID: "3", AccountID: "c", Name: "bob", Level: 70},
|
||||
} {
|
||||
if err := group.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
owner, err := NewOwner(group, func() time.Time { return time.Unix(10, 0) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.Add(ctx, "Alice", 1, "Bob", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.Add(ctx, "Alice", 1, "bob", 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := owner.List(ctx, "Alice")
|
||||
if err != nil || len(list) != 2 || list[0].Character.Name != "Bob" || list[1].Character.Name != "bob" {
|
||||
t.Fatalf("list=%+v err=%v", list, err)
|
||||
}
|
||||
observers, err := owner.Observers(ctx, "Bob")
|
||||
if err != nil || len(observers) != 1 || observers[0] != "Alice" {
|
||||
t.Fatalf("observers=%v err=%v", observers, err)
|
||||
}
|
||||
if _, err := owner.Add(ctx, "Alice", 1, "Alice", 1); !errors.Is(err, ErrSelf) {
|
||||
t.Fatalf("self error=%v", err)
|
||||
}
|
||||
if err := owner.Remove(ctx, "Alice", "Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ = owner.List(ctx, "Alice")
|
||||
if len(list) != 1 || list[0].Character.Name != "bob" {
|
||||
t.Fatalf("remaining=%+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// The friend window sends the op290 unknown-target sentinel because it adds by
|
||||
// typed name. Requiring the sentinel to equal the resolved character id made
|
||||
// every such add answer "该角色不存在" (op290 failure 0x15).
|
||||
func TestOwnerAddAcceptsUnknownTargetIDSentinel(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
group := memory.NewMemoryGroup()
|
||||
for _, character := range []repository.CharacterRecord{
|
||||
{CharacterID: "1", AccountID: "a", Name: "鬼剑士", Level: 70},
|
||||
{CharacterID: "42", AccountID: "b", Name: "奶爸", Level: 70},
|
||||
} {
|
||||
if err := group.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
owner, err := NewOwner(group, func() time.Time { return time.Unix(10, 0) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
friend, err := owner.Add(ctx, "鬼剑士", 1, "奶爸", UnknownCharacterID)
|
||||
if err != nil || friend.Character.CharacterID != "42" {
|
||||
t.Fatalf("add by name friend=%+v err=%v", friend, err)
|
||||
}
|
||||
// The sentinel must not turn a self add into a relation.
|
||||
if _, err := owner.Add(ctx, "鬼剑士", 1, "鬼剑士", UnknownCharacterID); !errors.Is(err, ErrSelf) {
|
||||
t.Fatalf("self add error=%v", err)
|
||||
}
|
||||
// A scene target still carries a real id, and a mismatched one stays rejected.
|
||||
if _, err := owner.Add(ctx, "鬼剑士", 1, "奶爸", 7); !errors.Is(err, ErrCharacterNotFound) {
|
||||
t.Fatalf("mismatched id error=%v", err)
|
||||
}
|
||||
if _, err := owner.Add(ctx, "鬼剑士", 1, "无此角色", UnknownCharacterID); !errors.Is(err, ErrCharacterNotFound) {
|
||||
t.Fatalf("missing name error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add 必须把"这条关系什么时候建立的"记下来:商城赠送的 7 天门槛只能靠它算。
|
||||
// 而老关系(本字段上线之前建立的)必须报 trusted=false,让调用方放行而不是封锁。
|
||||
func TestOwnerFriendshipSinceReportsTrustedAndLegacyRelations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
group := memory.NewMemoryGroup()
|
||||
for _, character := range []repository.CharacterRecord{
|
||||
{CharacterID: "1", AccountID: "a", Name: "Alice", Level: 90},
|
||||
{CharacterID: "2", AccountID: "b", Name: "Bob", Level: 80},
|
||||
{CharacterID: "3", AccountID: "c", Name: "Carol", Level: 80},
|
||||
} {
|
||||
if err := group.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
moment := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
owner, err := NewOwner(group, func() time.Time { return moment })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.Add(ctx, "Alice", 1, "Bob", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
at, trusted, friends, err := owner.FriendshipSince(ctx, "Alice", "Bob")
|
||||
if err != nil || !friends || !trusted || !at.Equal(moment) {
|
||||
t.Fatalf("fresh relation at=%v trusted=%t friends=%t err=%v", at, trusted, friends, err)
|
||||
}
|
||||
|
||||
// 不是好友:friends=false,调用方必须拒绝赠送。
|
||||
if _, trusted, friends, err := owner.FriendshipSince(ctx, "Alice", "Carol"); err != nil || friends || trusted {
|
||||
t.Fatalf("non-friend trusted=%t friends=%t err=%v", trusted, friends, err)
|
||||
}
|
||||
// 单向关系同样不算好友:Bob 那边没有 Alice。
|
||||
if _, trusted, friends, err := owner.FriendshipSince(ctx, "Bob", "Alice"); err != nil || friends || trusted {
|
||||
t.Fatalf("reverse-only relation trusted=%t friends=%t err=%v", trusted, friends, err)
|
||||
}
|
||||
|
||||
// 老数据:直接落到仓储上,模拟 friend_since 字段上线之前就存在的关系。
|
||||
legacy, found, err := group.UnitedFriend.Load(ctx, "Alice")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load legacy record found=%t err=%v", found, err)
|
||||
}
|
||||
legacy.FriendNames = append(legacy.FriendNames, "Carol")
|
||||
legacy.FriendSince["Carol"] = time.Time{}
|
||||
if err := group.UnitedFriend.Save(ctx, legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at, trusted, friends, err = owner.FriendshipSince(ctx, "Alice", "Carol")
|
||||
if err != nil || !friends || trusted || !at.IsZero() {
|
||||
t.Fatalf("legacy relation at=%v trusted=%t friends=%t err=%v", at, trusted, friends, err)
|
||||
}
|
||||
|
||||
// 删掉再加必须重新计时,不能沿用旧时间戳。
|
||||
if err := owner.Remove(ctx, "Alice", "Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
later := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
|
||||
owner, err = NewOwner(group, func() time.Time { return later })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.Add(ctx, "Alice", 1, "Bob", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at, trusted, friends, err = owner.FriendshipSince(ctx, "Alice", "Bob")
|
||||
if err != nil || !friends || !trusted || !at.Equal(later) {
|
||||
t.Fatalf("re-added relation at=%v trusted=%t friends=%t err=%v", at, trusted, friends, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user