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,189 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCargoItem = errors.New("guild cargo item or count mismatch")
|
||||
ErrCargoFull = errors.New("guild cargo destination is full")
|
||||
)
|
||||
|
||||
type CargoHistory struct {
|
||||
Name string `json:"name"`
|
||||
Action byte `json:"action"`
|
||||
Item dnfrepo.ItemStack `json:"item"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CargoRule is resolved from the active PVF and the persisted source item.
|
||||
type CargoRule struct {
|
||||
ItemID uint32
|
||||
Equipment bool
|
||||
Allowed bool
|
||||
StackLimit int64
|
||||
SlotStart, SlotEnd int16
|
||||
}
|
||||
|
||||
func (g *Guild) TransferCargo(characterID string, inventory *dnfrepo.InventoryRecord, deposit bool, bagSlot, cargoSlot int16, itemID uint32, count uint32, rule CargoRule, now time.Time) (int16, error) {
|
||||
member, found := g.Members[characterID]
|
||||
if !found {
|
||||
return 0, ErrNotMember
|
||||
}
|
||||
if inventory == nil || inventory.CharacterID != characterID || !g.validCargoSlot(cargoSlot) || count == 0 || count > math.MaxInt32 {
|
||||
return 0, ErrCargoItem
|
||||
}
|
||||
if !rule.Allowed {
|
||||
return 0, ErrPermissionDenied
|
||||
}
|
||||
if rule.ItemID != itemID || rule.SlotStart < 3 || rule.SlotEnd < rule.SlotStart || rule.StackLimit < 0 {
|
||||
return 0, ErrCargoItem
|
||||
}
|
||||
source := g.Cargo[cargoSlot]
|
||||
if deposit {
|
||||
source = inventory.Slots[cargoBagKey(bagSlot)]
|
||||
if bagSlot < rule.SlotStart || bagSlot > rule.SlotEnd {
|
||||
// Native main-inventory consumable quick slots are3..8.
|
||||
if rule.Equipment || bagSlot < 3 || bagSlot > 8 {
|
||||
return 0, ErrCargoItem
|
||||
}
|
||||
}
|
||||
}
|
||||
amount := int64(count)
|
||||
if source.ItemID != int64(itemID) || source.Count < amount || source.Count > math.MaxInt32 || (rule.Equipment && (source.Count != 1 || count != 1)) {
|
||||
return 0, ErrCargoItem
|
||||
}
|
||||
limit := rule.StackLimit
|
||||
if limit == 0 || limit > math.MaxInt32 {
|
||||
limit = math.MaxInt32
|
||||
}
|
||||
canMerge := func(target dnfrepo.ItemStack) bool {
|
||||
return !rule.Equipment && target.ItemID == source.ItemID && target.Count > 0 &&
|
||||
target.Bind == source.Bind && target.ExpireAt.Equal(source.ExpireAt) && target.Count <= limit-amount
|
||||
}
|
||||
if amount > limit {
|
||||
return 0, ErrCargoFull
|
||||
}
|
||||
if !deposit {
|
||||
bagSlot = -1
|
||||
for slot := rule.SlotStart; slot <= rule.SlotEnd; slot++ {
|
||||
if canMerge(inventory.Slots[cargoBagKey(slot)]) {
|
||||
bagSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
if bagSlot == -1 {
|
||||
for slot := rule.SlotStart; slot <= rule.SlotEnd; slot++ {
|
||||
if _, occupied := inventory.Slots[cargoBagKey(slot)]; !occupied {
|
||||
bagSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bagSlot == -1 {
|
||||
return 0, ErrCargoFull
|
||||
}
|
||||
}
|
||||
key := cargoBagKey(bagSlot)
|
||||
target, occupied := inventory.Slots[key]
|
||||
if deposit {
|
||||
target, occupied = g.Cargo[cargoSlot]
|
||||
}
|
||||
if occupied && !canMerge(target) {
|
||||
return 0, ErrCargoFull
|
||||
}
|
||||
if !occupied {
|
||||
target = source
|
||||
target.Count = 0
|
||||
}
|
||||
target = cargoCount(target, target.Count+amount, rule.Equipment)
|
||||
moved := cargoCount(source, amount, rule.Equipment)
|
||||
source = cargoCount(source, source.Count-amount, rule.Equipment)
|
||||
if g.Cargo == nil {
|
||||
g.Cargo = make(map[int16]dnfrepo.ItemStack)
|
||||
}
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
action := byte(2)
|
||||
if deposit {
|
||||
action = 1
|
||||
g.Cargo[cargoSlot] = target
|
||||
if source.Count == 0 {
|
||||
delete(inventory.Slots, key)
|
||||
} else {
|
||||
inventory.Slots[key] = source
|
||||
}
|
||||
} else {
|
||||
inventory.Slots[key] = target
|
||||
if source.Count == 0 {
|
||||
delete(g.Cargo, cargoSlot)
|
||||
} else {
|
||||
g.Cargo[cargoSlot] = source
|
||||
}
|
||||
}
|
||||
g.CargoHistory = append(g.CargoHistory, CargoHistory{Name: member.Name, Action: action, Item: moved, CreatedAt: now})
|
||||
// ponytail: retain the latest200 operations in the guild aggregate; use a
|
||||
// separate paginated repository if an archival transaction log is needed.
|
||||
if len(g.CargoHistory) > 200 {
|
||||
g.CargoHistory = g.CargoHistory[len(g.CargoHistory)-200:]
|
||||
}
|
||||
g.UpdatedAt, inventory.UpdatedAt = now, now
|
||||
return bagSlot, nil
|
||||
}
|
||||
|
||||
func (g *Guild) MoveCargo(characterID string, source, destination int16, sourceID, destinationID uint32, now time.Time) error {
|
||||
if _, found := g.Members[characterID]; !found {
|
||||
return ErrNotMember
|
||||
}
|
||||
if source == destination || !g.validCargoSlot(source) || !g.validCargoSlot(destination) || sourceID == 0 {
|
||||
return ErrCargoItem
|
||||
}
|
||||
a, aOK := g.Cargo[source]
|
||||
b, bOK := g.Cargo[destination]
|
||||
if !aOK || a.Count <= 0 || a.ItemID != int64(sourceID) || b.ItemID != int64(destinationID) || (bOK && b.Count <= 0) {
|
||||
return ErrCargoItem
|
||||
}
|
||||
g.Cargo[destination] = a
|
||||
if bOK {
|
||||
g.Cargo[source] = b
|
||||
} else {
|
||||
delete(g.Cargo, source)
|
||||
}
|
||||
g.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guild) validCargoSlot(slot int16) bool {
|
||||
return slot >= 0 && int(slot) < g.CargoLimit && int(slot) < MaxCargoLimit
|
||||
}
|
||||
|
||||
func cargoBagKey(slot int16) string { return "0:" + strconv.Itoa(int(slot)) }
|
||||
|
||||
func cargoCount(stack dnfrepo.ItemStack, count int64, equipment bool) dnfrepo.ItemStack {
|
||||
stack.Count = count
|
||||
if equipment {
|
||||
return stack
|
||||
}
|
||||
if len(stack.RawEntry) == 119 {
|
||||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||||
binary.LittleEndian.PutUint32(stack.RawEntry[6:10], uint32(count))
|
||||
}
|
||||
extra := make(map[string]string, len(stack.Extra))
|
||||
for key, value := range stack.Extra {
|
||||
extra[key] = value
|
||||
}
|
||||
for _, key := range []string{"amount_or_count", "amount", "count", "stack", "quantity"} {
|
||||
if _, exists := extra[key]; exists {
|
||||
extra[key] = strconv.FormatInt(count, 10)
|
||||
}
|
||||
}
|
||||
stack.Extra = extra
|
||||
return stack
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestTransferCargoPreservesEquipmentAndStackCounts(t *testing.T) {
|
||||
now := time.Unix(100, 0).UTC()
|
||||
g := &Guild{CargoLimit: 8, Members: map[string]Member{"1": {CharacterID: "1", Name: "master"}}}
|
||||
inv := &dnfrepo.InventoryRecord{CharacterID: "1", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:12": {ItemID: 700, Count: 3, RawEntry: make([]byte, 119), Extra: map[string]string{"item_kind": "stackable"}},
|
||||
}}
|
||||
rule := CargoRule{ItemID: 700, Allowed: true, StackLimit: 100, SlotStart: 9, SlotEnd: 64}
|
||||
if _, err := g.TransferCargo("1", inv, true, 12, 0, 700, 2, rule, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inv.Slots["0:12"].Count != 1 || g.Cargo[0].Count != 2 || len(g.CargoHistory) != 1 {
|
||||
t.Fatalf("deposit inventory=%+v cargo=%+v history=%d", inv.Slots, g.Cargo, len(g.CargoHistory))
|
||||
}
|
||||
if _, err := g.TransferCargo("1", inv, false, -1, 0, 700, 1, rule, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inv.Slots["0:12"].Count != 2 || g.Cargo[0].Count != 1 {
|
||||
t.Fatalf("withdraw inventory=%+v cargo=%+v", inv.Slots, g.Cargo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendAreaRequiresNextLevelAndDebitsOnce(t *testing.T) {
|
||||
now := time.Unix(100, 0).UTC()
|
||||
r := NewRegistry()
|
||||
g, err := r.Create(dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "a", Name: "master"}, "guild", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.GuardianAP, g.GuardianGP = 20000, 10000
|
||||
costs := map[byte]GuardianCost{1: {AP: 5000, GP: 2000}, 2: {AP: 10000, GP: 8000}}
|
||||
if err := r.ExtendArea("1", 1, costs, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.AreaLevel != 1 || g.GuardianAP != 15000 || g.GuardianGP != 8000 {
|
||||
t.Fatalf("first extension=%+v", g)
|
||||
}
|
||||
if err := r.ExtendArea("1", 1, costs, now); err == nil {
|
||||
t.Fatal("duplicate extension accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// 公会勋章合成 / 守护珠合成的配方表。
|
||||
//
|
||||
// 来源 etc/guilddungeoncompound.etc,两段:
|
||||
//
|
||||
// [compound info] -> 守护珠 [flag gem]
|
||||
// 0 4 2000 1 4 1500 2 4 750 3 2 0
|
||||
// [guild flag compound info] -> 公会勋章 [flag]
|
||||
// 0 4 2500 1 4 2000 2 4 1000 3 4 500 6 2 0
|
||||
//
|
||||
// 每三个一组:**稀有度、材料件数上限、每件材料的成功率(1/10000)**。稀有度就是
|
||||
// 物品自己的 [rarity](无该字段记 0),两条阶梯分别是:
|
||||
//
|
||||
// 勋章 0 -> 1 -> 2 -> 3 -> 6(6 是"完美的X图腾勋章",封顶)
|
||||
// 守护珠 0 -> 1 -> 2 -> 3(封顶)
|
||||
//
|
||||
// 成功率为 0 的那一行是**封顶行**,不是"永远失败":它是阶梯的最后一档,没有
|
||||
// 更高一档可以产出,所以直接判不可合成。
|
||||
//
|
||||
// # 件数与成功率的关系(2026-09-15 按玩家实测界面修正)
|
||||
//
|
||||
// 第二列是**上限**不是定值:客户端允许放 2..4 件,**放得越多成功率越高**。
|
||||
// 所以第三列是**每件**材料的贡献,总成功率 = 件数 × 第三列,封顶 100%:
|
||||
//
|
||||
// 勋章 稀有度 0 2件 50% 3件 75% 4件 100%
|
||||
// 稀有度 1 2件 40% 3件 60% 4件 80%
|
||||
// 稀有度 2 2件 20% 3件 30% 4件 40%
|
||||
// 稀有度 3 2件 10% 3件 15% 4件 20%
|
||||
// 守护珠 稀有度 0 2件 40% 3件 60% 4件 80%
|
||||
// 稀有度 1 2件 30% 3件 45% 4件 60%
|
||||
// 稀有度 2 2件 15% 3件 22.5% 4件 30%
|
||||
//
|
||||
// 支持这个读法的一个信号:勋章最低档 2500 × 4 恰好等于 10000,也就是满件数必成,
|
||||
// 是个不像巧合的整数。
|
||||
//
|
||||
// 最初的实现把第二列当成"必须正好这么多件",于是玩家放 2 件被一句
|
||||
//「无法进行合成。」挡掉 —— 那是这一版修正的起因。
|
||||
//
|
||||
// 注意这张表**不在客户端里**:DNF.exe 的任何节里都搜不到 "guilddungeoncompound"
|
||||
// 或 "guild flag compound" 这两个串。所以客户端不校验件数,玩家往格子里放几个就
|
||||
// 发几个(实测勋章那次只放了 2 个就发了 count=2),规则全部由服务端裁决。
|
||||
//
|
||||
// 第三列的语义(成功率 1/10000)是推断而非反汇编证据:客户端不读这张表,服务端
|
||||
// 原本也没有实现,所以没有任何一端能直接证明。运营 2026-09-15 确认按成功率实现,
|
||||
// 且失败照常消耗材料。要改语义只需改这里。
|
||||
var (
|
||||
ErrCompoundCatalogUnavailable = errors.New("guild compound catalog is unavailable")
|
||||
ErrCompoundCatalogInvalid = errors.New("guild compound catalog is malformed")
|
||||
)
|
||||
|
||||
const (
|
||||
compoundPath = "etc/guilddungeoncompound.etc"
|
||||
compoundGemKey = "compound info"
|
||||
compoundFlagKey = "guild flag compound info"
|
||||
compoundRateDenominat = 10000
|
||||
)
|
||||
|
||||
// CompoundKind 区分两条阶梯。
|
||||
type CompoundKind byte
|
||||
|
||||
const (
|
||||
CompoundGem CompoundKind = iota
|
||||
CompoundFlag
|
||||
)
|
||||
|
||||
func (k CompoundKind) String() string {
|
||||
if k == CompoundFlag {
|
||||
return "flag"
|
||||
}
|
||||
return "gem"
|
||||
}
|
||||
|
||||
// CompoundMinMaterialCount 是任何一档的最少件数。客户端的合成界面允许放 2..4 件,
|
||||
// 配方表只写了上限,下限只能从界面行为来。
|
||||
const CompoundMinMaterialCount uint32 = 2
|
||||
|
||||
// CompoundRecipe 是一档的配方。
|
||||
type CompoundRecipe struct {
|
||||
// Rarity 是被消耗的材料档位。
|
||||
Rarity uint32
|
||||
// MaxMaterialCount 是这一档最多能放几件材料。
|
||||
MaxMaterialCount uint32
|
||||
// RatePerMaterial 是**每件**材料贡献的万分率成功率。0 表示封顶、不可再合成。
|
||||
RatePerMaterial uint32
|
||||
}
|
||||
|
||||
// Compoundable 报告这一档能不能继续往上合。
|
||||
func (r CompoundRecipe) Compoundable() bool {
|
||||
return r.RatePerMaterial > 0 && r.MaxMaterialCount >= CompoundMinMaterialCount
|
||||
}
|
||||
|
||||
// AcceptsMaterialCount 报告这一档收不收这么多件。
|
||||
func (r CompoundRecipe) AcceptsMaterialCount(count uint32) bool {
|
||||
return r.Compoundable() && count >= CompoundMinMaterialCount && count <= r.MaxMaterialCount
|
||||
}
|
||||
|
||||
// SuccessRateFor 是放这么多件时的万分率成功率,封顶 100%。
|
||||
func (r CompoundRecipe) SuccessRateFor(count uint32) uint32 {
|
||||
if !r.AcceptsMaterialCount(count) {
|
||||
return 0
|
||||
}
|
||||
rate := r.RatePerMaterial * count
|
||||
if rate > CompoundRateDenominator {
|
||||
return CompoundRateDenominator
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
// CompoundLadder 是一条完整的阶梯,按稀有度升序。
|
||||
type CompoundLadder struct {
|
||||
rows []CompoundRecipe
|
||||
}
|
||||
|
||||
// Recipe 返回该稀有度的配方。
|
||||
func (l CompoundLadder) Recipe(rarity uint32) (CompoundRecipe, bool) {
|
||||
for _, row := range l.rows {
|
||||
if row.Rarity == rarity {
|
||||
return row, true
|
||||
}
|
||||
}
|
||||
return CompoundRecipe{}, false
|
||||
}
|
||||
|
||||
// NextRarity 返回这一档合成出来的稀有度,也就是阶梯里紧挨着的下一档。
|
||||
// 勋章的 3 -> 6 这一跳正是靠"表里的下一行"得到的,不是 rarity+1。
|
||||
func (l CompoundLadder) NextRarity(rarity uint32) (uint32, bool) {
|
||||
for index, row := range l.rows {
|
||||
if row.Rarity != rarity {
|
||||
continue
|
||||
}
|
||||
if !row.Compoundable() || index+1 >= len(l.rows) {
|
||||
return 0, false
|
||||
}
|
||||
return l.rows[index+1].Rarity, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Rarities lists the ladder in order, lowest first.
|
||||
func (l CompoundLadder) Rarities() []uint32 {
|
||||
out := make([]uint32, 0, len(l.rows))
|
||||
for _, row := range l.rows {
|
||||
out = append(out, row.Rarity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CompoundCatalog holds both ladders.
|
||||
type CompoundCatalog struct {
|
||||
gem CompoundLadder
|
||||
flag CompoundLadder
|
||||
}
|
||||
|
||||
// Ladder picks the ladder for a kind.
|
||||
func (c CompoundCatalog) Ladder(kind CompoundKind) CompoundLadder {
|
||||
if kind == CompoundFlag {
|
||||
return c.flag
|
||||
}
|
||||
return c.gem
|
||||
}
|
||||
|
||||
// LoadCompoundCatalog parses etc/guilddungeoncompound.etc.
|
||||
func LoadCompoundCatalog(source dnfpvf.Source) (CompoundCatalog, error) {
|
||||
if source == nil {
|
||||
return CompoundCatalog{}, ErrCompoundCatalogUnavailable
|
||||
}
|
||||
text, err := source.ReadText(compoundPath)
|
||||
if err != nil {
|
||||
return CompoundCatalog{}, fmt.Errorf("read %s: %w", compoundPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(compoundPath, text)
|
||||
if err != nil {
|
||||
return CompoundCatalog{}, fmt.Errorf("parse %s: %w", compoundPath, err)
|
||||
}
|
||||
gem, err := parseCompoundLadder(document.Ints(compoundGemKey), compoundGemKey)
|
||||
if err != nil {
|
||||
return CompoundCatalog{}, err
|
||||
}
|
||||
flag, err := parseCompoundLadder(document.Ints(compoundFlagKey), compoundFlagKey)
|
||||
if err != nil {
|
||||
return CompoundCatalog{}, err
|
||||
}
|
||||
return CompoundCatalog{gem: gem, flag: flag}, nil
|
||||
}
|
||||
|
||||
func parseCompoundLadder(values []int64, key string) (CompoundLadder, error) {
|
||||
if len(values) < 3 || len(values)%3 != 0 {
|
||||
return CompoundLadder{}, fmt.Errorf("%w: %s has %d values", ErrCompoundCatalogInvalid, key, len(values))
|
||||
}
|
||||
rows := make([]CompoundRecipe, 0, len(values)/3)
|
||||
for index := 0; index+2 < len(values); index += 3 {
|
||||
rarity, count, rate := values[index], values[index+1], values[index+2]
|
||||
if rarity < 0 || count < 0 || rate < 0 ||
|
||||
rarity > int64(^uint32(0)) || count > int64(^uint32(0)) || rate > compoundRateDenominat {
|
||||
return CompoundLadder{}, fmt.Errorf("%w: %s row %d = %d %d %d",
|
||||
ErrCompoundCatalogInvalid, key, index/3, rarity, count, rate)
|
||||
}
|
||||
if index > 0 && uint32(rarity) <= rows[len(rows)-1].Rarity {
|
||||
return CompoundLadder{}, fmt.Errorf("%w: %s rarities are not ascending at row %d",
|
||||
ErrCompoundCatalogInvalid, key, index/3)
|
||||
}
|
||||
rows = append(rows, CompoundRecipe{
|
||||
Rarity: uint32(rarity),
|
||||
MaxMaterialCount: uint32(count),
|
||||
RatePerMaterial: uint32(rate),
|
||||
})
|
||||
}
|
||||
return CompoundLadder{rows: rows}, nil
|
||||
}
|
||||
|
||||
// CompoundRateDenominator 是成功率的分母。
|
||||
const CompoundRateDenominator = compoundRateDenominat
|
||||
@@ -0,0 +1,151 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type compoundTestSource string
|
||||
|
||||
func (s compoundTestSource) ReadText(string) (string, error) { return string(s), nil }
|
||||
|
||||
// 运行时 PVF 的原文。
|
||||
const liveCompoundTable = `[compound info]
|
||||
0 4 2000 1 4 1500 2 4 750 3 2 0
|
||||
[/compound info]
|
||||
|
||||
[guild flag compound info]
|
||||
0 4 2500 1 4 2000 2 4 1000 3 4 500 6 2 0
|
||||
[/guid flag compound info]
|
||||
`
|
||||
|
||||
func TestLoadCompoundCatalogMatchesTheLivePVF(t *testing.T) {
|
||||
catalog, err := LoadCompoundCatalog(compoundTestSource(liveCompoundTable))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCompoundCatalog() error = %v", err)
|
||||
}
|
||||
|
||||
for _, row := range []struct {
|
||||
kind CompoundKind
|
||||
rarity uint32
|
||||
wantCount uint32
|
||||
wantRate uint32
|
||||
wantNext uint32
|
||||
wantHasNext bool
|
||||
}{
|
||||
{kind: CompoundGem, rarity: 0, wantCount: 4, wantRate: 2000, wantNext: 1, wantHasNext: true},
|
||||
{kind: CompoundGem, rarity: 1, wantCount: 4, wantRate: 1500, wantNext: 2, wantHasNext: true},
|
||||
{kind: CompoundGem, rarity: 2, wantCount: 4, wantRate: 750, wantNext: 3, wantHasNext: true},
|
||||
{kind: CompoundGem, rarity: 3, wantCount: 2, wantRate: 0},
|
||||
{kind: CompoundFlag, rarity: 0, wantCount: 4, wantRate: 2500, wantNext: 1, wantHasNext: true},
|
||||
{kind: CompoundFlag, rarity: 1, wantCount: 4, wantRate: 2000, wantNext: 2, wantHasNext: true},
|
||||
{kind: CompoundFlag, rarity: 2, wantCount: 4, wantRate: 1000, wantNext: 3, wantHasNext: true},
|
||||
// 勋章的 3 -> 6 这一跳必须靠"表里的下一行",不是 rarity+1:
|
||||
// 稀有度 4、5 的勋章根本不存在。
|
||||
{kind: CompoundFlag, rarity: 3, wantCount: 4, wantRate: 500, wantNext: 6, wantHasNext: true},
|
||||
{kind: CompoundFlag, rarity: 6, wantCount: 2, wantRate: 0},
|
||||
} {
|
||||
ladder := catalog.Ladder(row.kind)
|
||||
recipe, found := ladder.Recipe(row.rarity)
|
||||
if !found {
|
||||
t.Fatalf("%s rarity %d is missing from the ladder", row.kind, row.rarity)
|
||||
}
|
||||
if recipe.MaxMaterialCount != row.wantCount || recipe.RatePerMaterial != row.wantRate {
|
||||
t.Fatalf("%s rarity %d = %+v, want max_count=%d rate_per_material=%d",
|
||||
row.kind, row.rarity, recipe, row.wantCount, row.wantRate)
|
||||
}
|
||||
next, hasNext := ladder.NextRarity(row.rarity)
|
||||
if hasNext != row.wantHasNext || (hasNext && next != row.wantNext) {
|
||||
t.Fatalf("%s rarity %d next = %d, %t; want %d, %t",
|
||||
row.kind, row.rarity, next, hasNext, row.wantNext, row.wantHasNext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功率 0 的那一行是封顶行,不是"永远失败"。
|
||||
func TestCompoundTopRowIsNotCompoundable(t *testing.T) {
|
||||
catalog, err := LoadCompoundCatalog(compoundTestSource(liveCompoundTable))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCompoundCatalog() error = %v", err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
kind CompoundKind
|
||||
rarity uint32
|
||||
}{
|
||||
{kind: CompoundGem, rarity: 3},
|
||||
{kind: CompoundFlag, rarity: 6},
|
||||
} {
|
||||
recipe, _ := catalog.Ladder(row.kind).Recipe(row.rarity)
|
||||
if recipe.Compoundable() {
|
||||
t.Fatalf("%s rarity %d must be the top of the ladder", row.kind, row.rarity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCompoundCatalogRejectsMalformedTables(t *testing.T) {
|
||||
for name, table := range map[string]string{
|
||||
"件数不成三元组": "[compound info]\n0 4\n[/compound info]\n[guild flag compound info]\n0 4 2500\n[/x]\n",
|
||||
"稀有度不升序": "[compound info]\n1 4 2000 0 4 1500\n[/compound info]\n[guild flag compound info]\n0 4 2500\n[/x]\n",
|
||||
"成功率超过分母": "[compound info]\n0 4 20000\n[/compound info]\n[guild flag compound info]\n0 4 2500\n[/x]\n",
|
||||
} {
|
||||
if _, err := LoadCompoundCatalog(compoundTestSource(table)); !errors.Is(err, ErrCompoundCatalogInvalid) {
|
||||
t.Fatalf("%s: error = %v, want ErrCompoundCatalogInvalid", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := LoadCompoundCatalog(nil); !errors.Is(err, ErrCompoundCatalogUnavailable) {
|
||||
t.Fatalf("nil source error = %v, want ErrCompoundCatalogUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 件数是 2..上限的区间,成功率随件数线性上涨、封顶 100%。
|
||||
// 第一版把上限当成"必须正好这么多件",玩家放 2 件被一句「无法进行合成。」挡掉。
|
||||
func TestCompoundSuccessRateScalesWithMaterialCount(t *testing.T) {
|
||||
catalog, err := LoadCompoundCatalog(compoundTestSource(liveCompoundTable))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCompoundCatalog() error = %v", err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
kind CompoundKind
|
||||
rarity uint32
|
||||
count uint32
|
||||
want uint32
|
||||
}{
|
||||
// 勋章最低档满件数恰好 100%,是支持"第三列是每件贡献"这个读法的信号。
|
||||
{kind: CompoundFlag, rarity: 0, count: 2, want: 5000},
|
||||
{kind: CompoundFlag, rarity: 0, count: 3, want: 7500},
|
||||
{kind: CompoundFlag, rarity: 0, count: 4, want: 10000},
|
||||
{kind: CompoundFlag, rarity: 3, count: 2, want: 1000},
|
||||
{kind: CompoundFlag, rarity: 3, count: 4, want: 2000},
|
||||
{kind: CompoundGem, rarity: 0, count: 2, want: 4000},
|
||||
{kind: CompoundGem, rarity: 0, count: 4, want: 8000},
|
||||
{kind: CompoundGem, rarity: 2, count: 2, want: 1500},
|
||||
{kind: CompoundGem, rarity: 2, count: 4, want: 3000},
|
||||
// 区间外一律 0,且 AcceptsMaterialCount 会先把请求挡掉。
|
||||
{kind: CompoundGem, rarity: 0, count: 1, want: 0},
|
||||
{kind: CompoundGem, rarity: 0, count: 5, want: 0},
|
||||
// 封顶行任何件数都不可合成。
|
||||
{kind: CompoundGem, rarity: 3, count: 2, want: 0},
|
||||
{kind: CompoundFlag, rarity: 6, count: 2, want: 0},
|
||||
} {
|
||||
recipe, found := catalog.Ladder(row.kind).Recipe(row.rarity)
|
||||
if !found {
|
||||
t.Fatalf("%s rarity %d missing", row.kind, row.rarity)
|
||||
}
|
||||
if got := recipe.SuccessRateFor(row.count); got != row.want {
|
||||
t.Fatalf("%s rarity %d with %d materials = %d, want %d",
|
||||
row.kind, row.rarity, row.count, got, row.want)
|
||||
}
|
||||
if accepted := recipe.AcceptsMaterialCount(row.count); accepted != (row.want > 0) {
|
||||
t.Fatalf("%s rarity %d accepts %d = %t, want %t",
|
||||
row.kind, row.rarity, row.count, accepted, row.want > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功率封顶 100%:哪怕表被改成更大的每件贡献,也不能溢出分母。
|
||||
func TestCompoundSuccessRateIsCappedAtFullOdds(t *testing.T) {
|
||||
recipe := CompoundRecipe{Rarity: 0, MaxMaterialCount: 4, RatePerMaterial: 9000}
|
||||
if got := recipe.SuccessRateFor(4); got != CompoundRateDenominator {
|
||||
t.Fatalf("rate = %d, want the %d cap", got, CompoundRateDenominator)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidContent = errors.New("invalid guild content")
|
||||
ErrContentActive = errors.New("guild content already active")
|
||||
ErrCargoLimitReached = errors.New("guild cargo limit reached")
|
||||
ErrInsufficientMileage = errors.New("insufficient guild mileage")
|
||||
ErrContractClaim = errors.New("guild contract reward is not available")
|
||||
)
|
||||
|
||||
type Content struct {
|
||||
ID uint32
|
||||
Type byte
|
||||
Value int32
|
||||
Days int32
|
||||
Counted bool
|
||||
ItemID uint32
|
||||
Price uint32
|
||||
Gold bool
|
||||
}
|
||||
|
||||
type Benefit struct {
|
||||
Type byte `json:"type"`
|
||||
Value uint32 `json:"value"`
|
||||
ItemID uint32 `json:"item_id,omitempty"`
|
||||
Mode byte `json:"mode"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Claimed map[string]bool `json:"claimed,omitempty"`
|
||||
}
|
||||
|
||||
// Active reports whether this benefit still grants anything.
|
||||
//
|
||||
// The `Type != 7 || Value > 0` conjunct comes from the upstream guild branch and
|
||||
// exists for 公会传送 (type 7), whose Value IS its remaining-use counter rather than
|
||||
// a magnitude: a type-7 benefit that has spent its last use is not active even
|
||||
// though its window has not closed. No other benefit type changes behaviour.
|
||||
func (b Benefit) Active(now time.Time) bool {
|
||||
return (b.Type != 7 || b.Value > 0) && (b.ExpiresAt.IsZero() || now.Before(b.ExpiresAt))
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
Contents map[uint32]Content
|
||||
AuraMarks uint32
|
||||
AuraFrames uint32
|
||||
ContractItem uint32
|
||||
ContractCount uint32
|
||||
}
|
||||
|
||||
func LoadCatalog(source dnfpvf.Source) (Catalog, error) {
|
||||
catalog := Catalog{Contents: make(map[uint32]Content)}
|
||||
if source == nil {
|
||||
return catalog, ErrRepositoryUnavailable
|
||||
}
|
||||
text, err := source.ReadText("etc/(r)guild.etc")
|
||||
if err != nil {
|
||||
return catalog, err
|
||||
}
|
||||
doc, err := dnfpvf.Parse("etc/(r)guild.etc", text)
|
||||
if err != nil {
|
||||
return catalog, err
|
||||
}
|
||||
for _, section := range []struct {
|
||||
name string
|
||||
width int
|
||||
}{{"guild contents list", 9}, {"additional guild contents list", 10}} {
|
||||
tokens, found := doc.Section(section.name)
|
||||
if !found || len(tokens)%section.width != 0 {
|
||||
return catalog, fmt.Errorf("guild catalog: invalid %s", section.name)
|
||||
}
|
||||
for offset := 0; offset < len(tokens); offset += section.width {
|
||||
row := tokens[offset : offset+section.width]
|
||||
for i, token := range row {
|
||||
if i == 8 {
|
||||
if token.Kind != dnfpvf.TokenString {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
continue
|
||||
}
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int < -1 || token.Int > math.MaxInt32 {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
if row[0].Int <= 0 || row[1].Int <= 0 || row[1].Int > 255 || row[3].Int < -1 || row[3].Int > 36500 || row[4].Int < 0 || row[4].Int > 1 || row[5].Int <= 0 || row[7].Int <= 0 {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
c := Content{ID: uint32(row[0].Int), Type: byte(row[1].Int), Value: int32(row[2].Int), Days: int32(row[3].Int), Counted: row[4].Int == 1, ItemID: uint32(row[5].Int), Price: uint32(row[7].Int)}
|
||||
if section.width == 10 {
|
||||
if row[9].Int < 0 || row[9].Int > 1 {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
c.Gold = row[9].Int == 1
|
||||
}
|
||||
if _, exists := catalog.Contents[c.ID]; exists {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
catalog.Contents[c.ID] = c
|
||||
}
|
||||
}
|
||||
contract, found := doc.Section("guild contracts")
|
||||
if !found || len(contract) != 3 || contract[0].Int != 21 || contract[1].Int <= 0 || contract[1].Int > math.MaxUint32 || contract[2].Int <= 0 || contract[2].Int > math.MaxUint32 {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
catalog.ContractItem, catalog.ContractCount = uint32(contract[1].Int), uint32(contract[2].Int)
|
||||
text, err = source.ReadText("etc/guild/insigniashapelist.etc")
|
||||
if err != nil {
|
||||
return catalog, err
|
||||
}
|
||||
doc, err = dnfpvf.Parse("etc/guild/insigniashapelist.etc", text)
|
||||
if err != nil {
|
||||
return catalog, err
|
||||
}
|
||||
for _, section := range []struct {
|
||||
name string
|
||||
count *uint32
|
||||
}{{"insignia mark", &catalog.AuraMarks}, {"insignia frame", &catalog.AuraFrames}} {
|
||||
tokens, found := doc.Section(section.name)
|
||||
if !found || len(tokens) == 0 {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if token.Kind != dnfpvf.TokenString || token.Value == "" {
|
||||
return catalog, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
*section.count = uint32(len(tokens))
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func (r *Registry) SetAura(actorID string, mark, frame uint32, catalog Catalog, now time.Time) error {
|
||||
guild, actor, found := r.GuildForCharacter(actorID)
|
||||
if !found || actor.Grade != DefaultMasterGrade {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if mark >= catalog.AuraMarks || frame >= catalog.AuraFrames {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
guild.AuraMark, guild.AuraFrame, guild.UpdatedAt = mark, frame, now
|
||||
return nil
|
||||
}
|
||||
|
||||
// Purchase runs inside the caller's character/registry transaction. Inventory
|
||||
// delivery (type 20) must succeed before that transaction commits.
|
||||
func (r *Registry) Purchase(character *dnfrepo.CharacterRecord, content Content, now time.Time) (*Guild, error) {
|
||||
if character == nil {
|
||||
return nil, ErrCharacterNotFound
|
||||
}
|
||||
guild, actor, found := r.GuildForCharacter(character.CharacterID)
|
||||
if !found || actor.Grade != DefaultMasterGrade {
|
||||
return nil, ErrPermissionDenied
|
||||
}
|
||||
if content.ID == 0 || content.Price == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if content.Gold {
|
||||
if character.Stats["gold"] < int64(content.Price) {
|
||||
return nil, ErrInsufficientGold
|
||||
}
|
||||
} else if guild.Mileage < content.Price {
|
||||
return nil, ErrInsufficientMileage
|
||||
}
|
||||
// Type 7 is a shared counted benefit -- its Value is a remaining-use counter --
|
||||
// so buying more must top it up instead of being refused as "already active".
|
||||
// Type 4 is an increment rather than an absolute capacity target, for the same
|
||||
// reason. Upstream exempts exactly these two from the active check.
|
||||
if content.Type != 7 && content.Type != 4 {
|
||||
if existing, exists := guild.Benefits[content.Type]; exists && existing.Active(now) {
|
||||
return nil, ErrContentActive
|
||||
}
|
||||
}
|
||||
switch content.Type {
|
||||
case 6:
|
||||
if content.Value <= int32(guild.MemberLimit) {
|
||||
return nil, ErrContentActive
|
||||
}
|
||||
guild.MemberLimit = int(content.Value)
|
||||
case 4:
|
||||
// 公会金库扩容. PVF describes these products as "expand by 8/16 slots", so
|
||||
// Value is an increment, not an absolute target: without this branch the
|
||||
// cargo limit could never leave DefaultCargoLimit and the ported 金库 would
|
||||
// be permanently stuck at its starting size.
|
||||
if content.Value <= 0 || guild.CargoLimit >= MaxCargoLimit || int(content.Value) > MaxCargoLimit-guild.CargoLimit {
|
||||
return nil, ErrCargoLimitReached
|
||||
}
|
||||
guild.CargoLimit += int(content.Value)
|
||||
case 1, 2, 3, 5:
|
||||
// 公会 BUFF 一类按时效生效的权益. Days 0 is meaningless, -1 means "does not
|
||||
// expire" and Value -1 means "disabled", which is why both are compared
|
||||
// against -1 rather than 0. Types 3 and 5 are the Guild_Status rows the HUD
|
||||
// projection reads, so without this branch that projection can never light
|
||||
// up: Purchase is the only writer of Benefits[1/2/3/5] in this tree.
|
||||
if content.Days == 0 || content.Days < -1 || content.Value < -1 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if guild.Benefits == nil {
|
||||
guild.Benefits = make(map[byte]Benefit)
|
||||
}
|
||||
expiresAt := time.Time{}
|
||||
if content.Days > 0 {
|
||||
expiresAt = now.Add(time.Duration(content.Days) * 24 * time.Hour)
|
||||
}
|
||||
guild.Benefits[content.Type] = Benefit{
|
||||
Type: content.Type,
|
||||
Value: uint32(content.Value),
|
||||
ItemID: content.ItemID,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
case 7:
|
||||
// 公会传送次数. Type 7 is a shared counted benefit and its PVF item is only a
|
||||
// shop icon, so the purchase tops up the guild-wide counter that
|
||||
// Registry.UseTeleport later spends. Without this branch no guild can ever
|
||||
// hold a use, and because op434/435 now requires one (decision D4) party
|
||||
// teleport would be refused for everyone, permanently.
|
||||
if content.Value <= 0 || content.Days <= 0 || !content.Counted {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if err := guild.AddTeleportUses(uint32(content.Value), content, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case 20:
|
||||
// The PVF item is the actual rename approval delivered to the buyer.
|
||||
case 21:
|
||||
if content.Value < int32(len(guild.Members)) || content.Days <= 0 {
|
||||
return nil, ErrMemberLimit
|
||||
}
|
||||
if guild.Benefits == nil {
|
||||
guild.Benefits = make(map[byte]Benefit)
|
||||
}
|
||||
guild.Benefits[content.Type] = Benefit{Type: content.Type, Value: uint32(content.Value), ExpiresAt: now.Add(time.Duration(content.Days) * 24 * time.Hour), Claimed: make(map[string]bool)}
|
||||
default:
|
||||
// Other content types require their gameplay owner before charging.
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if content.Gold {
|
||||
character.Stats["gold"] -= int64(content.Price)
|
||||
character.UpdatedAt = now
|
||||
} else {
|
||||
guild.Mileage -= content.Price
|
||||
}
|
||||
guild.UpdatedAt = now
|
||||
return guild, nil
|
||||
}
|
||||
|
||||
func (r *Registry) ClaimContract(characterID string, now time.Time) (*Guild, error) {
|
||||
guild, _, found := r.GuildForCharacter(characterID)
|
||||
if !found {
|
||||
return nil, ErrPermissionDenied
|
||||
}
|
||||
benefit, found := guild.Benefits[21]
|
||||
if !found || !benefit.Active(now) || benefit.Claimed[characterID] || uint32(len(benefit.Claimed)) >= benefit.Value {
|
||||
return nil, ErrContractClaim
|
||||
}
|
||||
if benefit.Claimed == nil {
|
||||
benefit.Claimed = make(map[string]bool)
|
||||
}
|
||||
benefit.Claimed[characterID] = true
|
||||
guild.Benefits[21], guild.UpdatedAt = benefit, now
|
||||
return guild, nil
|
||||
}
|
||||
|
||||
// AddTeleportUses tops up the 公会传送 (type 7) benefit. Value is a remaining-use
|
||||
// counter, which is why Benefit.Active treats a depleted type-7 row as inactive.
|
||||
// Ported from the upstream guild branch; a purchase that carries the teleport
|
||||
// content is what calls it.
|
||||
func (g *Guild) AddTeleportUses(count uint32, content Content, now time.Time) error {
|
||||
if count == 0 || content.Type != 7 || content.Days <= 0 || content.Days > 36500 || !content.Counted {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
benefit := g.Benefits[7]
|
||||
if !benefit.Active(now) {
|
||||
benefit = Benefit{}
|
||||
}
|
||||
if count > math.MaxUint32-benefit.Value {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
benefit.Type, benefit.Mode, benefit.ItemID = 7, 1, content.ItemID
|
||||
benefit.Value += count
|
||||
if expires := now.Add(time.Duration(content.Days) * 24 * time.Hour); expires.After(benefit.ExpiresAt) {
|
||||
benefit.ExpiresAt = expires
|
||||
}
|
||||
if g.Benefits == nil {
|
||||
g.Benefits = make(map[byte]Benefit)
|
||||
}
|
||||
g.Benefits[7], g.UpdatedAt = benefit, now
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseTeleport spends one 公会传送 use. The caller must already hold the guild
|
||||
// mutation lock; the guild id is re-checked so a member who changed guild between
|
||||
// the request and the commit cannot spend the new guild's uses.
|
||||
func (r *Registry) UseTeleport(characterID string, guildID uint32, now time.Time) error {
|
||||
guild, _, found := r.GuildForCharacter(characterID)
|
||||
if !found || guild.ID != guildID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
benefit, found := guild.Benefits[7]
|
||||
if !found || !benefit.Active(now) || benefit.Value == 0 {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
benefit.Value--
|
||||
guild.Benefits[7], guild.UpdatedAt = benefit, now
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
ContributionCoinID uint32 = 490003262
|
||||
ContributionDungeon = "guild dungeon clear"
|
||||
ContributionChat = "guild chat"
|
||||
ContributionAttendance = "guild attendance"
|
||||
ContributionParty = "party dungeon clear"
|
||||
)
|
||||
|
||||
var ErrContributionInventoryFull = errors.New("guild contribution coin inventory is full")
|
||||
|
||||
type ContributionRule struct{ Required, Points uint32 }
|
||||
type ContributionCatalog map[string]ContributionRule
|
||||
|
||||
// Point amounts and activity parameters come exclusively from the deployed PVF.
|
||||
func LoadContributionCatalog(source dnfpvf.Source) (ContributionCatalog, error) {
|
||||
doc, err := readWarDocument(source, "etc/chn_hongbao/chn_hongbao.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, ok := doc.Section("hongbao point reward")
|
||||
if !ok || len(t) != 12 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
c := make(ContributionCatalog)
|
||||
for i := 0; i < len(t); i += 3 {
|
||||
kind := t[i].Value
|
||||
switch kind {
|
||||
case ContributionDungeon, ContributionChat, ContributionAttendance, ContributionParty:
|
||||
default:
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
v, e := warUnsigned(t[i+1 : i+3])
|
||||
if e != nil || len(v) != 2 || v[0] == 0 || v[1] == 0 || v[0] > 10000 || v[1] > 10000 || c[kind].Required != 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
c[kind] = ContributionRule{v[0], v[1]}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// These economy choices are local server policy, not fields inferred from PVF.
|
||||
type ContributionPolicy struct {
|
||||
CoinsPerPoint uint32 `json:"coins_per_point"`
|
||||
RewardsPerActivityPerDay uint32 `json:"rewards_per_activity_per_day"`
|
||||
ResetHour int `json:"reset_hour"`
|
||||
}
|
||||
|
||||
const ContributionPolicyScope = "global:guild_contribution_policy:v1"
|
||||
|
||||
func LoadContributionPolicy(ctx context.Context, repo dnfrepo.SettingsRepository) (*ContributionPolicy, error) {
|
||||
if repo == nil {
|
||||
return nil, ErrRepositoryUnavailable
|
||||
}
|
||||
r, found, err := repo.Load(ctx, ContributionPolicyScope)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
var p ContributionPolicy
|
||||
if err = json.Unmarshal([]byte(r.Values["state"]), &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !p.valid() {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
func SaveContributionPolicy(ctx context.Context, repo dnfrepo.SettingsRepository, p ContributionPolicy, now time.Time) error {
|
||||
if repo == nil || !p.valid() {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.Save(ctx, dnfrepo.SettingsRecord{Scope: ContributionPolicyScope, Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
||||
}
|
||||
func (p ContributionPolicy) valid() bool {
|
||||
return p.CoinsPerPoint > 0 && p.CoinsPerPoint <= 10000 && p.RewardsPerActivityPerDay > 0 && p.RewardsPerActivityPerDay <= 100 && p.ResetHour >= 0 && p.ResetHour < 24
|
||||
}
|
||||
func (p ContributionPolicy) Day(now time.Time) time.Time {
|
||||
n := now.In(time.FixedZone("CST", 8*3600))
|
||||
d := time.Date(n.Year(), n.Month(), n.Day(), p.ResetHour, 0, 0, 0, n.Location())
|
||||
if n.Before(d) {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// The character ledger survives leaving a guild: rejoining does not reset daily
|
||||
// limits or erase earned, undelivered coins. It never imports guild Mileage.
|
||||
type ContributionState struct {
|
||||
Version int `json:"version"`
|
||||
DayStart int64 `json:"day_start"`
|
||||
Progress map[string]uint32 `json:"progress,omitempty"`
|
||||
Rewarded map[string]uint32 `json:"rewarded,omitempty"`
|
||||
Receipts map[string]bool `json:"receipts,omitempty"`
|
||||
Points uint64 `json:"points"`
|
||||
PendingCoins uint32 `json:"pending_coins"`
|
||||
PaidCoins uint64 `json:"paid_coins"`
|
||||
}
|
||||
|
||||
func contributionScope(id string) string { return "character:" + id + ":guild_contribution:v1" }
|
||||
func LoadContribution(ctx context.Context, repo dnfrepo.SettingsRepository, id string) (ContributionState, error) {
|
||||
s := ContributionState{Version: 1}
|
||||
if repo == nil || id == "" {
|
||||
return s, ErrRepositoryUnavailable
|
||||
}
|
||||
r, found, err := repo.Load(ctx, contributionScope(id))
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if found && r.Values["state"] != "" {
|
||||
if err = json.Unmarshal([]byte(r.Values["state"]), &s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if s.Version != 1 {
|
||||
return s, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func SaveContribution(ctx context.Context, repo dnfrepo.SettingsRepository, id string, s ContributionState, now time.Time) error {
|
||||
if repo == nil || id == "" || s.Version != 1 {
|
||||
return ErrRepositoryUnavailable
|
||||
}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.Save(ctx, dnfrepo.SettingsRecord{Scope: contributionScope(id), Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
||||
}
|
||||
|
||||
// Observe accepts server-owned events. The party parameter is the distinct
|
||||
// same-guild participant count; other activities count successful occurrences.
|
||||
func (s *ContributionState) Observe(c ContributionCatalog, p ContributionPolicy, kind, receipt string, partyMembers uint32, occurredAt, now time.Time) (uint32, error) {
|
||||
rule, ok := c[kind]
|
||||
if s == nil || !p.valid() || !ok || rule.Required == 0 || rule.Points == 0 || receipt == "" || occurredAt.After(now) {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
day := p.Day(now).Unix()
|
||||
if occurredAt.Unix() < day || s.DayStart > day {
|
||||
return 0, nil
|
||||
}
|
||||
if s.DayStart != day {
|
||||
s.DayStart = day
|
||||
s.Progress = nil
|
||||
s.Rewarded = nil
|
||||
s.Receipts = nil
|
||||
}
|
||||
if s.Progress == nil {
|
||||
s.Progress = make(map[string]uint32)
|
||||
}
|
||||
if s.Rewarded == nil {
|
||||
s.Rewarded = make(map[string]uint32)
|
||||
}
|
||||
if s.Receipts == nil {
|
||||
s.Receipts = make(map[string]bool)
|
||||
}
|
||||
key := kind + ":" + receipt
|
||||
if s.Receipts[key] || s.Rewarded[kind] >= p.RewardsPerActivityPerDay {
|
||||
return 0, nil
|
||||
}
|
||||
if kind == ContributionParty && partyMembers < rule.Required {
|
||||
return 0, nil
|
||||
}
|
||||
if len(s.Receipts) >= 10000 {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
s.Receipts[key] = true
|
||||
s.Progress[kind]++
|
||||
required := rule.Required
|
||||
if kind == ContributionParty {
|
||||
required = 1
|
||||
}
|
||||
if s.Progress[kind] < required {
|
||||
return 0, nil
|
||||
}
|
||||
coins := uint64(rule.Points) * uint64(p.CoinsPerPoint)
|
||||
if coins > math.MaxUint32-uint64(s.PendingCoins) || s.Points > math.MaxUint64-uint64(rule.Points) {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
s.Progress[kind] = 0
|
||||
s.Rewarded[kind]++
|
||||
s.Points += uint64(rule.Points)
|
||||
s.PendingCoins += uint32(coins)
|
||||
s.Version = 1
|
||||
return rule.Points, nil
|
||||
}
|
||||
|
||||
// Deliver is called within the same repository transaction as the ledger save.
|
||||
// A grant may partially modify its input before reporting full; clone first.
|
||||
func (s *ContributionState) Deliver(inventory *dnfrepo.InventoryRecord, grant func(*dnfrepo.InventoryRecord, uint32) error) (bool, error) {
|
||||
if s.PendingCoins == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if inventory == nil || grant == nil || s.PaidCoins > math.MaxUint64-uint64(s.PendingCoins) {
|
||||
return false, ErrInvalidContent
|
||||
}
|
||||
next := dnfrepo.CloneInventory(*inventory)
|
||||
if err := grant(&next, s.PendingCoins); err != nil {
|
||||
if errors.Is(err, ErrContributionInventoryFull) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
*inventory = next
|
||||
s.PaidCoins += uint64(s.PendingCoins)
|
||||
s.PendingCoins = 0
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type ContributionEntry struct {
|
||||
GuildID uint32
|
||||
CharacterID string
|
||||
Members map[string]time.Time
|
||||
}
|
||||
|
||||
func (r Registry) ContributionEntry(id string) ContributionEntry {
|
||||
g, _, ok := r.GuildForCharacter(id)
|
||||
if !ok {
|
||||
return ContributionEntry{}
|
||||
}
|
||||
e := ContributionEntry{GuildID: g.ID, CharacterID: id, Members: make(map[string]time.Time, len(g.Members))}
|
||||
for id, m := range g.Members {
|
||||
e.Members[id] = m.JoinedAt
|
||||
}
|
||||
return e
|
||||
}
|
||||
func (r Registry) ContributionEligible(e ContributionEntry, party []string) (bool, uint32) {
|
||||
g, m, ok := r.GuildForCharacter(e.CharacterID)
|
||||
if !ok || g.ID != e.GuildID || !m.JoinedAt.Equal(e.Members[e.CharacterID]) {
|
||||
return false, 0
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, id := range party {
|
||||
member, ok := g.Members[id]
|
||||
joined, entered := e.Members[id]
|
||||
if ok && entered && member.JoinedAt.Equal(joined) {
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if !seen[e.CharacterID] {
|
||||
return true, 0
|
||||
}
|
||||
return true, uint32(len(seen))
|
||||
}
|
||||
func (r *Registry) AddContributionPoints(id string, points uint32, now time.Time) error {
|
||||
g, m, ok := r.GuildForCharacter(id)
|
||||
if !ok {
|
||||
return ErrNotMember
|
||||
}
|
||||
if points > math.MaxUint32-m.ContributionPoints {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
m.ContributionPoints += points
|
||||
g.Members[id] = m
|
||||
g.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
// 公会地下城 (guild dungeon). The whole progression is data, not code: each
|
||||
// stage owns a purification gauge, clearing a run adds GaugeOnClear to it, and
|
||||
// a stage counts as conquered once the gauge reaches ConquestGaugeRatio percent
|
||||
// of GaugeLimit. A stage is enterable only when every entry in RequiredConquest
|
||||
// is conquered, which is why seeding a client with an empty gauge set leaves
|
||||
// only 伤城 (8500, no prerequisites) open.
|
||||
//
|
||||
// Source: etc/guild/guilddungeon/guilddungeonstyle.lst picks the layout file by
|
||||
// style id; style 0 is 0_2Way6Dungeon.etc. Live values there:
|
||||
//
|
||||
// 8500 伤城 clear +150 (1.5%) conquest 50% normal (no prerequisite)
|
||||
// 8504 呐喊之地 clear +75 conquest 50% normal needs 8500
|
||||
// 8507 clear +50 conquest 50% hard needs 8500
|
||||
// 8505 失心迷宫 clear +50 conquest 50% hard needs 8504
|
||||
// 8503 哀泣之穴 clear +75 conquest 50% normal needs 8507
|
||||
// 8508 永恒殿堂 clear +30 conquest 100% final needs 8503 and 8505
|
||||
var (
|
||||
ErrDungeonCatalogUnavailable = errors.New("guild dungeon catalog is unavailable")
|
||||
ErrDungeonCatalogInvalid = errors.New("guild dungeon catalog is malformed")
|
||||
)
|
||||
|
||||
const (
|
||||
dungeonStyleListPath = "etc/guild/guilddungeon/guilddungeonstyle.lst"
|
||||
dungeonStyleDir = "etc/guild/guilddungeon/"
|
||||
)
|
||||
|
||||
// DungeonReward is one [gauge ratio] block: reaching that percentage of the
|
||||
// stage's gauge pays the item and the guild contribution once.
|
||||
type DungeonReward struct {
|
||||
GaugeRatio uint32
|
||||
ItemID uint32
|
||||
ItemCount uint32
|
||||
Contribute uint32
|
||||
}
|
||||
|
||||
type DungeonStage struct {
|
||||
Index uint32
|
||||
GaugeLimit uint32
|
||||
GaugeOnClear uint32
|
||||
ConquestGaugeRatio uint32
|
||||
Color string
|
||||
Type string
|
||||
RequiredConquest []uint32
|
||||
Rewards []DungeonReward
|
||||
}
|
||||
|
||||
// RewardsCrossed returns the reward blocks whose ratio the gauge has reached
|
||||
// but the previous gauge had not, so a repeat clear pays nothing twice.
|
||||
func (s DungeonStage) RewardsCrossed(previous, current uint32) []DungeonReward {
|
||||
if s.GaugeLimit == 0 || current <= previous {
|
||||
return nil
|
||||
}
|
||||
crossed := make([]DungeonReward, 0, len(s.Rewards))
|
||||
for _, reward := range s.Rewards {
|
||||
threshold := uint32(uint64(s.GaugeLimit) * uint64(reward.GaugeRatio) / 100)
|
||||
if previous < threshold && current >= threshold {
|
||||
crossed = append(crossed, reward)
|
||||
}
|
||||
}
|
||||
return crossed
|
||||
}
|
||||
|
||||
// ConquestGauge is the gauge value at which this stage counts as conquered.
|
||||
func (s DungeonStage) ConquestGauge() uint32 {
|
||||
return uint32(uint64(s.GaugeLimit) * uint64(s.ConquestGaugeRatio) / 100)
|
||||
}
|
||||
|
||||
type DungeonCatalog struct {
|
||||
FinalDungeon uint32
|
||||
Stages []DungeonStage
|
||||
byIndex map[uint32]DungeonStage
|
||||
}
|
||||
|
||||
func (c DungeonCatalog) Stage(index uint32) (DungeonStage, bool) {
|
||||
stage, found := c.byIndex[index]
|
||||
return stage, found
|
||||
}
|
||||
|
||||
// Conquered reports whether a stage's stored gauge has reached its threshold.
|
||||
func (c DungeonCatalog) Conquered(gauges map[uint32]uint32, index uint32) bool {
|
||||
stage, found := c.byIndex[index]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
return gauges[index] >= stage.ConquestGauge()
|
||||
}
|
||||
|
||||
// Unlocked reports whether a stage may be entered: every prerequisite conquered.
|
||||
// A stage with no prerequisites is always open.
|
||||
func (c DungeonCatalog) Unlocked(gauges map[uint32]uint32, index uint32) bool {
|
||||
stage, found := c.byIndex[index]
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
for _, required := range stage.RequiredConquest {
|
||||
if !c.Conquered(gauges, required) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LoadDungeonCatalog reads the style list and parses the layout it selects.
|
||||
func LoadDungeonCatalog(source dnfpvf.Source) (DungeonCatalog, error) {
|
||||
if source == nil {
|
||||
return DungeonCatalog{}, ErrDungeonCatalogUnavailable
|
||||
}
|
||||
listText, err := source.ReadText(dungeonStyleListPath)
|
||||
if err != nil {
|
||||
return DungeonCatalog{}, fmt.Errorf("guild dungeon style list: %w", err)
|
||||
}
|
||||
layout, err := firstDungeonStyleFile(listText)
|
||||
if err != nil {
|
||||
return DungeonCatalog{}, err
|
||||
}
|
||||
path := dungeonStyleDir + strings.ToLower(layout)
|
||||
text, err := source.ReadText(path)
|
||||
if err != nil {
|
||||
return DungeonCatalog{}, fmt.Errorf("guild dungeon layout %s: %w", path, err)
|
||||
}
|
||||
return parseDungeonCatalog(text)
|
||||
}
|
||||
|
||||
// firstDungeonStyleFile reads `0 \`0_2Way6Dungeon.etc\“ style rows and returns
|
||||
// the file for style 0, which is the only style this PVF ships.
|
||||
func firstDungeonStyleFile(text string) (string, error) {
|
||||
for _, field := range strings.Fields(text) {
|
||||
if strings.HasPrefix(field, "`") {
|
||||
name := strings.Trim(field, "`")
|
||||
if name != "" {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: style list has no layout file", ErrDungeonCatalogInvalid)
|
||||
}
|
||||
|
||||
// parseDungeonCatalog scans the layout text. The file nests `[dungeon]` blocks
|
||||
// with their own subsections, which the flat Section() accessor cannot express,
|
||||
// so this walks tokens the way the upgrade tables are read.
|
||||
func parseDungeonCatalog(text string) (DungeonCatalog, error) {
|
||||
catalog := DungeonCatalog{byIndex: make(map[uint32]DungeonStage)}
|
||||
fields := strings.Fields(text)
|
||||
var (
|
||||
stage *DungeonStage
|
||||
reward *DungeonReward
|
||||
pending string
|
||||
collecting bool
|
||||
)
|
||||
flushReward := func() {
|
||||
if stage != nil && reward != nil && reward.GaugeRatio != 0 {
|
||||
stage.Rewards = append(stage.Rewards, *reward)
|
||||
}
|
||||
reward = nil
|
||||
}
|
||||
flush := func() {
|
||||
if stage != nil && stage.Index != 0 {
|
||||
catalog.Stages = append(catalog.Stages, *stage)
|
||||
catalog.byIndex[stage.Index] = *stage
|
||||
}
|
||||
stage = nil
|
||||
}
|
||||
for _, field := range fields {
|
||||
if strings.HasPrefix(field, "[") {
|
||||
switch field {
|
||||
case "[dungeon]":
|
||||
flushReward()
|
||||
flush()
|
||||
stage = &DungeonStage{}
|
||||
case "[/dungeon]":
|
||||
flushReward()
|
||||
flush()
|
||||
case "[gauge":
|
||||
// "[gauge ratio]" opens a reward block; "[gauge limit]" and
|
||||
// "[gauge on clear]" are plain stage fields and are told apart
|
||||
// by the completed section name below.
|
||||
case "[/gauge":
|
||||
flushReward()
|
||||
case "[required":
|
||||
// "[required dungeon conquest]" splits across fields.
|
||||
collecting = true
|
||||
case "[/required":
|
||||
collecting = false
|
||||
}
|
||||
if !strings.HasPrefix(field, "[/") {
|
||||
pending = strings.Trim(field, "[]")
|
||||
} else {
|
||||
pending = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Section names that span several fields keep appending until a value
|
||||
// arrives, so "[gauge on clear]" is seen as "gauge" + "on" + "clear]".
|
||||
if strings.HasSuffix(field, "]") {
|
||||
pending += " " + strings.TrimSuffix(field, "]")
|
||||
if strings.HasPrefix(pending, "/required") || pending == "/required dungeon conquest" {
|
||||
collecting = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pending != "" && !strings.HasSuffix(pending, "]") && !isDungeonValue(field) {
|
||||
pending += " " + field
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.Trim(field, "`"), 10, 64)
|
||||
isNumber := err == nil
|
||||
key := strings.TrimSpace(pending)
|
||||
switch {
|
||||
case key == "final dungeon" && isNumber:
|
||||
catalog.FinalDungeon = uint32(value)
|
||||
case stage == nil:
|
||||
// Outside a [dungeon] block; nothing else is needed.
|
||||
case collecting && isNumber:
|
||||
stage.RequiredConquest = append(stage.RequiredConquest, uint32(value))
|
||||
case key == "gauge ratio" && isNumber:
|
||||
// A new [gauge ratio] block; the previous one is complete.
|
||||
flushReward()
|
||||
reward = &DungeonReward{GaugeRatio: uint32(value)}
|
||||
case key == "item" && reward != nil && isNumber:
|
||||
if reward.ItemID == 0 {
|
||||
reward.ItemID = uint32(value)
|
||||
} else if reward.ItemCount == 0 {
|
||||
reward.ItemCount = uint32(value)
|
||||
}
|
||||
case key == "contribute" && reward != nil && isNumber:
|
||||
reward.Contribute = uint32(value)
|
||||
case key == "dungeon index" && isNumber:
|
||||
stage.Index = uint32(value)
|
||||
case key == "gauge limit" && isNumber:
|
||||
stage.GaugeLimit = uint32(value)
|
||||
case key == "gauge on clear" && isNumber:
|
||||
stage.GaugeOnClear = uint32(value)
|
||||
case key == "conquest gauge ratio" && isNumber:
|
||||
stage.ConquestGaugeRatio = uint32(value)
|
||||
case key == "color":
|
||||
stage.Color = strings.Trim(field, "`")
|
||||
case key == "type":
|
||||
stage.Type = strings.Trim(field, "`")
|
||||
}
|
||||
}
|
||||
flushReward()
|
||||
flush()
|
||||
if len(catalog.Stages) == 0 {
|
||||
return catalog, fmt.Errorf("%w: no dungeon stages", ErrDungeonCatalogInvalid)
|
||||
}
|
||||
for _, stage := range catalog.Stages {
|
||||
if stage.GaugeLimit == 0 || stage.ConquestGaugeRatio == 0 || stage.ConquestGaugeRatio > 100 {
|
||||
return catalog, fmt.Errorf("%w: stage %d limit=%d ratio=%d",
|
||||
ErrDungeonCatalogInvalid, stage.Index, stage.GaugeLimit, stage.ConquestGaugeRatio)
|
||||
}
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func isDungeonValue(field string) bool {
|
||||
if strings.HasPrefix(field, "`") {
|
||||
return true
|
||||
}
|
||||
_, err := strconv.ParseInt(field, 10, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ResetDungeonWeek clears every gauge and paid-reward marker when the stored
|
||||
// week id is behind the current one, and returns whether it reset. The layout's
|
||||
// [weekly reward] block ties the cycle to the final dungeon, and the boundary
|
||||
// is the server's existing weekly line (Monday 06:00, premium.ServiceDayID),
|
||||
// not a new one invented here.
|
||||
func (r *Registry) ResetDungeonWeek(guildID uint32, weekID int64) (bool, error) {
|
||||
guild, found := r.Guilds[guildID]
|
||||
if !found || guild == nil {
|
||||
return false, ErrGuildNotFound
|
||||
}
|
||||
if guild.DungeonWeekID == weekID {
|
||||
return false, nil
|
||||
}
|
||||
guild.DungeonWeekID = weekID
|
||||
if len(guild.DungeonGauges) == 0 && len(guild.DungeonRewards) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
guild.DungeonGauges = nil
|
||||
guild.DungeonRewards = nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AddDungeonGauge credits one clear to a guild's stage and returns the gauge
|
||||
// before and after. Clamped to the stage's own limit so a repeat clear of a
|
||||
// finished stage cannot overflow it; the pair lets the caller pay only the
|
||||
// reward thresholds this clear actually crossed.
|
||||
func (r *Registry) AddDungeonGauge(guildID, index, amount, limit uint32) (uint32, uint32, error) {
|
||||
guild, found := r.Guilds[guildID]
|
||||
if !found || guild == nil {
|
||||
return 0, 0, ErrGuildNotFound
|
||||
}
|
||||
if guild.DungeonGauges == nil {
|
||||
guild.DungeonGauges = make(map[uint32]uint32)
|
||||
}
|
||||
previous := guild.DungeonGauges[index]
|
||||
gauge := previous + amount
|
||||
if limit != 0 && gauge > limit {
|
||||
gauge = limit
|
||||
}
|
||||
guild.DungeonGauges[index] = gauge
|
||||
return previous, gauge, nil
|
||||
}
|
||||
|
||||
// ClaimDungeonReward records that one stage's gauge-ratio reward has been paid
|
||||
// this week and reports whether this call is the one that claimed it. Rewards
|
||||
// are guild-wide: the first member to push the gauge over the line collects.
|
||||
func (r *Registry) ClaimDungeonReward(guildID, index, gaugeRatio uint32) (bool, error) {
|
||||
guild, found := r.Guilds[guildID]
|
||||
if !found || guild == nil {
|
||||
return false, ErrGuildNotFound
|
||||
}
|
||||
key := strconv.FormatUint(uint64(index), 10) + ":" + strconv.FormatUint(uint64(gaugeRatio), 10)
|
||||
if guild.DungeonRewards[key] {
|
||||
return false, nil
|
||||
}
|
||||
if guild.DungeonRewards == nil {
|
||||
guild.DungeonRewards = make(map[string]bool)
|
||||
}
|
||||
guild.DungeonRewards[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AddDungeonContribution pays a [contribute] block into the clearing member's
|
||||
// mileage and the guild's, mirroring how attendance credits mileage. Upstream
|
||||
// appends the history row inline; this tree funnels every 公会贡献 write through
|
||||
// grantMileage, so the row is written there instead. The reason byte stays zero,
|
||||
// which is what upstream's row carries (Reason unset) and what the op747 reader
|
||||
// turns into its per-role 签到 fallback.
|
||||
func (r *Registry) AddDungeonContribution(characterID string, amount uint32, now time.Time) error {
|
||||
guild, member, found := r.GuildForCharacter(characterID)
|
||||
if !found {
|
||||
return ErrNotMember
|
||||
}
|
||||
if amount == 0 {
|
||||
return nil
|
||||
}
|
||||
r.grantMileage(guild, characterID, member, amount, 0, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DungeonGauges returns a copy of a guild's stored gauges.
|
||||
func (r *Registry) DungeonGauges(guildID uint32) map[uint32]uint32 {
|
||||
guild, found := r.Guilds[guildID]
|
||||
if !found || guild == nil {
|
||||
return nil
|
||||
}
|
||||
gauges := make(map[uint32]uint32, len(guild.DungeonGauges))
|
||||
for index, value := range guild.DungeonGauges {
|
||||
gauges[index] = value
|
||||
}
|
||||
return gauges
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package guild
|
||||
|
||||
import "testing"
|
||||
|
||||
// The layout file nests [dungeon] blocks with their own subsections, and the
|
||||
// section names span several whitespace-separated fields, so the scanner is the
|
||||
// risky part. This fixture is a trimmed copy of the real
|
||||
// etc/guild/guilddungeon/0_2Way6Dungeon.etc.
|
||||
const dungeonCatalogFixture = `
|
||||
[weekly reward]
|
||||
|
||||
[final dungeon]
|
||||
8508
|
||||
[/weekly reward]
|
||||
|
||||
[dungeon]
|
||||
|
||||
[dungeon index]
|
||||
8500
|
||||
[gauge limit]
|
||||
10000
|
||||
[gauge on clear]
|
||||
150
|
||||
[conquest gauge ratio]
|
||||
50
|
||||
[color]
|
||||
` + "`black`" + `
|
||||
[type]
|
||||
` + "`normal`" + `
|
||||
[reward]
|
||||
|
||||
[gauge ratio]
|
||||
99
|
||||
[item]
|
||||
10154439 3
|
||||
[contribute]
|
||||
20
|
||||
[/gauge ratio]
|
||||
|
||||
[/reward]
|
||||
|
||||
[/dungeon]
|
||||
|
||||
[dungeon]
|
||||
|
||||
[dungeon index]
|
||||
8504
|
||||
[gauge limit]
|
||||
10000
|
||||
[gauge on clear]
|
||||
75
|
||||
[conquest gauge ratio]
|
||||
50
|
||||
[color]
|
||||
` + "`red`" + `
|
||||
[type]
|
||||
` + "`normal`" + `
|
||||
[required dungeon conquest]
|
||||
8500
|
||||
[/required dungeon conquest]
|
||||
|
||||
[/dungeon]
|
||||
|
||||
[dungeon]
|
||||
|
||||
[dungeon index]
|
||||
8503
|
||||
[gauge limit]
|
||||
10000
|
||||
[gauge on clear]
|
||||
75
|
||||
[conquest gauge ratio]
|
||||
50
|
||||
[color]
|
||||
` + "`blue`" + `
|
||||
[type]
|
||||
` + "`normal`" + `
|
||||
|
||||
[/dungeon]
|
||||
|
||||
[dungeon]
|
||||
|
||||
[dungeon index]
|
||||
8505
|
||||
[gauge limit]
|
||||
10000
|
||||
[gauge on clear]
|
||||
50
|
||||
[conquest gauge ratio]
|
||||
50
|
||||
[color]
|
||||
` + "`red`" + `
|
||||
[type]
|
||||
` + "`hard`" + `
|
||||
|
||||
[/dungeon]
|
||||
|
||||
[dungeon]
|
||||
|
||||
[dungeon index]
|
||||
8508
|
||||
[gauge limit]
|
||||
10000
|
||||
[gauge on clear]
|
||||
30
|
||||
[conquest gauge ratio]
|
||||
100
|
||||
[color]
|
||||
` + "`red`" + `
|
||||
[type]
|
||||
` + "`final`" + `
|
||||
[required dungeon conquest]
|
||||
8503 8505
|
||||
[/required dungeon conquest]
|
||||
|
||||
[/dungeon]
|
||||
`
|
||||
|
||||
func TestParseDungeonCatalogReadsStagesAndPrerequisites(t *testing.T) {
|
||||
catalog, err := parseDungeonCatalog(dungeonCatalogFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if catalog.FinalDungeon != 8508 {
|
||||
t.Fatalf("final dungeon=%d want 8508", catalog.FinalDungeon)
|
||||
}
|
||||
if len(catalog.Stages) != 5 {
|
||||
t.Fatalf("stages=%d want 5", len(catalog.Stages))
|
||||
}
|
||||
|
||||
first, ok := catalog.Stage(8500)
|
||||
if !ok {
|
||||
t.Fatal("8500 missing")
|
||||
}
|
||||
if first.GaugeLimit != 10000 || first.GaugeOnClear != 150 || first.ConquestGaugeRatio != 50 {
|
||||
t.Fatalf("8500=%+v", first)
|
||||
}
|
||||
if first.Color != "black" || first.Type != "normal" {
|
||||
t.Fatalf("8500 color=%q type=%q", first.Color, first.Type)
|
||||
}
|
||||
// The reward block's own numbers (99, 10154439, 3, 20) must not leak into
|
||||
// the stage: they sit inside [reward] and belong to a different scope.
|
||||
if len(first.RequiredConquest) != 0 {
|
||||
t.Fatalf("8500 must have no prerequisite, got %v", first.RequiredConquest)
|
||||
}
|
||||
// 50% of 10000.
|
||||
if got := first.ConquestGauge(); got != 5000 {
|
||||
t.Fatalf("8500 conquest gauge=%d want 5000", got)
|
||||
}
|
||||
|
||||
second, _ := catalog.Stage(8504)
|
||||
if len(second.RequiredConquest) != 1 || second.RequiredConquest[0] != 8500 {
|
||||
t.Fatalf("8504 prerequisites=%v want [8500]", second.RequiredConquest)
|
||||
}
|
||||
final, _ := catalog.Stage(8508)
|
||||
if len(final.RequiredConquest) != 2 || final.RequiredConquest[0] != 8503 || final.RequiredConquest[1] != 8505 {
|
||||
t.Fatalf("8508 prerequisites=%v want [8503 8505]", final.RequiredConquest)
|
||||
}
|
||||
if got := final.ConquestGauge(); got != 10000 {
|
||||
t.Fatalf("8508 conquest gauge=%d want 10000 (100%%)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The unlock rule is what the client's own gate mirrors: a stage opens only
|
||||
// once every prerequisite has reached its conquest threshold.
|
||||
func TestDungeonCatalogUnlockFollowsConquest(t *testing.T) {
|
||||
catalog, err := parseDungeonCatalog(dungeonCatalogFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gauges := map[uint32]uint32{}
|
||||
|
||||
// No prerequisites: always open, which is why an empty guild still sees 伤城.
|
||||
if !catalog.Unlocked(gauges, 8500) {
|
||||
t.Fatal("8500 must be open with no gauges")
|
||||
}
|
||||
if catalog.Unlocked(gauges, 8504) {
|
||||
t.Fatal("8504 must be shut before 8500 is conquered")
|
||||
}
|
||||
// One clear is not a conquest: 150 of the 5000 needed.
|
||||
gauges[8500] = 150
|
||||
if catalog.Unlocked(gauges, 8504) {
|
||||
t.Fatal("8504 opened after a single clear of 8500")
|
||||
}
|
||||
gauges[8500] = 4999
|
||||
if catalog.Unlocked(gauges, 8504) {
|
||||
t.Fatal("8504 opened one point short of the threshold")
|
||||
}
|
||||
gauges[8500] = 5000
|
||||
if !catalog.Unlocked(gauges, 8504) {
|
||||
t.Fatal("8504 still shut at exactly the threshold")
|
||||
}
|
||||
|
||||
// The final stage needs both prerequisites, and at 100%.
|
||||
gauges[8503] = 10000
|
||||
if catalog.Unlocked(gauges, 8508) {
|
||||
t.Fatal("8508 opened with only one prerequisite conquered")
|
||||
}
|
||||
// 8505 conquers at 50% of 10000, so one point short must still block.
|
||||
gauges[8505] = 4999
|
||||
if catalog.Unlocked(gauges, 8508) {
|
||||
t.Fatal("8508 opened while 8505 was below its own threshold")
|
||||
}
|
||||
gauges[8505] = 5000
|
||||
if !catalog.Unlocked(gauges, 8508) {
|
||||
t.Fatal("8508 shut with both prerequisites conquered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDungeonGaugeClampsToTheStageLimit(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Guilds[1] = &Guild{ID: 1}
|
||||
// The pair is (before, after): the caller needs both to tell which reward
|
||||
// thresholds this clear actually crossed.
|
||||
if previous, gauge, err := registry.AddDungeonGauge(1, 8500, 150, 10000); err != nil || previous != 0 || gauge != 150 {
|
||||
t.Fatalf("first clear %d->%d err=%v want 0->150", previous, gauge, err)
|
||||
}
|
||||
if previous, gauge, err := registry.AddDungeonGauge(1, 8500, 150, 10000); err != nil || previous != 150 || gauge != 300 {
|
||||
t.Fatalf("second clear %d->%d err=%v want 150->300", previous, gauge, err)
|
||||
}
|
||||
if _, gauge, err := registry.AddDungeonGauge(1, 8500, 100_000, 10000); err != nil || gauge != 10000 {
|
||||
t.Fatalf("clamped gauge=%d err=%v want 10000", gauge, err)
|
||||
}
|
||||
if _, _, err := registry.AddDungeonGauge(99, 8500, 1, 10000); err == nil {
|
||||
t.Fatal("an unknown guild must not accumulate a gauge")
|
||||
}
|
||||
}
|
||||
|
||||
// Rewards are paid on crossing a [gauge ratio] line, once. A repeat clear past
|
||||
// the line must not pay again, and a clear that jumps several lines at once
|
||||
// pays each of them.
|
||||
func TestRewardsCrossedPaysEachThresholdOnce(t *testing.T) {
|
||||
stage := DungeonStage{
|
||||
GaugeLimit: 10000,
|
||||
GaugeOnClear: 150,
|
||||
Rewards: []DungeonReward{
|
||||
{GaugeRatio: 99, ItemID: 10154439, ItemCount: 3, Contribute: 20},
|
||||
{GaugeRatio: 100, ItemID: 10154439, ItemCount: 3, Contribute: 20},
|
||||
},
|
||||
}
|
||||
if got := stage.RewardsCrossed(0, 150); len(got) != 0 {
|
||||
t.Fatalf("an early clear paid %d rewards", len(got))
|
||||
}
|
||||
// 99% of 10000 is 9900.
|
||||
if got := stage.RewardsCrossed(9800, 9900); len(got) != 1 || got[0].GaugeRatio != 99 {
|
||||
t.Fatalf("crossing 99%% paid %+v", got)
|
||||
}
|
||||
// Already past it: nothing more until 100%.
|
||||
if got := stage.RewardsCrossed(9900, 9950); len(got) != 0 {
|
||||
t.Fatalf("staying above 99%% paid again: %+v", got)
|
||||
}
|
||||
if got := stage.RewardsCrossed(9950, 10000); len(got) != 1 || got[0].GaugeRatio != 100 {
|
||||
t.Fatalf("crossing 100%% paid %+v", got)
|
||||
}
|
||||
// One clear that jumps both lines pays both.
|
||||
if got := stage.RewardsCrossed(0, 10000); len(got) != 2 {
|
||||
t.Fatalf("a full jump paid %d rewards, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// The claim marker is guild-wide and survives within the week, so a second
|
||||
// member cannot collect the same threshold.
|
||||
func TestClaimDungeonRewardIsOncePerGuildPerThreshold(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Guilds[1] = &Guild{ID: 1}
|
||||
claimed, err := registry.ClaimDungeonReward(1, 8500, 99)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("first claim=%t err=%v", claimed, err)
|
||||
}
|
||||
claimed, err = registry.ClaimDungeonReward(1, 8500, 99)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("second claim=%t err=%v, must not pay twice", claimed, err)
|
||||
}
|
||||
// A different threshold, and a different stage, are separate claims.
|
||||
if claimed, _ := registry.ClaimDungeonReward(1, 8500, 100); !claimed {
|
||||
t.Fatal("the 100%% threshold was blocked by the 99%% claim")
|
||||
}
|
||||
if claimed, _ := registry.ClaimDungeonReward(1, 8504, 99); !claimed {
|
||||
t.Fatal("another stage was blocked by the first stage's claim")
|
||||
}
|
||||
}
|
||||
|
||||
// A new week wipes gauges and claims so the cycle restarts; the same week is a
|
||||
// no-op, which is what keeps a clear from resetting the bar it just raised.
|
||||
func TestResetDungeonWeekClearsProgressOnlyOnANewWeek(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Guilds[1] = &Guild{ID: 1}
|
||||
if _, _, err := registry.AddDungeonGauge(1, 8500, 9000, 10000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registry.ClaimDungeonReward(1, 8500, 99); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// First settle of a fresh guild stamps the week without wiping anything.
|
||||
if reset, err := registry.ResetDungeonWeek(1, 100); err != nil || !reset {
|
||||
t.Fatalf("first settle reset=%t err=%v", reset, err)
|
||||
}
|
||||
if len(registry.DungeonGauges(1)) != 0 {
|
||||
t.Fatal("the first settle should have cleared the pre-existing progress")
|
||||
}
|
||||
|
||||
if _, _, err := registry.AddDungeonGauge(1, 8500, 600, 10000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Same week: progress must survive.
|
||||
if reset, err := registry.ResetDungeonWeek(1, 100); err != nil || reset {
|
||||
t.Fatalf("same-week reset=%t err=%v", reset, err)
|
||||
}
|
||||
if registry.DungeonGauges(1)[8500] != 600 {
|
||||
t.Fatalf("same-week settle wiped the gauge: %v", registry.DungeonGauges(1))
|
||||
}
|
||||
// Next week: wiped, claims too.
|
||||
if reset, err := registry.ResetDungeonWeek(1, 101); err != nil || !reset {
|
||||
t.Fatalf("new-week reset=%t err=%v", reset, err)
|
||||
}
|
||||
if len(registry.DungeonGauges(1)) != 0 {
|
||||
t.Fatalf("new week kept gauges: %v", registry.DungeonGauges(1))
|
||||
}
|
||||
if claimed, _ := registry.ClaimDungeonReward(1, 8500, 99); !claimed {
|
||||
t.Fatal("new week kept last week's reward claim")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type GuardianCost struct {
|
||||
AP uint32
|
||||
GP uint32
|
||||
}
|
||||
|
||||
func LoadAreaCosts(source dnfpvf.Source) (map[byte]GuardianCost, error) {
|
||||
if source == nil {
|
||||
return nil, ErrRepositoryUnavailable
|
||||
}
|
||||
text, err := source.ReadText("etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := dnfpvf.Parse("etc/agitwar.etc", text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, _ := doc.Section("area extend")
|
||||
if len(tokens) == 0 || len(tokens)%3 != 0 || len(tokens)/3 > math.MaxUint8 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
costs := make(map[byte]GuardianCost)
|
||||
for i := 0; i < len(tokens); i += 3 {
|
||||
for _, token := range tokens[i : i+3] {
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int < 0 || token.Int > math.MaxInt32 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
if tokens[i].Int != int64(i/3+1) {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
costs[byte(tokens[i].Int)] = GuardianCost{AP: uint32(tokens[i+1].Int), GP: uint32(tokens[i+2].Int)}
|
||||
}
|
||||
return costs, nil
|
||||
}
|
||||
|
||||
func (r *Registry) ExtendArea(characterID string, level byte, costs map[byte]GuardianCost, now time.Time) error {
|
||||
guild, member, found := r.GuildForCharacter(characterID)
|
||||
if !found {
|
||||
return ErrNotMember
|
||||
}
|
||||
if member.Grade != DefaultMasterGrade || guild.MasterCharacterID != characterID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
cost, valid := costs[level]
|
||||
if !valid || level == 0 || int(level) != int(guild.AreaLevel)+1 {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
if guild.GuardianAP < cost.AP || guild.GuardianGP < cost.GP {
|
||||
return ErrInsufficientGuardian
|
||||
}
|
||||
guild.GuardianAP -= cost.AP
|
||||
guild.GuardianGP -= cost.GP
|
||||
guild.AreaLevel, guild.UpdatedAt = level, now
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadGuardianCosts(source dnfpvf.Source) (map[uint32]GuardianCost, error) {
|
||||
if source == nil {
|
||||
return nil, ErrRepositoryUnavailable
|
||||
}
|
||||
text, err := source.ReadText("etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := dnfpvf.Parse("etc/agitwar.etc", text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
costs := make(map[uint32]GuardianCost)
|
||||
var id uint32
|
||||
var cost GuardianCost
|
||||
var inList, haveAP, haveGP bool
|
||||
invalid := fmt.Errorf("guild guardian catalog: %w", ErrInvalidContent)
|
||||
for _, section := range doc.Sections {
|
||||
tokens := doc.Tokens[section.Start:section.End]
|
||||
switch section.Name {
|
||||
case "guardian list":
|
||||
inList = true
|
||||
case "/guardian list":
|
||||
inList = false
|
||||
case "guardian", "ap", "gp":
|
||||
if !inList || (section.Name != "guardian" && id == 0) {
|
||||
continue
|
||||
}
|
||||
if len(tokens) != 1 || tokens[0].Kind != dnfpvf.TokenInt || tokens[0].Int < 0 || tokens[0].Int > math.MaxInt32 {
|
||||
return nil, invalid
|
||||
}
|
||||
value := uint32(tokens[0].Int)
|
||||
switch section.Name {
|
||||
case "guardian":
|
||||
if id != 0 || value == 0 {
|
||||
return nil, invalid
|
||||
}
|
||||
id, cost, haveAP, haveGP = value, GuardianCost{}, false, false
|
||||
case "ap":
|
||||
if haveAP {
|
||||
return nil, invalid
|
||||
}
|
||||
cost.AP, haveAP = value, true
|
||||
case "gp":
|
||||
if haveGP {
|
||||
return nil, invalid
|
||||
}
|
||||
cost.GP, haveGP = value, true
|
||||
}
|
||||
case "/guardian":
|
||||
if !inList {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := costs[id]; duplicate || id == 0 || !haveAP || !haveGP {
|
||||
return nil, invalid
|
||||
}
|
||||
costs[id], id = cost, 0
|
||||
}
|
||||
}
|
||||
if id != 0 || inList || len(costs) == 0 || len(costs) > 5 {
|
||||
return nil, invalid
|
||||
}
|
||||
return costs, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type GuardianGrowth struct {
|
||||
Experience map[byte]GuardianCost
|
||||
Skills map[byte]GuardianCost
|
||||
SkillLimits map[uint32][5]byte
|
||||
}
|
||||
|
||||
func readWarDocument(source dnfpvf.Source, name string) (*dnfpvf.Document, error) {
|
||||
if source == nil {
|
||||
return nil, ErrRepositoryUnavailable
|
||||
}
|
||||
text, err := source.ReadText(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dnfpvf.Parse(name, text)
|
||||
}
|
||||
|
||||
func warUnsigned(tokens []dnfpvf.Token) ([]uint32, error) {
|
||||
out := make([]uint32, len(tokens))
|
||||
for i, token := range tokens {
|
||||
if token.Kind != dnfpvf.TokenInt || token.Int < 0 || token.Int > math.MaxInt32 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
out[i] = uint32(token.Int)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func warCostTriples(doc *dnfpvf.Document, section string) (map[byte]GuardianCost, error) {
|
||||
tokens, _ := doc.Section(section)
|
||||
values, err := warUnsigned(tokens)
|
||||
if err != nil || len(values) == 0 || len(values)%3 != 0 || len(values)/3 >= 50 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
out := make(map[byte]GuardianCost)
|
||||
for i := 0; i < len(values); i += 3 {
|
||||
if values[i] != uint32(i/3+1) {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
out[byte(values[i])] = GuardianCost{values[i+1], values[i+2]}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func LoadGuardianGrowth(source dnfpvf.Source) (GuardianGrowth, error) {
|
||||
var out GuardianGrowth
|
||||
doc, err := readWarDocument(source, "etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Experience, err = warCostTriples(doc, "guardian exp")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Skills, err = warCostTriples(doc, "guardian skill")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// D01005..D01094 indexes the cost map by the current skill level.
|
||||
// Its native map insertion at level 0 creates the free initial unlock.
|
||||
out.Skills[0] = GuardianCost{}
|
||||
out.SkillLimits = make(map[uint32][5]byte)
|
||||
var id uint32
|
||||
inList := false
|
||||
for _, sec := range doc.Sections {
|
||||
tokens := doc.Tokens[sec.Start:sec.End]
|
||||
switch sec.Name {
|
||||
case "guardian list":
|
||||
inList = true
|
||||
case "/guardian list":
|
||||
inList = false
|
||||
case "guardian":
|
||||
if inList {
|
||||
v, e := warUnsigned(tokens)
|
||||
if e != nil || len(v) != 1 || v[0] == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
id = v[0]
|
||||
}
|
||||
case "/guardian":
|
||||
id = 0
|
||||
default:
|
||||
if !inList || id == 0 {
|
||||
continue
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if sec.Name != fmt.Sprintf("skill_%d", i+1) {
|
||||
continue
|
||||
}
|
||||
if len(tokens) < 3 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
v, e := warUnsigned(tokens[:3])
|
||||
if e != nil || v[2] == 0 || v[2] > uint32(len(out.Skills)) {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
limits := out.SkillLimits[id]
|
||||
if limits[i] != 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
limits[i] = byte(v[2])
|
||||
out.SkillLimits[id] = limits
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out.SkillLimits) == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
for _, limits := range out.SkillLimits {
|
||||
for _, v := range limits {
|
||||
if v == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
}
|
||||
for level := byte(2); int(level) <= len(out.Experience); level++ {
|
||||
prev, next := out.Experience[level-1], out.Experience[level]
|
||||
if next.AP < prev.AP || next.GP < prev.GP {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Registry) warManager(characterID string) (*Guild, error) {
|
||||
g, m, found := r.GuildForCharacter(characterID)
|
||||
if !found {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
// Native guardian controls check guild permission index 16 (26A3BE0).
|
||||
if g.Permissions(m.Grade)&(1<<16) == 0 {
|
||||
return nil, ErrPermissionDenied
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *Registry) GrowGuardian(characterID string, action byte, id, before, amount uint32, catalog GuardianGrowth, now time.Time) error {
|
||||
g, err := r.warManager(characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, valid := catalog.SkillLimits[id]
|
||||
if !valid || action > 6 || amount == 0 {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
var guardian *Guardian
|
||||
for i := range g.Guardians {
|
||||
if g.Guardians[i].ID == id {
|
||||
guardian = &g.Guardians[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if guardian == nil || guardian.Level == 0 || int(guardian.Level) > len(catalog.Experience)+1 {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
if action >= 2 {
|
||||
i := int(action - 2)
|
||||
if i >= int((guardian.Level-1)/10+1) || amount != before+1 || amount > uint32(limits[i]) {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
if uint32(guardian.Skills[i]) == amount {
|
||||
return nil
|
||||
}
|
||||
cost, ok := catalog.Skills[byte(before)]
|
||||
if before > math.MaxUint8 || !ok || uint32(guardian.Skills[i]) != before {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
if g.GuardianAP < cost.AP || g.GuardianGP < cost.GP {
|
||||
return ErrInsufficientGuardian
|
||||
}
|
||||
g.GuardianAP -= cost.AP
|
||||
g.GuardianGP -= cost.GP
|
||||
guardian.Skills[i] = byte(amount)
|
||||
} else {
|
||||
exp, balance := &guardian.APExp, &g.GuardianAP
|
||||
if action == 1 {
|
||||
exp, balance = &guardian.GPExp, &g.GuardianGP
|
||||
}
|
||||
if uint64(before)+uint64(amount) > math.MaxInt32 {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
if *exp == before+amount {
|
||||
return nil
|
||||
}
|
||||
need, ok := catalog.Experience[guardian.Level]
|
||||
if !ok || before != *exp {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
limit := need.AP
|
||||
if action == 1 {
|
||||
limit = need.GP
|
||||
}
|
||||
if before+amount > limit {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
if *balance < amount {
|
||||
return ErrInsufficientGuardian
|
||||
}
|
||||
*balance -= amount
|
||||
*exp += amount
|
||||
// The native progress bars display absolute AP/GP experience against
|
||||
// the current level's PVF thresholds; retain it across level changes.
|
||||
if guardian.APExp >= need.AP && guardian.GPExp >= need.GP {
|
||||
guardian.Level++
|
||||
}
|
||||
}
|
||||
g.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,166 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestRegistryLifecyclePersistsAndEnforcesAuthority(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
now := time.Date(2026, 8, 19, 10, 0, 0, 0, time.Local)
|
||||
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
|
||||
applicant := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:1", Name: "member", Level: 90}
|
||||
|
||||
registry := NewRegistry()
|
||||
created, err := registry.Create(master, "guild-one", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID != 1 || registry.NameAvailable("GUILD-ONE") {
|
||||
t.Fatalf("created=%+v available=%t", created, registry.NameAvailable("GUILD-ONE"))
|
||||
}
|
||||
if err := registry.Apply(created.ID, applicant, "join", now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registry.DecideApplication(created.ID, applicant.CharacterID, applicant.CharacterID, true, now); !errors.Is(err, ErrPermissionDenied) {
|
||||
t.Fatalf("unauthorized approval error=%v", err)
|
||||
}
|
||||
member, err := registry.DecideApplication(created.ID, master.CharacterID, applicant.CharacterID, true, now.Add(2*time.Minute))
|
||||
if err != nil || member.Grade != DefaultMemberGrade {
|
||||
t.Fatalf("approved member=%+v error=%v", member, err)
|
||||
}
|
||||
if err := registry.ChangeGrade(master.CharacterID, applicant.CharacterID, 2, now.Add(3*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Delegate(master.CharacterID, applicant.CharacterID, now.Add(4*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Save(ctx, repositories.Settings, registry, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := Load(ctx, repositories.Settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, found := reloaded.GuildForCharacter(applicant.CharacterID)
|
||||
if !found || got.MasterCharacterID != applicant.CharacterID || got.MasterName != applicant.Name {
|
||||
t.Fatalf("reloaded guild=%+v found=%t", got, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryInviteReplyAndDailyAttendance(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 9, 0, 0, 0, time.Local)
|
||||
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
|
||||
target := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:1", Name: "target", Level: 80}
|
||||
registry := NewRegistry()
|
||||
guild, err := registry.Create(master, "guild-two", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Invite(master.CharacterID, target, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined, err := registry.ReplyInvitation(target, true, now.Add(time.Minute))
|
||||
if err != nil || joined.ID != guild.ID {
|
||||
t.Fatalf("joined=%+v error=%v", joined, err)
|
||||
}
|
||||
if attended, err := registry.Attend(target.CharacterID, now.Add(2*time.Minute)); err != nil || attended {
|
||||
t.Fatalf("same-day attendance=%t error=%v", attended, err)
|
||||
}
|
||||
if attended, err := registry.Attend(target.CharacterID, now.Add(25*time.Hour)); err != nil || !attended {
|
||||
t.Fatalf("next-day attendance=%t error=%v", attended, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryMemoAuthority(t *testing.T) {
|
||||
now := time.Date(2026, 8, 19, 9, 0, 0, 0, time.Local)
|
||||
master := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master", Level: 90}
|
||||
member := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:1", Name: "member", Level: 80}
|
||||
registry := NewRegistry()
|
||||
guild, err := registry.Create(master, "guild-memo", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Invite(master.CharacterID, member, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registry.ReplyInvitation(member, true, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.SetMemo(member.CharacterID, member.CharacterID, "self", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.SetMemo(member.CharacterID, master.CharacterID, "forged", now); !errors.Is(err, ErrPermissionDenied) {
|
||||
t.Fatalf("member editing master memo error=%v", err)
|
||||
}
|
||||
if err := registry.SetMemo(master.CharacterID, member.CharacterID, "reviewed", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := guild.Members[member.CharacterID].Memo; got != "reviewed" {
|
||||
t.Fatalf("member memo=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryMembershipTransitionsClearApplicationsInEveryGuild(t *testing.T) {
|
||||
now := time.Date(2026, 8, 22, 14, 0, 0, 0, time.Local)
|
||||
masterOne := dnfrepo.CharacterRecord{CharacterID: "1", AccountID: "dnf:1", Name: "master-one", Level: 90}
|
||||
applicant := dnfrepo.CharacterRecord{CharacterID: "2", AccountID: "dnf:1", Name: "applicant", Level: 90}
|
||||
masterTwo := dnfrepo.CharacterRecord{CharacterID: "3", AccountID: "dnf:1", Name: "master-two", Level: 90}
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
guildOne, err := registry.Create(masterOne, "guild-one", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guildTwo, err := registry.Create(masterTwo, "guild-two", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Apply(guildOne.ID, applicant, "one", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Apply(guildTwo.ID, applicant, "two", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registry.Create(applicant, "guild-three", now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := guildOne.Applications[applicant.CharacterID]; found {
|
||||
t.Fatal("guild-one retained the creator's stale application")
|
||||
}
|
||||
if _, found := guildTwo.Applications[applicant.CharacterID]; found {
|
||||
t.Fatal("guild-two retained the creator's stale application")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approve", func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
guildOne, err := registry.Create(masterOne, "guild-one", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guildTwo, err := registry.Create(masterTwo, "guild-two", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Apply(guildOne.ID, applicant, "one", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Apply(guildTwo.ID, applicant, "two", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := registry.DecideApplication(guildOne.ID, masterOne.CharacterID, applicant.CharacterID, true, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := guildTwo.Applications[applicant.CharacterID]; found {
|
||||
t.Fatal("another guild retained the new member's stale application")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWarClosed = errors.New("guild war is closed")
|
||||
ErrWarNoOpponent = errors.New("no eligible rival guild")
|
||||
ErrWarActive = errors.New("guild war already active")
|
||||
ErrWarNotActive = errors.New("guild war is not active")
|
||||
ErrWarLimit = errors.New("guild war daily participation limit")
|
||||
ErrWarGuardian = errors.New("guild war requires an active guardian")
|
||||
)
|
||||
|
||||
type WarRules struct {
|
||||
Days [7]bool
|
||||
PauseStart, PauseEnd int
|
||||
PlaySeconds, DailyMax, EnemyHP, ClearDamage uint32
|
||||
FriendlyHP uint32
|
||||
EnemyDungeon uint32
|
||||
Dungeons map[uint32]bool
|
||||
RequiredClear []uint32
|
||||
ChallengeCosts []uint32
|
||||
}
|
||||
|
||||
func LoadWarRules(source dnfpvf.Source) (*WarRules, error) {
|
||||
doc, err := readWarDocument(source, "etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &WarRules{Dungeons: make(map[uint32]bool)}
|
||||
values := func(name string) ([]uint32, error) {
|
||||
t, ok := doc.Section(name)
|
||||
if !ok {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
return warUnsigned(t)
|
||||
}
|
||||
days, err := values("agitwar day")
|
||||
if err != nil || len(days) == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
for _, day := range days {
|
||||
if day > 6 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
r.Days[day] = true
|
||||
}
|
||||
pause, ok := doc.Section("war limit time")
|
||||
if !ok || len(pause) != 2 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
for i, t := range pause {
|
||||
v := strings.Split(t.Value, ":")
|
||||
if len(v) != 2 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
h, e := strconv.Atoi(v[0])
|
||||
m, e2 := strconv.Atoi(v[1])
|
||||
if e != nil || e2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if i == 0 {
|
||||
r.PauseStart = h*60 + m
|
||||
} else {
|
||||
r.PauseEnd = h*60 + m
|
||||
}
|
||||
}
|
||||
for _, s := range []struct {
|
||||
name string
|
||||
dst *uint32
|
||||
}{{"play time", &r.PlaySeconds}, {"daily max challenge", &r.DailyMax}, {"enemy hp", &r.EnemyHP}, {"clear damage", &r.ClearDamage}} {
|
||||
v, e := values(s.name)
|
||||
if e != nil || len(v) != 1 || v[0] == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
*s.dst = v[0]
|
||||
}
|
||||
if r.PlaySeconds > 86400 || r.DailyMax > 100 || r.ClearDamage > r.EnemyHP {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if v, e := values("hp"); e == nil && len(v) > 0 {
|
||||
r.FriendlyHP = v[0]
|
||||
} else {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
r.ChallengeCosts, err = values("challenge chance")
|
||||
if err != nil || len(r.ChallengeCosts) == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
r.RequiredClear, err = values("required clear dungeon")
|
||||
if err != nil || len(r.RequiredClear) == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
scope := ""
|
||||
for _, sec := range doc.Sections {
|
||||
switch sec.Name {
|
||||
case "enemy agit", "friend agit", "cannon", "support dungeon", "underpass dungeon":
|
||||
scope = sec.Name
|
||||
case "/enemy agit", "/friend agit", "/cannon", "/support dungeon", "/underpass dungeon":
|
||||
scope = ""
|
||||
case "index":
|
||||
if scope != "" {
|
||||
v, e := warUnsigned(doc.Tokens[sec.Start:sec.End])
|
||||
if e != nil || len(v) != 1 || v[0] == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
r.Dungeons[v[0]] = true
|
||||
if scope == "enemy agit" {
|
||||
r.EnemyDungeon = v[0]
|
||||
}
|
||||
}
|
||||
case "enemy barricade dungeon", "friend barricade dungeon":
|
||||
v, e := warUnsigned(doc.Tokens[sec.Start:sec.End])
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, id := range v {
|
||||
if id == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
r.Dungeons[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.EnemyDungeon == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
for _, id := range r.RequiredClear {
|
||||
if !r.Dungeons[id] {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *WarRules) Open(now time.Time) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
local := now.In(time.FixedZone("CST", 8*3600))
|
||||
day, _ := WarDay(now)
|
||||
minute := local.Hour()*60 + local.Minute()
|
||||
paused := minute >= r.PauseStart && minute < r.PauseEnd
|
||||
if r.PauseStart > r.PauseEnd {
|
||||
paused = minute >= r.PauseStart || minute < r.PauseEnd
|
||||
}
|
||||
return r.Days[int(day.Weekday())] && !paused
|
||||
}
|
||||
|
||||
// D13FD0 computes the next Monday at 06:00 for the native season boundary.
|
||||
func WarSeason(now time.Time) (time.Time, time.Time) {
|
||||
day, _ := WarDay(now)
|
||||
start := day.AddDate(0, 0, -(int(day.Weekday())+6)%7)
|
||||
return start, start.AddDate(0, 0, 7)
|
||||
}
|
||||
|
||||
type WarBattle struct {
|
||||
Key string `json:"key"`
|
||||
Opponent uint32 `json:"opponent"`
|
||||
OpponentGuardian Guardian `json:"opponent_guardian"`
|
||||
OwnGuardian Guardian `json:"own_guardian"`
|
||||
StartedAt, EndsAt int64
|
||||
EnemyHP uint32 `json:"enemy_hp"`
|
||||
Clears map[uint32]uint32 `json:"clears,omitempty"`
|
||||
Members map[string]bool `json:"members,omitempty"`
|
||||
Receipts map[string]bool `json:"receipts,omitempty"`
|
||||
}
|
||||
type WarState struct {
|
||||
DayStart, SeasonStart int64
|
||||
NextBattle uint64 `json:"next_battle"`
|
||||
Rivals []uint32 `json:"rivals,omitempty"`
|
||||
Results map[uint32]uint32 `json:"results,omitempty"`
|
||||
Participants map[string]uint32 `json:"participants,omitempty"`
|
||||
AttackWins, AttackLosses, DefenceWins, DefenceLosses uint32
|
||||
Battle *WarBattle `json:"battle,omitempty"`
|
||||
LastBattle *WarBattle `json:"last_battle,omitempty"`
|
||||
}
|
||||
|
||||
func (r *Registry) AdvanceWars(now time.Time) {
|
||||
for _, g := range r.Guilds {
|
||||
if g != nil && g.War.Battle != nil && now.Unix() >= g.War.Battle.EndsAt {
|
||||
r.finishWar(g, false)
|
||||
}
|
||||
}
|
||||
day, _ := WarDay(now)
|
||||
season, _ := WarSeason(now)
|
||||
for _, g := range r.Guilds {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
w := &g.War
|
||||
if w.DayStart != day.Unix() {
|
||||
w.DayStart = day.Unix()
|
||||
w.Rivals = nil
|
||||
w.Results = nil
|
||||
w.Participants = nil
|
||||
}
|
||||
if w.SeasonStart != season.Unix() {
|
||||
w.SeasonStart = season.Unix()
|
||||
w.AttackWins = 0
|
||||
w.AttackLosses = 0
|
||||
w.DefenceWins = 0
|
||||
w.DefenceLosses = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) AddWarRivals(characterID string, initial bool, rules *WarRules, now time.Time) (*Guild, error) {
|
||||
g, m, ok := r.GuildForCharacter(characterID)
|
||||
if !ok {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
if !rules.Open(now) {
|
||||
return nil, ErrWarClosed
|
||||
}
|
||||
if !initial && g.Permissions(m.Grade)&(1<<16) == 0 {
|
||||
return nil, ErrPermissionDenied
|
||||
}
|
||||
r.AdvanceWars(now)
|
||||
if initial && len(g.War.Rivals) > 0 {
|
||||
return g, nil
|
||||
}
|
||||
if len(g.War.Rivals) >= 40 {
|
||||
return nil, ErrWarLimit
|
||||
}
|
||||
existing := map[uint32]bool{g.ID: true}
|
||||
for _, id := range g.War.Rivals {
|
||||
existing[id] = true
|
||||
}
|
||||
candidates := []uint32{}
|
||||
for id, other := range r.Guilds {
|
||||
_, hasGuardian := other.WarGuardian()
|
||||
if other != nil && !existing[id] && hasGuardian && len(other.Members) > 0 {
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool { return candidates[i] < candidates[j] })
|
||||
if len(candidates) == 0 {
|
||||
return nil, ErrWarNoOpponent
|
||||
}
|
||||
costIndex := len(g.War.Rivals) / 8
|
||||
if costIndex >= len(rules.ChallengeCosts) {
|
||||
return nil, ErrWarLimit
|
||||
}
|
||||
cost := rules.ChallengeCosts[costIndex]
|
||||
if initial {
|
||||
cost = 0
|
||||
}
|
||||
if g.GuardianGP < cost {
|
||||
return nil, ErrInsufficientGuardian
|
||||
}
|
||||
g.GuardianGP -= cost
|
||||
g.War.Rivals = append(g.War.Rivals, candidates[:min(8, min(len(candidates), 40-len(g.War.Rivals)))]...)
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *Registry) StartWar(characterID string, target uint32, rules *WarRules, now time.Time) (*Guild, error) {
|
||||
g, m, ok := r.GuildForCharacter(characterID)
|
||||
if !ok {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
if g.Permissions(m.Grade)&(1<<16) == 0 {
|
||||
return nil, ErrPermissionDenied
|
||||
}
|
||||
if !rules.Open(now) {
|
||||
return nil, ErrWarClosed
|
||||
}
|
||||
r.AdvanceWars(now)
|
||||
if b := g.War.Battle; b != nil {
|
||||
if b.Opponent == target {
|
||||
return g, nil
|
||||
}
|
||||
return nil, ErrWarActive
|
||||
}
|
||||
ownGuardian, ownReady := g.WarGuardian()
|
||||
if !ownReady {
|
||||
return nil, ErrWarGuardian
|
||||
}
|
||||
candidate := false
|
||||
for _, id := range g.War.Rivals {
|
||||
candidate = candidate || id == target
|
||||
}
|
||||
enemy := r.Guilds[target]
|
||||
enemyGuardian, enemyReady := enemy.WarGuardian()
|
||||
if !candidate || enemy == nil || target == g.ID || !enemyReady {
|
||||
return nil, ErrWarNoOpponent
|
||||
}
|
||||
if g.War.Results[target] != 0 {
|
||||
return nil, ErrWarLimit
|
||||
}
|
||||
if g.War.Participants[characterID] >= rules.DailyMax {
|
||||
return nil, ErrWarLimit
|
||||
}
|
||||
g.War.NextBattle++
|
||||
end := now.Add(time.Duration(rules.PlaySeconds) * time.Second)
|
||||
_, dayEnd := WarDay(now)
|
||||
pause := dayEnd.Add(time.Duration(rules.PauseStart-360) * time.Minute)
|
||||
if pause.After(now) && pause.Before(end) {
|
||||
end = pause
|
||||
}
|
||||
g.War.Battle = &WarBattle{Key: fmt.Sprintf("%d:%d:%d", g.ID, g.War.NextBattle, now.UnixNano()), Opponent: target, StartedAt: now.Unix(), EndsAt: end.Unix(), EnemyHP: rules.EnemyHP, Clears: make(map[uint32]uint32), Members: make(map[string]bool), Receipts: make(map[string]bool)}
|
||||
g.War.Battle.OpponentGuardian, g.War.Battle.OwnGuardian = enemyGuardian, ownGuardian
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *Registry) finishWar(g *Guild, won bool) {
|
||||
b := g.War.Battle
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if g.War.Results == nil {
|
||||
g.War.Results = make(map[uint32]uint32)
|
||||
}
|
||||
enemy := r.Guilds[b.Opponent]
|
||||
if won {
|
||||
g.War.AttackWins++
|
||||
g.War.Results[b.Opponent] = 1
|
||||
if enemy != nil {
|
||||
enemy.War.DefenceLosses++
|
||||
}
|
||||
} else {
|
||||
g.War.AttackLosses++
|
||||
g.War.Results[b.Opponent] = 2
|
||||
if enemy != nil {
|
||||
enemy.War.DefenceWins++
|
||||
}
|
||||
}
|
||||
g.War.LastBattle = b
|
||||
g.War.Battle = nil
|
||||
}
|
||||
|
||||
func (r *Registry) CancelWar(characterID string, now time.Time) (*Guild, error) {
|
||||
g, m, ok := r.GuildForCharacter(characterID)
|
||||
if !ok {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
if g.Permissions(m.Grade)&(1<<16) == 0 {
|
||||
return nil, ErrPermissionDenied
|
||||
}
|
||||
r.AdvanceWars(now)
|
||||
r.finishWar(g, false)
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r Registry) WarAdmission(characterID string, dungeon uint32, rules *WarRules, now time.Time) (*Guild, error) {
|
||||
g, _, ok := r.GuildForCharacter(characterID)
|
||||
if !ok {
|
||||
return nil, ErrNotMember
|
||||
}
|
||||
b := g.War.Battle
|
||||
if !rules.Open(now) || b == nil || now.Unix() >= b.EndsAt || !rules.Dungeons[dungeon] {
|
||||
return nil, ErrWarNotActive
|
||||
}
|
||||
if !b.Members[characterID] && g.War.Participants[characterID] >= rules.DailyMax {
|
||||
return nil, ErrWarLimit
|
||||
}
|
||||
if dungeon == rules.EnemyDungeon {
|
||||
unlocked := false
|
||||
for _, id := range rules.RequiredClear {
|
||||
unlocked = unlocked || b.Clears[id] > 0
|
||||
}
|
||||
if !unlocked {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *Registry) JoinWar(characterID, key string, dungeon uint32, rules *WarRules, now time.Time) (bool, error) {
|
||||
g, err := r.WarAdmission(characterID, dungeon, rules, now)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
b := g.War.Battle
|
||||
if b.Key != key {
|
||||
return false, ErrWarNotActive
|
||||
}
|
||||
if b.Members[characterID] {
|
||||
return false, nil
|
||||
}
|
||||
if b.Members == nil {
|
||||
b.Members = make(map[string]bool)
|
||||
}
|
||||
if g.War.Participants == nil {
|
||||
g.War.Participants = make(map[string]uint32)
|
||||
}
|
||||
b.Members[characterID] = true
|
||||
g.War.Participants[characterID]++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *Registry) ClearWar(characterID, key, receipt string, dungeon uint32, rules *WarRules, now time.Time) error {
|
||||
g, _, ok := r.GuildForCharacter(characterID)
|
||||
if !ok {
|
||||
return ErrNotMember
|
||||
}
|
||||
b := g.War.Battle
|
||||
if last := g.War.LastBattle; last != nil && last.Key == key && last.Members[characterID] && last.Receipts[receipt] {
|
||||
return nil
|
||||
}
|
||||
if b == nil || b.Key != key || !b.Members[characterID] || now.Unix() >= b.EndsAt || !rules.Dungeons[dungeon] || receipt == "" {
|
||||
return ErrWarNotActive
|
||||
}
|
||||
if b.Receipts[receipt] {
|
||||
return nil
|
||||
}
|
||||
if b.Receipts == nil {
|
||||
b.Receipts = make(map[string]bool)
|
||||
}
|
||||
if b.Clears == nil {
|
||||
b.Clears = make(map[uint32]uint32)
|
||||
}
|
||||
b.Receipts[receipt] = true
|
||||
b.Clears[dungeon]++
|
||||
if dungeon == rules.EnemyDungeon {
|
||||
b.EnemyHP -= min(b.EnemyHP, rules.ClearDamage)
|
||||
if b.EnemyHP == 0 {
|
||||
r.finishWar(g, true)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package guild
|
||||
|
||||
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
|
||||
type WarGuardianCatalog struct {
|
||||
Dummy uint32
|
||||
Monsters map[uint32][]uint32
|
||||
}
|
||||
|
||||
func LoadWarGuardians(source dnfpvf.Source) (*WarGuardianCatalog, error) {
|
||||
doc, err := readWarDocument(source, "etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, _ := doc.Section("guardian dummy monster")
|
||||
v, err := warUnsigned(tokens)
|
||||
if err != nil || len(v) != 1 || v[0] == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
c := &WarGuardianCatalog{Dummy: v[0], Monsters: make(map[uint32][]uint32)}
|
||||
inside := false
|
||||
var id uint32
|
||||
for _, sec := range doc.Sections {
|
||||
switch sec.Name {
|
||||
case "guardian list":
|
||||
inside = true
|
||||
case "/guardian list":
|
||||
inside = false
|
||||
case "guardian":
|
||||
if inside {
|
||||
values, e := warUnsigned(doc.Tokens[sec.Start:sec.End])
|
||||
if e != nil || len(values) != 1 || values[0] == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
id = values[0]
|
||||
}
|
||||
case "/guardian":
|
||||
id = 0
|
||||
case "index":
|
||||
if inside && id != 0 {
|
||||
values, e := warUnsigned(doc.Tokens[sec.Start:sec.End])
|
||||
if e != nil || len(values) != 5 || c.Monsters[id] != nil {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
c.Monsters[id] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.Monsters) == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (g *Guild) WarGuardian() (Guardian, bool) {
|
||||
if g != nil {
|
||||
for _, guardian := range g.Guardians {
|
||||
if guardian.ID == g.ActiveGuardian && guardian.ID != 0 && guardian.Level >= 1 && guardian.Level <= 50 {
|
||||
return guardian, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Guardian{}, false
|
||||
}
|
||||
|
||||
func (c *WarGuardianCatalog) Monster(guardian Guardian) (uint32, error) {
|
||||
if c == nil || guardian.Level < 1 || guardian.Level > 50 {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
ids := c.Monsters[guardian.ID]
|
||||
// Native D4F69C..D4F6B5 derives the guardian star from (level-1)/10.
|
||||
star := int(guardian.Level-1) / 10
|
||||
if star >= len(ids) {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
return ids[star], nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
func LoadWarMissionsCatalog(source dnfpvf.Source) (*WarMissionCatalog, error) {
|
||||
doc, err := readWarDocument(source, "etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &WarMissionCatalog{}
|
||||
inSystem := false
|
||||
var m *WarMission
|
||||
categorySeen := false
|
||||
seen := make(map[uint32]bool)
|
||||
for _, sec := range doc.Sections {
|
||||
t := doc.Tokens[sec.Start:sec.End]
|
||||
if sec.Name == "mission system" {
|
||||
inSystem = true
|
||||
continue
|
||||
}
|
||||
if sec.Name == "/mission system" {
|
||||
if m != nil {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
inSystem = false
|
||||
continue
|
||||
}
|
||||
if !inSystem {
|
||||
continue
|
||||
}
|
||||
if sec.Name == "daily title mission index" || sec.Name == "season title mission index" {
|
||||
v, e := warUnsigned(t)
|
||||
if e != nil || len(v) != 1 || v[0] == 0 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if sec.Name == "daily title mission index" {
|
||||
out.DailyTitle = v[0]
|
||||
} else {
|
||||
out.SeasonTitle = v[0]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sec.Name == "mission" {
|
||||
if m != nil {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
m = &WarMission{}
|
||||
categorySeen = false
|
||||
continue
|
||||
}
|
||||
if sec.Name == "/mission" {
|
||||
if m == nil || !categorySeen {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
if m.ID == 0 || seen[m.ID] || m.Title == "" || m.Required == 0 || len(m.Rewards) == 0 || (m.Condition != "join" && m.Condition != "clear" && m.Condition != "mission") {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
seen[m.ID] = true
|
||||
out.Missions = append(out.Missions, *m)
|
||||
m = nil
|
||||
continue
|
||||
}
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
switch sec.Name {
|
||||
case "index":
|
||||
v, e := warUnsigned(t)
|
||||
if e != nil || len(v) != 1 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
m.ID = v[0]
|
||||
case "category":
|
||||
if categorySeen {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
categorySeen = true
|
||||
if len(t) != 1 || (t[0].Value != "daily" && t[0].Value != "season") {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
m.Season = t[0].Value == "season"
|
||||
case "title":
|
||||
if len(t) != 1 {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
m.Title = t[0].Value
|
||||
case "condition":
|
||||
if len(t) != 2 {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
v, e := warUnsigned(t[1:])
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
m.Condition = t[0].Value
|
||||
m.Required = v[0]
|
||||
case "dungeon":
|
||||
v, e := warUnsigned(t)
|
||||
if e != nil || len(v) == 0 {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
m.Dungeons = v
|
||||
case "reward":
|
||||
v, e := warUnsigned(t)
|
||||
if e != nil || len(v) == 0 || len(v)%2 != 0 {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
for i := 0; i < len(v); i += 2 {
|
||||
if v[i] == 0 || v[i+1] == 0 {
|
||||
return nil, invalidWarMission(m.ID)
|
||||
}
|
||||
m.Rewards = append(m.Rewards, WarMissionReward{v[i], v[i+1]})
|
||||
}
|
||||
}
|
||||
}
|
||||
if inSystem || m != nil || len(out.Missions) == 0 || len(out.Missions) > 1024 {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
for _, title := range []struct {
|
||||
id uint32
|
||||
season bool
|
||||
}{{out.DailyTitle, false}, {out.SeasonTitle, true}} {
|
||||
m, ok := out.Find(title.id)
|
||||
if !ok || m.Season != title.season || m.Condition != "mission" {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Missions, func(i, j int) bool { return out.Missions[i].ID < out.Missions[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var ErrWarMissionIncomplete = errors.New("guild war mission is incomplete")
|
||||
|
||||
type WarMissionReward struct{ ItemID, Count uint32 }
|
||||
type WarMission struct {
|
||||
ID uint32
|
||||
Season bool
|
||||
Title, Condition string
|
||||
Required uint32
|
||||
Dungeons []uint32
|
||||
Rewards []WarMissionReward
|
||||
}
|
||||
type WarMissionCatalog struct {
|
||||
Missions []WarMission
|
||||
DailyTitle, SeasonTitle uint32
|
||||
}
|
||||
|
||||
func (c *WarMissionCatalog) Find(id uint32) (WarMission, bool) {
|
||||
if c != nil {
|
||||
for _, m := range c.Missions {
|
||||
if m.ID == id {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return WarMission{}, false
|
||||
}
|
||||
|
||||
type WarMissionValue struct {
|
||||
Progress uint32 `json:"progress"`
|
||||
Claimed bool `json:"claimed,omitempty"`
|
||||
}
|
||||
type WarMissionState struct {
|
||||
DayStart int64 `json:"day_start"`
|
||||
SeasonStart int64 `json:"season_start"`
|
||||
Values map[uint32]WarMissionValue `json:"values"`
|
||||
Events map[string]int64 `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
// Guild war days reset at 06:00 Beijing. No historical progress is synthesized.
|
||||
func WarDay(now time.Time) (start, end time.Time) {
|
||||
local := now.In(time.FixedZone("CST", 8*60*60))
|
||||
start = time.Date(local.Year(), local.Month(), local.Day(), 6, 0, 0, 0, local.Location())
|
||||
if local.Before(start) {
|
||||
start = start.AddDate(0, 0, -1)
|
||||
}
|
||||
return start, start.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
func (s *WarMissionState) Reset(now time.Time, catalog *WarMissionCatalog) {
|
||||
day, _ := WarDay(now)
|
||||
season, _ := WarSeason(now)
|
||||
if s.Values == nil {
|
||||
s.Values = make(map[uint32]WarMissionValue)
|
||||
}
|
||||
if s.Events == nil {
|
||||
s.Events = make(map[string]int64)
|
||||
}
|
||||
for id := range s.Values {
|
||||
m, found := catalog.Find(id)
|
||||
if !found || (m.Season && s.SeasonStart != season.Unix()) || (!m.Season && s.DayStart != day.Unix()) {
|
||||
delete(s.Values, id)
|
||||
}
|
||||
}
|
||||
for key, at := range s.Events {
|
||||
if at < season.Unix() {
|
||||
delete(s.Events, key)
|
||||
}
|
||||
}
|
||||
s.DayStart, s.SeasonStart = day.Unix(), season.Unix()
|
||||
}
|
||||
|
||||
func warMissionScope(characterID string) string {
|
||||
return "character:" + characterID + ":guild_war_missions:v1"
|
||||
}
|
||||
func LoadWarMissions(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, catalog *WarMissionCatalog, now time.Time) (WarMissionState, error) {
|
||||
var s WarMissionState
|
||||
if repo == nil || characterID == "" || catalog == nil {
|
||||
return s, ErrRepositoryUnavailable
|
||||
}
|
||||
record, found, err := repo.Load(ctx, warMissionScope(characterID))
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if found && record.Values["state"] != "" {
|
||||
if err = json.Unmarshal([]byte(record.Values["state"]), &s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
}
|
||||
s.Reset(now, catalog)
|
||||
return s, nil
|
||||
}
|
||||
func SaveWarMissions(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, s WarMissionState, now time.Time) error {
|
||||
if repo == nil || characterID == "" {
|
||||
return ErrRepositoryUnavailable
|
||||
}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.Save(ctx, dnfrepo.SettingsRecord{Scope: warMissionScope(characterID), Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
||||
}
|
||||
|
||||
// Observe is called only for an accepted server-owned battle entry or clear.
|
||||
// Its receipt covers retries, including a retry after the daily reset.
|
||||
func (s *WarMissionState) Observe(catalog *WarMissionCatalog, kind string, dungeon uint32, key string, occurredAt, now time.Time) error {
|
||||
if catalog == nil || key == "" || (kind != "join" && kind != "clear") || occurredAt.After(now) {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
s.Reset(now, catalog)
|
||||
if occurredAt.Unix() < s.SeasonStart {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
key = kind + ":" + key
|
||||
if _, found := s.Events[key]; found {
|
||||
return nil
|
||||
}
|
||||
s.Events[key] = occurredAt.Unix()
|
||||
for _, m := range catalog.Missions {
|
||||
if m.Condition != kind || (!m.Season && occurredAt.Unix() < s.DayStart) {
|
||||
continue
|
||||
}
|
||||
if len(m.Dungeons) > 0 {
|
||||
matched := false
|
||||
for _, id := range m.Dungeons {
|
||||
matched = matched || id == dungeon
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
v := s.Values[m.ID]
|
||||
if v.Progress < m.Required {
|
||||
v.Progress++
|
||||
}
|
||||
s.Values[m.ID] = v
|
||||
}
|
||||
s.refreshTitles(catalog)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WarMissionState) refreshTitles(catalog *WarMissionCatalog) {
|
||||
for _, title := range catalog.Missions {
|
||||
if title.Condition != "mission" {
|
||||
continue
|
||||
}
|
||||
var completed uint32
|
||||
for _, m := range catalog.Missions {
|
||||
if m.Season == title.Season && m.Condition != "mission" && s.Values[m.ID].Progress >= m.Required {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
v := s.Values[title.ID]
|
||||
v.Progress = min(completed, title.Required)
|
||||
s.Values[title.ID] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Claim changes the receipt in memory. The caller commits it with all item
|
||||
// grants in one repository transaction. An already claimed request is a no-op.
|
||||
func (s *WarMissionState) Claim(catalog *WarMissionCatalog, id uint32, now time.Time) ([]WarMissionReward, error) {
|
||||
s.Reset(now, catalog)
|
||||
m, found := catalog.Find(id)
|
||||
if !found {
|
||||
return nil, ErrInvalidContent
|
||||
}
|
||||
v := s.Values[id]
|
||||
if v.Claimed {
|
||||
return nil, nil
|
||||
}
|
||||
if v.Progress < m.Required {
|
||||
return nil, ErrWarMissionIncomplete
|
||||
}
|
||||
v.Claimed = true
|
||||
s.Values[id] = v
|
||||
return append([]WarMissionReward(nil), m.Rewards...), nil
|
||||
}
|
||||
|
||||
// ExperienceRanks uses competition ranking: 1, 1, 3 for tied experience.
|
||||
// It derives ranks from all persisted guilds, including offline guilds.
|
||||
func (r Registry) ExperienceRanks() map[uint32]uint32 {
|
||||
list := make([]*Guild, 0, len(r.Guilds))
|
||||
for _, g := range r.Guilds {
|
||||
if g != nil && g.ID != 0 {
|
||||
list = append(list, g)
|
||||
}
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].Experience != list[j].Experience {
|
||||
return list[i].Experience > list[j].Experience
|
||||
}
|
||||
return list[i].ID < list[j].ID
|
||||
})
|
||||
ranks := make(map[uint32]uint32, len(list))
|
||||
var rank uint32
|
||||
for i, g := range list {
|
||||
if i == 0 || g.Experience != list[i-1].Experience {
|
||||
rank = uint32(i + 1)
|
||||
}
|
||||
ranks[g.ID] = rank
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
func invalidWarMission(id uint32) error {
|
||||
return fmt.Errorf("guild war mission %d: %w", id, ErrInvalidContent)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package guild
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
type WarShop struct {
|
||||
ID, NPC uint32
|
||||
Items map[uint32]bool
|
||||
}
|
||||
|
||||
type WarMerchant struct {
|
||||
NPC uint32
|
||||
Levels map[uint16]WarShop
|
||||
Costs map[uint16]GuardianCost
|
||||
}
|
||||
|
||||
type WarShopCatalog [2]WarMerchant
|
||||
|
||||
func warList(source dnfpvf.Source, listPath string) (map[uint32]string, error) {
|
||||
doc, err := readWarDocument(source, listPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uint32]string)
|
||||
for _, entry := range dnfpvf.ParseList(doc) {
|
||||
if entry.ID <= 0 || entry.ID > math.MaxInt32 {
|
||||
continue
|
||||
}
|
||||
out[uint32(entry.ID)] = path.Join(path.Dir(listPath), strings.TrimPrefix(entry.Path, path.Dir(listPath)+"/"))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func LoadWarShops(source dnfpvf.Source) (WarShopCatalog, error) {
|
||||
var out WarShopCatalog
|
||||
doc, err := readWarDocument(source, "etc/agitwar.etc")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
npcs, err := warList(source, "npc/npc.lst")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
shops, err := warList(source, "itemshop/itemshop.lst")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// Current EXE's upgrade requests identify these original NPCs, while
|
||||
// purchases identify the level-specific NPC from each [upgrade] row.
|
||||
for kind, npc := range []uint32{390, 389} {
|
||||
merchant := WarMerchant{NPC: npc, Levels: make(map[uint16]WarShop), Costs: make(map[uint16]GuardianCost)}
|
||||
npcdoc, e := readWarDocument(source, npcs[npc])
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
role, _ := npcdoc.Section("guild agit shop npc")
|
||||
if len(role) != 1 || role[0].Value != []string{"defence", "mercenary"}[kind] {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
upgrade, _ := npcdoc.Section("upgrade")
|
||||
if len(upgrade) < 3 || upgrade[0].Value != "guild agit" {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
v, e := warUnsigned(upgrade[1:])
|
||||
if e != nil || len(v)%2 != 0 || len(v)/2 > 10 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
for i := 0; i < len(v); i += 2 {
|
||||
if v[i] != uint32(i/2+1) || v[i+1] == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
merchant.Levels[uint16(v[i])] = WarShop{NPC: v[i+1]}
|
||||
}
|
||||
inUpgrade := false
|
||||
for _, sec := range doc.Sections {
|
||||
if sec.Name == "building upgrade" {
|
||||
inUpgrade = true
|
||||
continue
|
||||
}
|
||||
if sec.Name == "/building upgrade" {
|
||||
inUpgrade = false
|
||||
continue
|
||||
}
|
||||
if !inUpgrade || sec.Name != []string{"building", "mecenary"}[kind] {
|
||||
continue
|
||||
}
|
||||
v, e = warUnsigned(doc.Tokens[sec.Start:sec.End])
|
||||
if e != nil || len(v) != len(merchant.Levels)*2 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
for i := 0; i < len(v); i += 2 {
|
||||
merchant.Costs[uint16(i/2+1)] = GuardianCost{v[i], v[i+1]}
|
||||
}
|
||||
}
|
||||
if len(merchant.Costs) != len(merchant.Levels) {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
out[kind] = merchant
|
||||
}
|
||||
tokens, _ := doc.Section("shop list")
|
||||
ids, err := warUnsigned(tokens)
|
||||
if err != nil || len(ids) == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
for _, id := range ids {
|
||||
d, e := readWarDocument(source, shops[id])
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
npc, ok := d.Int("NPC")
|
||||
typ, _ := d.Section("type")
|
||||
if !ok || npc <= 0 || len(typ) != 1 || typ[0].Value != "[agitwar build shop]" {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
for k := range out {
|
||||
for level, shop := range out[k].Levels {
|
||||
if shop.NPC != uint32(npc) {
|
||||
continue
|
||||
}
|
||||
if shop.ID != 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
shop.ID = id
|
||||
shop.Items = make(map[uint32]bool)
|
||||
for _, sec := range d.Sections {
|
||||
if sec.Name == "item list" {
|
||||
v, e := warUnsigned(d.Tokens[sec.Start:sec.End])
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
for _, item := range v {
|
||||
if item > 0 {
|
||||
shop.Items[item] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(shop.Items) == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
out[k].Levels[level] = shop
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, merchant := range out {
|
||||
for _, shop := range merchant.Levels {
|
||||
if shop.ID == 0 {
|
||||
return out, ErrInvalidContent
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (g *Guild) WarShopLevel(kind int) uint16 {
|
||||
if g == nil || kind < 0 || kind >= len(g.WarShopLevels) {
|
||||
return 0
|
||||
}
|
||||
level := g.WarShopLevels[kind]
|
||||
if level == 0 {
|
||||
return 1
|
||||
} // Native 38D1020 constructor: two u16 levels, both 1.
|
||||
return level
|
||||
}
|
||||
|
||||
func (r *Registry) UpgradeWarShop(characterID string, npc uint32, before, after uint16, catalog WarShopCatalog, now time.Time) (byte, error) {
|
||||
g, err := r.warManager(characterID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for kind, merchant := range catalog {
|
||||
if merchant.NPC != npc {
|
||||
continue
|
||||
}
|
||||
cost, valid := merchant.Costs[after]
|
||||
if !valid || before == 0 || uint32(after) != uint32(before)+1 {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
current := g.WarShopLevel(kind)
|
||||
if current == after {
|
||||
return byte(kind), nil
|
||||
}
|
||||
if current != before {
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
if g.GuardianAP < cost.AP || g.GuardianGP < cost.GP {
|
||||
return 0, ErrInsufficientGuardian
|
||||
}
|
||||
g.GuardianAP -= cost.AP
|
||||
g.GuardianGP -= cost.GP
|
||||
g.WarShopLevels[kind] = after
|
||||
g.UpdatedAt = now
|
||||
return byte(kind), nil
|
||||
}
|
||||
return 0, ErrInvalidContent
|
||||
}
|
||||
|
||||
func LoadWarProductCost(source dnfpvf.Source, itemPath string) (GuardianCost, error) {
|
||||
doc, err := readWarDocument(source, itemPath)
|
||||
if err != nil {
|
||||
return GuardianCost{}, err
|
||||
}
|
||||
ap, apOK := doc.Int("require ap")
|
||||
gp, gpOK := doc.Int("require gp")
|
||||
if (!apOK && !gpOK) || ap < 0 || gp < 0 || ap > math.MaxInt32 || gp > math.MaxInt32 || ap+gp == 0 {
|
||||
return GuardianCost{}, ErrInvalidContent
|
||||
}
|
||||
return GuardianCost{uint32(ap), uint32(gp)}, nil
|
||||
}
|
||||
|
||||
func (r *Registry) BuyWarProduct(characterID string, shopID, npc, item, count uint32, catalog WarShopCatalog, cost GuardianCost, now time.Time) error {
|
||||
g, err := r.warManager(characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 || count > math.MaxUint16 || cost.AP == 0 && cost.GP == 0 {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
valid := false
|
||||
for kind, merchant := range catalog {
|
||||
shop, ok := merchant.Levels[g.WarShopLevel(kind)]
|
||||
if ok && shop.ID == shopID && shop.NPC == npc && shop.Items[item] {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return ErrInvalidContent
|
||||
}
|
||||
ap, gp := uint64(cost.AP)*uint64(count), uint64(cost.GP)*uint64(count)
|
||||
if ap > uint64(g.GuardianAP) || gp > uint64(g.GuardianGP) {
|
||||
return ErrInsufficientGuardian
|
||||
}
|
||||
g.GuardianAP -= uint32(ap)
|
||||
g.GuardianGP -= uint32(gp)
|
||||
g.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user