package gameserver import ( "fmt" "os" "sync" "sync/atomic" "time" "google.golang.org/protobuf/proto" "gac-hkrpg-go/internal/common" kcplib "gac-hkrpg-go/pkg/kcp" goproto "gac-hkrpg-go/protocol/proto" ) const maxPacketSize = 1400 // PropEntry mirrors PlayerSession.prop_entities: entity_id -> (prop_id, inst_id, state). type PropEntry struct { GroupID uint32 PropID uint32 InstID uint32 State uint32 } // Session is the Go port of PlayerSession. type Session struct { Token uint32 ConvID uint32 Addr string publicSDKURL string jsonMu sync.RWMutex stateMu sync.Mutex // serializes packet handlers and hot reload mutations json *common.FreesrData kcpMu sync.Mutex kcp *kcplib.KCP startTime time.Time nextSceneSave int64 battleProbeMu sync.Mutex battleProbe []byte PropEntities map[uint32]PropEntry CurrentSceneFloor uint32 // MonsterEntities tracks real overworld monster entities (entity_id -> event_id) // spawned in the current scene. Used by SceneCastSkill to decide whether an // attack actually hit a monster (the camera-detach bug fix). MonsterEntities map[uint32]uint32 SceneMonsterIdentities map[uint32]sceneMonsterIdentity partyActorSlots map[uint32]bool // Set by the challenge scene loader, cleared when loading an overworld scene. ChallengeStageID uint32 ChallengeBattleConfig *common.BattleConfig ChallengeRun *challengeRun PeakHardModes map[uint32]bool TierceTeams map[uint32][][]uint32 TierceBuffs map[uint32][]uint32 currencyWarsRevision uint64 currencyWarsOpen bool pendingExRankSync map[uint32]bool techniqueMP *uint32 leaderSlot uint32 paidTechniqueCasts map[uint32]bool closed atomic.Bool closeOnce sync.Once closeCh chan struct{} } // NewSession builds a session bound to one UDP endpoint. func NewSession(sendTo func(data []byte) error, conv, token uint32) *Session { s := &Session{ Token: token, ConvID: conv, kcp: kcplib.New(conv, token, false, sendTo), startTime: time.Now(), PropEntities: map[uint32]PropEntry{}, MonsterEntities: map[uint32]uint32{}, closeCh: make(chan struct{}), } return s } // Close marks the session as shut down (idempotent). func (s *Session) Close() { s.closeOnce.Do(func() { s.closed.Store(true) close(s.closeCh) }) } // Done returns a channel closed when the session shuts down. func (s *Session) Done() <-chan struct{} { return s.closeCh } // JSON returns the player data snapshot (read-mostly). func (s *Session) JSON() *common.FreesrData { s.jsonMu.RLock() defer s.jsonMu.RUnlock() return s.json } // SetJSON installs (or reloads) the player data. func (s *Session) SetJSON(d *common.FreesrData) { s.jsonMu.Lock() if s.json == nil && d != nil { s.leaderSlot = d.TeamPreset(d.ActiveTeamPreset).LeaderSlot } s.json = d s.jsonMu.Unlock() } // sessionTimeMs is the KCP clock (milliseconds since session start). // The Rust server fed KCP seconds, which broke RTT math; ms matches the // protocol's design and the client's clock domain. func (s *Session) sessionTimeMs() uint32 { return uint32(time.Since(s.startTime).Milliseconds()) } // Consume feeds a raw UDP payload into KCP and processes completed packets. func (s *Session) Consume(data []byte) error { if s.closed.Load() { return nil } s.kcpMu.Lock() if err := s.kcp.Input(data); err != nil { s.kcpMu.Unlock() return err } if err := s.kcp.Update(s.sessionTimeMs()); err != nil { s.kcpMu.Unlock() return err } var packets []*NetPacket buf := make([]byte, 65536) for { n, err := s.kcp.Recv(buf) if err != nil { break } pkt, err := DecodeNetPacket(buf[:n]) if err != nil { PacketLog(fmt.Sprintf("[ERROR] bad packet: %v", err)) continue } packets = append(packets, pkt) } s.kcpMu.Unlock() for _, pkt := range packets { if pkt.CmdType == goproto.CmdIDByName["PlayerLogoutCsReq"] { Log("[PACKET] Player logged out") s.Close() return nil } DispatchMessage(s, pkt.CmdType, pkt.Body) } s.kcpMu.Lock() err := s.kcp.Update(s.sessionTimeMs()) s.kcpMu.Unlock() return err } // Send marshals a proto response and pushes it through KCP. func (s *Session) Send(msg proto.Message) error { name := string(msg.ProtoReflect().Descriptor().Name()) switch name { case "GridFightGetDataScRsp", "GridFightStartGamePlayScRsp", "GridFightSyncUpdateResultScNotify", "GridFightSettleNotify", "GridFightEnterBattleStageScRsp", "GridFightEndBattleStageNotify", "GridFightGetArchiveScRsp": catalog, err := common.LoadCurrencyWarsCatalog() if err != nil { return err } cwProjectRoleIDs(msg, catalog) } cmdID, ok := goproto.CmdIDByName[name] if !ok { return fmt.Errorf("no cmd id registered for %s", name) } body, err := protoMarshal(msg) if err != nil { return err } PacketLog(fmt.Sprintf("[PACKET SEND] cmd_id: %d %s (len: %d)", cmdID, name, len(body))) Log(fmt.Sprintf("sent packet with CmdID: %d (%s)", cmdID, name)) if err := s.SendRaw(&NetPacket{CmdType: cmdID, Body: body}); err != nil { return err } s.captureBattleResponse(msg, cmdID, body) return nil } // SendRaw writes an already-framed packet into the KCP stream. func (s *Session) SendRaw(pkt *NetPacket) error { if s.closed.Load() { return nil } PacketLog(fmt.Sprintf("[PACKET SEND RAW] cmd_id: %d %s (len: %d)", pkt.CmdType, goproto.NameByCmdID[pkt.CmdType], len(pkt.Body))) payload := pkt.Encode() s.kcpMu.Lock() defer s.kcpMu.Unlock() if _, err := s.kcp.Send(payload); err != nil { return err } return s.kcp.Flush() } // DriveUpdate is called by the gateway ticker to run retransmissions. func (s *Session) DriveUpdate() { if s.closed.Load() { return } s.kcpMu.Lock() defer s.kcpMu.Unlock() _ = s.kcp.Update(s.sessionTimeMs()) } // SyncPlayer is the Go port of PlayerSession::sync_player — re-pushes the // whole inventory + avatar set after freesr-data.json changes. func (s *Session) SyncPlayer() error { player := s.JSON() if player == nil { Log("data is not init!") return nil } materials, err := bagMaterials(player) if err != nil { return err } supportedRelics, err := supportedRelicIDs() if err != nil { return err } // clear relics & lightcones del := &goproto.PlayerSyncScNotify{ DelRelicList: uint32Range(1, 3000), DelEquipmentList: uint32Range(3001, 3500), } if err := s.Send(del); err != nil { return err } // clear all avatar equip items avatarSync := &goproto.AvatarSync{} for _, id := range BaseAvatarIDs { if av, ok := player.Avatars[id]; ok { avatarSync.AvatarList = append(avatarSync.AvatarList, av.ToAvatarProto(nil, player.MainCharacter.ID(), player.MarchType.ID())) } } for _, av := range player.Avatars { avatarSync.AvatarPathDataInfoList = append(avatarSync.AvatarPathDataInfoList, av.ToAvatarPathDataProto(nil, nil, player.DressedSkins[av.AvatarID])) } if err := s.Send(&goproto.PlayerSyncScNotify{AvatarSync: avatarSync}); err != nil { return err } // sync new relics & lightcones sync := &goproto.PlayerSyncScNotify{} for i := range player.Relics { if _, supported := supportedRelics[player.Relics[i].RelicID]; supported { sync.RelicList = append(sync.RelicList, player.Relics[i].ToProtoRelic()) } } for i := range player.Lightcones { sync.EquipmentList = append(sync.EquipmentList, player.Lightcones[i].ToProtoEquipment()) } if err := s.Send(sync); err != nil { return err } // sync avatars with equip items attached avatarSync2 := &goproto.AvatarSync{} for _, id := range BaseAvatarIDs { if av, ok := player.Avatars[id]; ok { avatarSync2.AvatarList = append(avatarSync2.AvatarList, av.ToAvatarProto(player.FindLightconeFor(id), player.MainCharacter.ID(), player.MarchType.ID())) } } for _, av := range player.Avatars { avatarSync2.AvatarPathDataInfoList = append(avatarSync2.AvatarPathDataInfoList, av.ToAvatarPathDataProto(player.FindLightconeFor(av.AvatarID), filterSupportedRelics(player.FindRelicsFor(av.AvatarID), supportedRelics), player.DressedSkins[av.AvatarID])) } // FreeSR imports can change ranks as well as equipment. Publish the same // material quantities as GetBag, without deleting/recreating materials. return s.Send(&goproto.PlayerSyncScNotify{AvatarSync: avatarSync2, MaterialList: materials}) } func uint32Range(from, to uint32) []uint32 { out := make([]uint32, 0, to-from+1) for i := from; i <= to; i++ { out = append(out, i) } return out } // --------------------------------------------------------------------------- // logging helpers (Rust: tracing + util::packet_log) // --------------------------------------------------------------------------- var logMu sync.Mutex func Log(msg string) { logMu.Lock() defer logMu.Unlock() common.LogInfo("%s", msg) } func PacketLog(msg string) { logMu.Lock() defer logMu.Unlock() f, err := os.OpenFile("gameserver.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() fmt.Fprintf(f, "[%d] %s\n", time.Now().UnixMilli(), msg) } // SceneDebugLog appends to server_debug.log (Rust: handlers::scene_debug_log). func SceneDebugLog(format string, args ...any) { logMu.Lock() defer logMu.Unlock() f, err := os.OpenFile("server_debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return } defer f.Close() fmt.Fprintf(f, "[%d] %s\n", time.Now().UnixMilli(), fmt.Sprintf(format, args...)) }