Merge branch 'even-more-outfit-packets-2' into outfit

This commit is contained in:
Resaec 2025-08-30 22:44:18 +02:00
commit 446dee8235
38 changed files with 2840 additions and 380 deletions

View file

@ -0,0 +1,107 @@
-- Tables for Outfits
--
-- Includes Outfit, OutfitMember and OutfitPoints
/*
This migration allows for the storage of all outfit relevant data.
Outfit
- each outfit has one entry in the outfit table
- each name is unique and ranks are inlined (static 1:n, join unnecessary)
- faction is limited to 0,1,2,3
- decal is limited to 0 through 26 (inclusive)
OutfitMember
- each avatar can at most be a member in one outfit
- each outfit can only have one rank 7 (leader) member
- rank is limited to 0 through 7 (inclusive)
- there is a "quick access" index on outfit and avatar for rank 7 (leader)
OutfitPoint
- each (outfit, avatar) combination can only exist once
- a (outfit, NULL) combination can not be limited yet (not a big deal)
- deleting a avatar will have his points remain as (outfit, NULL)
- leaving an outfit will have the points remain as (outfit, NULL)
*/
-- OUTFIT
CREATE TABLE outfit (
"id" SERIAL PRIMARY KEY,
"created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BOOLEAN NOT NULL DEFAULT FALSE, -- allow for recovery of accidentially deleted outfits
"faction" SMALLINT NOT NULL,
"owner_id" INTEGER NOT NULL,
"decal" SMALLINT NOT NULL DEFAULT 0,
"name" VARCHAR(32) NOT NULL,
"motd" VARCHAR(255) NULL,
"rank0" VARCHAR(32) NULL, -- Non-Officer Rank 1
"rank1" VARCHAR(32) NULL,
"rank2" VARCHAR(32) NULL,
"rank3" VARCHAR(32) NULL, -- Non-Officer Rank 4
"rank4" VARCHAR(32) NULL, -- Fourth In Command
"rank5" VARCHAR(32) NULL,
"rank6" VARCHAR(32) NULL, -- Second In Command
"rank7" VARCHAR(32) NULL, -- Outfit Leader
CONSTRAINT "outfit_faction_check" CHECK("faction" BETWEEN 0 AND 3), -- allowed faction IDs
CONSTRAINT "outfit_decal_check" CHECK("decal" BETWEEN 0 AND 26), -- allowed decal IDs
CONSTRAINT "outfit_owner_id_avatar_id_fkey" FOREIGN KEY ("owner_id") REFERENCES avatar ("id")
);
CREATE INDEX "outfit_created_brin_idx" ON "outfit" USING BRIN ("created"); -- super small, index for physically sequential data
CREATE INDEX "outfit_faction_deleted_idx" ON "outfit" ("faction", "deleted"); -- optimize index for search: SELECT * FROM "outfit" WHERE "faction" = ? AND "deleted" = false;
-- OUTFITMEMBER
CREATE TABLE outfitmember (
"id" BIGSERIAL PRIMARY KEY,
"created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"outfit_id" INTEGER NOT NULL,
"avatar_id" INTEGER NOT NULL,
"rank" SMALLINT NOT NULL DEFAULT 0, -- lowest rank
CONSTRAINT "outfitmember_rank_check" CHECK("rank" BETWEEN 0 AND 7), -- allowed ranks
CONSTRAINT "outfitmember_outfit_id_outfit_id_fkey" FOREIGN KEY ("outfit_id") REFERENCES outfit ("id") ON DELETE CASCADE,
CONSTRAINT "outfitmember_avatar_id_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES avatar ("id") ON DELETE RESTRICT
);
CREATE INDEX "outfitmember_outfit_id_idx" ON "outfitmember" ("outfit_id"); -- FK index
CREATE UNIQUE INDEX "outfitmember_avatar_id_unique" ON "outfitmember" ("avatar_id"); -- FK index, enforce one outfit per avatar
CREATE UNIQUE INDEX "outfitmember_outfit_id_rank_partial_leader_unique" ON "outfitmember" ("outfit_id", "rank") WHERE "rank" = 7; -- quick access to outfit leader and ony one leader per outfit
-- OUTFITPOINT
CREATE TABLE outfitpoint (
"id" BIGSERIAL PRIMARY KEY,
"outfit_id" INTEGER NOT NULL,
"avatar_id" INTEGER NULL,
"points" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "outfitpoint_points_check" CHECK ("points" >= 0),
-- enforce unique combinations (left side index)
CONSTRAINT "outfitpoint_outfit_avatar_unique_idx" UNIQUE ("outfit_id", "avatar_id"), -- UNIQUE NULLS NOT DISTINCT
CONSTRAINT "outfitpoint_outfit_fkey" FOREIGN KEY ("outfit_id") REFERENCES outfit ("id") ON DELETE CASCADE, -- delete points of outfit when outfit is deleted
CONSTRAINT "outfitpoint_avatar_fkey" FOREIGN KEY ("avatar_id") REFERENCES avatar ("id") ON DELETE SET NULL -- keep points for outfit when player is deleted
);
-- add right side index (avatar_id)
CREATE INDEX "outfitpoint_avatar_idx" ON "outfitpoint" ("avatar_id");
-- MATERIALIZED VIEW for OUTFITPOINT
CREATE MATERIALIZED VIEW outfitpoint_mv AS
SELECT
"outfit_id",
SUM("points") as "points"
FROM
"outfitpoint"
GROUP BY "outfit_id";
CREATE UNIQUE INDEX "outfitpoint_mv_outfit_id_unique" ON "outfitpoint_mv" ("outfit_id");

View file

