outfit checkpoint

This commit is contained in:
ScrawnyRonnie 2025-08-28 21:06:19 -04:00
parent 57b3fd69ab
commit 402259e338
14 changed files with 541 additions and 13 deletions

View file

@ -12,7 +12,7 @@ import net.psforever.objects.serverobject.mount.Mountable
import net.psforever.objects.serverobject.terminals.{ProximityUnit, Terminal}
import net.psforever.objects.zones.Zone
import net.psforever.packet.PlanetSideGamePacket
import net.psforever.packet.game.{AIDamage, ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarGrenadeStateMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BeginZoningMessage, BindPlayerMessage, BugReportMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ChatMsg, ChildObjectStateMessage, ConnectToWorldRequestMessage, CreateShortcutMessage, DeployObjectMessage, DeployRequestMessage, DismountVehicleCargoMsg, DismountVehicleMsg, DisplayedAwardMessage, DropItemMessage, DroppodLaunchRequestMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FavoritesRequest, FrameVehicleStateMessage, FriendsRequest, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, HitMessage, InvalidTerrainMessage, ItemTransactionMessage, LashMessage, LongRangeProjectileInfoMessage, LootItemMessage, MountVehicleCargoMsg, MountVehicleMsg, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, OutfitRequest, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, ProjectileStateMessage, ProximityTerminalUseMessage, ReleaseAvatarRequestMessage, ReloadMessage, RequestDestroyMessage, SetChatFilterMessage, SpawnRequestMessage, SplashHitMessage, SquadDefinitionActionMessage, SquadMembershipRequest, SquadWaypointRequest, TargetingImplantRequest, TradeMessage, UnuseItemMessage, UplinkRequest, UseItemMessage, VehicleStateMessage, VehicleSubStateMessage, VoiceHostInfo, VoiceHostRequest, WarpgateRequest, WeaponDelayFireMessage, WeaponDryFireMessage, WeaponFireMessage, WeaponLazeTargetPositionMessage, ZipLineMessage}
import net.psforever.packet.game.{AIDamage, ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarGrenadeStateMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BeginZoningMessage, BindPlayerMessage, BugReportMessage, ChangeAmmoMessage, ChangeFireModeMessage, ChangeFireStateMessage_Start, ChangeFireStateMessage_Stop, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ChatMsg, ChildObjectStateMessage, ConnectToWorldRequestMessage, CreateShortcutMessage, DeployObjectMessage, DeployRequestMessage, DismountVehicleCargoMsg, DismountVehicleMsg, DisplayedAwardMessage, DropItemMessage, DroppodLaunchRequestMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FavoritesRequest, FrameVehicleStateMessage, FriendsRequest, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, HitMessage, InvalidTerrainMessage, ItemTransactionMessage, LashMessage, LongRangeProjectileInfoMessage, LootItemMessage, MountVehicleCargoMsg, MountVehicleMsg, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, OutfitMembershipRequest, OutfitMembershipResponse, OutfitRequest, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, ProjectileStateMessage, ProximityTerminalUseMessage, ReleaseAvatarRequestMessage, ReloadMessage, RequestDestroyMessage, SetChatFilterMessage, SpawnRequestMessage, SplashHitMessage, SquadDefinitionActionMessage, SquadMembershipRequest, SquadWaypointRequest, TargetingImplantRequest, TradeMessage, UnuseItemMessage, UplinkRequest, UseItemMessage, VehicleStateMessage, VehicleSubStateMessage, VoiceHostInfo, VoiceHostRequest, WarpgateRequest, WeaponDelayFireMessage, WeaponDryFireMessage, WeaponFireMessage, WeaponLazeTargetPositionMessage, ZipLineMessage}
import net.psforever.services.{InterstellarClusterService => ICS}
import net.psforever.services.CavernRotationService
import net.psforever.services.CavernRotationService.SendCavernRotationUpdates
@ -610,7 +610,14 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
case packet: HitHint =>
logic.general.handleHitHint(packet)
case _: OutfitRequest => ()
case packet: OutfitRequest =>
logic.general.handleOutfitRequest(packet)
case packet: OutfitMembershipRequest =>
logic.general.handleOutfitMembershipRequest(packet)
case packet: OutfitMembershipResponse =>
logic.general.handleOutfitMembershipResponse(packet)
case pkt =>
data.log.warn(s"Unhandled GamePacket $pkt")

View file

