DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+189
View File
@@ -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")
}
})
}
+431
View File
@@ -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
}