@ -253,6 +253,8 @@ object AvatarActor {
final case class SupportExperienceDeposit(bep: Long, delay: Long) extends Command
case class Outfitpoint(id: Long, outfit_id: Long, avatar_id: Option[Long], points: Long)
/**
* A player loadout represents all of the items in the player's hands (equipment slots)
* and all of the items in the player's backpack (inventory)
@ -970,6 +972,22 @@ object AvatarActor {
}
}
def setOutfitPoints(avatarId: Long, exp: Long): Future[Unit] = {
import ctx._
import scala.concurrent.ExecutionContext.Implicits.global
val avatarOpt: Option[Long] = Some(avatarId)
ctx.transaction { implicit ec =>
for {
currOp <- ctx.run(query[Outfitpoint].filter(_.avatar_id == lift(avatarOpt)).map(_.points))
.map(_.headOption.getOrElse(0L))
newOp = currOp + exp
_ <- ctx.run(query[Outfitpoint].filter(_.avatar_id == lift(avatarOpt)).update(_.points -> lift(newOp)))
} yield ()
}
}
def loadExperienceDebt(avatarId: Long): Future[Long] = {
import ctx._
import scala.concurrent.ExecutionContext.Implicits.global
@ -2994,6 +3012,13 @@ class AvatarActor(
}
}
avatar = avatar.copy(bep = newBep, implants = implants)
if (player.outfit_id != 0) {
setOutfitPoints(player.avatar.id, bep).onComplete {
case Success(_) =>
case Failure(exception) => log.error(exception)("db failure")
}
}
case Failure(exception) =>
log.error(exception)("db failure")
}
@ -3010,6 +3035,12 @@ class AvatarActor(
zone.id,
AvatarAction.PlanetsideAttributeToAll(sess.player.GUID, 18, cep)
)
if (sess.player.outfit_id != 0) {
setOutfitPoints(sess.player.avatar.id, cep * 2).onComplete {
case Success(_) =>
case Failure(exception) => log.error(exception)("db failure")
}
}
case Failure(exception) =>
log.error(exception)("db failure")
}

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

@ -10,7 +10,7 @@ import net.psforever.objects.serverobject.containable.ContainableBehavior
import net.psforever.objects.serverobject.mount.Mountable
import net.psforever.objects.sourcing.PlayerSource
import net.psforever.objects.vital.interaction.Adversarial
import net.psforever.packet.game.{AvatarImplantMessage, CreateShortcutMessage, ImplantAction}
import net.psforever.packet.game.{AvatarImplantMessage, CreateShortcutMessage, ImplantAction, PlanetsideStringAttributeMessage}
import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage}
import net.psforever.types.ImplantType
@ -252,6 +252,9 @@ class AvatarHandlerLogic(val ops: SessionAvatarHandlers, implicit val context: A
case AvatarResponse.PlanetsideAttributeSelf(attributeType, attributeValue) if isSameTarget =>
sendResponse(PlanetsideAttributeMessage(guid, attributeType, attributeValue))
case AvatarResponse.PlanetsideStringAttribute(attributeType, attributeValue) =>
sendResponse(PlanetsideStringAttributeMessage(guid, attributeType, attributeValue))
case AvatarResponse.GenericObjectAction(objectGuid, actionCode) if isNotSameTarget =>
sendResponse(GenericObjectActionMessage(objectGuid, actionCode))

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,56 @@ 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.AcceptInvite(_)) =>
SessionOutfitHandlers.HandleOutfitInviteAccept(player, sessionLogic)
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.RejectInvite(_)) =>
SessionOutfitHandlers.HandleOutfitInviteReject(player)
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.Kick(memberId, _)) =>
SessionOutfitHandlers.HandleOutfitKick(zones, memberId, player, sessionLogic)
case OutfitMembershipRequest(_, OutfitMembershipRequestAction.SetRank(memberId, newRank, _)) =>
SessionOutfitHandlers.HandleOutfitPromote(zones, memberId, newRank, player)
case _ =>
}
}
def handleOutfitMembershipResponse(pkt: OutfitMembershipResponse): Unit = {}
def handleOutfitRequest(pkt: OutfitRequest): Unit = {
pkt match {
case OutfitRequest(_, OutfitRequestAction.Motd(message)) =>
SessionOutfitHandlers.HandleOutfitMotd(zones, message, player)
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.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,732 @@
// Copyright (c) 2025 PSForever
package net.psforever.actors.session.support
import io.getquill.{ActionReturning, EntityQuery, Insert, PostgresJAsyncContext, Query, Quoted, SnakeCase, Update}
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,
deleted: Boolean,
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: Option[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
player.Zone.AvatarEvents ! AvatarServiceMessage(player.Zone.id,
AvatarAction.PlanetsideAttributeToAll(player.GUID, 39, outfit.id))
player.Zone.AvatarEvents ! AvatarServiceMessage(player.Zone.id,
AvatarAction.PlanetsideStringAttribute(player.GUID, 0, 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.InviteAccepted, 0, 0,
invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, outfit.name, flag = false))
PlayerControl.sendResponse(invited.Zone, invited.Name,
OutfitMembershipResponse(
OutfitMembershipResponse.PacketType.InviteAccepted, 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
invited.Zone.AvatarEvents ! AvatarServiceMessage(invited.Zone.id,
AvatarAction.PlanetsideAttributeToAll(invited.GUID, 39, outfit.id))
invited.Zone.AvatarEvents ! AvatarServiceMessage(invited.Zone.id,
AvatarAction.PlanetsideStringAttribute(invited.GUID, 0, 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 HandleOutfitInviteReject(invited: Player): Unit = {
OutfitInviteManager.getOutfitInvite(invited.CharId) match {
case Some(outfitInvite) =>
PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name,
OutfitMembershipResponse(
OutfitMembershipResponse.PacketType.InviteRejected, 0, 0,
invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, "", flag = false))
PlayerControl.sendResponse(invited.Zone, invited.Name,
OutfitMembershipResponse(
OutfitMembershipResponse.PacketType.InviteRejected, 0, 0,
invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, "", flag = true))
OutfitInviteManager.removeOutfitInvite(invited.CharId)
case None =>
}
}
def HandleOutfitKick(zones: Seq[Zone], kickedId: Long, kickedBy: Player, session: SessionData): Unit = {
// if same id, player has left the outfit by their own choice
if (kickedId == kickedBy.CharId) {
removeMemberFromOutfit(kickedBy.outfit_id, kickedId).map {
case (deleted, _) =>
if (deleted > 0) {
PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name,
OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
zones.filter(z => z.AllPlayers.nonEmpty).flatMap(_.AllPlayers)
.filter(p => p.outfit_id == kickedBy.outfit_id).foreach(outfitMember =>
PlayerControl.sendResponse(outfitMember.Zone, outfitMember.Name,
OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
)
session.chat.LeaveChannel(OutfitChannel(kickedBy.outfit_id))
kickedBy.outfit_name = ""
kickedBy.outfit_id = 0
kickedBy.Zone.AvatarEvents ! AvatarServiceMessage(kickedBy.Zone.id,
AvatarAction.PlanetsideAttributeToAll(kickedBy.GUID, 39, 0))
kickedBy.Zone.AvatarEvents ! AvatarServiceMessage(kickedBy.Zone.id,
AvatarAction.PlanetsideStringAttribute(kickedBy.GUID, 0, ""))
}
}.recover { case e =>
e.printStackTrace()
}
}
else {
removeMemberFromOutfit(kickedBy.outfit_id, kickedId).map {
case (deleted, _) =>
if (deleted > 0) {
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.PlanetsideStringAttribute(kicked.GUID, 0, ""))
kicked.outfit_id = 0
kicked.outfit_name = ""
PlayerControl.sendResponse(kicked.Zone, kicked.Name,
OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
}
val avatarName: Future[Option[String]] =
ctx.run(
quote { query[Avatar].filter(_.id == lift(kickedId)).map(_.name) }
).map(_.headOption)
avatarName.foreach {
case Some(name) => PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name,
OutfitMembershipResponse(OutfitMembershipResponse.PacketType.Kick, 0, 1, kickedBy.CharId, kickedId, name, "", flag = true))
case None => PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name,
OutfitMembershipResponse(OutfitMembershipResponse.PacketType.Kick, 0, 1, kickedBy.CharId, kickedId, "NameNotFound", "", flag = true))
}
zones.filter(z => z.AllPlayers.nonEmpty).flatMap(_.AllPlayers)
.filter(p => p.outfit_id == kickedBy.outfit_id).foreach(outfitMember =>
PlayerControl.sendResponse(outfitMember.Zone, outfitMember.Name,
OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Unk1()))
)
// this needs to be the kicked player
// session.chat.LeaveChannel(OutfitChannel(kickedBy.outfit_id))
// new number of outfit members?
//PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, OutfitEvent(kickedBy.outfit_id, OutfitEventAction.Unk5(34)))
}
}.recover { case e =>
e.printStackTrace()
}
}
}
def HandleOutfitPromote(zones: Seq[Zone], promotedId: Long, newRank: Int, promoter: Player): Unit = {
val outfit_id = promoter.outfit_id
findPlayerByIdForOutfitAction(zones, promotedId, promoter).foreach { promoted =>
if (newRank == 7) {
// demote owner to rank 6
// promote promoted to rank 7
// update outfit
updateOutfitOwner(outfit_id, promoter.avatar.id, promoted.avatar.id)
// TODO: does every member get the notification like this?
getOutfitMemberPoints(outfit_id, promoter.avatar.id).map {
owner_points =>
// announce owner rank change
zones.foreach(zone => {
zone.AllPlayers.filter(_.outfit_id == outfit_id).foreach(outfitMember => {
PlayerControl.sendResponse(
zone, outfitMember.Name,
OutfitMemberEvent(outfit_id, promoter.avatar.id,
OutfitMemberEventAction.Unk0(promoter.Name, 6, owner_points, 0, OutfitMemberEventAction.PacketType.Padding, 0)))
})
})
}
// update promoter rank
PlayerControl.sendResponse(
promoter.Zone, promoter.Name,
OutfitMemberUpdate(outfit_id, promoter.avatar.id, rank = 6, flag = true))
}
else {
// promote promoted
updateOutfitMemberRank(outfit_id, promoted.avatar.id, rank = newRank)
}
// TODO: does every member get the notification like this?
getOutfitMemberPoints(outfit_id, promoted.avatar.id).map {
member_points =>
// tell everyone about the new rank of the promoted member
zones.foreach(zone => {
zone.AllPlayers.filter(_.outfit_id == outfit_id).foreach(player => {
PlayerControl.sendResponse(
zone, player.Name,
OutfitMemberEvent(outfit_id, promoted.avatar.id,
OutfitMemberEventAction.Unk0(promoted.Name, newRank, member_points, 0, OutfitMemberEventAction.PacketType.Padding, 0)))
})
})
}
// update promoted rank
PlayerControl.sendResponse(
promoted.Zone, promoted.Name,
OutfitMemberUpdate(outfit_id, promoted.avatar.id, rank = newRank, flag = true))
}
}
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.rank0.getOrElse(""),
outfit.rank1.getOrElse(""),
outfit.rank2.getOrElse(""),
outfit.rank3.getOrElse(""),
outfit.rank4.getOrElse(""),
outfit.rank5.getOrElse(""),
outfit.rank6.getOrElse(""),
outfit.rank7.getOrElse(""),
),
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")
)
}
}
def HandleOutfitMotd(zones: Seq[Zone], message: String, player: Player): Unit = {
val outfit_id = player.outfit_id
val outfitDetails = for {
_ <- updateOutfitMotd(outfit_id, message)
outfitOpt <- ctx.run(getOutfitById(outfit_id)).map(_.headOption)
memberCount <- ctx.run(query[Outfitmember].filter(_.outfit_id == lift(outfit_id)).size)
pointsTotal <- ctx.run(querySchema[OutfitpointMv]("outfitpoint_mv").filter(_.outfit_id == lift(outfit_id)))
} yield (outfitOpt, memberCount, pointsTotal.headOption.map(_.points).getOrElse(0L))
for {
(outfitOpt, memberCount, totalPoints) <- outfitDetails
} yield {
outfitOpt.foreach { outfit =>
// send to all online players in outfit
val outfit_event = OutfitEvent(
outfit_id,
Unk2(
OutfitInfo(
outfit_name = outfit.name,
outfit_points1 = totalPoints,
outfit_points2 = totalPoints,
member_count = memberCount,
outfit_rank_names = OutfitRankNames(
outfit.rank0.getOrElse(""),
outfit.rank1.getOrElse(""),
outfit.rank2.getOrElse(""),
outfit.rank3.getOrElse(""),
outfit.rank4.getOrElse(""),
outfit.rank5.getOrElse(""),
outfit.rank6.getOrElse(""),
outfit.rank7.getOrElse(""),
),
motd = outfit.motd.getOrElse(""),
unk10 = 0,
unk11 = true,
unk12 = 0,
created_timestamp = outfit.created.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli / 1000,
unk23 = 0,
unk24 = 0,
unk25 = 0
)
)
)
zones.foreach(zone => {
zone.AllPlayers.filter(_.outfit_id == outfit_id).foreach(player => {
PlayerControl.sendResponse(
zone, player.Name,
outfit_event
)
})
})
}
}
// C >> S OutfitRequest(41593365, Motd(Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net))
// S >> C OutfitEvent(Unk2, 529744, Unk2(OutfitInfo(PlanetSide_Forever_Vanu, 0, 0, 3, OutfitRankNames(, , , , , , , ), Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net, 0, 1, 0, 1458331641, 0, 0, 0)))
}
def HandleLoginOutfitCheck(player: Player, session: SessionData): Unit = {
ctx.run(getOutfitOnLogin(player.avatar.id)).flatMap { memberships =>
memberships.headOption match {
case Some(membership) =>
val outfitId = membership.outfit_id
(for {
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) =>
val seconds: Long = outfit.created.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli / 1000
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitEvent(outfitId, Unk2(OutfitInfo(
outfit.name, points, points, memberCount,
OutfitRankNames(outfit.rank0.getOrElse(""), outfit.rank1.getOrElse(""), outfit.rank2.getOrElse(""),
outfit.rank3.getOrElse(""), outfit.rank4.getOrElse(""), outfit.rank5.getOrElse(""),
outfit.rank6.getOrElse(""), outfit.rank7.getOrElse("")),
outfit.motd.getOrElse(""),
14, unk11 = true, 0, seconds, 0, 0, 0))))
PlayerControl.sendResponse(player.Zone, player.Name,
OutfitMemberUpdate(outfit.id, player.CharId, membership.rank, flag = true))
session.chat.JoinChannel(OutfitChannel(outfit.id))
player.outfit_id = outfit.id
player.outfit_name = outfit.name
player.Zone.AvatarEvents ! AvatarServiceMessage(player.Zone.id,
AvatarAction.PlanetsideAttributeToAll(player.GUID, 39, outfit.id))
player.Zone.AvatarEvents ! AvatarServiceMessage(player.Zone.id,
AvatarAction.PlanetsideStringAttribute(player.GUID, 0, outfit.name))
case (None, _, _) =>
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "Failed to load outfit"))
}
.recover { case _ =>
PlayerControl.sendResponse(player.Zone, player.Name,
ChatMsg(ChatMessageType.UNK_227, "Failed to load outfit"))
}
case None =>
Future.successful(())
}
}
}
/* 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(_.AllPlayers)
.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(_.AllPlayers)
.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(Some(avatar_id): Option[Long])
)
}
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 removeMemberFromOutfit(outfit_id: Long, avatar_id: Long): Future[(Long, Long)] = {
val avatarOpt: Option[Long] = Some(avatar_id)
ctx.transaction { _ =>
for {
deleted <- ctx.run(
query[Outfitmember]
.filter(_.outfit_id == lift(outfit_id))
.filter(_.avatar_id == lift(avatar_id))
.delete
)
updated <- ctx.run(
query[Outfitpoint]
.filter(_.outfit_id == lift(outfit_id))
.filter(_.avatar_id == lift(avatarOpt))
.update(_.avatar_id -> None)
)
} yield (deleted, updated)
}
}
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 getOutfitMemberPoints(outfit_id: Long, avatar_id: Long): Future[Long] = {
val avatarOpt: Option[Long] = Some(avatar_id)
for {
points <- ctx.run(
query[Outfitpoint]
.filter(_.outfit_id == lift(outfit_id))
.filter(_.avatar_id == lift(avatarOpt))
.map(_.points)
)
} yield (points.headOption.getOrElse(0))
}
def getOutfitPoints(id: Long): Quoted[EntityQuery[OutfitpointMv]] = quote {
querySchema[OutfitpointMv]("outfitpoint_mv").filter(_.outfit_id == lift(id))
}
def getOutfitOnLogin(avatarId: Long): Quoted[EntityQuery[Outfitmember]] = quote {
query[Outfitmember].filter(_.avatar_id == lift(avatarId))
}
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.getOrElse(0L) == 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))
}
}
def updateMemberRankById(outfit_id: Long, avatar_id: Long, rank: Int): Quoted[Update[Outfitmember]] = quote {
query[Outfitmember]
.filter(_.outfit_id == lift(outfit_id))
.filter(_.avatar_id == lift(avatar_id))
.update(_.rank -> lift(rank))
}
def updateOutfitMemberRank(outfit_id: Long, avatar_id: Long, rank: Int): Future[Unit] = {
ctx.transaction { implicit ec =>
for {
_ <- ctx.run(updateMemberRankById(outfit_id, avatar_id, rank))
} yield ()
}
}
def updateOutfitOwnerById(outfit_id: Long, owner_id: Long): Quoted[Update[Outfit]] = quote {
query[Outfit]
.filter(_.id == lift(outfit_id))
.update(_.owner_id -> lift(owner_id))
}
def updateOutfitOwner(outfit_id: Long, owner_id: Long, new_owner_id: Long): Future[Unit] = {
ctx.transaction { implicit ec =>
for {
_ <- ctx.run(updateMemberRankById(outfit_id, owner_id, 6))
_ <- ctx.run(updateMemberRankById(outfit_id, new_owner_id, 7))
_ <- ctx.run(updateOutfitOwnerById(outfit_id, new_owner_id))
} yield ()
}
}
def updateOutfitMotdById(outfit_id: Long, motd: Option[String]): Quoted[Update[Outfit]] = quote {
query[Outfit]
.filter(_.id == lift(outfit_id))
.update(_.motd -> lift(motd))
}
def updateOutfitMotd(outfit_id: Long, motd: String): Future[Unit] = {
ctx.transaction { implicit ec =>
for {
_ <- ctx.run(updateOutfitMotdById(outfit_id, Some(motd)))
} yield ()
}
}
}

View file

@ -2532,6 +2532,7 @@ class ZoningOperations(
sessionLogic.general.toggleTeleportSystem(obj, TelepadLike.AppraiseTeleportationSystem(obj, continent))
}
}
SessionOutfitHandlers.HandleLoginOutfitCheck(player, sessionLogic)
//make weather happen
sendResponse(WeatherMessage(List(),List(
StormInfo(Vector3(0.1f, 0.15f, 0.0f), 240, 217),
@ -2655,6 +2656,7 @@ class ZoningOperations(
log.debug(s"AvatarRejoin: ${player.Name} - $guid -> $data")
}
setupAvatarFunc = AvatarCreate
SessionOutfitHandlers.HandleLoginOutfitCheck(player, sessionLogic)
//make weather happen
sendResponse(WeatherMessage(List(),List(
StormInfo(Vector3(0.1f, 0.15f, 0.0f), 240, 217),
@ -3196,8 +3198,6 @@ class ZoningOperations(
continent.AllPlayers.filter(_.ExoSuit == ExoSuitType.MAX).foreach(max => sendResponse(PlanetsideAttributeMessage(max.GUID, 4, max.Armor)))
// AvatarAwardMessage
//populateAvatarAwardRibbonsFunc(1, 20L)
sendResponse(PlanetsideStringAttributeMessage(guid, 0, "Outfit Name"))
//squad stuff (loadouts, assignment)
sessionLogic.squad.squadSetup()
//MapObjectStateBlockMessage and ObjectCreateMessage?

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)
@ -646,6 +648,8 @@ object Player {
obj.silenced = player.silenced
obj.allowInteraction = player.allowInteraction
obj.avatar.scorecard.respawn()
obj.outfit_name = player.outfit_name
obj.outfit_id = player.outfit_id
obj
} else {
player

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

@ -1,4 +1,4 @@
// Copyright (c) 2017 PSForever
// Copyright (c) 2017-2025 PSForever
package net.psforever.packet
import scodec.{Attempt, Codec, DecodeResult, Err}
@ -469,13 +469,13 @@ object GamePacketOpcode extends Enumeration {
case 0x8a => game.PlayerStasisMessage.decode
case 0x8b => noDecoder(UnknownMessage139)
case 0x8c => game.OutfitMembershipRequest.decode
case 0x8d => noDecoder(OutfitMembershipResponse)
case 0x8d => game.OutfitMembershipResponse.decode
case 0x8e => game.OutfitRequest.decode
case 0x8f => noDecoder(OutfitEvent)
case 0x8f => game.OutfitEvent.decode
// OPCODES 0x90-9f
case 0x90 => noDecoder(OutfitMemberEvent)
case 0x91 => noDecoder(OutfitMemberUpdate)
case 0x90 => game.OutfitMemberEvent.decode
case 0x91 => game.OutfitMemberUpdate.decode
case 0x92 => game.PlanetsideStringAttributeMessage.decode
case 0x93 => game.DataChallengeMessage.decode
case 0x94 => game.DataChallengeMessageResp.decode
@ -483,7 +483,7 @@ object GamePacketOpcode extends Enumeration {
case 0x96 => game.SimDataChallenge.decode
case 0x97 => game.SimDataChallengeResp.decode
// 0x98
case 0x98 => noDecoder(OutfitListEvent)
case 0x98 => game.OutfitListEvent.decode
case 0x99 => noDecoder(EmpireIncentivesMessage)
case 0x9a => game.InvalidTerrainMessage.decode
case 0x9b => noDecoder(SyncMessage)

View file

@ -0,0 +1,263 @@
// Copyright (c) 2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.GamePacketOpcode.Type
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
import scodec.{Attempt, Codec, Err}
import scodec.bits.BitVector
import scodec.codecs._
import shapeless.{::, HNil}
final case class OutfitEvent(
outfit_id: Long,
action: OutfitEventAction
) extends PlanetSideGamePacket {
type Packet = OutfitEvent
def opcode: Type = GamePacketOpcode.OutfitEvent
def encode: Attempt[BitVector] = OutfitEvent.encode(this)
}
abstract class OutfitEventAction(val code: Int)
object OutfitEventAction {
final case class OutfitRankNames(
rank1: String,
rank2: String,
rank3: String,
rank4: String,
rank5: String,
rank6: String,
rank7: String,
rank8: String,
)
final case class OutfitInfo(
outfit_name: String,
outfit_points1: Long,
outfit_points2: Long, // same as outfit_points1
member_count: Long,
outfit_rank_names: OutfitRankNames,
motd: String,
unk10: Int,
unk11: Boolean,
unk12: Long, // only set if unk11 is false
created_timestamp: Long,
unk23: Long,
unk24: Long,
unk25: Long,
)
final case class Unk0(
outfit_info: OutfitInfo
) extends OutfitEventAction(code = 0)
final case class Unk1(
) extends OutfitEventAction(code = 1)
final case class Unk2(
outfit_info: OutfitInfo,
) extends OutfitEventAction(code = 2)
final case class Unk3(
) extends OutfitEventAction(code = 3)
final case class UpdateOutfitId(
new_outfit_id: Long,
) extends OutfitEventAction(code = 4)
final case class Unk5(
unk1: Long,
) extends OutfitEventAction(code = 5)
final case class Unknown(badCode: Int, data: BitVector) extends OutfitEventAction(badCode)
/**
* The `Codec`s used to transform the input stream into the context of a specific action
* and extract the field data from that stream.
*/
object Codecs {
private val everFailCondition = conditional(included = false, bool)
private val OutfitRankNamesCodec: Codec[OutfitRankNames] = (
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString ::
PacketHelpers.encodedWideString
).xmap[OutfitRankNames](
{
case u0 :: u1 :: u2 :: u3 :: u4 :: u5 :: u6 :: u7 :: HNil =>
OutfitRankNames(u0, u1, u2, u3, u4, u5, u6, u7)
},
{
case OutfitRankNames(u0, u1, u2, u3, u4, u5, u6, u7) =>
u0 :: u1 :: u2 :: u3 :: u4 :: u5 :: u6 :: u7 :: HNil
}
)
private val InfoCodec: Codec[OutfitInfo] = (
("outfit_name" | PacketHelpers.encodedWideStringAligned(5)) ::
("outfit_points1" | uint32L) ::
("outfit_points2" | uint32L) ::
("member_count" | uint32L) ::
("outfit_rank_names" | OutfitRankNamesCodec) ::
("motd" | PacketHelpers.encodedWideString) ::
("" | uint8L) ::
("" | bool) ::
("" | uint32L) ::
("created_timestamp" | uint32L) ::
("" | uint32L) ::
("" | uint32L) ::
("" | uint32L)
).xmap[OutfitInfo](
{
case outfit_name :: outfit_points1 :: outfit_points2 :: member_count :: outfit_rank_names :: motd :: u10 :: u11 :: u12 :: created_timestamp :: u23 :: u24 :: u25 :: HNil =>
OutfitInfo(outfit_name, outfit_points1, outfit_points2, member_count, outfit_rank_names, motd, u10, u11, u12, created_timestamp, u23, u24, u25)
},
{
case OutfitInfo(outfit_name, outfit_points1, outfit_points2, member_count, outfit_rank_names, motd, u10, u11, u12, created_timestamp, u23, u24, u25) =>
outfit_name :: outfit_points1 :: outfit_points2 :: member_count :: outfit_rank_names :: motd :: u10 :: u11 :: u12 :: created_timestamp :: u23 :: u24 :: u25 :: HNil
}
)
val Unk0Codec: Codec[Unk0] = (
("outfit_info" | InfoCodec)
).xmap[Unk0](
{
case info =>
Unk0(info)
},
{
case Unk0(info) =>
info
}
)
val Unk1Codec: Codec[Unk1] = PacketHelpers.emptyCodec(Unk1())
val Unk2Codec: Codec[Unk2] = (
("outfit_info" | InfoCodec)
).xmap[Unk2](
{
case info =>
Unk2(info)
},
{
case Unk2(info) =>
info
}
)
val Unk3Codec: Codec[Unk3] = PacketHelpers.emptyCodec(Unk3())
val UpdateOutfitIdCodec: Codec[UpdateOutfitId] = ( // update outfit_id? // 2016.03.18 #10640 // after this packet the referenced id changes to the new one, old is not used again
("new_outfit_id" | uint32L)
).xmap[UpdateOutfitId](
{
case new_outfit_id =>
UpdateOutfitId(new_outfit_id)
},
{
case UpdateOutfitId(new_outfit_id) =>
new_outfit_id
}
)
val Unk5Codec: Codec[Unk5] = (
("" | uint32L)
).xmap[Unk5](
{
case u1 =>
Unk5(u1)
},
{
case Unk5(u1) =>
u1
}
)
/**
* A common form for known action code indexes with an unknown purpose and transformation is an "Unknown" object.
*
* @param action the action behavior code
* @return a transformation between the action code and the unknown bit data
*/
def unknownCodec(action: Int): Codec[Unknown] =
bits.xmap[Unknown](
data => Unknown(action, data),
{
case Unknown(_, data) => data
}
)
/**
* The action code was completely unanticipated!
*
* @param action the action behavior code
* @return nothing; always fail
*/
def failureCodec(action: Int): Codec[OutfitEventAction] =
everFailCondition.exmap[OutfitEventAction](
_ => Attempt.failure(Err(s"can not match a codec pattern for decoding $action")),
_ => Attempt.failure(Err(s"can not match a codec pattern for encoding $action"))
)
}
}
object OutfitEvent extends Marshallable[OutfitEvent] {
object PacketType extends Enumeration {
type Type = Value
val Unk0: PacketType.Value = Value(0) // start listing of members
val Unk1: PacketType.Value = Value(1) // end listing of members
val Unk2: PacketType.Value = Value(2) // send after creating an outfit // normal info, same as Unk0
val Unk3: PacketType.Value = Value(3) // below
val UpdateOutfitId: PacketType.Value = Value(4)
val Unk5: PacketType.Value = Value(5)
val Unk6: PacketType.Value = Value(6)
val Unk7: PacketType.Value = Value(7)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(3))
}
private def selectFromType(code: Int): Codec[OutfitEventAction] = {
import OutfitEventAction.Codecs._
import scala.annotation.switch
((code: @switch) match {
case 0 => Unk0Codec // view outfit window and members
case 1 => Unk1Codec
case 2 => Unk2Codec // sent after /outfitcreate and on login if in an outfit
case 3 => Unk3Codec
case 4 => UpdateOutfitIdCodec
case 5 => Unk5Codec
case 6 => unknownCodec(action = code)
case 7 => unknownCodec(action = code)
case _ => failureCodec(code)
}).asInstanceOf[Codec[OutfitEventAction]]
}
implicit val codec: Codec[OutfitEvent] = (
("packet_type" | PacketType.codec) >>:~ { packet_type =>
("outfit_guid" | uint32L) ::
("action" | selectFromType(packet_type.id))
}
).xmap[OutfitEvent](
{
case _ :: outfit_guid :: action :: HNil =>
OutfitEvent(outfit_guid, action)
},
{
case OutfitEvent(outfit_guid, action) =>
OutfitEvent.PacketType(action.code) :: outfit_guid :: action :: HNil
}
)
}