@ -30,11 +30,13 @@ import net.psforever.objects.vehicles.Utility
import net.psforever.objects.vital.Vitality
import net.psforever.objects.zones.{ZoneProjectile, Zoning}
import net.psforever.packet.PlanetSideGamePacket
import net.psforever.packet.game.{ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ChatMsg, CollisionIs, ConnectToWorldRequestMessage, CreateShortcutMessage, DeadState, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, RequestDestroyMessage, TargetingImplantRequest, TerrainCondition, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage}
import net.psforever.packet.game.OutfitEventAction.{OutfitInfo, OutfitRankNames, Unk0, Unk1}
import net.psforever.packet.game.{ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ChatMsg, CollisionIs, ConnectToWorldRequestMessage, CreateShortcutMessage, DeadState, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, OutfitEvent, OutfitMemberEvent, OutfitMembershipRequest, OutfitMembershipResponse, OutfitRequest, OutfitRequestAction, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, RequestDestroyMessage, TargetingImplantRequest, TerrainCondition, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage}
import net.psforever.services.RemoverActor
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.services.local.{LocalAction, LocalServiceMessage}
import net.psforever.types.{CapacitorStateType, ChatMessageType, Cosmetic, ExoSuitType, PlanetSideEmpire, PlanetSideGUID, Vector3}
import scodec.bits.ByteVector
import scala.util.Success
@ -665,6 +667,19 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex
val HitHint(_, _) = pkt
}
def handleOutfitMembershipRequest(pkt: OutfitMembershipRequest): Unit = {}
def handleOutfitMembershipResponse(pkt: OutfitMembershipResponse): Unit = {}
def handleOutfitRequest(pkt: OutfitRequest): Unit = {
pkt match {
case OutfitRequest(_, OutfitRequestAction.Unk3(true)) =>
case OutfitRequest(_, OutfitRequestAction.Unk3(false)) =>
case _ =>
}
}
/* messages */
def handleRenewCharSavedTimer(): Unit = { /* */ }

View file

