服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
766 lines
22 KiB
Go
766 lines
22 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// skillSwitchRegisteredBuffLevelBonus returns the buff skill the character has
|
|
// registered in the BUFF-enhancement (换装) window and the total skill-level
|
|
// bonus that registration contributes to that skill.
|
|
//
|
|
// The bonus is computed from the durable registration (skill id + the bound
|
|
// equipment items), not from the client's live skill state. It is the value
|
|
// that must be folded into the mode1/mode3 skill pages so that party members,
|
|
// which simulate the caster's buff from those pages, see the same level the
|
|
// caster sees for itself.
|
|
func (s *Service) skillSwitchRegisteredBuffLevelBonus(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
character dnfrepo.CharacterRecord,
|
|
) (uint16, int, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
repos, ok := s.projectionRepositories(ctx)
|
|
if !ok || repos.Settings == nil {
|
|
return 0, 0, nil
|
|
}
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
state, err := loadSkillSwitchInventoryState(ctx, repos.Settings, characterID)
|
|
if err != nil || state.SkillID == 0 || len(state.Items) == 0 {
|
|
return 0, 0, err
|
|
}
|
|
job, ok := characterJobByte(character)
|
|
if !ok {
|
|
return state.SkillID, 0, nil
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if archiveErr != nil {
|
|
return state.SkillID, 0, nil
|
|
}
|
|
rawJobTag, err := currentRentalCharacterJobTag(archive, job)
|
|
if err != nil {
|
|
return state.SkillID, 0, nil
|
|
}
|
|
jobTag := skillSwitchJobTag(rawJobTag)
|
|
|
|
growType := numericCharacterStatValue(character, "grow_type")
|
|
_, awakeningStage := dnfprofession.Decode(byte(growType & 0xff))
|
|
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return state.SkillID, 0, err
|
|
}
|
|
|
|
requiredLevel := 0
|
|
active := false
|
|
s.initialSkillsMu.Lock()
|
|
if s.skillCatalog != nil {
|
|
if skill, found := s.skillCatalog.Find(job, state.SkillID); found {
|
|
requiredLevel = skill.RequiredLevel
|
|
active = skill.Active
|
|
}
|
|
}
|
|
s.initialSkillsMu.Unlock()
|
|
|
|
// The registered item template contributes its own PVF bonus; the item
|
|
// instance additionally carries enchant-card and emblem ids in its raw row.
|
|
// Load the instance records so the embedded bonuses can be folded in too.
|
|
var inventory dnfrepo.InventoryRecord
|
|
var equipment dnfrepo.EquipmentRecord
|
|
if repos.Inventory != nil {
|
|
if record, _, loadErr := repos.Inventory.Load(ctx, characterID); loadErr == nil {
|
|
inventory = record
|
|
}
|
|
}
|
|
if repos.Equipment != nil {
|
|
if record, _, loadErr := repos.Equipment.Load(ctx, characterID); loadErr == nil {
|
|
equipment = record
|
|
}
|
|
}
|
|
|
|
total := 0
|
|
registeredParts := map[byte]bool{}
|
|
for _, item := range state.Items {
|
|
registeredParts[item.EquipmentPart] = true
|
|
extra, raw, found := skillSwitchRegisteredItemInstance(item, inventory, equipment)
|
|
if !found {
|
|
bonus, itemErr := skillSwitchItemBuffLevelBonus(
|
|
catalog, item.ItemID, jobTag, state.SkillID, requiredLevel, active, awakeningStage,
|
|
)
|
|
if itemErr != nil {
|
|
continue
|
|
}
|
|
total += bonus
|
|
continue
|
|
}
|
|
total += skillSwitchItemInstanceBuffBonus(
|
|
catalog, item.ItemID, extra, raw, jobTag, state.SkillID, requiredLevel, active, awakeningStage,
|
|
)
|
|
}
|
|
|
|
// The client also derives the caster's worn-equipment bonus from the mode1
|
|
// equipment block. For a registered slot that worn bonus is applied in
|
|
// addition to the skill-page total, so subtract it here to keep peers at
|
|
// exactly the registered level instead of registered + worn.
|
|
for _, equipped := range equipment.Entries {
|
|
part := byte(equipped.SlotIndex)
|
|
if !registeredParts[part] || equipped.ItemID <= 0 {
|
|
continue
|
|
}
|
|
total -= skillSwitchItemInstanceBuffBonus(
|
|
catalog, uint32(equipped.ItemID), equipped.Extra, equipped.RawEntry,
|
|
jobTag, state.SkillID, requiredLevel, active, awakeningStage,
|
|
)
|
|
}
|
|
return state.SkillID, total, nil
|
|
}
|
|
|
|
// skillSwitchItemInstanceBuffBonus sums the buff-skill level bonus carried by
|
|
// one equipment instance: its own PVF rows plus embedded enchant card, emblems
|
|
// and avatar ability.
|
|
func skillSwitchItemInstanceBuffBonus(
|
|
catalog *pvfDungeonDropCatalog,
|
|
itemID uint32,
|
|
extra map[string]string,
|
|
raw []byte,
|
|
jobTag string,
|
|
skillID uint16,
|
|
requiredLevel int,
|
|
active bool,
|
|
awakeningStage byte,
|
|
) int {
|
|
total := 0
|
|
if bonus, err := skillSwitchItemBuffLevelBonus(catalog, itemID, jobTag, skillID, requiredLevel, active, awakeningStage); err == nil {
|
|
total += bonus
|
|
}
|
|
cardID, hasCard := skillSwitchEnchantCardID(extra)
|
|
if !hasCard {
|
|
cardID, hasCard = skillSwitchLegacyEnchantCardID(catalog, raw)
|
|
}
|
|
if hasCard {
|
|
total += skillSwitchEnchantCardBuffLevelBonus(
|
|
catalog, cardID, skillSwitchEnchantUpgradeCount(extra),
|
|
jobTag, skillID, requiredLevel, active, awakeningStage,
|
|
)
|
|
}
|
|
for _, emblemID := range skillSwitchEmblemItemIDs(extra, raw) {
|
|
if emblemID == 0 || emblemID == ^uint32(0) {
|
|
continue
|
|
}
|
|
total += skillSwitchEmblemBuffLevelBonus(
|
|
catalog, emblemID, jobTag, skillID, requiredLevel, active, awakeningStage,
|
|
)
|
|
}
|
|
if abilityNo, ok := skillSwitchAvatarAbilityNo(extra); ok {
|
|
total += skillSwitchAvatarAbilitySkillBonus(catalog, itemID, abilityNo, jobTag, skillID)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// skillSwitchItemBuffLevelBonus resolves one registered item and returns the
|
|
// buff-skill level bonus carried by its PVF definition.
|
|
func skillSwitchItemBuffLevelBonus(
|
|
catalog *pvfDungeonDropCatalog,
|
|
itemID uint32,
|
|
jobTag string,
|
|
skillID uint16,
|
|
requiredLevel int,
|
|
active bool,
|
|
awakeningStage byte,
|
|
) (int, error) {
|
|
doc, ok := skillSwitchResolveItemDoc(catalog, itemID)
|
|
if !ok {
|
|
return 0, nil
|
|
}
|
|
return skillSwitchDocumentBuffLevelBonus(doc, jobTag, skillID, requiredLevel, active, awakeningStage), nil
|
|
}
|
|
|
|
// skillSwitchEnchantCardBuffLevelBonus sums the enchant card's `[enchant
|
|
// index]` skill-level rows for the equipment's current upgrade stage. If the
|
|
// exact stage is absent the base stage (index 0) is used.
|
|
func skillSwitchEnchantCardBuffLevelBonus(
|
|
catalog *pvfDungeonDropCatalog,
|
|
cardID uint32,
|
|
upgrade byte,
|
|
jobTag string,
|
|
skillID uint16,
|
|
requiredLevel int,
|
|
active bool,
|
|
awakeningStage byte,
|
|
) int {
|
|
doc, ok := skillSwitchResolveItemDoc(catalog, cardID)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
type indexBonus struct {
|
|
index int64
|
|
sections []dnfpvf.Section
|
|
}
|
|
var stages []indexBonus
|
|
inIndex := false
|
|
curIndex := int64(-1)
|
|
var curSections []dnfpvf.Section
|
|
for _, section := range doc.Sections {
|
|
name := sectionName(section.Name)
|
|
switch name {
|
|
case "enchant index":
|
|
inIndex = true
|
|
curIndex = -1
|
|
curSections = nil
|
|
if value, ok := firstSectionInt(doc, section); ok {
|
|
curIndex = value
|
|
}
|
|
continue
|
|
case "/enchant index":
|
|
if inIndex && curIndex >= 0 {
|
|
stages = append(stages, indexBonus{index: curIndex, sections: curSections})
|
|
}
|
|
inIndex = false
|
|
curSections = nil
|
|
continue
|
|
default:
|
|
if inIndex {
|
|
curSections = append(curSections, section)
|
|
}
|
|
}
|
|
}
|
|
for _, stage := range stages {
|
|
if stage.index == int64(upgrade) {
|
|
return skillSwitchSectionsSkillBonus(doc, stage.sections, jobTag, skillID, requiredLevel, active, awakeningStage)
|
|
}
|
|
}
|
|
for _, stage := range stages {
|
|
if stage.index == 0 {
|
|
return skillSwitchSectionsSkillBonus(doc, stage.sections, jobTag, skillID, requiredLevel, active, awakeningStage)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// skillSwitchEmblemBuffLevelBonus sums a socketed emblem's `[enchant]`
|
|
// skill-level rows.
|
|
func skillSwitchEmblemBuffLevelBonus(
|
|
catalog *pvfDungeonDropCatalog,
|
|
emblemID uint32,
|
|
jobTag string,
|
|
skillID uint16,
|
|
requiredLevel int,
|
|
active bool,
|
|
awakeningStage byte,
|
|
) int {
|
|
doc, ok := skillSwitchResolveItemDoc(catalog, emblemID)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
var sections []dnfpvf.Section
|
|
enchantDepth := 0
|
|
for _, section := range doc.Sections {
|
|
name := sectionName(section.Name)
|
|
switch name {
|
|
case "enchant":
|
|
enchantDepth++
|
|
continue
|
|
case "/enchant":
|
|
if enchantDepth > 0 {
|
|
enchantDepth--
|
|
}
|
|
continue
|
|
default:
|
|
if enchantDepth > 0 {
|
|
sections = append(sections, section)
|
|
}
|
|
}
|
|
}
|
|
return skillSwitchSectionsSkillBonus(doc, sections, jobTag, skillID, requiredLevel, active, awakeningStage)
|
|
}
|
|
|
|
// skillSwitchResolveItemDoc loads and parses one item's PVF document.
|
|
func skillSwitchResolveItemDoc(catalog *pvfDungeonDropCatalog, itemID uint32) (*dnfpvf.Document, bool) {
|
|
if catalog == nil || itemID == 0 {
|
|
return nil, false
|
|
}
|
|
definition, err := catalog.ResolveItem(itemID)
|
|
if err != nil || definition.PVFPath == "" || catalog.source == nil {
|
|
return nil, false
|
|
}
|
|
text, err := catalog.source.ReadText(definition.PVFPath)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
doc, err := dnfpvf.Parse(definition.PVFPath, text)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return doc, true
|
|
}
|
|
|
|
// skillSwitchRegisteredItemInstance locates the durable inventory/equipment
|
|
// instance bound by one registration entry and returns its extra fields and raw
|
|
// row. Registration binds by durable item serial, never by grid cell.
|
|
func skillSwitchRegisteredItemInstance(
|
|
binding skillSwitchBinding,
|
|
inventory dnfrepo.InventoryRecord,
|
|
equipment dnfrepo.EquipmentRecord,
|
|
) (map[string]string, []byte, bool) {
|
|
for _, stack := range inventory.Slots {
|
|
if uint32(stack.ItemID) != binding.ItemID {
|
|
continue
|
|
}
|
|
if skillSwitchItemSerial(stack.Extra, stack.RawEntry) != binding.ItemSerial {
|
|
continue
|
|
}
|
|
return stack.Extra, stack.RawEntry, true
|
|
}
|
|
for _, stack := range inventory.Warehouse {
|
|
if uint32(stack.ItemID) != binding.ItemID {
|
|
continue
|
|
}
|
|
if skillSwitchItemSerial(stack.Extra, stack.RawEntry) != binding.ItemSerial {
|
|
continue
|
|
}
|
|
return stack.Extra, stack.RawEntry, true
|
|
}
|
|
for _, entry := range equipment.Entries {
|
|
if uint32(entry.ItemID) != binding.ItemID {
|
|
continue
|
|
}
|
|
if currentSkillSwitchEquipmentSerial(entry) != binding.ItemSerial {
|
|
continue
|
|
}
|
|
return entry.Extra, entry.RawEntry, true
|
|
}
|
|
return nil, nil, false
|
|
}
|
|
|
|
// skillSwitchEnchantCardID reads the enchanted monster-card item id from the
|
|
// instance extra fields.
|
|
func skillSwitchEnchantCardID(extra map[string]string) (uint32, bool) {
|
|
for _, key := range []string{"enchant_card_id", "pet_enchant_card_item_id"} {
|
|
raw := strings.TrimSpace(extra[key])
|
|
if raw == "" || raw == "0" {
|
|
continue
|
|
}
|
|
if id, err := strconv.ParseUint(raw, 10, 32); err == nil && id != 0 {
|
|
return uint32(id), true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// skillSwitchLegacyEnchantCardID recovers the enchant card id from a legacy
|
|
// instance whose extra fields predate enchant_card_id but whose raw row still
|
|
// carries the card id at raw+0x0E. The candidate is verified to resolve as a
|
|
// PVF item so an ordinary quality seed at the same offset is not mistaken for
|
|
// a card.
|
|
func skillSwitchLegacyEnchantCardID(catalog *pvfDungeonDropCatalog, raw []byte) (uint32, bool) {
|
|
if catalog == nil || len(raw) < 0x12 {
|
|
return 0, false
|
|
}
|
|
candidate := binary.LittleEndian.Uint32(raw[0x0E:0x12])
|
|
if candidate == 0 || candidate == ^uint32(0) {
|
|
return 0, false
|
|
}
|
|
if _, err := catalog.ResolveItem(candidate); err != nil {
|
|
return 0, false
|
|
}
|
|
return candidate, true
|
|
}
|
|
|
|
// skillSwitchEnchantUpgradeCount reads the enchant upgrade stage.
|
|
func skillSwitchEnchantUpgradeCount(extra map[string]string) byte {
|
|
for _, key := range []string{"enchant_upgrade_count", "byte_12"} {
|
|
raw := strings.TrimSpace(extra[key])
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
if value, err := strconv.ParseUint(raw, 10, 8); err == nil {
|
|
return byte(value)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// skillSwitchEmblemItemIDs reads the socketed emblem item ids from the
|
|
// instance extra vector, falling back to the current raw+0x3C vector.
|
|
func skillSwitchEmblemItemIDs(extra map[string]string, raw []byte) []uint32 {
|
|
if data, ok := currentEquipmentEmblemDataFromExtra(extra); ok {
|
|
return skillSwitchEmblemIDsFromData(data)
|
|
}
|
|
if len(raw) >= currentEquipmentVectorOffset+9 {
|
|
count := int(raw[currentEquipmentVectorOffset])
|
|
if count > 0 && count <= 2 {
|
|
ids := make([]uint32, 0, count)
|
|
ids = append(ids, binary.LittleEndian.Uint32(raw[currentEquipmentVectorOffset+1:currentEquipmentVectorOffset+5]))
|
|
if count > 1 {
|
|
ids = append(ids, binary.LittleEndian.Uint32(raw[currentEquipmentVectorOffset+5:currentEquipmentVectorOffset+9]))
|
|
}
|
|
return ids
|
|
}
|
|
}
|
|
// Legacy rows stored the emblem vector at raw+0x2F before the current
|
|
// raw+0x3C collection existed.
|
|
if data, ok := currentEquipmentLegacyEmblemDataFromRaw(raw); ok {
|
|
return skillSwitchEmblemIDsFromData(data)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func skillSwitchEmblemIDsFromData(data [currentEquipmentEmblemDataBytes]byte) []uint32 {
|
|
count := int(data[0])
|
|
if count <= 0 || count > 2 {
|
|
return nil
|
|
}
|
|
ids := make([]uint32, 0, count)
|
|
if count >= 1 {
|
|
ids = append(ids, binary.LittleEndian.Uint32(data[1:5]))
|
|
}
|
|
if count >= 2 {
|
|
ids = append(ids, binary.LittleEndian.Uint32(data[5:9]))
|
|
}
|
|
return ids
|
|
}
|
|
|
|
const skillSwitchAbilityDataPath = "skill/abilitydatas.dat"
|
|
|
|
// skillSwitchAvatarAbilityNo reads the avatar's selected ability number from
|
|
// its instance extra. The field is only present after an ability selection, so
|
|
// a missing field means no skill bonus is active.
|
|
func skillSwitchAvatarAbilityNo(extra map[string]string) (byte, bool) {
|
|
raw := strings.TrimSpace(extra["avatar_ability_no"])
|
|
if raw == "" {
|
|
return 0, false
|
|
}
|
|
value, err := strconv.ParseUint(raw, 10, 8)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return byte(value), true
|
|
}
|
|
|
|
// skillSwitchAvatarAbilitySkillBonus resolves an avatar's selected ability
|
|
// through its [ability case index] into skill/abilitydatas.dat and returns the
|
|
// buff-skill level it grants, if the selected ability targets that buff skill.
|
|
func skillSwitchAvatarAbilitySkillBonus(
|
|
catalog *pvfDungeonDropCatalog,
|
|
itemID uint32,
|
|
abilityNo byte,
|
|
jobTag string,
|
|
buffSkillID uint16,
|
|
) int {
|
|
doc, ok := skillSwitchResolveItemDoc(catalog, itemID)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
caseIndex, found := doc.Int("ability case index")
|
|
if !found {
|
|
return 0
|
|
}
|
|
dataText, err := catalog.source.ReadText(skillSwitchAbilityDataPath)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
dataDoc, err := dnfpvf.Parse(skillSwitchAbilityDataPath, dataText)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, section := range dataDoc.Sections {
|
|
if sectionName(section.Name) != "ability case" {
|
|
continue
|
|
}
|
|
tokens := sectionTokens(dataDoc, section)
|
|
if len(tokens) < 2 || tokens[0].Kind != dnfpvf.TokenInt || tokens[0].Int != caseIndex {
|
|
continue
|
|
}
|
|
for index := 1; index+4 < len(tokens); index += 5 {
|
|
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index].Int != int64(abilityNo) {
|
|
continue
|
|
}
|
|
if tokens[index+2].Kind != dnfpvf.TokenString && tokens[index+2].Kind != dnfpvf.TokenIdent {
|
|
continue
|
|
}
|
|
if tokens[index+3].Kind != dnfpvf.TokenInt || tokens[index+4].Kind != dnfpvf.TokenInt {
|
|
continue
|
|
}
|
|
if tokens[index+3].Int <= 0 || tokens[index+3].Int > 0xffff || uint16(tokens[index+3].Int) != buffSkillID {
|
|
continue
|
|
}
|
|
normJob := skillSwitchJobTag(tokens[index+2].Value)
|
|
if normJob != "all" && normJob != "common" && normJob != jobTag {
|
|
continue
|
|
}
|
|
return int(tokens[index+4].Int)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// skillSwitchDocumentBuffLevelBonus sums the buff-skill level bonus declared by
|
|
// one item PVF document's own (non-embedded) sections for the given character
|
|
// job tag and buff skill.
|
|
//
|
|
// Two PVF forms are recognized:
|
|
//
|
|
// 1. Exact rows in [skill levelup]: `<job> <skill_id> <level>` triples. The
|
|
// level may be negative (some weapons carry a -1 penalty).
|
|
// 2. Range rows inside [all skill item container]/[all skill item]:
|
|
// `[item growtype] <job> <stage>` scopes the block, and
|
|
// `[skill apply condition]` carries `[skill group]`, `[lower bound level]`,
|
|
// `[upper bound level]` and `[value]`; a learned skill whose required level
|
|
// falls inside the bounds gains `value` levels.
|
|
//
|
|
// [skill levelup] rows nested inside enchant/emblem containers describe the
|
|
// bonus the item grants *when embedded into another item*, not the item's own
|
|
// worn bonus, so those rows are skipped here (the embedded bonus reaches the
|
|
// worn item through its own raw option vector instead).
|
|
func skillSwitchDocumentBuffLevelBonus(
|
|
doc *dnfpvf.Document,
|
|
jobTag string,
|
|
skillID uint16,
|
|
requiredLevel int,
|
|
active bool,
|
|
awakeningStage byte,
|
|
) int {
|
|
return skillSwitchSectionsSkillBonus(doc, skillSwitchTopLevelSections(doc), jobTag, skillID, requiredLevel, active, awakeningStage)
|
|
}
|
|
|
|
// skillSwitchTopLevelSections returns the document sections outside any
|
|
// enchant/enchant-table/enchant-index container.
|
|
func skillSwitchTopLevelSections(doc *dnfpvf.Document) []dnfpvf.Section {
|
|
if doc == nil {
|
|
return nil
|
|
}
|
|
var out []dnfpvf.Section
|
|
embedDepth := 0
|
|
for _, section := range doc.Sections {
|
|
name := sectionName(section.Name)
|
|
switch name {
|
|
case "enchant", "enchant table", "enchant index":
|
|
embedDepth++
|
|
continue
|
|
case "/enchant", "/enchant table", "/enchant index":
|
|
if embedDepth > 0 {
|
|
embedDepth--
|
|
}
|
|
continue
|
|
}
|
|
if embedDepth == 0 {
|
|
out = append(out, section)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// skillSwitchSectionsSkillBonus sums both exact `[skill levelup]` rows and
|
|
// `[all skill item]` range rows across an arbitrary section slice. Callers
|
|
// pre-select the slice (a document's top-level sections, one enchant index, or
|
|
// one enchant block), which is what makes the embedded bonuses reusable.
|
|
func skillSwitchSectionsSkillBonus(
|
|
doc *dnfpvf.Document,
|
|
sections []dnfpvf.Section,
|
|
jobTag string,
|
|
skillID uint16,
|
|
requiredLevel int,
|
|
active bool,
|
|
awakeningStage byte,
|
|
) int {
|
|
total := 0
|
|
|
|
groupDepth := 0
|
|
groupMatched := false
|
|
|
|
inApply := false
|
|
applyGroupActiveOnly := false
|
|
applyLower := int64(0)
|
|
applyUpper := int64(0)
|
|
applyValue := int64(0)
|
|
applyHasLower := false
|
|
applyHasUpper := false
|
|
applyHasValue := false
|
|
|
|
for _, section := range sections {
|
|
name := sectionName(section.Name)
|
|
switch name {
|
|
case "all skill item":
|
|
groupDepth++
|
|
groupMatched = false
|
|
continue
|
|
case "/all skill item":
|
|
if groupDepth > 0 {
|
|
groupDepth--
|
|
groupMatched = false
|
|
}
|
|
continue
|
|
case "item growtype":
|
|
if groupDepth > 0 {
|
|
groupMatched = skillSwitchItemGrowTypeMatches(doc, section, jobTag, awakeningStage)
|
|
}
|
|
continue
|
|
case "skill apply condition":
|
|
if groupDepth > 0 && groupMatched {
|
|
inApply = true
|
|
applyGroupActiveOnly = false
|
|
applyLower, applyUpper, applyValue = 0, 0, 0
|
|
applyHasLower, applyHasUpper, applyHasValue = false, false, false
|
|
}
|
|
continue
|
|
case "/skill apply condition":
|
|
if inApply {
|
|
if applyHasLower && applyHasUpper && applyHasValue &&
|
|
(!applyGroupActiveOnly || active) &&
|
|
requiredLevel > 0 &&
|
|
int64(requiredLevel) >= applyLower &&
|
|
int64(requiredLevel) <= applyUpper {
|
|
total += int(applyValue)
|
|
}
|
|
inApply = false
|
|
}
|
|
continue
|
|
case "skill group":
|
|
if inApply {
|
|
if value, ok := firstSectionText(doc, section); ok {
|
|
applyGroupActiveOnly = skillSwitchJobTag(value) == "active"
|
|
}
|
|
}
|
|
continue
|
|
case "lower bound level":
|
|
if inApply {
|
|
if value, ok := firstSectionInt(doc, section); ok {
|
|
applyLower = value
|
|
applyHasLower = true
|
|
}
|
|
}
|
|
continue
|
|
case "upper bound level":
|
|
if inApply {
|
|
if value, ok := firstSectionInt(doc, section); ok {
|
|
applyUpper = value
|
|
applyHasUpper = true
|
|
}
|
|
}
|
|
continue
|
|
case "value":
|
|
if inApply {
|
|
if value, ok := firstSectionInt(doc, section); ok {
|
|
applyValue = value
|
|
applyHasValue = true
|
|
}
|
|
}
|
|
continue
|
|
case "skill levelup":
|
|
total += skillSwitchSkillLevelupBonus(doc, section, jobTag, skillID)
|
|
continue
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// skillSwitchItemGrowTypeMatches reports whether an [item growtype] block
|
|
// applies to the character: the job tag must match (or be [all]) and the block
|
|
// stage must be no higher than the character's awakening stage. Stage 0 blocks
|
|
// always apply; higher stages apply cumulatively, matching the profession
|
|
// grant logic that merges every stage from 1 through the character's stage.
|
|
func skillSwitchItemGrowTypeMatches(
|
|
doc *dnfpvf.Document,
|
|
section dnfpvf.Section,
|
|
jobTag string,
|
|
awakeningStage byte,
|
|
) bool {
|
|
tokens := sectionTokens(doc, section)
|
|
var blockJob string
|
|
var stage int64 = -1
|
|
hasStage := false
|
|
for _, token := range tokens {
|
|
if (token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent) && blockJob == "" {
|
|
blockJob = token.Value
|
|
continue
|
|
}
|
|
if token.Kind == dnfpvf.TokenInt && !hasStage {
|
|
stage = token.Int
|
|
hasStage = true
|
|
continue
|
|
}
|
|
}
|
|
if blockJob == "" {
|
|
return false
|
|
}
|
|
normJob := skillSwitchJobTag(blockJob)
|
|
if normJob != "all" && normJob != jobTag {
|
|
return false
|
|
}
|
|
if hasStage && stage > 0 && int64(awakeningStage) < stage {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// skillSwitchSkillLevelupBonus sums exact `<job> <skill_id> <level>` rows in a
|
|
// single [skill levelup] section whose job and skill id match the character.
|
|
func skillSwitchSkillLevelupBonus(
|
|
doc *dnfpvf.Document,
|
|
section dnfpvf.Section,
|
|
jobTag string,
|
|
skillID uint16,
|
|
) int {
|
|
tokens := sectionTokens(doc, section)
|
|
total := 0
|
|
for index := 0; index+2 < len(tokens); index += 3 {
|
|
jobToken := tokens[index]
|
|
if jobToken.Kind != dnfpvf.TokenString && jobToken.Kind != dnfpvf.TokenIdent {
|
|
continue
|
|
}
|
|
skillToken := tokens[index+1]
|
|
levelToken := tokens[index+2]
|
|
if skillToken.Kind != dnfpvf.TokenInt || levelToken.Kind != dnfpvf.TokenInt {
|
|
continue
|
|
}
|
|
if skillToken.Int <= 0 || skillToken.Int > 0xffff || uint16(skillToken.Int) != skillID {
|
|
continue
|
|
}
|
|
normJob := skillSwitchJobTag(jobToken.Value)
|
|
if normJob != "all" && normJob != jobTag {
|
|
continue
|
|
}
|
|
total += int(levelToken.Int)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func sectionName(value string) string {
|
|
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " "))
|
|
}
|
|
|
|
func sectionTokens(doc *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
|
|
if doc == nil || section.Start < 0 || section.End < section.Start || section.End > len(doc.Tokens) {
|
|
return nil
|
|
}
|
|
return doc.Tokens[section.Start:section.End]
|
|
}
|
|
|
|
func firstSectionInt(doc *dnfpvf.Document, section dnfpvf.Section) (int64, bool) {
|
|
for _, token := range sectionTokens(doc, section) {
|
|
if token.Kind == dnfpvf.TokenInt {
|
|
return token.Int, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func firstSectionText(doc *dnfpvf.Document, section dnfpvf.Section) (string, bool) {
|
|
for _, token := range sectionTokens(doc, section) {
|
|
if token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent {
|
|
return token.Value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|