View file

@ -0,0 +1,158 @@
// Copyright (c) 2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.GamePacketOpcode.Type
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
import scodec.{Attempt, Codec, Err}
import scodec.bits.{BitVector, ByteVector}
import scodec.codecs._
import shapeless.{::, HNil}
final case class OutfitListEvent(
action: OutfitListEventAction
) extends PlanetSideGamePacket {
type Packet = OutfitListEvent
def opcode: Type = GamePacketOpcode.OutfitListEvent
def encode: Attempt[BitVector] = OutfitListEvent.encode(this)
}
abstract class OutfitListEventAction(val code: Int)
object OutfitListEventAction {
final case class ListElementOutfit(
outfit_id: Long,
points: Long,
members: Long,
outfit_name: String,
outfit_leader: String,
) extends OutfitListEventAction(code = 2)
/*
TODO: Check packet when bundle packet has been implemented (packet containing OutfitListEvent packets back to back)
For now it seems like there is no valid packet captured
*/
final case class Unk3(
unk1: Long,
) extends OutfitListEventAction(code = 3)
final case class Unknown(badCode: Int, data: BitVector) extends OutfitListEventAction(badCode)
/**
* The `Codec`s used to transform the input stream into the context of a specific action
* and extract the field data from that stream.
*/
object Codecs {
private val everFailCondition = conditional(included = false, bool)
val ListElementOutfitCodec: Codec[ListElementOutfit] = (
("unk1" | uint32L) ::
("points" | uint32L) ::
("members" | uint32L) ::
("outfit_name" | PacketHelpers.encodedWideStringAligned(5)) ::
("outfit_leader" | PacketHelpers.encodedWideString)
).xmap[ListElementOutfit](
{
case u1 :: points :: members :: outfit_name :: outfit_leader :: HNil =>
ListElementOutfit(u1, points, members, outfit_name, outfit_leader)
},
{
case ListElementOutfit(u1, points, members, outfit_name, outfit_leader) =>
u1 :: points :: members :: outfit_name :: outfit_leader :: HNil
}
)
val Unk3Codec: Codec[Unk3] = (
("unk1" | uint32L)
).xmap[Unk3](
{
case u1 =>
Unk3(u1)
},
{
case Unk3(u1) =>
u1
}
)
/**
* A common form for known action code indexes with an unknown purpose and transformation is an "Unknown" object.
*
* @param action the action behavior code
* @return a transformation between the action code and the unknown bit data
*/
def unknownCodec(action: Int): Codec[Unknown] =
bits.xmap[Unknown](
data => Unknown(action, data),
{
case Unknown(_, data) => data
}
)
/**
* The action code was completely unanticipated!
*
* @param action the action behavior code
* @return nothing; always fail
*/
def failureCodec(action: Int): Codec[OutfitListEventAction] =
everFailCondition.exmap[OutfitListEventAction](
_ => Attempt.failure(Err(s"can not match a codec pattern for decoding $action")),
_ => Attempt.failure(Err(s"can not match a codec pattern for encoding $action"))
)
}
}
object OutfitListEvent extends Marshallable[OutfitListEvent] {
import shapeless.{::, HNil}
object PacketType extends Enumeration {
type Type = Value
val Unk0: PacketType.Value = Value(0)
val Unk1: PacketType.Value = Value(1)
val ListElementOutfit: PacketType.Value = Value(2)
val Unk3: PacketType.Value = Value(3)
val Unk4: PacketType.Value = Value(4)
val Unk5: PacketType.Value = Value(5)
val unk6: PacketType.Value = Value(6)
val unk7: PacketType.Value = Value(7)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(3))
}
private def selectFromType(code: Int): Codec[OutfitListEventAction] = {
import OutfitListEventAction.Codecs._
import scala.annotation.switch
((code: @switch) match {
case 0 => unknownCodec(action = code)
case 1 => unknownCodec(action = code)
case 2 => ListElementOutfitCodec
case 3 => Unk3Codec
case 4 => unknownCodec(action = code)
case 5 => unknownCodec(action = code)
case 6 => unknownCodec(action = code)
case 7 => unknownCodec(action = code)
case _ => failureCodec(code)
}).asInstanceOf[Codec[OutfitListEventAction]]
}
implicit val codec: Codec[OutfitListEvent] = (
("packet_type" | PacketType.codec) >>:~ { packet_type =>
("action" | selectFromType(packet_type.id)).hlist
}
).xmap[OutfitListEvent](
{
case _ :: action :: HNil =>
OutfitListEvent(action)
},
{
case OutfitListEvent(action) =>
OutfitListEvent.PacketType(action.code) :: action :: HNil
}
)
}

View file