@ -7,7 +7,7 @@ import net.psforever.actors.session.spectator.SpectatorMode
import net.psforever.actors.session.support.{ChatFunctions, ChatOperations, SessionData}
import net.psforever.objects.Session
import net.psforever.packet.game.{ChatMsg, ServerType, SetChatFilterMessage}
import net.psforever.services.chat.{DefaultChannel, SquadChannel}
import net.psforever.services.chat.{DefaultChannel, OutfitChannel, SquadChannel}
import net.psforever.types.ChatMessageType
import net.psforever.types.ChatMessageType.{CMT_TOGGLESPECTATORMODE, CMT_TOGGLE_GM}
import net.psforever.util.Config
@ -79,6 +79,9 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext
case (CMT_SQUAD, _, _) =>
ops.commandSquad(session, message, SquadChannel(sessionLogic.squad.squad_guid))
case (CMT_OUTFIT, _, _) =>
ops.commandOutfit(session, message, OutfitChannel(sessionLogic.player.outfit_id))
case (CMT_WHO | CMT_WHO_CSR | CMT_WHO_CR | CMT_WHO_PLATOONLEADERS | CMT_WHO_SQUADLEADERS | CMT_WHO_TEAMS, _, _) =>
ops.commandWho(session)
@ -100,7 +103,7 @@ class ChatLogic(val ops: ChatOperations, implicit val context: ActorContext) ext
def handleIncomingMessage(message: ChatMsg, fromSession: Session): Unit = {
import ChatMessageType._
message.messageType match {
case CMT_BROADCAST | CMT_SQUAD | CMT_PLATOON | CMT_COMMAND | CMT_NOTE =>
case CMT_BROADCAST | CMT_SQUAD | CMT_PLATOON | CMT_COMMAND | CMT_NOTE | CMT_OUTFIT =>
ops.commandIncomingSendAllIfOnline(session, message)
case CMT_OPEN =>

View file

@ -4,7 +4,7 @@ package net.psforever.actors.session.normal
import akka.actor.typed.scaladsl.adapter._
import akka.actor.{ActorContext, ActorRef, typed}
import net.psforever.actors.session.{AvatarActor, SessionActor}
import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData}
import net.psforever.actors.session.support.{GeneralFunctions, GeneralOperations, SessionData, SessionOutfitHandlers}
import net.psforever.objects.{Account, BoomerDeployable, BoomerTrigger, ConstructionItem, GlobalDefinitions, LivePlayerList, Player, SensorDeployable, ShieldGeneratorDeployable, SpecialEmp, TelepadDeployable, Tool, TrapDeployable, TurretDeployable, Vehicle}
import net.psforever.objects.avatar.{Avatar, PlayerControl, SpecialCarry}
import net.psforever.objects.ballistics.Projectile
@ -37,13 +37,16 @@ import net.psforever.objects.vital.etc.SuicideReason
import net.psforever.objects.vital.interaction.DamageInteraction
import net.psforever.objects.zones.{ZoneProjectile, Zoning}
import net.psforever.packet.PlanetSideGamePacket
import net.psforever.packet.game.{ActionCancelMessage, ActionResultMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestAction, CharacterRequestMessage, ChatMsg, CollisionIs, ConnectToWorldRequestMessage, CreateShortcutMessage, DeadState, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, RequestDestroyMessage, TargetingImplantRequest, TerrainCondition, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage}
import net.psforever.packet.game.OutfitEventAction.{OutfitInfo, OutfitRankNames, Unk2}
import net.psforever.packet.game.{ActionCancelMessage, ActionResultMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestAction, CharacterRequestMessage, ChatMsg, CollisionIs, ConnectToWorldRequestMessage, CreateShortcutMessage, DeadState, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, OutfitEvent, OutfitMembershipRequest, OutfitMembershipRequestAction, OutfitMembershipResponse, OutfitRequest, OutfitRequestAction, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, RequestDestroyMessage, TargetingImplantRequest, TerrainCondition, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage}
import net.psforever.services.account.{AccountPersistenceService, RetrieveAccountData}
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.services.chat.OutfitChannel
import net.psforever.services.local.{LocalAction, LocalServiceMessage}
import net.psforever.services.local.support.CaptureFlagManager
import net.psforever.types.{CapacitorStateType, ChatMessageType, Cosmetic, ExoSuitType, ImplantType, PlanetSideEmpire, PlanetSideGUID, Vector3}
import net.psforever.util.Config
import net.psforever.zones.Zones.zones
import scala.concurrent.duration._
@ -796,6 +799,54 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex
val HitHint(_, _) = pkt
}
def handleOutfitMembershipRequest(pkt: OutfitMembershipRequest): Unit = {
pkt match {
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.Form(_, outfitName)) =>
if (player.outfit_id == 0) {
SessionOutfitHandlers.HandleOutfitForm(outfitName, player, sessionLogic)
}
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.Invite(_, invitedName)) =>
SessionOutfitHandlers.HandleOutfitInvite(zones, invitedName, player)
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.Kick(memberId, _)) =>
SessionOutfitHandlers.HandleOutfitKick(zones, memberId, player)
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.SetRank(memberId, newRank, _)) =>
SessionOutfitHandlers.HandleOutfitPromote(zones, memberId, newRank, player)
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.AcceptInvite(_)) =>
SessionOutfitHandlers.HandleOutfitInviteAccept(player, sessionLogic)
case _ =>
}
}
def handleOutfitMembershipResponse(pkt: OutfitMembershipResponse): Unit = {}
def handleOutfitRequest(pkt: OutfitRequest): Unit = {
pkt match {
case OutfitRequest(_, OutfitRequestAction.Ranks(List(r1, r2, r3, r4, r5, r6, r7, r8))) =>
// update db
//sendResponse(OutfitEvent(6418, Unk2(OutfitInfo(player.outfit_name, 0, 0, 1, OutfitRankNames(r1.getOrElse(""), r2.getOrElse(""), r3.getOrElse(""), r4.getOrElse(""), r5.getOrElse(""), r6.getOrElse(""), r7.getOrElse(""), r8.getOrElse("")), "Welcome to the first PSForever Outfit!", 0, unk11=true, 0, 8888888, 0, 0, 0))))
case OutfitRequest(_, OutfitRequestAction.Motd(message)) =>
// update db
//sendResponse(OutfitEvent(6418, Unk2(OutfitInfo(player.outfit_name, 0, 0, 1, OutfitRankNames("", "", "", "", "", "", "", ""), message, 0, unk11=true, 0, 8888888, 0, 0, 0))))
case OutfitRequest(_, OutfitRequestAction.Unk3(true)) =>
SessionOutfitHandlers.HandleViewOutfitWindow(zones, player, player.outfit_id)
case OutfitRequest(_, OutfitRequestAction.Unk3(false)) =>
case OutfitRequest(_, OutfitRequestAction.Unk4(true)) =>
SessionOutfitHandlers.HandleGetOutfitList(player)
case _ =>
}
}
/* messages */
def handleRenewCharSavedTimer(): Unit = {

View file

@ -13,7 +13,7 @@ import net.psforever.objects.serverobject.doors.Door
import net.psforever.objects.vehicles.Utility
import net.psforever.objects.zones.ZoneProjectile
import net.psforever.packet.PlanetSideGamePacket
import net.psforever.packet.game.{ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ConnectToWorldRequestMessage, CreateShortcutMessage, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, ImplantAction, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, RequestDestroyMessage, TargetingImplantRequest, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage}
import net.psforever.packet.game.{ActionCancelMessage, AvatarFirstTimeEventMessage, AvatarImplantMessage, AvatarJumpMessage, BattleplanMessage, BindPlayerMessage, BugReportMessage, ChangeFireModeMessage, ChangeShortcutBankMessage, CharacterCreateRequestMessage, CharacterRequestMessage, ConnectToWorldRequestMessage, CreateShortcutMessage, DeployObjectMessage, DisplayedAwardMessage, DropItemMessage, EmoteMsg, FacilityBenefitShieldChargeRequestMessage, FriendsRequest, GenericAction, GenericActionMessage, GenericCollisionMsg, GenericObjectActionAtPositionMessage, GenericObjectActionMessage, GenericObjectStateMsg, HitHint, ImplantAction, InvalidTerrainMessage, LootItemMessage, MoveItemMessage, ObjectDetectedMessage, ObjectHeldMessage, OutfitMembershipRequest, OutfitMembershipResponse, OutfitRequest, PickupItemMessage, PlanetsideAttributeMessage, PlayerStateMessageUpstream, RequestDestroyMessage, TargetingImplantRequest, TradeMessage, UnuseItemMessage, UseItemMessage, VoiceHostInfo, VoiceHostRequest, ZipLineMessage}
import net.psforever.services.account.AccountPersistenceService
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.types.{ExoSuitType, Vector3}
@ -375,6 +375,12 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex
def handleHitHint(pkt: HitHint): Unit = { /* intentionally blank */ }
def handleOutfitMembershipRequest(pkt: OutfitMembershipRequest): Unit = {}
def handleOutfitMembershipResponse(pkt: OutfitMembershipResponse): Unit = {}
def handleOutfitRequest(pkt: OutfitRequest): Unit = {}
/* messages */
def handleRenewCharSavedTimer(): Unit = { /* intentionally blank */ }

View file

@ -14,7 +14,7 @@ import net.psforever.objects.LivePlayerList
import net.psforever.objects.sourcing.PlayerSource
import net.psforever.objects.zones.ZoneInfo
import net.psforever.packet.game.SetChatFilterMessage
import net.psforever.services.chat.{DefaultChannel, SquadChannel}
import net.psforever.services.chat.{DefaultChannel, OutfitChannel, SquadChannel}
import net.psforever.services.local.{LocalAction, LocalServiceMessage}
import net.psforever.services.teamwork.{SquadResponse, SquadService, SquadServiceResponse}
import net.psforever.types.ChatMessageType.CMT_QUIT
@ -446,6 +446,14 @@ class ChatOperations(
}
}
def commandOutfit(session: Session, message: ChatMsg, toChannel: ChatChannel): Unit = {
channels.foreach {
case _/*channel*/: OutfitChannel =>
commandSendToRecipient(session, message, toChannel)
case _ => ()
}
}
def commandWho(session: Session): Unit = {
val players = session.zone.Players
val popTR = players.count(_.faction == PlanetSideEmpire.TR)

View file

@ -166,6 +166,12 @@ trait GeneralFunctions extends CommonSessionInterfacingFunctionality {
def handleCanNotPutItemInSlot(msg: Containable.CanNotPutItemInSlot): Unit
def handleReceiveDefaultMessage(default: Any, sender: ActorRef): Unit
def handleOutfitMembershipRequest(pkt: OutfitMembershipRequest): Unit
def handleOutfitMembershipResponse(pkt: OutfitMembershipResponse): Unit
def handleOutfitRequest(pkt: OutfitRequest): Unit
}
class GeneralOperations(

View file

@ -0,0 +1,34 @@
package net.psforever.actors.session.support
import net.psforever.objects.Player
import scala.collection.mutable
case class OutfitInvite(
sentTo: Player,
sentFrom: Player,
timestamp: Long = System.currentTimeMillis() / 1000
)
object OutfitInviteManager {
private val invites = mutable.Map[Long, OutfitInvite]()
private val ExpirationSeconds = 320
def addOutfitInvite(invite: OutfitInvite): Boolean = {
invites.get(invite.sentTo.CharId) match {
case Some(existing) if (System.currentTimeMillis() / 1000 - existing.timestamp) < ExpirationSeconds =>
false // Reject new invite (previous one is still valid)
case _ =>
invites(invite.sentTo.CharId) = invite
true
}
}
def removeOutfitInvite(sentToId: Long): Unit = {
invites.remove(sentToId)
}
def getOutfitInvite(sentToId: Long): Option[OutfitInvite] = invites.get(sentToId)
def getAllOutfitInvites: List[OutfitInvite] = invites.values.toList
}

View file

@ -0,0 +1,390 @@
// Copyright (c) 2025 PSForever
package net.psforever.actors.session.support
import io.getquill.{ActionReturning, EntityQuery, Insert, PostgresJAsyncContext, Query, Quoted, SnakeCase}
import net.psforever.objects.avatar.PlayerControl
import net.psforever.objects.zones.Zone
import net.psforever.objects.Player
import net.psforever.packet.game.OutfitEventAction.{OutfitInfo, OutfitRankNames, Unk0, Unk1, Unk2}
import net.psforever.packet.game.OutfitMembershipResponse.PacketType.CreateResponse
import net.psforever.packet.game._
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.services.chat.OutfitChannel
import net.psforever.types.ChatMessageType
import net.psforever.util.Config
import java.time.LocalDateTime
import scala.util.{Failure, Success}
object SessionOutfitHandlers {
case class Avatar(id: Long, name: String, faction_id: Int, last_login: java.time.LocalDateTime)
case class Outfit(id: Long, name: String, faction: Int, owner_id: Long, motd: Option[String], created: java.time.LocalDateTime,
rank0: Option[String],
rank1: Option[String],
rank2: Option[String],
rank3: Option[String],
rank4: Option[String],
rank5: Option[String],
rank6: Option[String],
rank7: Option[String])
case class Outfitmember(id: Long, outfit_id: Long, avatar_id: Long, rank: Int)
case class Outfitpoint(id: Long, outfit_id: Long, avatar_id: Long, points: Long)
case class OutfitpointMv(outfit_id: Long, points: Long)
val ctx = new PostgresJAsyncContext(SnakeCase, Config.config.getConfig("database"))
import ctx._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
def HandleOutfitForm(outfitName: String, player: Player, session: SessionData): Unit = {
val cleanedName = sanitizeOutfitName(outfitName)
cleanedName match {
case Some(validName) =>
ctx.run(findOutfitByName(validName)).flatMap {
case existing if existing.nonEmpty =>
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "@OutfitErrorNameAlreadyTaken"))
Future.successful(())
case _ =>
createNewOutfit(validName, player.Faction.id, player.CharId).map { outfit =>
val seconds: Long =
outfit.created.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli / 1000
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitEvent(outfit.id, Unk2(
OutfitInfo(
outfit.name, 0, 0, 1,
OutfitRankNames("", "", "", "", "", "", "", ""),
"",
14, unk11 = true, 0, seconds, 0, 0, 0))))
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitMemberUpdate(outfit.id, player.CharId, 7, flag = true))
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "@OutfitCreateSuccess"))
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitMembershipResponse(CreateResponse, 0, 0, player.CharId, 0, "", "", flag = true))
player.outfit_id = outfit.id
player.outfit_name = outfit.name
session.chat.JoinChannel(OutfitChannel(player.outfit_id))
}
.recover { case e =>
e.printStackTrace()
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "@OutfitCreateFailure"))
}
}
case None =>
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "@OutfitCreateFailure"))
}
}
def HandleOutfitInvite(zones: Seq[Zone], invitedName: String, sentFrom: Player): Unit = {
findPlayerByNameForOutfitAction(zones, invitedName, sentFrom).foreach { invitedPlayer =>
PlayerControl.sendResponse(invitedPlayer.Zone, invitedPlayer.Name,
OutfitMembershipResponse(OutfitMembershipResponse.PacketType.Invite, 0, 0,
sentFrom.CharId, sentFrom.CharId, sentFrom.Name, sentFrom.outfit_name, flag = false))
PlayerControl.sendResponse(sentFrom.Zone, sentFrom.Name,
OutfitMembershipResponse(OutfitMembershipResponse.PacketType.Invite, 0, 0,
sentFrom.CharId, invitedPlayer.CharId, invitedPlayer.Name, sentFrom.outfit_name, flag = true))
val outfitInvite = OutfitInvite(invitedPlayer, sentFrom)
OutfitInviteManager.addOutfitInvite(outfitInvite)
}
}
def HandleOutfitInviteAccept(invited: Player, session: SessionData): Unit = {
OutfitInviteManager.getOutfitInvite(invited.CharId) match {
case Some(outfitInvite) =>
val outfitId = outfitInvite.sentFrom.outfit_id
(for {
_ <- addMemberToOutfit(outfitId, invited.CharId)
outfitOpt <- ctx.run(getOutfitById(outfitId)).map(_.headOption)
memberCount <- ctx.run(getOutfitMemberCount(outfitId))
points <- ctx.run(getOutfitPoints(outfitId)).map(_.headOption.map(_.points).getOrElse(0L))
} yield (outfitOpt, memberCount, points))
.map {
case (Some(outfit), memberCount, points) =>
PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name,
OutfitMembershipResponse(
OutfitMembershipResponse.PacketType.Unk2, 0, 0,
invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, outfit.name, flag = false))
PlayerControl.sendResponse(invited.Zone, invited.Name,
OutfitMembershipResponse(
OutfitMembershipResponse.PacketType.Unk2, 0, 0,
invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, outfit.name, flag = true))
PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name,
OutfitEvent(outfitId, OutfitEventAction.Unk5(memberCount)))
PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name,
OutfitMemberEvent(outfitId, invited.CharId,
OutfitMemberEventAction.Unk0(invited.Name, 0, 0, 0,
OutfitMemberEventAction.PacketType.Padding, 0)))
val seconds: Long = outfit.created.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli / 1000
PlayerControl.sendResponse(invited.Zone, invited.Name,
OutfitEvent(outfitId, Unk0(OutfitInfo(
outfit.name, points, points, memberCount,
OutfitRankNames("", "", "", "", "", "", "", ""),
outfit.motd.getOrElse(""),
14, unk11 = true, 0, seconds, 0, 0, 0))))
PlayerControl.sendResponse(invited.Zone, invited.Name,
OutfitMemberUpdate(outfit.id, invited.CharId, 0, flag=true))
OutfitInviteManager.removeOutfitInvite(invited.CharId)
session.chat.JoinChannel(OutfitChannel(outfit.id))
invited.outfit_id = outfit.id
invited.outfit_name = outfit.name
case (None, _, _) =>
PlayerControl.sendResponse(invited.Zone, invited.Name,
ChatMsg(ChatMessageType.UNK_227, "Failed to join outfit"))
}
.recover { case _ =>
PlayerControl.sendResponse(invited.Zone, invited.Name,
ChatMsg(ChatMessageType.UNK_227, "Failed to join outfit"))
}
case None =>
}
}
def HandleOutfitKick(zones: Seq[Zone], kickedId: Long, kickedBy: Player): Unit = {
// if same id, player has left the outfit by their own choice
if (kickedId == kickedBy.CharId) {
// db stuff first
PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
}
else {
// db stuff first
// tell player they've been kicked (if online)
findPlayerByIdForOutfitAction(zones, kickedId, kickedBy).foreach { kicked =>
PlayerControl.sendResponse(kicked.Zone, kicked.Name, OutfitMembershipResponse(OutfitMembershipResponse.PacketType.Kick, 0, 1, kickedBy.CharId, kicked.CharId, kicked.Name, kickedBy.Name, flag = false))
kicked.Zone.AvatarEvents ! AvatarServiceMessage(kicked.Zone.id, AvatarAction.PlanetsideAttributeToAll(kicked.GUID, 39, 0))
//kicked.Zone.AvatarEvents ! AvatarServiceMessage(kicked.Zone.id, AvatarAction.PlanetsideStringAttributeMessage(kicked.GUID, 0, ""))
kicked.outfit_id = 0
kicked.outfit_name = ""
PlayerControl.sendResponse(kicked.Zone, kicked.Name, OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
// move this out of foreach - db will provide kicked char details
PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, OutfitMembershipResponse(OutfitMembershipResponse.PacketType.Kick, 0, 1, kickedBy.CharId, kicked.CharId, kicked.Name, "", flag = true))
PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
// new number of outfit members?
PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, OutfitEvent(kickedBy.outfit_id, OutfitEventAction.Unk5(34)))
}
}
}
def HandleOutfitPromote(zones: Seq[Zone], promotedId: Long, newRank: Int, promoter: Player): Unit = {
// send to all online players in outfit
findPlayerByIdForOutfitAction(zones, promotedId, promoter).foreach { promoted =>
PlayerControl.sendResponse(promoted.Zone, promoted.Name, OutfitMemberEvent(6418, promotedId, OutfitMemberEventAction.Unk0(promoted.Name, newRank, 1032432, 0, OutfitMemberEventAction.PacketType.Padding, 0)))
PlayerControl.sendResponse(promoter.Zone, promoter.Name, OutfitMemberEvent(6418, promotedId, OutfitMemberEventAction.Unk0(promoted.Name, newRank, 1032432, 0, OutfitMemberEventAction.PacketType.Padding, 0)))
}
}
def HandleViewOutfitWindow(zones: Seq[Zone], player: Player, outfitId: Long): Unit = {
val outfitDetailsF = for {
outfitOpt <- ctx.run(getOutfitById(outfitId)).map(_.headOption)
memberCount <- ctx.run(query[Outfitmember].filter(_.outfit_id == lift(outfitId)).size)
pointsTotal <- ctx.run(querySchema[OutfitpointMv]("outfitpoint_mv").filter(_.outfit_id == lift(outfitId)))
} yield (outfitOpt, memberCount, pointsTotal.headOption.map(_.points).getOrElse(0L))
val membersF = ctx.run(getOutfitMembersWithDetails(outfitId))
for {
(outfitOpt, memberCount, totalPoints) <- outfitDetailsF
members <- membersF
} yield {
outfitOpt.foreach { outfit =>
val seconds: Long = outfit.created.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli / 1000
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitEvent(outfit.id, Unk0(OutfitInfo(
outfit.name,
totalPoints,
totalPoints,
memberCount,
OutfitRankNames("", "", "", "", "", "", "", ""),
outfit.motd.getOrElse(""),
14, unk11 = true, 0, seconds, 0, 0, 0))))
members.foreach { case (avatarId, avatarName, points, rank, login) =>
val lastLogin = findPlayerByIdForOutfitAction(zones, avatarId, player) match {
case Some(_) => 0L
case None if player.Name == avatarName => 0L
case None => (System.currentTimeMillis() - login.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli) / 1000
}
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitMemberEvent(outfit.id, avatarId,
OutfitMemberEventAction.Unk0(
avatarName,
rank,
points,
lastLogin,
OutfitMemberEventAction.PacketType.Padding, 0)))
}
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitEvent(outfit.id, Unk1()))
}
}
}
def HandleGetOutfitList(player: Player): Unit = {
val q = getOutfitsByEmpire(player.Faction.id)
val futureResult = ctx.run(q)
futureResult.onComplete {
case Success(rows) =>
rows.foreach { case (outfitId, points, name, leaderName, memberCount) =>
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitListEvent(
OutfitListEventAction.ListElementOutfit(
outfitId,
points,
memberCount,
name,
leaderName)))
}
case Failure(_) =>
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "Outfit list failed to return")
)
}
}
/* supporting functions */
def sanitizeOutfitName(name: String): Option[String] = {
val cleaned = name
.replaceAll("""[^A-Za-z0-9\-="\;\[\]\(\)\. ]""", "") // Remove disallowed chars
.replaceAll(" +", " ") // Collapse multiple spaces to one
.trim // Remove leading/trailing spaces
if (cleaned.length >= 2 && cleaned.length <= 32) Some(cleaned) else None
}
def findPlayerByNameForOutfitAction(zones: Iterable[Zone], targetName: String, inviter: Player): Option[Player] = {
zones
.flatMap(_.LivePlayers)
.find(p =>
p.Name.equalsIgnoreCase(targetName) && p.Name != inviter.Name &&
p.Faction == inviter.Faction && p.outfit_id == 0
)
}
def findPlayerByIdForOutfitAction(zones: Iterable[Zone], targetId: Long, initiator: Player): Option[Player] = {
zones
.flatMap(_.LivePlayers)
.find(p =>
p.CharId == targetId && p.Name != initiator.Name &&
p.Faction == initiator.Faction && p.outfit_id == initiator.outfit_id
)
}
/* db actions */
def findOutfitByName(name: String): Quoted[EntityQuery[Outfit]] = quote {
query[Outfit].filter(outfit => lift(name).toLowerCase == outfit.name.toLowerCase)
}
def insertNewOutfit(name: String, faction: Int, owner_id: Long): Quoted[ActionReturning[Outfit, Outfit]] = quote {
query[Outfit]
.insert(_.name -> lift(name), _.faction -> lift(faction), _.owner_id -> lift(owner_id))
.returning(outfit => outfit)
}
def insertOutfitMember(outfit_id: Long, avatar_id: Long, rank: Int): Quoted[Insert[Outfitmember]] = quote {
query[Outfitmember].insert(
_.outfit_id -> lift(outfit_id),
_.avatar_id -> lift(avatar_id),
_.rank -> lift(rank)
)
}
def insertOutfitPoint(outfit_id: Long, avatar_id: Long): Quoted[Insert[Outfitpoint]] = quote {
query[Outfitpoint].insert(
_.outfit_id -> lift(outfit_id),
_.avatar_id -> lift(avatar_id)
)
}
def createNewOutfit(name: String, faction: Int, owner_id: Long): Future[Outfit] = {
ctx.transaction { implicit ec =>
for {
outfit <- ctx.run(insertNewOutfit(name, faction, owner_id))
_ <- ctx.run(insertOutfitMember(outfit.id, owner_id, rank=7))
_ <- ctx.run(insertOutfitPoint(outfit.id, owner_id))
} yield outfit
}
}
def addMemberToOutfit(outfit_id: Long, avatar_id: Long): Future[Unit] = {
ctx.transaction { implicit ec =>
for {
_ <- ctx.run(insertOutfitMember(outfit_id, avatar_id, rank=0))
_ <- ctx.run(insertOutfitPoint(outfit_id, avatar_id))
} yield ()
}
}
def getOutfitById(id: Long): Quoted[EntityQuery[Outfit]] = quote {
query[Outfit].filter(_.id == lift(id))
}
def getOutfitMemberCount(id: Long): Quoted[Long] = quote {
query[Outfitmember].filter(_.outfit_id == lift(id)).size
}
def getOutfitPoints(id: Long): Quoted[EntityQuery[OutfitpointMv]] = quote {
querySchema[OutfitpointMv]("outfitpoint_mv").filter(_.outfit_id == lift(id))
}
def getOutfitMembersWithDetails(outfitId: Long): Quoted[Query[(Long, String, Long, Int, LocalDateTime)]] = quote {
query[Outfitmember]
.filter(_.outfit_id == lift(outfitId))
.join(query[Avatar]).on(_.avatar_id == _.id)
.leftJoin(query[Outfitpoint]).on {
case ((member, _), points) =>
points.outfit_id == member.outfit_id && points.avatar_id == member.avatar_id
}
.map {
case ((member, avatar), pointsOpt) =>
(member.avatar_id, avatar.name, pointsOpt.map(_.points).getOrElse(0L), member.rank, avatar.last_login)
}
}
def getOutfitsByEmpire(playerEmpireId: Int): Quoted[Query[(Long, Long, String, String, Long)]] = quote {
query[Outfit]
.filter(_.faction == lift(playerEmpireId))
.join(query[Avatar]).on((outfit, avatar) => outfit.owner_id == avatar.id)
.leftJoin(
query[Outfitmember]
.groupBy(_.outfit_id)
.map { case (oid, members) => (oid, members.size) }
).on { case ((outfit, _), (oid, _)) => oid == outfit.id }
.leftJoin(querySchema[OutfitpointMv]("outfitpoint_mv")).on {
case (((outfit, _), _), points) => points.outfit_id == outfit.id
}
.map {
case (((outfit, leader), memberCounts), points) =>
(outfit.id, points.map(_.points).getOrElse(0L), outfit.name, leader.name, memberCounts.map(_._2).getOrElse(0L))
}
}
}

