服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
359 lines
9.5 KiB
Go
359 lines
9.5 KiB
Go
package joust
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
)
|
|
|
|
const EventPVFPath = "event/chn_event/chn_joust.evt"
|
|
|
|
var ErrPVFMalformed = errors.New("joust PVF is malformed")
|
|
|
|
// Rider is one immutable knight definition copied from the active Script.pvf.
|
|
// Win and Loss retain the four seven-value client battle timeline profiles;
|
|
// they are not the historical win/loss counters in op1241.
|
|
type Rider struct {
|
|
ID byte
|
|
AttackType byte
|
|
Name string
|
|
Win []uint16
|
|
Loss []uint16
|
|
}
|
|
|
|
type Catalog struct {
|
|
minimumLevel int
|
|
maximumBet uint32
|
|
rewardItemID int64
|
|
participationItemID int64
|
|
materialItemIDs []int64
|
|
riders []Rider
|
|
}
|
|
|
|
func LoadCatalog(ctx context.Context, source dnfpvf.Source) (*Catalog, error) {
|
|
if source == nil {
|
|
return nil, dnfpvf.ErrSourceRequired
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
text, err := source.ReadText(EventPVFPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read joust PVF %q: %w", EventPVFPath, err)
|
|
}
|
|
document, err := dnfpvf.Parse(EventPVFPath, text)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse joust PVF %q: %w", EventPVFPath, err)
|
|
}
|
|
|
|
minimumLevel, err := joustSingletonInt(document, "min level")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
maximumBet, err := joustSingletonInt(document, "max betting")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rewardItemID, err := joustSingletonInt(document, "reward")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
participationItemID, err := joustSingletonInt(document, "betting reward")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
materials, err := joustIntegerSection(document, "material")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
riders, err := joustRiders(document)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if minimumLevel != MinimumLevel || maximumBet != MaximumBetPerRound ||
|
|
rewardItemID != PermanentCrystalID || participationItemID != ParticipationItemID ||
|
|
len(materials) != 2 || materials[0] != PermanentCrystalID || materials[1] != EventCrystalID {
|
|
return nil, fmt.Errorf(
|
|
"%w: incompatible rules min=%d max=%d reward=%d betting_reward=%d materials=%v",
|
|
ErrPVFMalformed,
|
|
minimumLevel,
|
|
maximumBet,
|
|
rewardItemID,
|
|
participationItemID,
|
|
materials,
|
|
)
|
|
}
|
|
if len(riders) < OpeningKnightCount {
|
|
return nil, fmt.Errorf("%w: riders=%d want-at-least=%d", ErrPVFMalformed, len(riders), OpeningKnightCount)
|
|
}
|
|
return &Catalog{
|
|
minimumLevel: int(minimumLevel),
|
|
maximumBet: uint32(maximumBet),
|
|
rewardItemID: rewardItemID,
|
|
participationItemID: participationItemID,
|
|
materialItemIDs: append([]int64(nil), materials...),
|
|
riders: cloneRiders(riders),
|
|
}, nil
|
|
}
|
|
|
|
func (c *Catalog) Riders() []Rider {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
return cloneRiders(c.riders)
|
|
}
|
|
|
|
func joustSingletonInt(document *dnfpvf.Document, name string) (int64, error) {
|
|
values, err := joustIntegerSection(document, name)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(values) != 1 {
|
|
return 0, fmt.Errorf("%w: path=%s section=[%s] values=%d want=1", ErrPVFMalformed, EventPVFPath, name, len(values))
|
|
}
|
|
return values[0], nil
|
|
}
|
|
|
|
func joustIntegerSection(document *dnfpvf.Document, name string) ([]int64, error) {
|
|
if document == nil {
|
|
return nil, fmt.Errorf("%w: nil document", ErrPVFMalformed)
|
|
}
|
|
count := 0
|
|
for _, section := range document.Sections {
|
|
if strings.EqualFold(strings.TrimSpace(section.Name), name) {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 {
|
|
return nil, fmt.Errorf("%w: path=%s section=[%s] count=%d want=1", ErrPVFMalformed, EventPVFPath, name, count)
|
|
}
|
|
tokens, ok := document.Section(name)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: path=%s section=[%s] unreadable", ErrPVFMalformed, EventPVFPath, name)
|
|
}
|
|
values := make([]int64, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
if token.Kind != dnfpvf.TokenInt {
|
|
return nil, fmt.Errorf("%w: path=%s section=[%s] token=%q is not an integer", ErrPVFMalformed, EventPVFPath, name, token.Raw)
|
|
}
|
|
values = append(values, token.Int)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
type joustRiderBuilder struct {
|
|
rider Rider
|
|
indexSet bool
|
|
attackTypeSet bool
|
|
nameSet bool
|
|
activeField string
|
|
}
|
|
|
|
func joustRiders(document *dnfpvf.Document) ([]Rider, error) {
|
|
if document == nil {
|
|
return nil, fmt.Errorf("%w: nil document", ErrPVFMalformed)
|
|
}
|
|
riders := make([]Rider, 0, 12)
|
|
seen := make(map[byte]struct{}, 12)
|
|
var current *joustRiderBuilder
|
|
for _, token := range document.Tokens {
|
|
if token.Kind == dnfpvf.TokenSection {
|
|
section := strings.ToLower(strings.TrimSpace(token.Value))
|
|
switch section {
|
|
case "knight":
|
|
if current != nil {
|
|
return nil, fmt.Errorf("%w: nested [knight] at line=%d", ErrPVFMalformed, token.Line)
|
|
}
|
|
current = &joustRiderBuilder{}
|
|
case "/knight":
|
|
if current == nil {
|
|
return nil, fmt.Errorf("%w: unmatched [/knight] at line=%d", ErrPVFMalformed, token.Line)
|
|
}
|
|
if err := current.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, duplicate := seen[current.rider.ID]; duplicate {
|
|
return nil, fmt.Errorf("%w: duplicate rider index=%d", ErrPVFMalformed, current.rider.ID)
|
|
}
|
|
seen[current.rider.ID] = struct{}{}
|
|
riders = append(riders, cloneRider(current.rider))
|
|
current = nil
|
|
default:
|
|
if current != nil {
|
|
current.activeField = section
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if current == nil {
|
|
continue
|
|
}
|
|
if err := current.consume(token); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if current != nil {
|
|
return nil, fmt.Errorf("%w: unterminated [knight]", ErrPVFMalformed)
|
|
}
|
|
return riders, nil
|
|
}
|
|
|
|
func (b *joustRiderBuilder) consume(token dnfpvf.Token) error {
|
|
if b == nil {
|
|
return fmt.Errorf("%w: nil rider builder", ErrPVFMalformed)
|
|
}
|
|
switch b.activeField {
|
|
case "index":
|
|
value, err := joustByteToken(token, "index")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.indexSet {
|
|
return fmt.Errorf("%w: duplicate rider index value", ErrPVFMalformed)
|
|
}
|
|
b.rider.ID = value
|
|
b.indexSet = true
|
|
case "attack type":
|
|
value, err := joustByteToken(token, "attack type")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b.attackTypeSet {
|
|
return fmt.Errorf("%w: duplicate rider attack type", ErrPVFMalformed)
|
|
}
|
|
b.rider.AttackType = value
|
|
b.attackTypeSet = true
|
|
case "knight name":
|
|
if token.Kind != dnfpvf.TokenString || strings.TrimSpace(token.Value) == "" || b.nameSet {
|
|
return fmt.Errorf("%w: invalid rider name token=%q", ErrPVFMalformed, token.Raw)
|
|
}
|
|
b.rider.Name = token.Value
|
|
b.nameSet = true
|
|
case "win":
|
|
value, err := joustUint16Token(token, "win")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.rider.Win = append(b.rider.Win, value)
|
|
case "loss":
|
|
value, err := joustUint16Token(token, "loss")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.rider.Loss = append(b.rider.Loss, value)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *joustRiderBuilder) validate() error {
|
|
if b == nil || !b.indexSet || !b.attackTypeSet || !b.nameSet || len(b.rider.Win) != 28 || len(b.rider.Loss) != 28 {
|
|
return fmt.Errorf(
|
|
"%w: rider index_set=%t attack_set=%t name_set=%t win=%d loss=%d",
|
|
ErrPVFMalformed,
|
|
b != nil && b.indexSet,
|
|
b != nil && b.attackTypeSet,
|
|
b != nil && b.nameSet,
|
|
func() int {
|
|
if b == nil {
|
|
return 0
|
|
}
|
|
return len(b.rider.Win)
|
|
}(),
|
|
func() int {
|
|
if b == nil {
|
|
return 0
|
|
}
|
|
return len(b.rider.Loss)
|
|
}(),
|
|
)
|
|
}
|
|
if _, ok := attackTypeTableOffset(b.rider.AttackType); !ok {
|
|
return fmt.Errorf("%w: rider=%d unsupported attack type=%d", ErrPVFMalformed, b.rider.ID, b.rider.AttackType)
|
|
}
|
|
if err := validateBattleTimelineProfiles(b.rider.ID, "win", b.rider.Win); err != nil {
|
|
return err
|
|
}
|
|
if err := validateBattleTimelineProfiles(b.rider.ID, "loss", b.rider.Loss); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateBattleTimelineProfiles(rider byte, field string, values []uint16) error {
|
|
for group := 0; group < 4; group++ {
|
|
previous := uint16(0)
|
|
for position := 0; position < 7; position++ {
|
|
value := values[group*7+position]
|
|
if value < previous || value > 100 {
|
|
return fmt.Errorf("%w: rider=%d %s group=%d position=%d timeline=%d previous=%d", ErrPVFMalformed, rider, field, group, position, value, previous)
|
|
}
|
|
previous = value
|
|
}
|
|
if previous == 0 {
|
|
return fmt.Errorf("%w: rider=%d %s group=%d terminal timeline is zero", ErrPVFMalformed, rider, field, group)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func attackTypeTableOffset(attackType byte) (int, bool) {
|
|
switch attackType {
|
|
case 0:
|
|
return 0, true
|
|
case 1:
|
|
return 7, true
|
|
case 27:
|
|
return 14, true
|
|
case 28:
|
|
return 21, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func (c *Catalog) rider(id byte) (Rider, bool) {
|
|
if c == nil {
|
|
return Rider{}, false
|
|
}
|
|
for _, rider := range c.riders {
|
|
if rider.ID == id {
|
|
return cloneRider(rider), true
|
|
}
|
|
}
|
|
return Rider{}, false
|
|
}
|
|
|
|
func joustByteToken(token dnfpvf.Token, field string) (byte, error) {
|
|
if token.Kind != dnfpvf.TokenInt || token.Int < 0 || token.Int > 255 {
|
|
return 0, fmt.Errorf("%w: rider %s token=%q", ErrPVFMalformed, field, token.Raw)
|
|
}
|
|
return byte(token.Int), nil
|
|
}
|
|
|
|
func joustUint16Token(token dnfpvf.Token, field string) (uint16, error) {
|
|
if token.Kind != dnfpvf.TokenInt || token.Int < 0 || token.Int > 65535 {
|
|
return 0, fmt.Errorf("%w: rider %s token=%q", ErrPVFMalformed, field, token.Raw)
|
|
}
|
|
return uint16(token.Int), nil
|
|
}
|
|
|
|
func cloneRiders(source []Rider) []Rider {
|
|
out := make([]Rider, len(source))
|
|
for index := range source {
|
|
out[index] = cloneRider(source[index])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneRider(source Rider) Rider {
|
|
source.Win = append([]uint16(nil), source.Win...)
|
|
source.Loss = append([]uint16(nil), source.Loss...)
|
|
return source
|
|
}
|