@ -0,0 +1,150 @@
// Copyright (c) 2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.GamePacketOpcode.Type
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
import scodec.{Attempt, Codec, Err}
import scodec.bits.BitVector
import scodec.codecs._
import shapeless.{::, HNil}
final case class OutfitMemberEvent(
outfit_id: Long,
member_id: Long,
action: OutfitMemberEventAction
) extends PlanetSideGamePacket {
type Packet = OutfitMemberEvent
def opcode: Type = GamePacketOpcode.OutfitMemberEvent
def encode: Attempt[BitVector] = OutfitMemberEvent.encode(this)
}
abstract class OutfitMemberEventAction(val code: Int)
object OutfitMemberEventAction {
object PacketType extends Enumeration {
type Type = Value
val Unk0: PacketType.Value = Value(0)
val Padding: PacketType.Value = Value(1)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(1))
}
/*
action is unimplemented! if action == 0 unk2 will contain one additional uint32L
padding contains one uint4L of padding. may contain uint32L of unknown data depending on action
*/
final case class Unk0(
member_name: String,
rank: Int,
points: Long, // client divides this by 100
last_online: Long, // seconds ago from current time, 0 if online
action: PacketType.Type, // should always be 1, otherwise there will be actual data in padding. not implemented!
padding: Int // should always be 0, 4 bits of padding // only contains data if action is 0
) extends OutfitMemberEventAction(code = 0)
final case class Unk1(
) extends OutfitMemberEventAction(code = 1)
final case class Unknown(badCode: Int, data: BitVector) extends OutfitMemberEventAction(badCode)
/**
* The `Codec`s used to transform the input stream into the context of a specific action
* and extract the field data from that stream.
*/
object Codecs {
private val everFailCondition = conditional(included = false, bool)
val Unk0Codec: Codec[Unk0] = (
("member_name" | PacketHelpers.encodedWideStringAligned(6)) :: // from here is packet_type == 0 only
("rank" | uint(3)) ::
("points" | uint32L) ::
("last_login" | uint32L) ::
("action" | OutfitMemberEventAction.PacketType.codec) ::
("padding" | uint4L)
).xmap[Unk0](
{
case member_name :: rank :: points :: last_login :: action :: padding :: HNil =>
Unk0(member_name, rank, points, last_login, action, padding)
},
{
case Unk0(member_name, rank, points, last_login, action, padding) =>
member_name :: rank :: points :: last_login :: action :: padding :: HNil
}
)
val Unk1Codec: Codec[Unk1] = PacketHelpers.emptyCodec(Unk1())
/**
* A common form for known action code indexes with an unknown purpose and transformation is an "Unknown" object.
* @param action the action behavior code
* @return a transformation between the action code and the unknown bit data
*/
def unknownCodec(action: Int): Codec[Unknown] =
bits.xmap[Unknown](
data => Unknown(action, data),
{
case Unknown(_, data) => data
}
)
/**
* The action code was completely unanticipated!
* @param action the action behavior code
* @return nothing; always fail
*/
def failureCodec(action: Int): Codec[OutfitMemberEventAction] =
everFailCondition.exmap[OutfitMemberEventAction](
_ => Attempt.failure(Err(s"can not match a codec pattern for decoding $action")),
_ => Attempt.failure(Err(s"can not match a codec pattern for encoding $action"))
)
}
}
object OutfitMemberEvent extends Marshallable[OutfitMemberEvent] {
object PacketType extends Enumeration {
type Type = Value
val Unk0: PacketType.Value = Value(0)
val Unk1: PacketType.Value = Value(1) // Info: Player has been invited / response to OutfitMembershipRequest Unk2 for that player
val Unk2: PacketType.Value = Value(2)
val Unk3: PacketType.Value = Value(3)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(2))
}
private def selectFromType(code: Int): Codec[OutfitMemberEventAction] = {
import OutfitMemberEventAction.Codecs._
import scala.annotation.switch
((code: @switch) match {
case 0 => Unk0Codec
case 1 => Unk1Codec
case 2 => unknownCodec(code)
case 3 => unknownCodec(code)
case _ => failureCodec(code)
}).asInstanceOf[Codec[OutfitMemberEventAction]]
}
implicit val codec: Codec[OutfitMemberEvent] = (
("packet_type" | PacketType.codec) >>:~ { packet_type =>
("outfit_id" | uint32L) ::
("member_id" | uint32L) ::
("action" | selectFromType(packet_type.id)).hlist
}
).xmap[OutfitMemberEvent](
{
case _ :: outfit_id :: member_id:: action :: HNil =>
OutfitMemberEvent(outfit_id, member_id, action)
},
{
case OutfitMemberEvent(outfit_id, member_id, action) =>
OutfitMemberEvent.PacketType(action.code) :: outfit_id :: member_id :: action :: HNil
}
)
}

View file

@ -0,0 +1,36 @@
// Copyright (c) 2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket}
import scodec.Codec
import scodec.codecs._
import shapeless.{::, HNil}
final case class OutfitMemberUpdate(
outfit_id: Long,
char_id: Long,
rank: Int, // 0-7
flag: Boolean,
) extends PlanetSideGamePacket {
type Packet = OutfitMemberUpdate
def opcode = GamePacketOpcode.OutfitMemberUpdate
def encode = OutfitMemberUpdate.encode(this)
}
object OutfitMemberUpdate extends Marshallable[OutfitMemberUpdate] {
implicit val codec: Codec[OutfitMemberUpdate] = (
("outfit_id" | uint32L) ::
("char_id" | uint32L) ::
("rank" | uint(3)) ::
("flag" | bool)
).xmap[OutfitMemberUpdate](
{
case outfit_id :: char_id :: rank :: flag :: HNil =>
OutfitMemberUpdate(outfit_id, char_id, rank, flag)
},
{
case OutfitMemberUpdate(outfit_id, char_id, rank, flag) =>
outfit_id :: char_id :: rank :: flag :: HNil
}
)
}

View file

