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) action := request.GetNCGOOEMOBIC() selectReq := action.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 && action != nil && runtime.Rules[key+"heal"] != 0 { replay = true } if replay { return cwNotify(&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}}), runtime, nil } return cwNotify(&pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_FMIGOKIDKJL{FMIGOKIDKJL: pending}}), runtime, nil } if offer.Phase != cwBonfireChoosing && offer.Phase != pb.KMJMHDPPAKH_KMJMHDPPAKH_POMADDDKFHN { return nil, nil, fmt.Errorf("CW Bonfire phase invalid") } action := request.GetNCGOOEMOBIC() if action == nil { return nil, nil, fmt.Errorf("CW Bonfire action missing") } selectReq := action.GetALEGIFEHGLH() if selectReq == nil { if !action.GetLGAJNMNOAEM() { if _, ok := action.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, runtime) 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 }