Files

193 lines
6.2 KiB
Go

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
}