@ -1,19 +1,16 @@
// Copyright (c) 2023 PSForever
// Copyright (c) 2023-2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.GamePacketOpcode.Type
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
import net.psforever.types.PlanetSideGUID
import scodec.{Attempt, Codec, Err}
import scodec.bits.BitVector
import scodec.codecs._
import shapeless.{::, HNil}
final case class OutfitMembershipRequest(
request_type: OutfitMembershipRequest.RequestType.Type,
avatar_guid: PlanetSideGUID,
unk1: Int,
action: OutfitAction
outfit_id: Long,
action: OutfitMembershipRequestAction
) extends PlanetSideGamePacket {
type Packet = OutfitMembershipRequest
@ -22,20 +19,53 @@ final case class OutfitMembershipRequest(
def encode: Attempt[BitVector] = OutfitMembershipRequest.encode(this)
}
abstract class OutfitAction(val code: Int)
object OutfitAction {
abstract class OutfitMembershipRequestAction(val code: Int)
final case class CreateOutfit(unk2: String, unk3: Int, unk4: Boolean, outfit_name: String) extends OutfitAction(code = 0)
/*
Codecs 2,5,6,7 can either work off of the avatar_id (if GUI was used) or member_name (if chat command was used)
*/
object OutfitMembershipRequestAction {
final case class FormOutfit(unk2: String, unk3: Int, unk4: Boolean, outfit_name: String) extends OutfitAction(code = 1)
final case class Create(
unk1: String,
outfit_name: String
) extends OutfitMembershipRequestAction(code = 0)
final case class AcceptOutfitInvite(unk2: String) extends OutfitAction(code = 3)
final case class Form(
unk1: String,
outfit_name: String
) extends OutfitMembershipRequestAction(code = 1)
final case class RejectOutfitInvite(unk2: String) extends OutfitAction(code = 4)
final case class Invite(
avatar_id: Long,
member_name: String,
) extends OutfitMembershipRequestAction(code = 2)
final case class CancelOutfitInvite(unk5: Int, unk6: Int, outfit_name: String) extends OutfitAction(code = 5)
final case class AcceptInvite(
member_name: String
) extends OutfitMembershipRequestAction(code = 3)
final case class Unknown(badCode: Int, data: BitVector) extends OutfitAction(badCode)
final case class RejectInvite(
member_name: String
) extends OutfitMembershipRequestAction(code = 4)
final case class CancelInvite(
avatar_id: Long,
member_name: String,
) extends OutfitMembershipRequestAction(code = 5)
final case class Kick(
avatar_id: Long,
member_name: String,
) extends OutfitMembershipRequestAction(code = 6)
final case class SetRank(
avatar_id: Long,
rank: Int,
member_name: String,
) extends OutfitMembershipRequestAction(code = 7)
final case class Unknown(badCode: Int, data: BitVector) extends OutfitMembershipRequestAction(badCode)
/**
* The `Codec`s used to transform the input stream into the context of a specific action
@ -44,68 +74,121 @@ object OutfitAction {
object Codecs {
private val everFailCondition = conditional(included = false, bool)
val CreateOutfitCodec: Codec[CreateOutfit] =
(PacketHelpers.encodedWideString :: uint4L :: bool :: PacketHelpers.encodedWideString).xmap[CreateOutfit](
{
case unk2 :: unk3 :: unk4 :: outfit_name :: HNil =>
CreateOutfit(unk2, unk3, unk4, outfit_name)
},
{
case CreateOutfit(unk2, unk3, unk4, outfit_name) =>
unk2 :: unk3 :: unk4 :: outfit_name :: HNil
}
)
val CreateCodec: Codec[Create] = (
PacketHelpers.encodedWideStringAligned(5) ::
PacketHelpers.encodedWideString
).xmap[Create](
{
case u1 :: outfit_name :: HNil =>
Create(u1, outfit_name)
},
{
case Create(u1, outfit_name) =>
u1 :: outfit_name :: HNil
}
)
val FormOutfitCodec: Codec[FormOutfit] =
(PacketHelpers.encodedWideString :: uint4L :: bool :: PacketHelpers.encodedWideString).xmap[FormOutfit](
{
case unk2 :: unk3 :: unk4 :: outfit_name :: HNil =>
FormOutfit(unk2, unk3, unk4, outfit_name)
},
{
case FormOutfit(unk2, unk3, unk4, outfit_name) =>
unk2 :: unk3 :: unk4 :: outfit_name :: HNil
}
)
val FormCodec: Codec[Form] = (
PacketHelpers.encodedWideStringAligned(5) ::
PacketHelpers.encodedWideString
).xmap[Form](
{
case u1 :: outfit_name :: HNil =>
Form(u1, outfit_name)
},
{
case Form(u1, outfit_name) =>
u1 :: outfit_name :: HNil
}
)
val AcceptOutfitCodec: Codec[AcceptOutfitInvite] =
PacketHelpers.encodedWideString.xmap[AcceptOutfitInvite](
{
case unk2 =>
AcceptOutfitInvite(unk2)
},
{
case AcceptOutfitInvite(unk2) =>
unk2
}
)
val InviteCodec: Codec[Invite] = (
uint32L ::
PacketHelpers.encodedWideStringAligned(5)
).xmap[Invite](
{
case avatar_id :: member_name :: HNil =>
Invite(avatar_id, member_name)
},
{
case Invite(avatar_id, member_name) =>
avatar_id :: member_name :: HNil
}
)
val RejectOutfitCodec: Codec[RejectOutfitInvite] =
PacketHelpers.encodedWideString.xmap[RejectOutfitInvite](
{
case unk2 =>
RejectOutfitInvite(unk2)
},
{
case RejectOutfitInvite(unk2) =>
unk2
}
)
val AcceptInviteCodec: Codec[AcceptInvite] = (
PacketHelpers.encodedWideString
).xmap[AcceptInvite](
{
case member_name =>
AcceptInvite(member_name)
},
{
case AcceptInvite(member_name) =>
member_name
}
)
val RejectInviteCodec: Codec[RejectInvite] = (
PacketHelpers.encodedWideString
).xmap[RejectInvite](
{
case member_name =>
RejectInvite(member_name)
},
{
case RejectInvite(member_name) =>
member_name
}
)
val CancelInviteCodec: Codec[CancelInvite] = (
uint32L ::
PacketHelpers.encodedWideStringAligned(5)
).xmap[CancelInvite](
{
case avatar_id :: outfit_name :: HNil =>
CancelInvite(avatar_id, outfit_name)
},
{
case CancelInvite(avatar_id, outfit_name) =>
avatar_id :: outfit_name :: HNil
}
)
val KickCodec: Codec[Kick] = (
uint32L ::
PacketHelpers.encodedWideStringAligned(5)
).xmap[Kick](
{
case avatar_id :: member_name :: HNil =>
Kick(avatar_id, member_name)
},
{
case Kick(avatar_id, member_name) =>
avatar_id :: member_name :: HNil
}
)
val SetRankCodec: Codec[SetRank] = (
uint32L ::
uintL(3) ::
PacketHelpers.encodedWideStringAligned(2)
).xmap[SetRank](
{
case avatar_id :: rank :: member_name :: HNil =>
SetRank(avatar_id, rank, member_name)
},
{
case SetRank(avatar_id, rank, member_name) =>
avatar_id :: rank :: member_name :: HNil
}
)
val CancelOutfitCodec: Codec[CancelOutfitInvite] =
(uint16L :: uint16L :: PacketHelpers.encodedWideStringAligned(5)).xmap[CancelOutfitInvite](
{
case unk5 :: unk6 :: outfit_name :: HNil =>
CancelOutfitInvite(unk5, unk6, outfit_name)
},
{
case CancelOutfitInvite(unk5, unk6, outfit_name) =>
unk5 :: unk6 :: outfit_name :: HNil
}
)
/**
* A common form for known action code indexes with an unknown purpose and transformation is an "Unknown" object.
*
* @param action the action behavior code
* @return a transformation between the action code and the unknown bit data
*/
@ -119,11 +202,12 @@ object OutfitAction {
/**
* The action code was completely unanticipated!
*
* @param action the action behavior code
* @return nothing; always fail
*/
def failureCodec(action: Int): Codec[OutfitAction] =
everFailCondition.exmap[OutfitAction](
def failureCodec(action: Int): Codec[OutfitMembershipRequestAction] =
everFailCondition.exmap[OutfitMembershipRequestAction](
_ => Attempt.failure(Err(s"can not match a codec pattern for decoding $action")),
_ => Attempt.failure(Err(s"can not match a codec pattern for encoding $action"))
)
@ -132,53 +216,52 @@ object OutfitAction {
object OutfitMembershipRequest extends Marshallable[OutfitMembershipRequest] {
object RequestType extends Enumeration {
object PacketType extends Enumeration {
type Type = Value
val Create: RequestType.Value = Value(0)
val Form: RequestType.Value = Value(1)
val Unk2: RequestType.Value = Value(2)
val Accept: RequestType.Value = Value(3)
val Reject: RequestType.Value = Value(4)
val Cancel: RequestType.Value = Value(5)
val Unk6: RequestType.Value = Value(6) // 6 and 7 seen as failed decodes, validity unknown
val Unk7: RequestType.Value = Value(7)
val Create: PacketType.Value = Value(0)
val Form: PacketType.Value = Value(1)
val Invite: PacketType.Value = Value(2)
val Accept: PacketType.Value = Value(3)
val Reject: PacketType.Value = Value(4)
val Cancel: PacketType.Value = Value(5)
val Kick: PacketType.Value = Value(6)
val SetRank: PacketType.Value = Value(7)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(3))
}
private def selectFromType(code: Int): Codec[OutfitAction] = {
import OutfitAction.Codecs._
private def selectFromType(code: Int): Codec[OutfitMembershipRequestAction] = {
import OutfitMembershipRequestAction.Codecs._
import scala.annotation.switch
((code: @switch) match {
case 0 => CreateOutfitCodec
case 1 => FormOutfitCodec // so far same as Create
case 2 => unknownCodec(action = code)
case 3 => AcceptOutfitCodec
case 4 => RejectOutfitCodec // so far same as Accept
case 5 => CancelOutfitCodec
case 6 => unknownCodec(action = code)
case 7 => unknownCodec(action = code)
// 3 bit limit
case 0 => CreateCodec
case 1 => FormCodec // so far same as Create
case 2 => InviteCodec
case 3 => AcceptInviteCodec
case 4 => RejectInviteCodec
case 5 => CancelInviteCodec
case 6 => KickCodec
case 7 => SetRankCodec
case _ => failureCodec(code)
}).asInstanceOf[Codec[OutfitAction]]
}).asInstanceOf[Codec[OutfitMembershipRequestAction]]
}
implicit val codec: Codec[OutfitMembershipRequest] = (
("request_type" | RequestType.codec) >>:~ { request_type =>
("avatar_guid" | PlanetSideGUID.codec) ::
("unk1" | uint16L) ::
("action" | selectFromType(request_type.id))
("packet_type" | PacketType.codec) >>:~ { packet_type =>
("outfit_id" | uint32L) ::
("action" | selectFromType(packet_type.id))
}
).xmap[OutfitMembershipRequest](
{
case request_type :: avatar_guid :: u1 :: action :: HNil =>
OutfitMembershipRequest(request_type, avatar_guid, u1, action)
case _ :: outfit_id :: action :: HNil =>
OutfitMembershipRequest(outfit_id, action)
},
{
case OutfitMembershipRequest(request_type, avatar_guid, u1, action) =>
request_type :: avatar_guid :: u1 :: action :: HNil
case OutfitMembershipRequest(outfit_id, action) =>
OutfitMembershipRequest.PacketType(action.code) :: outfit_id :: action :: HNil
}
)
}

View file

@ -0,0 +1,64 @@
// Copyright (c) 2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.GamePacketOpcode.Type
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
import scodec.{Attempt, Codec}
import scodec.bits.BitVector
import scodec.codecs._
import shapeless.{::, HNil}
final case class OutfitMembershipResponse(
packet_type: OutfitMembershipResponse.PacketType.Type,
unk0: Int,
unk1: Int,
outfit_id: Long,
target_id: Long,
str1: String,
str2: String,
flag: Boolean
) extends PlanetSideGamePacket {
type Packet = OutfitMembershipResponse
def opcode: Type = GamePacketOpcode.OutfitMembershipResponse
def encode: Attempt[BitVector] = OutfitMembershipResponse.encode(this)
}
object OutfitMembershipResponse extends Marshallable[OutfitMembershipResponse] {
object PacketType extends Enumeration {
type Type = Value
val CreateResponse: PacketType.Value = Value(0)
val Invite: PacketType.Value = Value(1) // response to OutfitMembershipRequest Unk2 for that player
val InviteAccepted: PacketType.Value = Value(2)
val InviteRejected: PacketType.Value = Value(3)
val Unk4: PacketType.Value = Value(4)
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)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(3))
}
implicit val codec: Codec[OutfitMembershipResponse] = (
("packet_type" | PacketType.codec) ::
("unk0" | uintL(5)) ::
("unk1" | uintL(3)) ::
("outfit_id" | uint32L) ::
("target_id" | uint32L) ::
("str1" | PacketHelpers.encodedWideStringAligned(5)) ::
("str2" | PacketHelpers.encodedWideString) ::
("flag" | bool)
).xmap[OutfitMembershipResponse](
{
case packet_type :: u0 :: u1 :: outfit_id :: target_id :: str1 :: str2 :: flag :: HNil =>
OutfitMembershipResponse(packet_type, u0, u1, outfit_id, target_id, str1, str2, flag)
},
{
case OutfitMembershipResponse(packet_type, u0, u1, outfit_id, target_id, str1, str2, flag) =>
packet_type :: u0 :: u1 :: outfit_id :: target_id :: str1 :: str2 :: flag :: HNil
}
)
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2023 PSForever
// Copyright (c) 2023-2025 PSForever
package net.psforever.packet.game
import net.psforever.packet.{GamePacketOpcode, Marshallable, PacketHelpers, PlanetSideGamePacket}
@ -7,77 +7,77 @@ import scodec.bits.ByteVector
import scodec.codecs._
import shapeless.{::, HNil}
/**
* na
*/
abstract class OutfitRequestForm(val code: Int)
object OutfitRequestForm {
/**
* na
* @param str na
*/
final case class Unk0(str: String) extends OutfitRequestForm(code = 0)
/**
* na
* @param list na
*/
final case class Unk1(list: List[Option[String]]) extends OutfitRequestForm(code = 1)
/**
* na
* @param unk na
*/
final case class Unk2(unk: Int) extends OutfitRequestForm(code = 2)
/**
* na
* @param unk na
*/
final case class Unk3(unk: Boolean) extends OutfitRequestForm(code = 3)
/**
* na
* @param unk na
*/
final case class Unk4(unk: Boolean) extends OutfitRequestForm(code = 4)
/**
* na
* @param unk na
*/
final case class Fail(unk: ByteVector) extends OutfitRequestForm(code = -1)
final case class OutfitRequest(
outfit_id: Long,
action: OutfitRequestAction
) extends PlanetSideGamePacket {
type Packet = OutfitRequest
def opcode = GamePacketOpcode.OutfitRequest
def encode = OutfitRequest.encode(this)
}
/**
* na
* @param id na
* @param info na
*/
final case class OutfitRequest(id: Long, info: OutfitRequestForm)
extends PlanetSideGamePacket {
type Packet = OrbitalStrikeWaypointMessage
def opcode = GamePacketOpcode.OutfitRequest
def encode = OutfitRequest.encode(this)
abstract class OutfitRequestAction(val code: Int)
object OutfitRequestAction {
/**
* na
* @param str na
*/
final case class Motd(str: String) extends OutfitRequestAction(code = 0)
/**
* na
* @param list na
*/
final case class Ranks(list: List[Option[String]]) extends OutfitRequestAction(code = 1)
/**
* na
* @param unk na
*/
final case class Unk2(unk: Int) extends OutfitRequestAction(code = 2)
/**
* na
* @param unk na
*/
final case class Unk3(menuOpen: Boolean) extends OutfitRequestAction(code = 3)
/**
* na
* @param unk na
*/
final case class Unk4(menuOpen: Boolean) extends OutfitRequestAction(code = 4)
/**
* na
* @param unk na
*/
final case class Fail(unk: ByteVector) extends OutfitRequestAction(code = -1)
}
object OutfitRequest extends Marshallable[OutfitRequest] {
/**
* na
*/
private val unk0Codec: Codec[OutfitRequestForm] = PacketHelpers.encodedWideStringAligned(adjustment = 5).hlist
.xmap[OutfitRequestForm] (
private val MotdCodec: Codec[OutfitRequestAction] = PacketHelpers.encodedWideStringAligned(adjustment = 5).hlist
.xmap[OutfitRequestAction] (
{
case value :: HNil => OutfitRequestForm.Unk0(value)
case value :: HNil => OutfitRequestAction.Motd(value)
},
{
case OutfitRequestForm.Unk0(value) => value :: HNil
case OutfitRequestAction.Motd(value) => value :: HNil
}
)
/**
* na
*/
private val unk1Codec: Codec[OutfitRequestForm] = unk1PaddedEntryCodec(len = 8, pad = 5).xmap[OutfitRequestForm] (
list => OutfitRequestForm.Unk1(list),
private val RankCodec: Codec[OutfitRequestAction] = unk1PaddedEntryCodec(len = 8, pad = 5).xmap[OutfitRequestAction] (
list => OutfitRequestAction.Ranks(list),
{
case OutfitRequestForm.Unk1(list) => list
case OutfitRequestAction.Ranks(list) => list
}
)
@ -104,54 +104,66 @@ object OutfitRequest extends Marshallable[OutfitRequest] {
/**
* na
*/
private val unk2Codec: Codec[OutfitRequestForm] = uint8.hlist.xmap[OutfitRequestForm] (
private val unk2Codec: Codec[OutfitRequestAction] = uint8.hlist.xmap[OutfitRequestAction] (
{
case value :: HNil => OutfitRequestForm.Unk2(value)
case value :: HNil => OutfitRequestAction.Unk2(value)
},
{
case OutfitRequestForm.Unk2(value) => value :: HNil
case OutfitRequestAction.Unk2(value) => value :: HNil
}
)
/**
* na
*/
private val unk3Codec: Codec[OutfitRequestForm] = bool.hlist.xmap[OutfitRequestForm] (
private val unk3Codec: Codec[OutfitRequestAction] = bool.hlist.xmap[OutfitRequestAction] (
{
case value :: HNil => OutfitRequestForm.Unk3(value)
case value :: HNil => OutfitRequestAction.Unk3(value)
},
{
case OutfitRequestForm.Unk3(value) => value :: HNil
case OutfitRequestAction.Unk3(value) => value :: HNil
}
)
/**
* na
*/
private val unk4Codec: Codec[OutfitRequestForm] = bool.hlist.xmap[OutfitRequestForm] (
private val unk4Codec: Codec[OutfitRequestAction] = bool.hlist.xmap[OutfitRequestAction] (
{
case value :: HNil => OutfitRequestForm.Unk4(value)
case value :: HNil => OutfitRequestAction.Unk4(value)
},
{
case OutfitRequestForm.Unk4(value) => value :: HNil
case OutfitRequestAction.Unk4(value) => value :: HNil
}
)
/**
* na
*/
private def failCodec(code: Int): Codec[OutfitRequestForm] = conditional(included = false, bool).exmap[OutfitRequestForm](
_ => Attempt.Failure(Err(s"can not decode $code-type info - what is this thing?")),
_ => Attempt.Failure(Err(s"can not encode $code-type info - no such thing"))
private def failCodec(action: Int): Codec[OutfitRequestAction] = conditional(included = false, bool).exmap[OutfitRequestAction](
_ => Attempt.Failure(Err(s"can not decode $action-type info - what is this thing?")),
_ => Attempt.Failure(Err(s"can not encode $action-type info - no such thing"))
)
object PacketType extends Enumeration {
type Type = Value
val Motd: PacketType.Value = Value(0)
val Rank: PacketType.Value = Value(1)
val Unk2: PacketType.Value = Value(2)
val Detail: PacketType.Value = Value(3)
val List: PacketType.Value = Value(4) // sent by client if menu is either open (true) or closed (false)
implicit val codec: Codec[Type] = PacketHelpers.createEnumerationCodec(this, uintL(3))
}
/**
* na
*/
private def infoCodec(code: Int): Codec[OutfitRequestForm] = {
private def selectFromType(code: Int): Codec[OutfitRequestAction] = {
code match {
case 0 => unk0Codec
case 1 => unk1Codec
case 0 => MotdCodec
case 1 => RankCodec
case 2 => unk2Codec
case 3 => unk3Codec
case 4 => unk4Codec
@ -160,18 +172,18 @@ object OutfitRequest extends Marshallable[OutfitRequest] {
}
implicit val codec: Codec[OutfitRequest] = (
uint(bits = 3) >>:~ { code =>
("packet_type" | PacketType.codec) >>:~ { packet_type =>
("id" | uint32L) ::
("info" | infoCodec(code))
("action" | selectFromType(packet_type.id)).hlist
}
).xmap[OutfitRequest](
).xmap[OutfitRequest](
{
case _:: id:: info :: HNil =>
OutfitRequest(id, info)
case _ :: id:: action :: HNil =>
OutfitRequest(id, action)
},
{
case OutfitRequest(id, info) =>
info.code :: id :: info :: HNil
case OutfitRequest(id, action) =>
OutfitRequest.PacketType(action.code) :: id :: action :: HNil
}
)
}

View file

@ -82,9 +82,7 @@ object SquadMemberEvent extends Marshallable[SquadMemberEvent] {
Some(outfit_id)
) =>
Attempt.Successful(
MemberEvent.Add :: unk2 :: char_id :: member_position :: Some(player_name) :: Some(zone_number) :: Some(
outfit_id
) :: HNil
MemberEvent.Add :: unk2 :: char_id :: member_position :: Some(player_name) :: Some(zone_number) :: Some(outfit_id) :: HNil
)
case SquadMemberEvent(MemberEvent.UpdateZone, unk2, char_id, member_position, None, Some(zone_number), None) =>
Attempt.Successful(

View file

@ -198,6 +198,14 @@ class AvatarService(zone: Zone) extends Actor {
AvatarResponse.PlanetsideAttributeSelf(attribute_type, attribute_value)
)
)
case AvatarAction.PlanetsideStringAttribute(guid, attribute_type, attribute_value) =>
AvatarEvents.publish(
AvatarServiceResponse(
s"/$forChannel/Avatar",
guid,
AvatarResponse.PlanetsideStringAttribute(attribute_type, attribute_value)
)
)
case AvatarAction.PlayerState(
guid,
pos,

View file

@ -78,6 +78,8 @@ object AvatarAction {
extends Action
final case class PlanetsideAttributeSelf(player_guid: PlanetSideGUID, attribute_type: Int, attribute_value: Long)
extends Action
final case class PlanetsideStringAttribute(player_guid: PlanetSideGUID, attribute_type: Int, attribute_value: String)
extends Action
final case class PlayerState(
player_guid: PlanetSideGUID,
pos: Vector3,

View file

@ -47,7 +47,7 @@ object AvatarResponse {
final case class EquipmentInHand(pkt: ObjectCreateMessage) extends Response
final case class GenericObjectAction(object_guid: PlanetSideGUID, action_code: Int) extends Response
final case class HitHint(source_guid: PlanetSideGUID) extends Response
final case class Killed(cause: DamageResult, mount_guid: Option[PlanetSideGUID]) extends Response
final case class Killed(cause: DamageResult, mount_guid: Option[PlanetSideGUID]) extends Response
final case class LoadPlayer(pkt: ObjectCreateMessage) extends Response
final case class LoadProjectile(pkt: ObjectCreateMessage) extends Response
final case class ObjectDelete(item_guid: PlanetSideGUID, unk: Int) extends Response
@ -56,6 +56,7 @@ object AvatarResponse {
final case class PlanetsideAttribute(attribute_type: Int, attribute_value: Long) extends Response
final case class PlanetsideAttributeToAll(attribute_type: Int, attribute_value: Long) extends Response
final case class PlanetsideAttributeSelf(attribute_type: Int, attribute_value: Long) extends Response
final case class PlanetsideStringAttribute(attribute_type: Int, attribute_value: String) extends Response
final case class PlayerState(
pos: Vector3,
vel: Option[Vector3],

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)

View file

@ -0,0 +1,241 @@
// Copyright (c) 2025 PSForever
package game
import net.psforever.packet._
import net.psforever.packet.game.OutfitEvent
import net.psforever.packet.game.OutfitEventAction._
import org.specs2.mutable._
import scodec.bits._
class OutfitEventTest extends Specification {
val unk0_ABC: ByteVector = ByteVector.fromValidHex(
"8f 1 a8c2 0001" + // packet head
"2a 0 42006c00610063006b002000410072006d006f0072006500640020005200650061007000650072007300" + // Black Armored Reapers
"1d9c4d0d" +
"1d9c4d0d" +
"ab00 0000" +
"88 44006f00670020004d00650061007400" + // Dog Meat
"87 5200750073007300690061006e00" + // Russian
"80" + //
"80" + //
"8d 5300710075006100640020004c00650061006400650072007300" + // Squad Leaders
"91 41006300740069006e006700200043006f006d006d0061006e006400650072007300" + // Acting Commanders
"87 5200650061007000650072007300" + // Reapers
"80" + //
"00" +
"9c 5c0023003000300030003000660066004d0075006d0062006c00650020005c00230030003000330033006600660049006e0066006f0020005c0023003000300036003600660066006900730020005c0023003000300039003900660066007400680065006d006f006f00730065002e00740079007000650066007200610067002e0063006f006d0020005c00230030003000630063006600660070006f007200740020005c002300300030006600660066006600390033003500300020005c0023003000300063006300660066006a006f0069006e0020005c0023003000300039003900660066006900740020005c0023003000300036003600660066006f00720020005c0023003000300033003300660066006200650020005c0023003000300030003000660066006b00690063006b00650064002e00" +
"0f80" +
"0000 00737296 24000000 00000000 00000000 0000")
val unk1_ABC: ByteVector = hex"8f 2 302a 10 00 0"
val unk2_ABC: ByteVector = ByteVector.fromValidHex(
"8f 4 0201feff" +
"2e 0 50006c0061006e006500740053006900640065005f0046006f00720065007600650072005f00560061006e007500" + // PlanetSide_Forever_Vanu
"00000000" +
"00000000" +
"0100 0000" +
"80" +
"80" +
"80" +
"80" +
"80" +
"80" +
"80" +
"80" +
"80" +
"0070" +
"4982 00000000 00000000 00000000 00000000 0000")
val unk3_ABC: ByteVector = hex"8f 6 0201 fe fe 0"
val unk4_ABC: ByteVector = hex"8f 8 0201 fefe a02a 1000 0"
val unk5_ABC: ByteVector = hex"8f a 0201 fefe 0400 0000 0"
"decode Unk0 ABC" in {
PacketCoding.decodePacket(unk0_ABC).require match {
case OutfitEvent(outfit_guid, action) =>
outfit_guid mustEqual 25044
action mustEqual Unk0(
OutfitInfo(
outfit_name = "Black Armored Reapers",
outfit_points1 = 223190045,
outfit_points2 = 223190045,
member_count = 171,
OutfitRankNames("Dog Meat","Russian","","","Squad Leaders","Acting Commanders","Reapers",""),
"\\#0000ffMumble \\#0033ffInfo \\#0066ffis \\#0099ffthemoose.typefrag.com \\#00ccffport \\#00ffff9350 \\#00ccffjoin \\#0099ffit \\#0066ffor \\#0033ffbe \\#0000ffkicked.",
15,
unk11 = true,
unk12 = 0,
created_timestamp = 1210901990,
0,
0,
0,
)
)
case _ =>
ko
}
}
"encode Unk0 ABC" in {
val msg = OutfitEvent(
25044,
Unk0(
OutfitInfo(
outfit_name = "Black Armored Reapers",
outfit_points1 = 223190045,
outfit_points2 = 223190045,
member_count = 171,
OutfitRankNames("Dog Meat","Russian","","","Squad Leaders","Acting Commanders","Reapers",""),
"\\#0000ffMumble \\#0033ffInfo \\#0066ffis \\#0099ffthemoose.typefrag.com \\#00ccffport \\#00ffff9350 \\#00ccffjoin \\#0099ffit \\#0066ffor \\#0033ffbe \\#0000ffkicked.",
15,
unk11 = true,
unk12 = 0,
created_timestamp = 1210901990,
0,
0,
0,
)
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk0_ABC
}
"decode Unk1 ABC" in {
PacketCoding.decodePacket(unk1_ABC).require match {
case OutfitEvent(outfit_guid, action) =>
outfit_guid mustEqual 529688L
action mustEqual Unk1()
case _ =>
ko
}
}
"encode Unk1 ABC" in {
val msg = OutfitEvent(
529688L,
Unk1()
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk1_ABC
}
"decode Unk2 ABC" in {
PacketCoding.decodePacket(unk2_ABC).require match {
case OutfitEvent(outfit_guid, action) =>
outfit_guid mustEqual 2147418113L
action mustEqual Unk2(OutfitInfo(
outfit_name = "PlanetSide_Forever_Vanu",
outfit_points1 = 0,
outfit_points2 = 0,
member_count = 1,
OutfitRankNames("","","","","","","",""),
"",
0,
unk11 = false,
unk12 = 300000,
created_timestamp = 0,
0,
0,
0,
))
case _ =>
ko
}
}
"encode Unk2 ABC" in {
val msg = OutfitEvent(
2147418113L,
Unk2(
OutfitInfo(
outfit_name = "PlanetSide_Forever_Vanu",
outfit_points1 = 0,
outfit_points2 = 0,
member_count = 1,
OutfitRankNames("","","","","","","",""),
"",
0,
unk11 = false,
unk12 = 300000,
created_timestamp = 0,
0,
0,
0,
)
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk2_ABC
}
"decode Unk3 ABC" in {
PacketCoding.decodePacket(unk3_ABC).require match {
case OutfitEvent(outfit_guid, action) =>
outfit_guid mustEqual 2147418113L
action mustEqual Unk3()
case _ =>
ko
}
}
"encode Unk3 ABC" in {
val msg = OutfitEvent(
2147418113L,
Unk3()
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk3_ABC
}
"decode Unk4 ABC" in {
PacketCoding.decodePacket(unk4_ABC).require match {
case OutfitEvent(outfit_guid, action) =>
outfit_guid mustEqual 2147418113L
action mustEqual UpdateOutfitId(
new_outfit_id = 529744L,
)
case _ =>
ko
}
}
"encode Unk4 ABC" in {
val msg = OutfitEvent(
2147418113L,
UpdateOutfitId(
new_outfit_id = 529744L,
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk4_ABC
}
"decode Unk5 ABC" in {
PacketCoding.decodePacket(unk5_ABC).require match {
case OutfitEvent(outfit_guid, action) =>
outfit_guid mustEqual 2147418113L
action mustEqual Unk5(
unk1 = 2,
)
case _ =>
ko
}
}
"encode Unk5 ABC" in {
val msg = OutfitEvent(
2147418113L,
Unk5(
unk1 = 2,
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk5_ABC
}
}

View file

@ -0,0 +1,50 @@
// Copyright (c) 2025 PSForever
package game
import net.psforever.packet._
import net.psforever.packet.game.OutfitListEvent
import net.psforever.packet.game.OutfitListEventAction.ListElementOutfit
import org.specs2.mutable._
import scodec.bits.ByteVector
class OutfitListEventTest extends Specification {
val unk2_0_ABC: ByteVector = ByteVector.fromValidHex("98 5 e83a0000 000e1800 0800000 11404e0069006700680074004c006f00720064007300 854e005900430061007400")
val unk2_0_DEF: ByteVector = ByteVector.fromValidHex("98 4 ec281001 51a62800 3400000 11a0490052004f004e004600490053005400200043006c0061006e00 8654006f006c006a00")
val unk2_1_ABC: ByteVector = ByteVector.fromValidHex("98 4 723c0000 2aa81e00 2200000 11006900470061006d00650073002d004500 906900670061006d006500730043005400460057006800610063006b002d004500")
val unk2_2_ABC: ByteVector = ByteVector.fromValidHex("98 4 9a3c0001 16da4e00 0400000 11a042006c006f006f00640020006f0066002000560061006e007500 864b00610072006e002d004500")
val unk2_3_ABC: ByteVector = ByteVector.fromValidHex("98 4 9c3c0000 df587c00 1400000 11a054006800650020004e00650076006500720068006f006f006400 8e6f00460058006f00530074006f006e0065004d0061006e002d004700")
val unk2_4_ABC: ByteVector = ByteVector.fromValidHex("98 4 c03c0000 24060400 0600000 1220540068006500200042006c00610063006b0020004b006e0069006700680074007300 874400720061007a00760065006e00")
val unk2_5_ABC: ByteVector = ByteVector.fromValidHex("98 5 383c0001 4b709a00 0c00000 10a03e005400760053003c00 89430061007000650062006f00610074007300")
val unk2_6_ABC: ByteVector = ByteVector.fromValidHex("98 5 b03c0000 35d67000 0400000 11404c006f0073007400200043006100750073006500 895a00650072006f004b00650077006c006c00")
val unk2_7_ABC: ByteVector = ByteVector.fromValidHex("98 4 043e0001 9fb82616 1400000 11e0540068006500200042006c00610063006b00200054006f00770065007200 874b00720075007000680065007800")
"decode unk0_ABC" in {
PacketCoding.decodePacket(unk2_0_ABC).require match {
case OutfitListEvent(ListElementOutfit(unk1, points, members, outfit_name, outfit_leader)) =>
unk1 mustEqual 7668
points mustEqual 788224
members mustEqual 4
outfit_name mustEqual "NightLords"
outfit_leader mustEqual "NYCat"
case _ =>
ko
}
}
"encode unk0_ABC" in {
val msg = OutfitListEvent(
ListElementOutfit(
7668,
788224,
4,
"NightLords",
"NYCat"
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk2_0_ABC
}
}

View file

@ -0,0 +1,112 @@
// Copyright (c) 2025 PSForever
package game
import net.psforever.packet._
import net.psforever.packet.game.{OutfitMemberEvent, OutfitMemberEventAction}
import net.psforever.packet.game.OutfitMemberEventAction._
import org.specs2.mutable._
import scodec.bits._
class OutfitMemberEventTest extends Specification {
//val unk0_ABC: ByteVector = hex"90 3518 4000 1a4e 4100 2 180 450078007000650072007400 8483 07e0 119d bfe0 70" // 0x90048640001030c28022404c0061007a00650072003100390038003200f43a45e00b4c604010
val Lazer = hex"90 0 4864 0001 030c 2802 24 0 4c0061007a00650072003100390038003200 f43a 45e0 0b4c 6040 10"
val Lazer2 = hex"90 0 4864 0001 030c 2802 24 0 4c0061007a00650072003100390038003200 e6dc 25a0 153e 6040 10"
val OpolE = hex"90 0 4864 0003 aad6 280a 14 0 4f0070006f006c004500 c9a1 80e0 0d03 2040 10"
val Billy = hex"90 0 4864 0003 a41a 280a 20 0 620069006c006c007900320035003600 935f 6000 186a b040 50"
val Virus = hex"90 0 4864 0002 1b64 4c02 28 0 5600690072007500730047006900760065007200 2f89 0080 0000 0000 10"
val PvtPa = hex"90 0 4864 0000 1e69 e80a 2c 0 500076007400500061006e00630061006b0065007300 705e a080 0a85 e060 10"
val Night = hex"90 0 4864 0002 4cf0 3802 28 0 4e006900670068007400770069006e0067003100 b8fb 9a40 0da6 ec80 50"
val unk1 = hex"90 5 40542002 3f61e808 0"
"decode Lazer padding" in {
PacketCoding.decodePacket(Lazer).require match {
case OutfitMemberEvent(outfit_id, member_id, Unk0(member_name, rank, points, last_login, action, padding)) =>
outfit_id mustEqual 6418
member_id mustEqual 705344
member_name mustEqual "Lazer1982"
rank mustEqual 7
points mustEqual 3134113
last_login mustEqual 156506
action mustEqual OutfitMemberEventAction.PacketType.Padding
padding mustEqual 0
case _ =>
ko
}
}
"encode Lazer padding" in {
val msg = OutfitMemberEvent(
outfit_id = 6418,
member_id = 705344,
Unk0(
member_name = "Lazer1982",
rank = 7,
points = 3134113,
last_online = 156506,
action = OutfitMemberEventAction.PacketType.Padding,
padding = 0
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual Lazer
}
"decode OpolE padding" in {
PacketCoding.decodePacket(OpolE).require match {
case OutfitMemberEvent(outfit_id, member_id, Unk0(member_name, rank, points, last_login, action, unk0_padding)) =>
outfit_id mustEqual 6418
member_id mustEqual 42644970
member_name mustEqual "OpolE"
rank mustEqual 6
points mustEqual 461901
last_login mustEqual 137576
action mustEqual OutfitMemberEventAction.PacketType.Padding
unk0_padding mustEqual 0
case _ =>
ko
}
}
"encode OpolE padding" in {
val msg = OutfitMemberEvent(
outfit_id = 6418,
member_id = 42644970,
Unk0(
member_name = "OpolE",
rank = 6,
points = 461901,
last_online = 137576,
action = OutfitMemberEventAction.PacketType.Padding,
padding = 0
)
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual OpolE
}
"decode Unk1" in {
PacketCoding.decodePacket(unk1).require match {
case OutfitMemberEvent(outfit_id, member_id, Unk1()) =>
outfit_id mustEqual 529744
member_id mustEqual 41605263
case _ =>
ko
}
}
"encode Unk1" in {
val msg = OutfitMemberEvent(
outfit_id = 529744,
member_id = 41605263,
Unk1()
)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk1
}
}

View file

@ -0,0 +1,51 @@
// Copyright (c) 2023-2025 PSForever
package game
import net.psforever.packet._
import net.psforever.packet.game.OutfitMemberUpdate
import org.specs2.mutable._
import scodec.bits._
class OutfitMemberUpdateTest extends Specification {
val updateRankToOwnerOfOutfitInFormation = hex"91 0100ff7f15aa7a02f0"
val normalRankChange = hex"91 1219000086d9130090"
"decode updateOwnerOfOutfitInFormation" in {
PacketCoding.decodePacket(updateRankToOwnerOfOutfitInFormation).require match {
case OutfitMemberUpdate(outfit_id, char_id, rank, flag) =>
outfit_id mustEqual 2147418113
char_id mustEqual 41593365
rank mustEqual 7
flag mustEqual true
case _ =>
ko
}
}
"encode updateOwnerOfOutfitInFormation" in {
val msg = OutfitMemberUpdate(outfit_id = 2147418113, char_id = 41593365, rank = 7, flag = true)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual updateRankToOwnerOfOutfitInFormation
}
"decode normalRankChange" in {
PacketCoding.decodePacket(normalRankChange).require match {
case OutfitMemberUpdate(outfit_id, char_id, rank, flag) =>
outfit_id mustEqual 6418
char_id mustEqual 1300870
rank mustEqual 4
flag mustEqual true
case _ =>
ko
}
}
"encode normalRankChange" in {
val msg = OutfitMemberUpdate(outfit_id = 6418, char_id = 1300870, rank = 4, flag = true)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual normalRankChange
}
}

View file

@ -1,20 +1,20 @@
// Copyright (c) 2023 PSForever
// Copyright (c) 2023-2025 PSForever
package game
import net.psforever.packet._
import net.psforever.packet.game._
import net.psforever.packet.game.OutfitAction.{AcceptOutfitInvite, CancelOutfitInvite, CreateOutfit, FormOutfit, RejectOutfitInvite}
import net.psforever.packet.game.OutfitMembershipRequest.RequestType
import net.psforever.types.PlanetSideGUID
import net.psforever.packet.game.OutfitMembershipRequest
import net.psforever.packet.game.OutfitMembershipRequestAction._
import org.specs2.mutable._
import scodec.bits._
class OutfitMembershipRequestTest extends Specification {
val create_ABC = hex"8c 0 0200 000 1000 83 410042004300"
val create_2222 = hex"8c 0 1000 000 1000 84 3200320032003200"
val form_abc = hex"8c 2 0200 000 1000 83 610062006300"
val form_1 = hex"8c 2 1000 000 1000 81 3100"
val unk3 = hex"8c 5 bb39 9e0 2000 0000 1080 750072006f006200" // -- "urob" -- could be false positive -- seems to gets an OMSResp -> 0x8d271bb399e025af8f405080550072006f0062008080
val invite_old = hex"8c 5 bb399e0 2000 0000 1140 7600690072007500730067006900760065007200" // -- virusgiver
val unk3 = hex"8c 5 bb399e0 2000 0000 1080 750072006f006200" // -- "urob" -- could be false positive -- seems to gets an OMSResp -> 0x8d271bb399e025af8f405080550072006f0062008080
val accept_1 = hex"8c 6 0200 000 1000"
val accept_2 = hex"8c 6 0400 000 1000"
val reject_1 = hex"8c 8 0200 000 1000"
@ -23,20 +23,24 @@ class OutfitMembershipRequestTest extends Specification {
val cancel_1_abc = hex"8c a 0200 000 0000 0000 1060 610062006300"
val cancel_3_def = hex"8c a 0600 000 0000 0000 1060 640065006600" // /outfitcancel 123 def -- first parameter is skipped
// dumped from half implemented outfit
val invite = hex"8c4020000000000000116069006e00760069007400650054006500730074003100"
val kick = hex"8cc020000017ac8f405000"
val setrank = hex"8ce020000017ac8f404600" // setting rank from 0 to 1
"decode CreateOutfit ABC" in {
PacketCoding.decodePacket(create_ABC).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Create
avatar_id mustEqual PlanetSideGUID(1)
unk1 mustEqual 0
action mustEqual CreateOutfit("", 0, unk4 = false, "ABC")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 1
action mustEqual Create("", "ABC")
case _ =>
ko
}
}
"encode CreateOutfit ABC" in {
val msg = OutfitMembershipRequest(RequestType.Create, PlanetSideGUID(1), 0, CreateOutfit("", 0, unk4 = false, "ABC"))
val msg = OutfitMembershipRequest(1, Create("", "ABC"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual create_ABC
@ -44,18 +48,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode CreateOutfit 2222" in {
PacketCoding.decodePacket(create_2222).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Create
avatar_id mustEqual PlanetSideGUID(8)
unk1 mustEqual 0
action mustEqual CreateOutfit("", 0, unk4 = false, "2222")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 8
action mustEqual Create("", "2222")
case _ =>
ko
}
}
"encode CreateOutfit 2222" in {
val msg = OutfitMembershipRequest(RequestType.Create, PlanetSideGUID(8), 0, CreateOutfit("", 0, unk4 = false, "2222"))
val msg = OutfitMembershipRequest(8, Create("", "2222"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual create_2222
@ -63,18 +65,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode FormOutfit abc" in {
PacketCoding.decodePacket(form_abc).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Form
avatar_id mustEqual PlanetSideGUID(1)
unk1 mustEqual 0
action mustEqual FormOutfit("", 0, unk4 = false, "abc")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 1
action mustEqual Form("", "abc")
case _ =>
ko
}
}
"encode FormOutfit abc" in {
val msg = OutfitMembershipRequest(RequestType.Form, PlanetSideGUID(1), 0, FormOutfit("", 0, unk4 = false, "abc"))
val msg = OutfitMembershipRequest(1, Form("", "abc"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual form_abc
@ -82,37 +82,50 @@ class OutfitMembershipRequestTest extends Specification {
"decode FormOutfit 1" in {
PacketCoding.decodePacket(form_1).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Form
avatar_id mustEqual PlanetSideGUID(8)
unk1 mustEqual 0
action mustEqual FormOutfit("", 0, unk4 = false, "1")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 8
action mustEqual Form("", "1")
case _ =>
ko
}
}
"encode FormOutfit 1" in {
val msg = OutfitMembershipRequest(RequestType.Form, PlanetSideGUID(8), 0, FormOutfit("", 0, unk4 = false, "1"))
val msg = OutfitMembershipRequest(8, Form("", "1"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual form_1
}
"decode Invite" in {
PacketCoding.decodePacket(invite_old).require match {
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 30383325L
action mustEqual Invite(0, "virusgiver")
case _ =>
ko
}
}
"encode Invite" in {
val msg = OutfitMembershipRequest(30383325L, Invite(0, "virusgiver"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual invite_old
}
"decode AcceptOutfitInvite 1" in {
PacketCoding.decodePacket(accept_1).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Accept
avatar_id mustEqual PlanetSideGUID(1)
unk1 mustEqual 0
action mustEqual AcceptOutfitInvite("")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 1
action mustEqual AcceptInvite("")
case _ =>
ko
}
}
"encode AcceptOutfitInvite 1" in {
val msg = OutfitMembershipRequest(RequestType.Accept, PlanetSideGUID(1), 0, AcceptOutfitInvite(""))
val msg = OutfitMembershipRequest(1, AcceptInvite(""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual accept_1
@ -120,18 +133,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode AcceptOutfitInvite 2" in {
PacketCoding.decodePacket(accept_2).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Accept
avatar_id mustEqual PlanetSideGUID(2)
unk1 mustEqual 0
action mustEqual AcceptOutfitInvite("")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 2
action mustEqual AcceptInvite("")
case _ =>
ko
}
}
"encode AcceptOutfitInvite 2" in {
val msg = OutfitMembershipRequest(RequestType.Accept, PlanetSideGUID(2), 0, AcceptOutfitInvite(""))
val msg = OutfitMembershipRequest(2, AcceptInvite(""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual accept_2
@ -139,18 +150,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode RejectOutfitInvite 1" in {
PacketCoding.decodePacket(reject_1).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Reject
avatar_id mustEqual PlanetSideGUID(1)
unk1 mustEqual 0
action mustEqual RejectOutfitInvite("")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 1
action mustEqual RejectInvite("")
case _ =>
ko
}
}
"encode RejectOutfitInvite 1" in {
val msg = OutfitMembershipRequest(RequestType.Reject, PlanetSideGUID(1), 0, RejectOutfitInvite(""))
val msg = OutfitMembershipRequest(1, RejectInvite(""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual reject_1
@ -158,18 +167,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode RejectOutfitInvite 2" in {
PacketCoding.decodePacket(reject_2).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Reject
avatar_id mustEqual PlanetSideGUID(2)
unk1 mustEqual 0
action mustEqual RejectOutfitInvite("")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 2
action mustEqual RejectInvite("")
case _ =>
ko
}
}
"encode RejectOutfitInvite 2" in {
val msg = OutfitMembershipRequest(RequestType.Reject, PlanetSideGUID(2), 0, RejectOutfitInvite(""))
val msg = OutfitMembershipRequest(2, RejectInvite(""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual reject_2
@ -177,18 +184,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode CancelOutfitInvite 3" in {
PacketCoding.decodePacket(cancel_3).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Cancel
avatar_id mustEqual PlanetSideGUID(3)
unk1 mustEqual 0
action mustEqual CancelOutfitInvite(0, 0, "")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 3
action mustEqual CancelInvite(0, "")
case _ =>
ko
}
}
"encode CancelOutfitInvite 3" in {
val msg = OutfitMembershipRequest(RequestType.Cancel, PlanetSideGUID(3), 0, CancelOutfitInvite(0, 0, ""))
val msg = OutfitMembershipRequest(3, CancelInvite(0, ""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual cancel_3
@ -196,18 +201,16 @@ class OutfitMembershipRequestTest extends Specification {
"decode CancelOutfitInvite 1 abc" in {
PacketCoding.decodePacket(cancel_1_abc).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Cancel
avatar_id mustEqual PlanetSideGUID(1)
unk1 mustEqual 0
action mustEqual CancelOutfitInvite(0, 0, "abc")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 1
action mustEqual CancelInvite(0, "abc")
case _ =>
ko
}
}
"encode CancelOutfitInvite 1 abc" in {
val msg = OutfitMembershipRequest(RequestType.Cancel, PlanetSideGUID(1), 0, CancelOutfitInvite(0, 0, "abc"))
val msg = OutfitMembershipRequest(1, CancelInvite(0, "abc"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual cancel_1_abc
@ -215,20 +218,77 @@ class OutfitMembershipRequestTest extends Specification {
"decode CancelOutfitInvite 3 def" in {
PacketCoding.decodePacket(cancel_3_def).require match {
case OutfitMembershipRequest(request_type, avatar_id, unk1, action) =>
request_type mustEqual RequestType.Cancel
avatar_id mustEqual PlanetSideGUID(3)
unk1 mustEqual 0
action mustEqual CancelOutfitInvite(0, 0, "def")
case OutfitMembershipRequest(outfit_id, action) =>
outfit_id mustEqual 3
action mustEqual CancelInvite(0, "def")
case _ =>
ko
}
}
"encode CancelOutfitInvite 3 def" in {
val msg = OutfitMembershipRequest(RequestType.Cancel, PlanetSideGUID(3), 0, CancelOutfitInvite(0, 0, "def"))
val msg = OutfitMembershipRequest(3, CancelInvite(0, "def"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual cancel_3_def
}
//
"decode invite" in {
PacketCoding.decodePacket(invite).require match {
case OutfitMembershipRequest(outfit_id, Invite(unk1, member_name)) =>
outfit_id mustEqual 1
unk1 mustEqual 0
member_name mustEqual "inviteTest1"
case _ =>
ko
}
}
"encode invite" in {
val msg = OutfitMembershipRequest(1, Invite(0, "inviteTest1"))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual invite
}
"decode kick" in {
PacketCoding.decodePacket(kick).require match {
case OutfitMembershipRequest(outfit_id, Kick(avatar_id, member_name)) =>
outfit_id mustEqual 1
avatar_id mustEqual 41575613
member_name mustEqual ""
case _ =>
ko
}
}
"encode kick" in {
val msg = OutfitMembershipRequest(1, Kick(41575613, ""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual kick
}
"decode setrank" in {
PacketCoding.decodePacket(setrank).require match {
case OutfitMembershipRequest(outfit_id, SetRank(avatar_id, rank, member_name)) =>
outfit_id mustEqual 1
avatar_id mustEqual 41575613
rank mustEqual 1
member_name mustEqual ""
case _ =>
ko
}
}
"encode setrank" in {
val msg = OutfitMembershipRequest(1, SetRank(41575613, 1, ""))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual setrank
}
}

View file

@ -0,0 +1,156 @@
// Copyright (c) 2023-2025 PSForever
package game
import net.psforever.packet._
import net.psforever.packet.game.OutfitMembershipResponse
import net.psforever.packet.game.OutfitMembershipResponse.PacketType
import org.specs2.mutable._
import scodec.bits._
class OutfitMembershipResponseTest extends Specification {
val createResponse = hex"8d 0 002b54f404000000010008080"
val unk1 = hex"8d 2 01bb399e03ddb4f4050a078004e00690063006b009550006c0061006e006500740053006900640065005f0046006f00720065007600650072005f005400520000"
val unk2 = hex"8d 4 0049b0f4042b54f4051405a006500720067006c0069006e006700390032009750006c0061006e006500740053006900640065005f0046006f00720065007600650072005f00560061006e00750000"
val unk3 = hex"8d 6 00e8c2f40510d3b6030008080"
val unk4 = hex"8d 8 002b54f404000000010008080"
val unk5 = hex"8d a 022b54f4051fb0f4051c05000530046006f0075007400660069007400740065007300740031008080"
"decode CreateResponse" in {
PacketCoding.decodePacket(createResponse).require match {
case OutfitMembershipResponse(packet_type, unk0, unk1, outfit_id, target_id, str1, str2, flag) =>
packet_type mustEqual PacketType.CreateResponse
unk0 mustEqual 0
unk1 mustEqual 0
outfit_id mustEqual 41593365
target_id mustEqual 0
str1 mustEqual ""
str2 mustEqual ""
flag mustEqual true
case _ =>
ko
}
}
"encode CreateResponse" in {
val msg = OutfitMembershipResponse(PacketType.CreateResponse, 0, 0, 41593365, 0, "", "", flag = true)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual createResponse
}
"decode unk1" in {
PacketCoding.decodePacket(unk1).require match {
case OutfitMembershipResponse(packet_type, unk0, unk1, outfit_id, target_id, str1, str2, flag) =>
packet_type mustEqual PacketType.Invite
unk0 mustEqual 0
unk1 mustEqual 0
outfit_id mustEqual 30383325
target_id mustEqual 41605870
str1 mustEqual "xNick"
str2 mustEqual "PlanetSide_Forever_TR"
flag mustEqual false
case _ =>
ko
}
}
"encode unk1" in {
val msg = OutfitMembershipResponse(PacketType.Invite, 0, 0, 30383325, 41605870, "xNick", "PlanetSide_Forever_TR", flag = false)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk1
}
"decode unk2" in {
PacketCoding.decodePacket(unk2).require match {
case OutfitMembershipResponse(packet_type, unk0, unk1, outfit_id, target_id, str1, str2, flag) =>
packet_type mustEqual PacketType.InviteAccepted
unk0 mustEqual 0
unk1 mustEqual 0
outfit_id mustEqual 41605156
target_id mustEqual 41593365
str1 mustEqual "Zergling92"
str2 mustEqual "PlanetSide_Forever_Vanu"
flag mustEqual false
case _ =>
ko
}
}
"encode unk2" in {
val msg = OutfitMembershipResponse(PacketType.InviteAccepted, 0, 0, 41605156, 41593365, "Zergling92", "PlanetSide_Forever_Vanu", flag = false)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk2
}
"decode unk3" in {
PacketCoding.decodePacket(unk3).require match {
case OutfitMembershipResponse(packet_type, unk0, unk1, outfit_id, target_id, str1, str2, flag) =>
packet_type mustEqual PacketType.InviteRejected
unk0 mustEqual 0
unk1 mustEqual 0
outfit_id mustEqual 41574772
target_id mustEqual 31156616
str1 mustEqual ""
str2 mustEqual ""
flag mustEqual true
case _ =>
ko
}
}
"encode unk3" in {
val msg = OutfitMembershipResponse(PacketType.InviteRejected, 0, 0, 41574772, 31156616, "", "", flag = true)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk3
}
"decode unk4" in {
PacketCoding.decodePacket(unk4).require match {
case OutfitMembershipResponse(packet_type, unk0, unk1, outfit_id, target_id, str1, str2, flag) =>
packet_type mustEqual PacketType.Unk4
unk0 mustEqual 0
unk1 mustEqual 0
outfit_id mustEqual 41593365
target_id mustEqual 0
str1 mustEqual ""
str2 mustEqual ""
flag mustEqual true
case _ =>
ko
}
}
"encode unk4" in {
val msg = OutfitMembershipResponse(PacketType.Unk4, 0, 0, 41593365, 0, "", "", flag = true)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk4
}
"decode unk5" in {
PacketCoding.decodePacket(unk5).require match {
case OutfitMembershipResponse(packet_type, unk0, unk1, outfit_id, target_id, str1, str2, flag) =>
packet_type mustEqual PacketType.Kick
unk0 mustEqual 0
unk1 mustEqual 1
outfit_id mustEqual 41593365
target_id mustEqual 41605263
str1 mustEqual "PSFoutfittest1"
str2 mustEqual ""
flag mustEqual true
case _ =>
ko
}
}
"encode unk5" in {
val msg = OutfitMembershipResponse(PacketType.Kick, 0, 1, 41593365, 41605263, "PSFoutfittest1", "", flag = true)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual unk5
}
}

View file

@ -1,103 +0,0 @@
// Copyright (c) 2023 PSForever
package game
import org.specs2.mutable._
import net.psforever.packet._
import net.psforever.packet.game._
import net.psforever.types.PlanetSideGUID
import scodec.bits._
class OutfitRequestTest extends Specification {
val string0 = hex"8e02b54f40401780560061006e00750020006f0075007400660069007400200066006f0072002000740068006500200070006c0061006e00650074007300690064006500200066006f00720065007600650072002000700072006f006a006500630074002100200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002d00660069006e00640020006f007500740020006d006f00720065002000610062006f0075007400200074006800650020005000530045004d0055002000700072006f006a0065006300740020006100740020005000530066006f00720065007600650072002e006e0065007400"
val string2 = hex"8e22b54f405800c000c000c000c000c000c000c000"
val string4 = hex"8e42b54f404aa0" //faked by modifying the previous example
val string6 = hex"8e649e822010"
val string8 = hex"8e81b2cf4050"
"decode 0" in {
PacketCoding.decodePacket(string0).require match {
case OutfitRequest(id, OutfitRequestForm.Unk0(str)) =>
id mustEqual 41593365L
str mustEqual "Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net"
case _ =>
ko
}
}
"decode 1" in {
PacketCoding.decodePacket(string2).require match {
case OutfitRequest(id, OutfitRequestForm.Unk1(list)) =>
id mustEqual 41593365L
list mustEqual List(Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""))
case _ =>
ko
}
}
"decode 2 (fake)" in {
PacketCoding.decodePacket(string4).require match {
case OutfitRequest(id, OutfitRequestForm.Unk2(value)) =>
id mustEqual 41593365L
value mustEqual 85
case _ =>
ko
}
}
"decode 3" in {
PacketCoding.decodePacket(string6).require match {
case OutfitRequest(id, OutfitRequestForm.Unk3(value)) =>
id mustEqual 1176612L
value mustEqual true
case _ =>
ko
}
}
"decode 4" in {
PacketCoding.decodePacket(string8).require match {
case OutfitRequest(id, OutfitRequestForm.Unk4(value)) =>
id mustEqual 41588237L
value mustEqual true
case _ =>
ko
}
}
"encode 0" in {
val msg = OutfitRequest(41593365L, OutfitRequestForm.Unk0(
"Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net"
))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string0
}
"encode 1" in {
val msg = OutfitRequest(41593365L, OutfitRequestForm.Unk1(List(Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""))))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string2
}
"encode 2 (fake)" in {
val msg = OutfitRequest(41593365L, OutfitRequestForm.Unk2(85))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string4
}
"encode 3" in {
val msg = OutfitRequest(1176612L, OutfitRequestForm.Unk3(true))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string6
}
"encode 4" in {
val msg = OutfitRequest(41588237L, OutfitRequestForm.Unk4(true))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string8
}
}

View file

@ -0,0 +1,103 @@
// Copyright (c) 2023-2025 PSForever
package game
import org.specs2.mutable._
import net.psforever.packet._
import net.psforever.packet.game.{OutfitRequest, OutfitRequestAction}
import scodec.bits._
class OutfitRequestTest extends Specification {
val setMotd = hex"8e 02b54f40401780560061006e00750020006f0075007400660069007400200066006f0072002000740068006500200070006c0061006e00650074007300690064006500200066006f00720065007600650072002000700072006f006a006500630074002100200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002000200020002d00660069006e00640020006f007500740020006d006f00720065002000610062006f0075007400200074006800650020005000530045004d0055002000700072006f006a0065006300740020006100740020005000530066006f00720065007600650072002e006e0065007400"
val setRanks = hex"8e 22b54f405800c000c000c000c000c000c000c000"
val string4 = hex"8e 42b54f404aa0" //faked by modifying the previous example
val string6 = hex"8e 649e822010"
val string8 = hex"8e 81b2cf4050"
"decode Motd" in {
PacketCoding.decodePacket(setMotd).require match {
case OutfitRequest(id, OutfitRequestAction.Motd(str)) =>
id mustEqual 41593365L
str mustEqual "Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net"
case _ =>
ko
}
}
"decode Ranks" in {
PacketCoding.decodePacket(setRanks).require match {
case OutfitRequest(id, OutfitRequestAction.Ranks(list)) =>
id mustEqual 41593365L
list mustEqual List(Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""))
case _ =>
ko
}
}
"decode Unk2 (fake)" in {
PacketCoding.decodePacket(string4).require match {
case OutfitRequest(id, OutfitRequestAction.Unk2(value)) =>
id mustEqual 41593365L
value mustEqual 85
case _ =>
ko
}
}
"decode Unk3" in {
PacketCoding.decodePacket(string6).require match {
case OutfitRequest(id, OutfitRequestAction.Unk3(value)) =>
id mustEqual 1176612L
value mustEqual true
case _ =>
ko
}
}
"decode Unk4" in {
PacketCoding.decodePacket(string8).require match {
case OutfitRequest(id, OutfitRequestAction.Unk4(value)) =>
id mustEqual 41588237L
value mustEqual true
case _ =>
ko
}
}
"encode Motd" in {
val msg = OutfitRequest(41593365L, OutfitRequestAction.Motd(
"Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net"
))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual setMotd
}
"encode Ranks" in {
val msg = OutfitRequest(41593365L, OutfitRequestAction.Ranks(List(Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""), Some(""))))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual setRanks
}
"encode Unk2 (fake)" in {
val msg = OutfitRequest(41593365L, OutfitRequestAction.Unk2(85))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string4
}
"encode Unk3" in {
val msg = OutfitRequest(1176612L, OutfitRequestAction.Unk3(true))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string6
}
"encode Unk4" in {
val msg = OutfitRequest(41588237L, OutfitRequestAction.Unk4(true))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string8
}
}

View file

@ -1,4 +1,4 @@
// Copyright (c) 2019 PSForever
// Copyright (c) 2019-2025 PSForever
package game
import net.psforever.packet._
@ -11,14 +11,14 @@ class SquadMemberEventTest extends Specification {
"decode" in {
PacketCoding.decodePacket(string).require match {
case SquadMemberEvent(u1, u2, u3, u4, u5, u6, u7) =>
u1 mustEqual MemberEvent.Add
case SquadMemberEvent(event, u2, char_id, position, player_name, zone_number, outfit_id) =>
event mustEqual MemberEvent.Add
u2 mustEqual 7
u3 mustEqual 42771010L
u4 mustEqual 0
u5.contains("HofD") mustEqual true
u6.contains(7) mustEqual true
u7.contains(529745L) mustEqual true
char_id mustEqual 42771010L
position mustEqual 0
player_name.contains("HofD") mustEqual true
zone_number.contains(7) mustEqual true
outfit_id.contains(529745L) mustEqual true
case _ =>
ko
}