Files
GaCy-GO/internal/gameserver/currency_wars_bonfire.go
2026-10-28 18:54:43 +07:00

470 lines
18 KiB
Go

package gameserver
import (
"crypto/rand"
"encoding/json"
"fmt"
"maps"
"math"
"math/big"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
// Current Client _UpdateHasSelected (assembly identity recorded in the phase
// contract document) compares phase >= 2. FFGO's selected=1 is incompatible.
const (
cwBonfireChoosing = pb.KMJMHDPPAKH_KMJMHDPPAKH_HLLKDFFMIEN
cwBonfireSelected = pb.KMJMHDPPAKH_KMJMHDPPAKH_BGENLPCJGJF
)
// Entry is a navigation request (empty request, retcode-only response in the
// revision-tagged schema). Reward choices are separate pending transactions;
// opening the menu must not consume a choice or award HP/items by itself.
func init() {
cwHandle("GridFightEnterBonfire", func(s *Session, _ *pb.GridFightEnterBonfireCsReq, rsp *pb.GridFightEnterBonfireScRsp) error {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH)
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
if data == nil || data.DHFOFLFCLAH == nil {
rsp.Retcode = uint32(pb.Retcode_RET_GRID_FIGHT_NOT_IN_GAMEPLAY)
return nil
}
if revision != s.currencyWarsRevision {
return common.ErrCurrencyWarsSaveConflict
}
if _, active := CURRENT_BATTLE.Load(s.Token); active {
return fmt.Errorf("CW Bonfire cannot open during Battle")
}
stage, node := cwStage(data.DHFOFLFCLAH)
if stage == nil || node == nil {
return nil
}
// FFGO revision 440df54730ba's gfBonfireEnter resynchronizes the
// current Bonfire action before acknowledging entry. Our current
// protocol maps that action to tag 1920 (IIDBLCFAAPC), independently
// of its renamed reference type. Opening never consumes its rewards.
pending := data.DHFOFLFCLAH.FMIGOKIDKJL
if pending != nil {
if pending.GetIIDBLCFAAPC() == nil {
return fmt.Errorf("CW Bonfire entry is blocked by pending queue %d", pending.QueuePosition)
}
copyPending := proto.Clone(pending).(*pb.CHCHAJIANIM)
if err := cwSend(s, cwNotify(&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: copyPending}})); err != nil {
return err
}
} else {
if !node.FCNNBODJNIK || len(node.HKKLNENEPNE) == 0 {
return fmt.Errorf("CW active node is not a Bonfire")
}
book, err := common.LoadCurrencyWarsBonfire(catalog)
if err != nil {
return err
}
player, _, _ := cwComponents(data.DHFOFLFCLAH)
if player == nil || revision >= uint64(^uint32(0)) {
return fmt.Errorf("CW Bonfire player/queue missing")
}
interest, err := catalog.BaseInterest(player.Gold, player.KNFOCCPOJEA)
if err != nil {
return err
}
streak, err := cwBonfireStreakGold(catalog, player.GDAKCOICIDA)
if err != nil {
return err
}
offer := &pb.NCEPOLNLNKH{Phase: cwBonfireChoosing, EIFICNFGGHI: node.PICMDONCKCO, AHJJJNBOMPG: interest, DANCNIMJOLE: streak}
for _, event := range node.HKKLNENEPNE {
if _, exists := book.Events[event]; !exists {
return fmt.Errorf("CW Bonfire event %d missing", event)
}
count := len(offer.KFNMIEMBJGD)
for _, option := range book.Options {
if option.EventID != event {
continue
}
if option.OptionShowType != "None" {
return fmt.Errorf("CW Bonfire display %s unsupported", option.OptionShowType)
}
offer.KFNMIEMBJGD = append(offer.KFNMIEMBJGD, &pb.MHCOIBONODA{EventId: event, HAFOKMHCGFM: option.OptionID})
}
if count == len(offer.KFNMIEMBJGD) {
return fmt.Errorf("CW Bonfire event %d has no options", event)
}
}
pending = &pb.CHCHAJIANIM{QueuePosition: uint32(revision + 1), FGLIODAJFML: &pb.CHCHAJIANIM_IIDBLCFAAPC{IIDBLCFAAPC: offer}}
data.DHFOFLFCLAH.FMIGOKIDKJL = pending
if err = s.saveCurrencyWarsData(data, catalog); err != nil {
return err
}
if err = cwSend(s, cwNotify(&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}})); err != nil {
return err
}
}
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_BONFIRE_ENTRY route=%d chapter=%d section=%d revision=%d", stage.EMOHLJHPOPC, stage.DCAHJELNJNP, stage.SectionId, s.currencyWarsRevision))
return nil
})
}
func cwBonfireStreakGold(c *common.CurrencyWarsCatalog, streak uint32) (uint32, error) {
raw, e := common.ReadResourceFile("resources/CurrencyWarsSettlementRules.json")
if e != nil {
return 0, e
}
var r struct {
Client string `json:"target_client_version"`
Assembly string `json:"target_assembly_sha256"`
Victories []struct{ Low, Up, Bonus uint32 } `json:"victory_rules"`
}
if e = json.Unmarshal(raw, &r); e != nil {
return 0, e
}
if r.Client != c.ClientVersion || r.Assembly != c.Provenance.GameAssemblySHA256 {
return 0, fmt.Errorf("CW Bonfire income revision differs")
}
var result uint32
found := false
for _, v := range r.Victories {
if v.Low <= streak && streak <= v.Up {
if found {
return 0, fmt.Errorf("CW streak ranges overlap")
}
result = v.Bonus
found = true
}
}
if !found {
return 0, fmt.Errorf("CW streak income missing")
}
return result, nil
}
func cwBonfireRuntime(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*common.CurrencyWarsRuntimeState, error) {
cfg, e := common.LoadRuntimeConfig()
if e != nil {
return nil, e
}
snapshot, e := common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
if e != nil {
return nil, e
}
rules := map[string]uint32{}
if snapshot != nil && snapshot.Runtime != nil && snapshot.Runtime.RunID == run.UniqueId {
maps.Copy(rules, snapshot.Runtime.Rules)
}
return &common.CurrencyWarsRuntimeState{RunID: run.UniqueId, Rules: rules}, nil
}
func cwSelectBonfire(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, request *pb.CAMIICJDOBO) (*pb.GridFightSyncUpdateResultScNotify, *common.CurrencyWarsRuntimeState, error) {
pending := run.GetFMIGOKIDKJL()
offer := pending.GetIIDBLCFAAPC()
if offer == nil || request == nil {
return nil, nil, fmt.Errorf("CW Bonfire choice is not active")
}
if request.GetECDACDNDHGO() != nil {
return cwTakeBonfire(c, run, offer)
}
if offer.Phase == cwBonfireSelected {
runtime, e := cwBonfireRuntime(c, run)
if e != nil {
return nil, nil, e
}
stage, _ := cwStage(run)
key := fmt.Sprintf("BonfireChoice/%d/%d/", stage.DCAHJELNJNP, stage.SectionId)
if offer.Phase != pb.KMJMHDPPAKH_KMJMHDPPAKH_POMADDDKFHN {
return nil, nil, fmt.Errorf("CW Bonfire choice was already selected")
}
if request.GetNCGOOEMOBIC().GetLGAJNMNOAEM() {
book, e := common.LoadCurrencyWarsBonfire(c)
if e != nil { return nil, nil, e }
stage, _ := cwStage(run)
player, _, _ := cwComponents(run)
if stage == nil || player == nil || player.GridFightLineupHp > player.PPKKHHNEOHB {
return nil, nil, fmt.Errorf("CW Bonfire heal components invalid")
}
heal, ok := book.Heal[stage.DCAHJELNJNP]
if !ok { return nil, nil, fmt.Errorf("CW Bonfire chapter heal missing") }
hp := uint64(player.GridFightLineupHp) + uint64(heal)
if hp > uint64(player.PPKKHHNEOHB) { hp = uint64(player.PPKKHHNEOHB) }
player.GridFightLineupHp = uint32(hp)
// gfPendingMarkBonfireSelected uses phase 1 for both heal and option.
offer.Phase = pb.KMJMHDPPAKH_KMJMHDPPAKH_HLLKDFFMIEN
return cwNotify(
&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_GridFightLineupHp{GridFightLineupHp: &pb.FMJDDIHBELC{GridFightLineupHp: player.GridFightLineupHp, PPKKHHNEOHB: player.PPKKHHNEOHB}}},
&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}},
), nil, nil
}
selectReq := request.GetNCGOOEMOBIC().GetALEGIFEHGLH()
replay := selectReq != nil && runtime.Rules[key+"event"] == selectReq.EventId && runtime.Rules[key+"option"] == selectReq.HAFOKMHCGFM
if selectReq != nil && !replay {
event, option, proven, proofErr := cwLegacyBonfireChoice(c, run, runtime)
if proofErr != nil {
return nil, nil, proofErr
}
replay = proven && event == selectReq.EventId && option == selectReq.HAFOKMHCGFM
}
if selectReq == nil && request.GetNCGOOEMOBIC() != nil && runtime.Rules[key+"heal"] != 0 {
replay = true
}
if replay {
return cwNotify(&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}}), runtime, nil
}
return nil, nil, fmt.Errorf("CW Bonfire option was already selected")
}
if offer.Phase != cwBonfireChoosing && offer.Phase != pb.KMJMHDPPAKH_KMJMHDPPAKH_POMADDDKFHN {
return nil, nil, fmt.Errorf("CW Bonfire phase invalid")
}
if offer.Phase != pb.KMJMHDPPAKH_KMJMHDPPAKH_POMADDDKFHN {
return nil, nil, fmt.Errorf("CW Bonfire choice was already selected")
}
if request.GetNCGOOEMOBIC().GetLGAJNMNOAEM() {
book, e := common.LoadCurrencyWarsBonfire(c)
if e != nil { return nil, nil, e }
stage, _ := cwStage(run)
player, _, _ := cwComponents(run)
if stage == nil || player == nil || player.GridFightLineupHp > player.PPKKHHNEOHB {
return nil, nil, fmt.Errorf("CW Bonfire heal components invalid")
}
heal, ok := book.Heal[stage.DCAHJELNJNP]
if !ok { return nil, nil, fmt.Errorf("CW Bonfire chapter heal missing") }
hp := uint64(player.GridFightLineupHp) + uint64(heal)
if hp > uint64(player.PPKKHHNEOHB) { hp = uint64(player.PPKKHHNEOHB) }
player.GridFightLineupHp = uint32(hp)
// gfPendingMarkBonfireSelected uses phase 1 for both heal and option.
offer.Phase = pb.KMJMHDPPAKH_KMJMHDPPAKH_HLLKDFFMIEN
return cwNotify(
&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_GridFightLineupHp{GridFightLineupHp: &pb.FMJDDIHBELC{GridFightLineupHp: player.GridFightLineupHp, PPKKHHNEOHB: player.PPKKHHNEOHB}}},
&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}},
), nil, nil
}
selectReq := request.GetNCGOOEMOBIC().GetALEGIFEHGLH()
if selectReq == nil {
if _, ok := request.GetNCGOOEMOBIC().GetFFBBOFAGGDI().(*pb.MLAENDOFBCK_LGAJNMNOAEM); !ok {
return nil, nil, fmt.Errorf("CW Bonfire action missing")
}
book, e := common.LoadCurrencyWarsBonfire(c)
if e != nil {
return nil, nil, e
}
stage, _ := cwStage(run)
player, _, _ := cwComponents(run)
if stage == nil || player == nil || player.GridFightLineupHp > player.PPKKHHNEOHB {
return nil, nil, fmt.Errorf("CW Bonfire heal state invalid")
}
heal, ok := book.Heal[stage.DCAHJELNJNP]
if !ok {
return nil, nil, fmt.Errorf("CW Bonfire chapter heal missing")
}
if remaining := player.PPKKHHNEOHB - player.GridFightLineupHp; heal > remaining {
heal = remaining
}
player.GridFightLineupHp += heal
offer.Phase = cwBonfireSelected
runtime, e := cwBonfireRuntime(c, run)
if e == nil {
runtime.Rules[fmt.Sprintf("BonfireChoice/%d/%d/heal", stage.DCAHJELNJNP, stage.SectionId)] = 1
}
return cwNotify(cwBonfireHPSync(player), &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}}), runtime, e
}
found := false
for _, v := range offer.KFNMIEMBJGD {
if v.EventId == selectReq.EventId && v.HAFOKMHCGFM == selectReq.HAFOKMHCGFM {
found = true
}
}
if !found {
return nil, nil, fmt.Errorf("CW Bonfire selection is outside current offer")
}
book, e := common.LoadCurrencyWarsBonfire(c)
if e != nil {
return nil, nil, e
}
runtime, e := cwBonfireRuntime(c, run)
if e != nil {
return nil, nil, e
}
for _, option := range book.Options {
if option.EventID != selectReq.EventId || option.OptionID != selectReq.HAFOKMHCGFM {
continue
}
rules, e := book.OptionRules(option)
if e != nil {
return nil, nil, e
}
maps.Copy(runtime.Rules, rules)
offer.Phase = cwBonfireSelected
stage, _ := cwStage(run)
key := fmt.Sprintf("BonfireChoice/%d/%d/", stage.DCAHJELNJNP, stage.SectionId)
runtime.Rules[key+"event"] = selectReq.EventId
runtime.Rules[key+"option"] = selectReq.HAFOKMHCGFM
return cwNotify(&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}}), runtime, nil
}
return nil, nil, fmt.Errorf("CW Bonfire selected recipe missing")
}
func cwBonfireHPSync(player *pb.NIHNGPKHEPC) *pb.KFNCAIHIHCM {
return &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_GridFightLineupHp{GridFightLineupHp: &pb.FMJDDIHBELC{PPKKHHNEOHB: player.PPKKHHNEOHB, GridFightLineupHp: player.GridFightLineupHp}}}
}
// Reference gfPendingHandleBonfire's take branch pays the saved offer and wave
// EXP, then finishes the action and creates the next node. Everything here is
// detached: the caller commits income, inventory and route with one revision CAS.
func cwTakeBonfire(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, offer *pb.NCEPOLNLNKH) (*pb.GridFightSyncUpdateResultScNotify, *common.CurrencyWarsRuntimeState, error) {
if offer.Phase != cwBonfireSelected {
return nil, nil, fmt.Errorf("CW Bonfire take requires a selected option or heal")
}
player, _, shop := cwComponents(run)
if player == nil || shop == nil {
return nil, nil, fmt.Errorf("CW Bonfire reward components missing")
}
runtime, err := cwBonfireRuntime(c, run)
if err != nil {
return nil, nil, err
}
sum := uint64(player.Gold) + uint64(offer.EIFICNFGGHI) + uint64(offer.AHJJJNBOMPG) + uint64(offer.DANCNIMJOLE)
if sum > math.MaxUint32 {
return nil, nil, fmt.Errorf("CW Bonfire income overflow")
}
gain, err := c.IntegerRule("GridFight_GainExpWhenWaveEnd")
if err != nil || gain < 0 || uint64(gain) > math.MaxUint32 {
return nil, nil, fmt.Errorf("CW Bonfire EXP rule invalid")
}
player.Gold = uint32(sum)
if err = cwApplyBaseExperience(c, player, shop, uint32(gain)); err != nil {
return nil, nil, err
}
notify := cwNotify(cwGoldSync(player.Gold), cwCapacitySync(player), cwShopSync(shop),
&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_PLPHEIBLPDL{PLPHEIBLPDL: &pb.PIHKLPJGEPM{Level: player.EHNJDIDABHD, Exp: player.GridFightCurLevelExp, HFMKLAPEKFA: player.NDAINFLOLPJ}}})
for _, drop := range offer.DJOBNPIAEJP {
// The reference take branch's saved drops are Orb IDs (drop enum 6),
// not a fresh random roll. Never grant a different item on resume/retry.
if drop == nil || drop.FKKGLFOHGGA != pb.MJJIFGINJAJ_MJJIFGINJAJ_ANMDMBGNOHL || drop.Num != 1 {
return nil, nil, fmt.Errorf("CW Bonfire saved drop is not a supported Orb")
}
added, e := cwApplyCommand(c, run, common.CurrencyWarsCommand{Field: "orb", Value: drop.FNLMKJLKLFH, Quantity: 1})
if e != nil {
return nil, nil, e
}
notify.GCPNIHHBGGD = append(notify.GCPNIHHBGGD, added.GCPNIHHBGGD...)
}
next, err := cwAdvanceRouteNode(c, run)
if err != nil {
return nil, nil, err
}
notify.GCPNIHHBGGD = append(notify.GCPNIHHBGGD, next.GCPNIHHBGGD...)
return notify, runtime, nil
}
// Prove a legacy selection from the old writer's same-node effect marker and
// exactly one offered option whose supported rule values are already saved.
// A bare phase=1 is insufficient: new offers legitimately use that phase.
func cwLegacyBonfireChoice(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, runtime *common.CurrencyWarsRuntimeState) (event, option uint32, proven bool, err error) {
stage, _ := cwStage(run)
if runtime == nil || runtime.RunID != run.UniqueId || stage == nil || runtime.Rules[fmt.Sprintf("PetPalStarDownApplied/%d/%d", stage.DCAHJELNJNP, stage.SectionId)] == 0 {
return
}
book, e := common.LoadCurrencyWarsBonfire(c)
if e != nil {
err = e
return
}
count := 0
for _, offered := range run.GetFMIGOKIDKJL().GetIIDBLCFAAPC().GetKFNMIEMBJGD() {
for _, candidate := range book.Options {
if candidate.EventID != offered.EventId || candidate.OptionID != offered.HAFOKMHCGFM {
continue
}
rules, e := book.OptionRules(candidate)
if e != nil {
continue
}
matches := len(rules) > 0
for k, v := range rules {
if saved, ok := runtime.Rules[k]; !ok || saved != v {
matches = false
}
}
if matches {
count++
event = candidate.EventID
option = candidate.OptionID
}
}
}
proven = count == 1
return
}
// Projection only; snapshot readers never rewrite persistent or replay effects.
func cwProjectLegacyBonfirePhase(c *common.CurrencyWarsCatalog, data *pb.GridFightGetDataScRsp, runtime *common.CurrencyWarsRuntimeState) error {
run := data.GetDHFOFLFCLAH()
offer := run.GetFMIGOKIDKJL().GetIIDBLCFAAPC()
if offer == nil || offer.Phase != cwBonfireChoosing {
return nil
}
_, _, proven, err := cwLegacyBonfireChoice(c, run, runtime)
if err == nil && proven {
offer.Phase = cwBonfireSelected
}
return err
}
// Reference gfRouteLowerEnemyStars: for each newly generated enemy above one
// star, roll [1,100] and reduce one star at the selected percentage. The saved
// marker makes retries/resume deterministic instead of rolling repeatedly.
func cwApplyBonfireEnemyRules(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, runtime *common.CurrencyWarsRuntimeState) (*common.CurrencyWarsRuntimeState, error) {
if runtime == nil {
var e error
runtime, e = cwBonfireRuntime(c, run)
if e != nil {
return nil, e
}
}
chance := runtime.Rules["PetPalStarDown"]
if chance == 0 {
return runtime, nil
}
if chance > 100 {
return nil, fmt.Errorf("CW enemy-star probability invalid")
}
stage, _ := cwStage(run)
if stage == nil {
return runtime, nil
}
layer := stage.GetIMPNAHBCADN().GetMPOBBIPMNNJ()
if layer == nil || len(layer.BPDABHGFBFO) == 0 {
return runtime, nil
}
key := fmt.Sprintf("PetPalStarDownApplied/%d/%d", stage.DCAHJELNJNP, stage.SectionId)
if runtime.Rules[key] != 0 {
return runtime, nil
}
for _, branch := range layer.BPDABHGFBFO {
for _, wave := range branch.MonsterWaveList {
for _, monster := range wave.PBLBBDBDLEK {
if monster.RoleStar <= 1 {
continue
}
roll, e := rand.Int(rand.Reader, big.NewInt(100))
if e != nil {
return nil, e
}
if uint32(roll.Uint64()+1) <= chance {
monster.RoleStar--
}
}
}
}
runtime.Rules[key] = 1
return runtime, nil
}