View file

@ -85,6 +85,8 @@ class Player(var avatar: Avatar)
var silenced: Boolean = false
var death_by: Int = 0
var lastShotSeq_time: Int = -1
var outfit_name: String = ""
var outfit_id: Long = 0
/** From PlanetsideAttributeMessage */
var PlanetsideAttribute: Array[Long] = Array.ofDim(120)

View file

@ -93,8 +93,8 @@ object AvatarConverter {
0
)
val ab: (Boolean, Int) => CharacterAppearanceB = CharacterAppearanceB(
0L,
outfit_name = "",
obj.outfit_id,
obj.outfit_name,
outfit_logo = 0,
unk1 = false,
obj.isBackpack,

View file

@ -31,11 +31,11 @@ object OutfitMembershipResponse extends Marshallable[OutfitMembershipResponse] {
type Type = Value
val CreateResponse: PacketType.Value = Value(0)
val Unk1: PacketType.Value = Value(1) // Info: Player has been invited / response to OutfitMembershipRequest Unk2 for that player
val Invite: PacketType.Value = Value(1) // Info: Player has been invited / response to OutfitMembershipRequest Unk2 for that player
val Unk2: PacketType.Value = Value(2) // Invited / Accepted / Added
val Unk3: PacketType.Value = Value(3)
val Unk4: PacketType.Value = Value(4)
val Unk5: PacketType.Value = Value(5)
val Kick: PacketType.Value = Value(5)
val Unk6: PacketType.Value = Value(6) // 6 and 7 seen as failed decodes, validity unknown
val Unk7: PacketType.Value = Value(7)

View file

@ -12,3 +12,5 @@ final case class SquadChannel(guid: PlanetSideGUID) extends ChatChannel
case object SpectatorChannel extends ChatChannel
case object CustomerServiceChannel extends ChatChannel
final case class OutfitChannel(id: Long) extends ChatChannel

View file

@ -57,6 +57,7 @@ class ChatService(context: ActorContext[ChatService.Command]) extends AbstractBe
(channel, message.messageType) match {
case (SquadChannel(_), CMT_SQUAD) => ()
case (SquadChannel(_), CMT_VOICE) if message.contents.startsWith("SH") => ()
case (OutfitChannel(_), CMT_OUTFIT) => ()
case (DefaultChannel, messageType) if messageType != CMT_SQUAD => ()
case (SpectatorChannel, messageType) if messageType != CMT_SQUAD => ()
case _ =>
@ -158,6 +159,9 @@ class ChatService(context: ActorContext[ChatService.Command]) extends AbstractBe
case CMT_SQUAD =>
subs.foreach(_.actor ! MessageResponse(session, message, channel))
case CMT_OUTFIT =>
subs.foreach(_.actor ! MessageResponse(session, message, channel))
case CMT_NOTE =>
subs
.filter(_.sessionSource.session.player.Name == message.recipient)