package dnfbridge import ( "context" "os" "strings" "longheng.io/server/internal/modules/dnf/dnfenum" ) // Real-actor PoC for the volunteer squad. // // This path is deliberately switch-gated and OFF by default. It publishes each // volunteer as a real character actor (mode0 + mode1 + mode3, objectKey = // character id, non-zero party owner context) instead of the support-soldier // APC projection. It is the first milestone of the "real character enters the // dungeon" route; attaching the client AIC to these actors is the next client // patch step, so this path currently ships appearance/roster only. const ( currentCompanionRealActorsEnv = "DNF_COMPANION_REAL_ACTORS" currentCompanionRealActorsFlagFile = "companion_real_actors.enabled" currentCompanionRealActorsMode0Kind = "real_party_actor" ) func currentCompanionRealActorsEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionRealActorsEnv))) { case "0", "off", "false", "no": return false } if _, err := os.Stat(currentCompanionRealActorsFlagFile); err == nil { return true } return false } // publishCurrentDungeonCompanionRealActors returns true only when every ready // volunteer was published as a real actor. A partial failure returns false so // the caller can keep the proven projection path instead of showing a broken // half-team. func (s *Service) publishCurrentDungeonCompanionRealActors( session *gameSession, runtime *runtimeDungeonState, ) bool { if s == nil || session == nil || runtime == nil || runtime.Companion == nil || !currentCompanionRealActorsEnabled() { return false } run := runtime.Companion if !run.Ready || len(run.Sources) == 0 { return false } ownerContext := currentPartySceneOwnerContext(session) if ownerContext == 0 && session.channel.ID > 0 && session.channel.ID <= 0xff { ownerContext = byte(session.channel.ID) } if ownerContext == 0 { s.logGameEvent(session, "game-companion-real-actors-skipped", "owner_character_id", session.selectedCharacterID, "reason", "party_owner_context_not_committed") return false } ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout) defer cancel() type realRow struct { id uint16 name string mode0 []byte mode1 []byte } rows := make([]realRow, 0, len(run.Sources)) for _, entry := range run.Sources { if !entry.Ready || entry.SourceCharacterID == 0 { continue } state := entry.ProgressionSnapshot if state.Source.CharacterID == "" { return false } name := strings.TrimSpace(state.Source.Name) if name == "" { name = entry.SourceName } mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict( ctx, session, entry.SourceCharacterID, name, state.Source, true, ownerContext) if err != nil || len(mode0) < 3 { s.logGameEvent(session, "game-companion-real-actor-failed", "source_character_id", entry.SourceCharacterID, "stage", "mode0", "error", err) return false } reader := csharpLegacyUserInfoReader{ ctx: ctx, characterID: state.Source.CharacterID, service: s, session: session, suppressActorSkillResources: true, equipmentSnapshot: &state.Equipment, } mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner( state.Source, true, entry.SourceCharacterID, true, 0, currentSceneObjectRoute, ownerContext) if len(mode1) < 3 { s.logGameEvent(session, "game-companion-real-actor-failed", "source_character_id", entry.SourceCharacterID, "stage", "mode1") return false } rows = append(rows, realRow{id: entry.SourceCharacterID, name: name, mode0: mode0, mode1: mode1}) } if len(rows) == 0 { return false } // The client's native multi-row reader creates scene actors for a party // prelude. Sending one row at a time produced global visuals that a dungeon // room never rendered; the roster must be one counted mode0/mode1 body. var multiMode0, multiMode1 packetWriter multiMode0.writeByte(0) multiMode0.writeUint16(uint16(len(rows))) multiMode1.writeByte(1) multiMode1.writeUint16(uint16(len(rows))) for _, row := range rows { multiMode0.writeBytes(row.mode0[3:]) multiMode1.writeBytes(row.mode1[3:]) } for _, body := range [][]byte{multiMode0.bytes(), multiMode1.bytes()} { if err := s.sendGameUpperRawClass( session, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil { s.logGameEvent(session, "game-companion-real-actor-failed", "stage", "multi-row-send", "error", err) return false } } session.townMu.Lock() position := session.townPositionSnapshot session.townMu.Unlock() stateBits := currentActorDefaultUserStateBits if runtime.Character.Stats != nil { if value, exists := runtime.Character.Stats["user_state_bits"]; exists && value >= 0 && value <= 0xff { stateBits = byte(value) } } for index, row := range rows { x := int(position.PositionX) + index*40 y := int(position.PositionY) if !position.PositionValid { x = int(numericCharacterStatValue(runtime.Character, "pos_x")) + index*40 y = int(numericCharacterStatValue(runtime.Character, "pos_y")) } if x < 0 { x = 0 } if x > 0xffff { x = 0xffff } if y < 0 { y = 0 } if y > 0xffff { y = 0xffff } areaBody := buildCurrentTownUserAreaNotificationBody( currentSceneActorObjectKey(row.id), position.TownID, 0xff, uint16(x), uint16(y), position.MovementCode, stateBits, ) if err := s.sendGameUpperRawClass( session, currentTownUserAreaNotificationMsgID, areaBody, 0); err != nil { s.logGameEvent(session, "game-companion-real-actor-failed", "source_character_id", row.id, "stage", "op23", "error", err) return false } s.logGameEvent(session, "game-companion-real-actor-sent", "source_character_id", row.id, "name", row.name, "owner_context", ownerContext, "object_key", currentSceneActorObjectKey(row.id), "mode0_bytes", len(row.mode0), "mode1_bytes", len(row.mode1), "path", currentCompanionRealActorsMode0Kind) } sent := len(rows) if sent == 0 { return false } s.logGameEvent(session, "game-companion-real-actors-published", "owner_character_id", session.selectedCharacterID, "count", sent, "party_owner_context", ownerContext, "aic", "pending_client_attachment") return true }