package gameserver import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" "google.golang.org/protobuf/proto" ) // This advances the existing captured-entry prototype, not the final // procedural portal/effect processor. Each starter outcome is a complete, // internally consistent reference capture. No adventure inventory is touched. func currencyWarsEntryChoice(catalog *common.CurrencyWarsCatalog, before *goproto.MPIMAEMCEFH, queue, portal uint32) (*goproto.MPIMAEMCEFH, *goproto.GridFightSyncUpdateResultScNotify, error) { if catalog == nil || before == nil || before.FMIGOKIDKJL == nil || before.FMIGOKIDKJL.QueuePosition != queue { return nil, nil, fmt.Errorf("CW pending queue differs from the active run") } offer := before.FMIGOKIDKJL.GetMFFCPLBNHCF() if offer == nil { return nil, nil, fmt.Errorf("CW active pending action is not an initial portal selection") } found := false for _, id := range offer.NPHOIMEEDHM { found = found || id == portal } if portal == 0 || !found { return nil, nil, fmt.Errorf("CW portal is not in the active offer") } for _, component := range before.NAGDJGMMCBE { if team := component.GetEJIPINIKNCK(); team != nil && len(team.GridGameRoleList) != 0 { return nil, nil, fmt.Errorf("CW entry prototype cannot replace an existing team") } } raw, err := common.ReadResourceFile("resources/CurrencyWarsEntryTransitions.json") if err != nil { return nil, nil, err } var resource struct { Format uint32 `json:"format_version"` Client string `json:"target_client_version"` Assembly string `json:"target_assembly_sha256"` Source string `json:"source_binary_sha256"` Choices []struct { Division uint32 `json:"division_id"` Season uint32 `json:"season_id"` Overclock bool `json:"is_overclock"` Portal uint32 `json:"portal_id"` BeforeQueue uint32 `json:"before_queue"` AfterWire []byte `json:"after_wire"` NotifyWire []byte `json:"notify_wire"` AfterSHA string `json:"after_sha256"` NotifySHA string `json:"notify_sha256"` } `json:"choices"` } if err := json.Unmarshal(raw, &resource); err != nil { return nil, nil, err } if resource.Format != 1 || resource.Client != catalog.ClientVersion || resource.Assembly != catalog.Provenance.GameAssemblySHA256 { return nil, nil, fmt.Errorf("CW entry transition revision differs from the active catalog") } if h, err := hex.DecodeString(resource.Source); err != nil || len(h) != sha256.Size { return nil, nil, fmt.Errorf("CW entry transition source identity is missing") } var after *goproto.MPIMAEMCEFH var notify *goproto.GridFightSyncUpdateResultScNotify for _, choice := range resource.Choices { if choice.Division != before.DivisionId || choice.Season != before.LJNGPCKPJLF || choice.Overclock != before.IsOverlock || choice.Portal != portal || choice.BeforeQueue != queue { continue } if after != nil { return nil, nil, fmt.Errorf("CW entry transition key is duplicated") } if !currencyWarsWireDigest(choice.AfterWire, choice.AfterSHA) || !currencyWarsWireDigest(choice.NotifyWire, choice.NotifySHA) { return nil, nil, fmt.Errorf("CW entry transition wire digest differs") } after = new(goproto.MPIMAEMCEFH) notify = new(goproto.GridFightSyncUpdateResultScNotify) if err := proto.Unmarshal(choice.AfterWire, after); err != nil { return nil, nil, err } if err := proto.Unmarshal(choice.NotifyWire, notify); err != nil { return nil, nil, err } if after.DivisionId != before.DivisionId || after.LJNGPCKPJLF != before.LJNGPCKPJLF || after.IsOverlock != before.IsOverlock || after.FMIGOKIDKJL == nil || after.FMIGOKIDKJL.QueuePosition <= queue { return nil, nil, fmt.Errorf("CW captured transition identity or next queue is invalid") } selected := false finished := false for _, result := range notify.GCPNIHHBGGD { for _, update := range result.JKPJFKCBIEM { if value := update.GetHPIIGHOELBH(); value != nil && value.PortalBuffId == portal { selected = true } finished = finished || update.GetPMFBIGLPJJA() == queue } } if !selected || !finished { return nil, nil, fmt.Errorf("CW captured transition lacks portal or queue acknowledgement") } if err := validateCurrencyWarsEntryComponents(after, catalog); err != nil { return nil, nil, err } after.UniqueId = before.UniqueId } if after == nil { return nil, nil, fmt.Errorf("CW initial portal outcome is not captured for this division/mode") } // The native reference's GetData projection omits selected portals even // though the live sync includes them. Preserve this in our resume snapshot. for _, component := range after.NAGDJGMMCBE { if stage := component.GetKLCPAAHDPJF(); stage != nil { stage.GridFightPortalBuffList = append(stage.GridFightPortalBuffList, &goproto.GIDEDMOPFKF{PortalBuffId: portal}) } } cwEnsureSpotlights(after) return after, notify, nil } func currencyWarsWireDigest(wire []byte, expected string) bool { hash := sha256.Sum256(wire) return len(wire) > 0 && hex.EncodeToString(hash[:]) == expected } func init() { // The revision's request name is misleading. Controlled reference replay // establishes this request's MFFCPLBNHCF arm as portal selection and its // response as GridFightHandlePendingActionScRsp. No numeric alias is used. cwHandleNamed("GridFightHandleBonfireHealHp", "GridFightHandlePendingActionScRsp", func(s *Session, req *goproto.GridFightHandleBonfireHealHpCsReq, rsp *goproto.GridFightHandlePendingActionScRsp) error { rsp.QueuePosition = req.QueuePosition rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_CONF_MISS) selection := req.GetMFFCPLBNHCF() if req.GetIIDBLCFAAPC() != nil { if _, active := CURRENT_BATTLE.Load(s.Token); active { return fmt.Errorf("CW Bonfire selection cannot change during Battle") } } if selection == nil && req.GetNNDGEJFLGGF() == nil && req.GetLGNPHHBGKFH() == nil && req.GetIBCHMBNIPLK() == nil && req.GetDKDFDGJGIHM() == nil && req.GetFIKAGBAEOAP() == nil && req.GetBAKAKODKECM() == nil && req.GetIIDBLCFAAPC() == nil { PacketLog(fmt.Sprintf("CW_PENDING_ACTION unsupported queue=%d action=%T", req.QueuePosition, req.FGLIODAJFML)) return nil } 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(goproto.Retcode_RET_GRID_FIGHT_NOT_IN_GAMEPLAY) return nil } if revision != s.currencyWarsRevision { rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) return nil } before := data.DHFOFLFCLAH if before.FMIGOKIDKJL == nil || before.FMIGOKIDKJL.QueuePosition != req.QueuePosition { rsp.Retcode = uint32(goproto.Retcode_RET_GRID_FIGHT_PARAM_NOT_MATCH) return nil } var after *goproto.MPIMAEMCEFH var notify *goproto.GridFightSyncUpdateResultScNotify var runtime *common.CurrencyWarsRuntimeState if selection != nil { after, notify, err = currencyWarsEntryChoice(catalog, before, req.QueuePosition, selection.JCAPCNLJGFC) } else { after = proto.Clone(before).(*goproto.MPIMAEMCEFH) switch { case req.GetIIDBLCFAAPC() != nil && before.FMIGOKIDKJL.GetIIDBLCFAAPC() != nil: notify, runtime, err = cwSelectBonfire(catalog, after, req.GetIIDBLCFAAPC()) case req.GetBAKAKODKECM() != nil && before.FMIGOKIDKJL.GetBAKAKODKECM() != nil: notify, err = cwSelectEncounter(after, req.GetBAKAKODKECM()) case req.GetFIKAGBAEOAP() != nil && before.FMIGOKIDKJL.GetFIKAGBAEOAP() != nil: notify, err = cwSelectSupply(catalog, after, req.GetFIKAGBAEOAP()) case req.GetDKDFDGJGIHM() != nil && before.FMIGOKIDKJL.GetEEKMIAMGHFL() != nil: notify, err = cwSelectSpotlight(catalog, after, req.GetDKDFDGJGIHM()) case req.GetNNDGEJFLGGF() != nil && before.FMIGOKIDKJL.GetNNDGEJFLGGF() != nil: stage, _ := cwStage(after) route, loadErr := catalog.RouteNodes(stage.GetEMOHLJHPOPC()) if loadErr != nil || len(route) == 0 { err = fmt.Errorf("CW RoundBegin route missing: %v", loadErr) } else if stage.DCAHJELNJNP == route[0].ChapterID && stage.SectionId == route[0].SectionID { notify, err = cwInitialRound(catalog, after) } else { notify, err = cwNextRound(catalog, after) } case req.GetLGNPHHBGKFH() != nil && before.FMIGOKIDKJL.GetLGNPHHBGKFH() != nil: notify, err = cwReturnPreparation(after) case req.GetIBCHMBNIPLK() != nil && before.FMIGOKIDKJL.GetIBCHMBNIPLK() != nil: notify, err = cwSelectInvestment(catalog, after, req.GetIBCHMBNIPLK().AugmentId) default: err = fmt.Errorf("pending request kind differs from active queue") } } if err != nil { PacketLog(fmt.Sprintf("CW_PENDING_ACTION rejected queue=%d action=%T error=%v", req.QueuePosition, req.FGLIODAJFML, err)) return nil } data.DHFOFLFCLAH = after runtime, err = cwApplyBonfireEnemyRules(catalog, after, runtime) if err != nil { return err } // Enemy stars and their one-time rule marker must commit together. if stage, _ := cwStage(after); stage != nil && runtime.Rules["PetPalStarDown"] != 0 && stage.GetIMPNAHBCADN().GetMPOBBIPMNNJ() != nil { notify.GCPNIHHBGGD = append(notify.GCPNIHHBGGD, cwNotify(&goproto.KFNCAIHIHCM{MNLCHEJKDHO: &goproto.KFNCAIHIHCM_OOICHLAMNIG{OOICHLAMNIG: &goproto.AINFNFIDGFF{DCAHJELNJNP: stage.DCAHJELNJNP, SectionId: stage.SectionId, IMPNAHBCADN: stage.IMPNAHBCADN}}}).GCPNIHHBGGD...) } if err := s.saveCurrencyWarsDataWithRuntime(data, catalog, runtime); err != nil { return err } if req.GetIIDBLCFAAPC().GetECDACDNDHGO() != nil { player, _, _ := cwComponents(after) stage, _ := cwStage(after) PacketLog(fmt.Sprintf("CW_BONFIRE_TAKE saved old_queue=%d gold=%d level=%d exp=%d next=%d/%d revision=%d", req.QueuePosition, player.Gold, player.EHNJDIDABHD, player.GridFightCurLevelExp, stage.DCAHJELNJNP, stage.SectionId, s.currencyWarsRevision)) } // Save before synchronizing, then finish the request after the ordered // notification, matching the reference's pending-action callback flow. if err := cwSend(s, notify); err != nil { return err } rsp.Retcode = 0 PacketLog(fmt.Sprintf("CW_PENDING_ACTION advanced action=%T queue=%d next_queue=%d preparing=%t revision=%d", req.FGLIODAJFML, req.QueuePosition, after.GetFMIGOKIDKJL().GetQueuePosition(), after.FMIGOKIDKJL == nil, s.currencyWarsRevision)) return nil }) }