diff --git a/server/src/main/resources/db/migration/V015__OutfitStructure.sql b/server/src/main/resources/db/migration/V015__OutfitStructure.sql new file mode 100644 index 00000000..7a0a1186 --- /dev/null +++ b/server/src/main/resources/db/migration/V015__OutfitStructure.sql @@ -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"); diff --git a/src/main/scala/net/psforever/actors/session/AvatarActor.scala b/src/main/scala/net/psforever/actors/session/AvatarActor.scala index 12291c4a..68ffb98d 100644 --- a/src/main/scala/net/psforever/actors/session/AvatarActor.scala +++ b/src/main/scala/net/psforever/actors/session/AvatarActor.scala @@ -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 @@ -1737,6 +1755,12 @@ class AvatarActor( case AwardCep(cep) => if (experienceDebt == 0L) { setCep(avatar.cep + cep) + if (session.get.player.outfit_id != 0) { + setOutfitPoints(avatar.id.toLong, cep * 2).onComplete { + case Success(_) => + case Failure(exception) => log.error(exception)("db failure") + } + } } else if (cep > 0) { sessionActor ! SessionActor.SendResponse(ExperienceAddedMessage()) } @@ -2994,6 +3018,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") } diff --git a/src/main/scala/net/psforever/actors/session/SessionActor.scala b/src/main/scala/net/psforever/actors/session/SessionActor.scala index 25b5c377..21093f14 100644 --- a/src/main/scala/net/psforever/actors/session/SessionActor.scala +++ b/src/main/scala/net/psforever/actors/session/SessionActor.scala @@ -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") diff --git a/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala index 2cd1782e..a920f4fe 100644 --- a/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/csr/GeneralLogic.scala @@ -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, Initial, 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.OutfitWindowOpen(true)) => + + case OutfitRequest(_, OutfitRequestAction.OutfitWindowOpen(false)) => + + case _ => + } + } /* messages */ def handleRenewCharSavedTimer(): Unit = { /* */ } diff --git a/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala b/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala index e9936cda..fdf8ed17 100644 --- a/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/AvatarHandlerLogic.scala @@ -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)) @@ -476,6 +479,12 @@ class AvatarHandlerLogic(val ops: SessionAvatarHandlers, implicit val context: A case AvatarResponse.ShareKillExperienceWithSquad(killer, exp) => ops.shareKillExperienceWithSquad(killer, exp) + case AvatarResponse.ShareAntExperienceWithSquad(owner, exp, vehicle) => + ops.shareAntExperienceWithSquad(owner, exp, vehicle) + + case AvatarResponse.RemoveFromOutfitChat(outfit_id) => + ops.removeFromOutfitChat(outfit_id) + case AvatarResponse.SendResponse(msg) => sendResponse(msg) diff --git a/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala b/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala index 0d0cd12d..55206318 100644 --- a/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/ChatLogic.scala @@ -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 => diff --git a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala index 608f06d0..a5156537 100644 --- a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala @@ -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,14 @@ 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.{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, 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.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 +797,65 @@ 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)) => + if (player.outfit_id != 0) { + 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, _)) => + if (player.outfit_id != 0) { + 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))) => + SessionOutfitHandlers.HandleOutfitRank(zones, List(r1, r2, r3, r4, r5, r6, r7, r8), player) + + case OutfitRequest(_, OutfitRequestAction.OutfitWindowOpen(true)) => + player.outfit_window_open = true + SessionOutfitHandlers.HandleViewOutfitWindow(zones, player, player.outfit_id) + + case OutfitRequest(_, OutfitRequestAction.OutfitWindowOpen(false)) => + player.outfit_window_open = false + + case OutfitRequest(_, OutfitRequestAction.OutfitListWindowOpen(true)) => + player.outfit_list_open = true + SessionOutfitHandlers.HandleGetOutfitList(player) + + case OutfitRequest(_, OutfitRequestAction.OutfitListWindowOpen(false)) => + player.outfit_list_open = false + + case _ => + } + } + /* messages */ def handleRenewCharSavedTimer(): Unit = { diff --git a/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala index e1f6415f..8c332585 100644 --- a/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/spectator/GeneralLogic.scala @@ -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 */ } diff --git a/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala b/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala index 1378dfe2..deda57ab 100644 --- a/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ChatOperations.scala @@ -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) diff --git a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala index e72e57a5..9f5ce393 100644 --- a/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/GeneralOperations.scala @@ -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( diff --git a/src/main/scala/net/psforever/actors/session/support/OutfitInvites.scala b/src/main/scala/net/psforever/actors/session/support/OutfitInvites.scala new file mode 100644 index 00000000..5465cee5 --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/support/OutfitInvites.scala @@ -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 +} + diff --git a/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala b/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala index c00b4a83..9c54321d 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionAvatarHandlers.scala @@ -3,12 +3,13 @@ package net.psforever.actors.session.support import akka.actor.{ActorContext, typed} import net.psforever.objects.serverobject.mount.Mountable -import net.psforever.objects.{Default, PlanetSideGameObject, Player} -import net.psforever.objects.sourcing.{PlayerSource, SourceEntry} +import net.psforever.objects.{Default, PlanetSideGameObject, Player, Vehicle} +import net.psforever.objects.sourcing.{PlayerSource, SourceEntry, UniquePlayer} import net.psforever.packet.game.objectcreate.ConstructorData import net.psforever.objects.zones.exp import net.psforever.services.Service import net.psforever.services.avatar.{AvatarAction, AvatarServiceMessage, AvatarServiceResponse} +import net.psforever.services.chat.OutfitChannel import scala.collection.mutable // @@ -145,6 +146,21 @@ class SessionAvatarHandlers( } } + def shareAntExperienceWithSquad(driver: UniquePlayer, exp: Long, vehicle: Vehicle): Unit = { + val squadUI = sessionLogic.squad.squadUI + val squadSize = squadUI.size + if (squadSize > 1) { + val squadMembers = squadUI.filterNot(_._1 == driver.charId).map { case (_, member) => member }.toList.map(_.name) + val playersInZone = vehicle.Zone.Players.map { avatar => (avatar.id, avatar.basic.name) } + val squadMembersHere = playersInZone.filter(member => squadMembers.contains(member._2)) + squadMembersHere.foreach { member => + vehicle.Zone.AvatarEvents ! AvatarServiceMessage( + member._2, + AvatarAction.AwardBep(member._1, exp, ExperienceType.Normal)) + } + } + } + /** * Properly format a `DestroyDisplayMessage` packet * given sufficient information about a target (victim) and an actor (killer). @@ -222,6 +238,10 @@ class SessionAvatarHandlers( } player.VehicleSeated = None } + + def removeFromOutfitChat(outfit_id: Long): Unit = { + sessionLogic.chat.LeaveChannel(OutfitChannel(outfit_id)) + } } object SessionAvatarHandlers { diff --git a/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala b/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala new file mode 100644 index 00000000..a3bbbb0a --- /dev/null +++ b/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala @@ -0,0 +1,874 @@ +// Copyright (c) 2025 PSForever +package net.psforever.actors.session.support + +import io.getquill.{Action, ActionReturning, EntityQuery, Insert, PostgresJAsyncContext, Query, Quoted, SnakeCase} +import net.psforever.objects.avatar.PlayerControl +import net.psforever.objects.zones.Zone +import net.psforever.objects.Player +import net.psforever.packet.game.OutfitEventAction.{Initial, Leaving, OutfitInfo, OutfitRankNames, Unk1, Update, UpdateMemberCount} +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 java.util.concurrent.Executors +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, Update( + 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, UpdateMemberCount(memberCount))) + + PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name, + OutfitMemberEvent(outfitId, invited.CharId, + OutfitMemberEventAction.Update(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, Initial(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) { + + // store outfit_id since it will be nulled soon + val outfit_id = kickedBy.outfit_id + + removeMemberFromOutfit(outfit_id, kickedId).map { + case (deleted, _) => + if (deleted > 0) { + + PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, + OutfitEvent(outfit_id, Leaving()) + ) + + session.chat.LeaveChannel(OutfitChannel(outfit_id)) + kickedBy.outfit_name = "" + kickedBy.outfit_id = 0 + + zones.filter(z => z.AllPlayers.nonEmpty).flatMap(_.AllPlayers) + .filter(p => p.outfit_id == outfit_id).foreach(outfitMember => + PlayerControl.sendResponse(outfitMember.Zone, outfitMember.Name, + OutfitMemberEvent(outfit_id, kickedId, OutfitMemberEventAction.Kicked())) + ) + + 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, + OutfitEvent(kickedBy.outfit_id, Leaving()) + ) + + PlayerControl.sendResponse(kicked.Zone, kicked.Name, + OutfitMembershipResponse(OutfitMembershipResponse.PacketType.YouGotKicked, 0, 1, + kickedBy.CharId, kicked.CharId, kickedBy.Name, kicked.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.Zone.AvatarEvents ! AvatarServiceMessage( + kicked.Name, AvatarAction.RemoveFromOutfitChat(kickedBy.outfit_id)) + + kicked.outfit_id = 0 + kicked.outfit_name = "" + PlayerControl.sendResponse(kicked.Zone, kicked.Name, + OutfitMemberEvent(kickedBy.outfit_id, kickedId, OutfitMemberEventAction.Kicked())) + } + 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.YouKicked, 0, 1, kickedBy.CharId, kickedId, name, "", flag = true)) + + case None => PlayerControl.sendResponse(kickedBy.Zone, kickedBy.Name, + OutfitMembershipResponse(OutfitMembershipResponse.PacketType.YouKicked, 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.Kicked())) + ) + // 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.Update(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.Update(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, Initial(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.Update( + 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, + Update( + 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) + .filter(_.outfit_window_open) + .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 HandleOutfitRank(zones: Seq[Zone], list: List[Option[String]], player: Player): Unit = { + + val outfit_id = player.outfit_id + + val outfitDetails = for { + _ <- updateOutfitRanks(outfit_id, list) + 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 with window open + val outfit_event = OutfitEvent( + outfit_id, + Update( + 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) + .filter(_.outfit_window_open) + .foreach(player => { + PlayerControl.sendResponse( + zone, player.Name, + outfit_event + ) + }) + }) + } + } + } + + private var outfitPointSheduleStarted = false + + def HandleLoginOutfitCheck(player: Player, session: SessionData): Unit = { + + // TODO: implement this the proper way, please + // start the shedule on first run of the function + if (!outfitPointSheduleStarted) { + outfitPointSheduleStarted = true + + Executors.newSingleThreadScheduledExecutor.scheduleAtFixedRate( + () => { + ctx.run(updateOutfitPointMV()) + }, + 0, + 5, + java.util.concurrent.TimeUnit.MINUTES + ) + } + + 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, Update(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[io.getquill.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[io.getquill.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[io.getquill.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 () + } + } + + def updateOutfitRanksById(outfit_id: Long, list: List[Option[String]]): Quoted[io.getquill.Update[Outfit]] = { + + // Normalize: turn empty strings into None + val normalized = list.map { + case Some(value) if value.trim.nonEmpty => Some(value) + case _ => None + } + + // Colorize: prepend \ in front of # if it is the fist character + val colorized = normalized.map { + case Some(s) if s.startsWith("#") => Some("\\" + s) + case other => other + } + + query[Outfit] + .filter(_.id == lift(outfit_id)) + .update( + _.rank0 -> lift(colorized(0)), + _.rank1 -> lift(colorized(1)), + _.rank2 -> lift(colorized(2)), + _.rank3 -> lift(colorized(3)), + _.rank4 -> lift(colorized(4)), + _.rank5 -> lift(colorized(5)), + _.rank6 -> lift(colorized(6)), + _.rank7 -> lift(colorized(7)) + ) + } + + def updateOutfitRanks(outfit_id: Long, list: List[Option[String]]): Future[Unit] = { + ctx.transaction { _ => + for { + _ <- ctx.run(updateOutfitRanksById(outfit_id, list)) + } yield () + } + } + + def updateOutfitPointMV(): Quoted[Action[Unit]] = quote( + infix"REFRESH MATERIALIZED VIEW CONCURRENTLY outfitpoint_mv".as[Action[Unit]] + ) +} diff --git a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala index f9dd3a06..f3075947 100644 --- a/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala +++ b/src/main/scala/net/psforever/actors/session/support/ZoningOperations.scala @@ -2532,6 +2532,9 @@ class ZoningOperations( sessionLogic.general.toggleTeleportSystem(obj, TelepadLike.AppraiseTeleportationSystem(obj, continent)) } } + if (player.outfit_id == 0) { + SessionOutfitHandlers.HandleLoginOutfitCheck(player, sessionLogic) + } //make weather happen sendResponse(WeatherMessage(List(),List( StormInfo(Vector3(0.1f, 0.15f, 0.0f), 240, 217), @@ -2655,6 +2658,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 +3200,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? diff --git a/src/main/scala/net/psforever/objects/Player.scala b/src/main/scala/net/psforever/objects/Player.scala index fd00fb54..3cdb1b47 100644 --- a/src/main/scala/net/psforever/objects/Player.scala +++ b/src/main/scala/net/psforever/objects/Player.scala @@ -85,6 +85,10 @@ 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 + var outfit_window_open: Boolean = false + var outfit_list_open: Boolean = false /** From PlanetsideAttributeMessage */ var PlanetsideAttribute: Array[Long] = Array.ofDim(120) @@ -646,6 +650,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 diff --git a/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala b/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala index 1d2f16fe..ea96ad0e 100644 --- a/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala +++ b/src/main/scala/net/psforever/objects/definition/converter/AvatarConverter.scala @@ -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, diff --git a/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala b/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala index a892e21b..ab4d3499 100644 --- a/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala +++ b/src/main/scala/net/psforever/objects/serverobject/resourcesilo/ResourceSiloControl.scala @@ -197,6 +197,8 @@ class ResourceSiloControl(resourceSilo: ResourceSilo) owner.name, AvatarAction.AwardBep(owner.charId, deposit, ExperienceType.Normal) ) + vehicle.Zone.AvatarEvents ! AvatarServiceMessage( + owner.name, AvatarAction.ShareAntExperienceWithSquad(owner, deposit, vehicle)) zones.exp.ToDatabase.reportNtuActivity(owner.charId, resourceSilo.Zone.Number, resourceSilo.Owner.GUID.guid, deposit) } } diff --git a/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala b/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala index fdd519ec..fcd7e619 100644 --- a/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala +++ b/src/main/scala/net/psforever/objects/serverobject/structures/participation/MajorFacilityHackParticipation.scala @@ -121,12 +121,25 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci } else { (defenderFaction, attackingFaction, socketOpt.nonEmpty, None) } - val (contributionVictor, contributionOpposing, _) = { - val (a, b1) = playerContribution.partition { case (_, (p, _, _)) => p.Faction == victorFaction } - val (b, c) = b1.partition { case (_, (p, _, _)) => p.Faction == opposingFaction } - (a.values, b.values, c.values) + val (contributionVictor, contributionOpposing) = { + val (a, b) = playerContribution.partition { case (_, (p, _, _)) => p.Faction == victorFaction } + //val (b, c) = b1.partition { case (_, (p, _, _)) => p.Faction == opposingFaction } + (a.values, b.values) } val contributionVictorSize = contributionVictor.size + + //sometimes attackingFaction and defenderFaction are the same *shrug* + val failSafeOpposingFaction = + contributionOpposing + .map { case (p, _, _) => p.Faction } + .groupBy(identity) + .view.mapValues(_.size) + .toSeq + .sortBy(-_._2) + .headOption + .map(_._1) + .getOrElse(PlanetSideEmpire.NEUTRAL) + if (contributionVictorSize > 0) { //setup for ... val populationIndices = playerPopulationOverTime.indices @@ -137,7 +150,7 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci val individualPopulationByLayer = allFactions.map { f => (f, populationIndices.indices.map { i => playerPopulationOverTime(i)(f) }) }.toMap[PlanetSideEmpire.Value, Seq[Int]] - (individualPopulationByLayer(victorFaction), individualPopulationByLayer(opposingFaction)) + (individualPopulationByLayer(victorFaction), individualPopulationByLayer(failSafeOpposingFaction)) } val contributionOpposingSize = contributionOpposing.size val killsByPlayersNotInTower = eliminateClosestTowerFromParticipating( @@ -147,7 +160,7 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci building.Definition.SOIRadius.toFloat, hackStart, completionTime, - opposingFaction, + failSafeOpposingFaction, contributionVictor ) ) @@ -215,7 +228,7 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci } val heatMapModifier = FacilityHackParticipation.heatMapComparison( FacilityHackParticipation.diffHeatForFactionMap(regionHeatMapProgression, victorFaction).values, - FacilityHackParticipation.diffHeatForFactionMap(regionHeatMapProgression, opposingFaction).values + FacilityHackParticipation.diffHeatForFactionMap(regionHeatMapProgression, failSafeOpposingFaction).values ) heatMapModifier * populationBalanceModifier } @@ -275,13 +288,13 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci Config.app.game.experience.cep.rate + competitionBonus ).toLong //8. reward participants that are still in the zone - val hackerId = hacker.CharId + //val hackerId = hacker.CharId val contributingPlayers = contributionVictor .filter { case (player, _, _) => player.Zone.id == building.Zone.id } .map { case (player, _, _) => player } .toList //terminal hacker (always cep) - if (contributingPlayers.exists(_.CharId == hackerId) && flagCarrier.map(_.CharId).getOrElse(0L) != hackerId) { + /*if (contributingPlayers.exists(_.CharId == hackerId) && flagCarrier.map(_.CharId).getOrElse(0L) != hackerId) { ToDatabase.reportFacilityCapture( hackerId, zoneNumber, @@ -290,10 +303,10 @@ final case class MajorFacilityHackParticipation(building: Building) extends Faci expType = "cep" ) events ! AvatarServiceMessage(hacker.Name, AvatarAction.AwardCep(hackerId, finalCep)) - } + }*/ //bystanders (cep if squad leader, bep otherwise) contributingPlayers - .filterNot { _.CharId == hackerId } + //.filterNot { _.CharId == hackerId } .foreach { player => val charId = player.CharId val contributionMultiplier = contributionPerPlayerByTime.getOrElse(charId, 1f) diff --git a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala index 6bc06e6d..0253d2b8 100644 --- a/src/main/scala/net/psforever/packet/GamePacketOpcode.scala +++ b/src/main/scala/net/psforever/packet/GamePacketOpcode.scala @@ -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) diff --git a/src/main/scala/net/psforever/packet/game/OutfitEvent.scala b/src/main/scala/net/psforever/packet/game/OutfitEvent.scala new file mode 100644 index 00000000..1732e4c8 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/OutfitEvent.scala @@ -0,0 +1,292 @@ +// 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, + ) + + /** + * Initial + * + * Send at the start of an OutfitWindow info dump. + * + * Not always complete, seen as an initialization after login, join or while outfit is in formation. + * @param outfit_info + */ + final case class Initial( + outfit_info: OutfitInfo + ) extends OutfitEventAction(code = 0) + + final case class Unk1( + ) extends OutfitEventAction(code = 1) + + /** + * Update + * + * Send after changing outfit Ranks, MOTD and other situations. + * @param outfit_info + */ + final case class Update( + outfit_info: OutfitInfo, + ) extends OutfitEventAction(code = 2) + + /** + * Send to players to tell them they left the outfit. + * + * Resets them to behave like they have no outfit. + * Will have them open the OutfitListWindow instead of the OutfitWindow. + */ + final case class Leaving( + ) extends OutfitEventAction(code = 3) + + /** + * Used to switch from the temporary "invalid" outfit ID used while formation to a valid ID used from that point on. + * @param new_outfit_id the new ID that represents this specific outfit in the DB + */ + final case class UpdateOutfitId( + new_outfit_id: Long, + ) extends OutfitEventAction(code = 4) + + /** + * Used to tell outfit members that the member count changed. + * Send after InviteAccept or Kick actions + * @param member_count + */ + final case class UpdateMemberCount( + member_count: 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[Initial] = ( + ("outfit_info" | InfoCodec) + ).xmap[Initial]( + { + case info => + Initial(info) + }, + { + case Initial(info) => + info + } + ) + + val Unk1Codec: Codec[Unk1] = PacketHelpers.emptyCodec(Unk1()) + + val Unk2Codec: Codec[Update] = ( + ("outfit_info" | InfoCodec) + ).xmap[Update]( + { + case info => + Update(info) + }, + { + case Update(info) => + info + } + ) + + val Unk3Codec: Codec[Leaving] = PacketHelpers.emptyCodec(Leaving()) + + 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 UpdateMemberCountCodec: Codec[UpdateMemberCount] = ( + ("" | uint32L) + ).xmap[UpdateMemberCount]( + { + case u1 => + UpdateMemberCount(u1) + }, + { + case UpdateMemberCount(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 UpdateMemberCount: 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 => UpdateMemberCountCodec + 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 + } + ) +} diff --git a/src/main/scala/net/psforever/packet/game/OutfitListEvent.scala b/src/main/scala/net/psforever/packet/game/OutfitListEvent.scala new file mode 100644 index 00000000..acd9ed99 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/OutfitListEvent.scala @@ -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 + } + ) +} diff --git a/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala b/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala new file mode 100644 index 00000000..a268a6d1 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/OutfitMemberEvent.scala @@ -0,0 +1,160 @@ +// 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)) + + } + + /** + * + * Update + * + * Update is used to inform outfit members about a new member. + * Gets send after an InviteAccept or Rank changes. + * + * @param member_name + * @param rank + * @param points client divides this by 100 + * @param last_online seconds ago from current time, 0 if online + * @param action should always be 1, otherwise there will be actual data in padding. not implemented! + * @param padding should always be 0, 4 bits of padding // only contains data if action is 0 + */ + final case class Update( + member_name: String, + rank: Int, + points: Long, + last_online: Long, + action: PacketType.Type, + padding: Int + ) extends OutfitMemberEventAction(code = 0) + + final case class Kicked( + ) 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 UpdateCodec: Codec[Update] = ( + ("member_name" | PacketHelpers.encodedWideStringAligned(6)) :: + ("rank" | uint(3)) :: + ("points" | uint32L) :: + ("last_login" | uint32L) :: + ("action" | OutfitMemberEventAction.PacketType.codec) :: + ("padding" | uint4L) + ).xmap[Update]( + { + case member_name :: rank :: points :: last_login :: action :: padding :: HNil => + Update(member_name, rank, points, last_login, action, padding) + }, + { + case Update(member_name, rank, points, last_login, action, padding) => + member_name :: rank :: points :: last_login :: action :: padding :: HNil + } + ) + + val KickedCodec: Codec[Kicked] = PacketHelpers.emptyCodec(Kicked()) + + /** + * 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 Update: PacketType.Value = Value(0) + val Kicked: PacketType.Value = Value(1) + 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 => UpdateCodec + case 1 => KickedCodec + 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 + } + ) +} diff --git a/src/main/scala/net/psforever/packet/game/OutfitMemberUpdate.scala b/src/main/scala/net/psforever/packet/game/OutfitMemberUpdate.scala new file mode 100644 index 00000000..979d26f9 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/OutfitMemberUpdate.scala @@ -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 + } + ) +} diff --git a/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala b/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala index 55989fc6..8b12ec5b 100644 --- a/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala +++ b/src/main/scala/net/psforever/packet/game/OutfitMembershipRequest.scala @@ -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 + requester_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( + target_id: Long, + target_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( + target_id: Long, + target_name: String, + ) extends OutfitMembershipRequestAction(code = 5) + + final case class Kick( + target_id: Long, + target_name: String, + ) extends OutfitMembershipRequestAction(code = 6) + + final case class SetRank( + target_id: Long, + rank: Int, + target_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 } ) } diff --git a/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala b/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala new file mode 100644 index 00000000..c38f1ba9 --- /dev/null +++ b/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala @@ -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, + requester_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 YouGotKicked: PacketType.Value = Value(4) + val YouKicked: 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 + } + ) +} diff --git a/src/main/scala/net/psforever/packet/game/OutfitRequest.scala b/src/main/scala/net/psforever/packet/game/OutfitRequest.scala index 05ebf66a..5520d98b 100644 --- a/src/main/scala/net/psforever/packet/game/OutfitRequest.scala +++ b/src/main/scala/net/psforever/packet/game/OutfitRequest.scala @@ -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( + requester_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 OutfitWindowOpen(menuOpen: Boolean) extends OutfitRequestAction(code = 3) + + /** + * na + * @param unk na + */ + final case class OutfitListWindowOpen(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,74 +104,86 @@ 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 OutfitWindowOpenCodec: Codec[OutfitRequestAction] = bool.hlist.xmap[OutfitRequestAction] ( { - case value :: HNil => OutfitRequestForm.Unk3(value) + case value :: HNil => OutfitRequestAction.OutfitWindowOpen(value) }, { - case OutfitRequestForm.Unk3(value) => value :: HNil + case OutfitRequestAction.OutfitWindowOpen(value) => value :: HNil } ) /** * na */ - private val unk4Codec: Codec[OutfitRequestForm] = bool.hlist.xmap[OutfitRequestForm] ( + private val OutfitListWindowOpenCodec: Codec[OutfitRequestAction] = bool.hlist.xmap[OutfitRequestAction] ( { - case value :: HNil => OutfitRequestForm.Unk4(value) + case value :: HNil => OutfitRequestAction.OutfitListWindowOpen(value) }, { - case OutfitRequestForm.Unk4(value) => value :: HNil + case OutfitRequestAction.OutfitListWindowOpen(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 OutfitWindowOpen: PacketType.Value = Value(3) + val OutfitListWindowOpen: 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 + case 3 => OutfitWindowOpenCodec + case 4 => OutfitListWindowOpenCodec case _ => failCodec(code) } } 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 } ) } diff --git a/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala b/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala index 69117ced..d3fb49a4 100644 --- a/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala +++ b/src/main/scala/net/psforever/packet/game/SquadMemberEvent.scala @@ -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( diff --git a/src/main/scala/net/psforever/services/avatar/AvatarService.scala b/src/main/scala/net/psforever/services/avatar/AvatarService.scala index f61e4394..bbdf1f99 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarService.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarService.scala @@ -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, @@ -467,6 +475,24 @@ class AvatarService(zone: Zone) extends Actor { ) ) + case AvatarAction.ShareAntExperienceWithSquad(owner, exp, vehicle) => + AvatarEvents.publish( + AvatarServiceResponse( + s"/$forChannel/Avatar", + Service.defaultPlayerGUID, + AvatarResponse.ShareAntExperienceWithSquad(owner, exp, vehicle) + ) + ) + + case AvatarAction.RemoveFromOutfitChat(outfit_id) => + AvatarEvents.publish( + AvatarServiceResponse( + s"/$forChannel/Avatar", + Service.defaultPlayerGUID, + AvatarResponse.RemoveFromOutfitChat(outfit_id) + ) + ) + case _ => () } diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala index 5cea9482..e17cfe0a 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceMessage.scala @@ -1,13 +1,13 @@ // Copyright (c) 2017 PSForever package net.psforever.services.avatar -import net.psforever.objects.Player +import net.psforever.objects.{Player, Vehicle} import net.psforever.objects.avatar.scoring.KDAStat import net.psforever.objects.ballistics.Projectile import net.psforever.objects.equipment.Equipment import net.psforever.objects.inventory.InventoryItem import net.psforever.objects.serverobject.environment.interaction.common.Watery.OxygenStateTarget -import net.psforever.objects.sourcing.SourceEntry +import net.psforever.objects.sourcing.{SourceEntry, UniquePlayer} import net.psforever.objects.vital.interaction.DamageResult import net.psforever.objects.zones.Zone import net.psforever.packet.PlanetSideGamePacket @@ -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, @@ -161,6 +163,8 @@ object AvatarAction { final case class AwardCep(charId: Long, bep: Long) extends Action final case class FacilityCaptureRewards(building_id: Int, zone_number: Int, exp: Long) extends Action final case class ShareKillExperienceWithSquad(killer: Player, exp: Long) extends Action + final case class ShareAntExperienceWithSquad(owner: UniquePlayer, exp: Long, vehicle: Vehicle) extends Action + final case class RemoveFromOutfitChat(outfit_id: Long) extends Action final case class TeardownConnection() extends Action // final case class PlayerStateShift(killer : PlanetSideGUID, victim : PlanetSideGUID) extends Action diff --git a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala index 5c26d9a0..87560d29 100644 --- a/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala +++ b/src/main/scala/net/psforever/services/avatar/AvatarServiceResponse.scala @@ -1,13 +1,13 @@ // Copyright (c) 2017 PSForever package net.psforever.services.avatar -import net.psforever.objects.Player +import net.psforever.objects.{Player, Vehicle} import net.psforever.objects.avatar.scoring.KDAStat import net.psforever.objects.ballistics.Projectile import net.psforever.objects.equipment.Equipment import net.psforever.objects.inventory.InventoryItem import net.psforever.objects.serverobject.environment.interaction.common.Watery.OxygenStateTarget -import net.psforever.objects.sourcing.SourceEntry +import net.psforever.objects.sourcing.{SourceEntry, UniquePlayer} import net.psforever.objects.vital.interaction.DamageResult import net.psforever.packet.PlanetSideGamePacket import net.psforever.packet.game.objectcreate.ConstructorData @@ -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], @@ -133,4 +134,6 @@ object AvatarResponse { final case class AwardCep(charId: Long, bep: Long) extends Response final case class FacilityCaptureRewards(building_id: Int, zone_number: Int, exp: Long) extends Response final case class ShareKillExperienceWithSquad(killer: Player, exp: Long) extends Response + final case class ShareAntExperienceWithSquad(owner: UniquePlayer, exp: Long, vehicle: Vehicle) extends Response + final case class RemoveFromOutfitChat(outfit_id: Long) extends Response } diff --git a/src/main/scala/net/psforever/services/chat/ChatChannel.scala b/src/main/scala/net/psforever/services/chat/ChatChannel.scala index 3fbea3e6..fab599fd 100644 --- a/src/main/scala/net/psforever/services/chat/ChatChannel.scala +++ b/src/main/scala/net/psforever/services/chat/ChatChannel.scala @@ -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 diff --git a/src/main/scala/net/psforever/services/chat/ChatService.scala b/src/main/scala/net/psforever/services/chat/ChatService.scala index 8803c366..68fc0edb 100644 --- a/src/main/scala/net/psforever/services/chat/ChatService.scala +++ b/src/main/scala/net/psforever/services/chat/ChatService.scala @@ -57,8 +57,9 @@ 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 (DefaultChannel, messageType) if messageType != CMT_SQUAD => () - case (SpectatorChannel, messageType) if messageType != CMT_SQUAD => () + case (OutfitChannel(_), CMT_OUTFIT) => () + case (DefaultChannel, messageType) if messageType != CMT_SQUAD && messageType != CMT_OUTFIT => () + case (SpectatorChannel, messageType) if messageType != CMT_SQUAD && messageType != CMT_OUTFIT => () case _ => log.error(s"invalid chat channel $channel for messageType ${message.messageType}") return this @@ -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) diff --git a/src/test/scala/game/OutfitEventTest.scala b/src/test/scala/game/OutfitEventTest.scala new file mode 100644 index 00000000..d54cc16f --- /dev/null +++ b/src/test/scala/game/OutfitEventTest.scala @@ -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 Initial( + 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, + Initial( + 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 Update(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, + Update( + 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 Leaving() + case _ => + ko + } + } + + "encode Unk3 ABC" in { + val msg = OutfitEvent( + 2147418113L, + Leaving() + ) + 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 UpdateMemberCount( + member_count = 2, + ) + case _ => + ko + } + } + + "encode Unk5 ABC" in { + val msg = OutfitEvent( + 2147418113L, + UpdateMemberCount( + member_count = 2, + ) + ) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual unk5_ABC + } +} diff --git a/src/test/scala/game/OutfitListEventTest.scala b/src/test/scala/game/OutfitListEventTest.scala new file mode 100644 index 00000000..79b5d037 --- /dev/null +++ b/src/test/scala/game/OutfitListEventTest.scala @@ -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 + } + +} diff --git a/src/test/scala/game/OutfitMemberEventTest.scala b/src/test/scala/game/OutfitMemberEventTest.scala new file mode 100644 index 00000000..8e0bdf54 --- /dev/null +++ b/src/test/scala/game/OutfitMemberEventTest.scala @@ -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, Update(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, + Update( + 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, Update(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, + Update( + 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, Kicked()) => + outfit_id mustEqual 529744 + member_id mustEqual 41605263 + case _ => + ko + } + } + + "encode Unk1" in { + val msg = OutfitMemberEvent( + outfit_id = 529744, + member_id = 41605263, + Kicked() + ) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual unk1 + } +} diff --git a/src/test/scala/game/OutfitMemberUpdateTest.scala b/src/test/scala/game/OutfitMemberUpdateTest.scala new file mode 100644 index 00000000..39e0ca3d --- /dev/null +++ b/src/test/scala/game/OutfitMemberUpdateTest.scala @@ -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 + } +} diff --git a/src/test/scala/game/OutfitMembershipRequestTest.scala b/src/test/scala/game/OutfitMembershipRequestTest.scala index 4b90df4e..b733c4d8 100644 --- a/src/test/scala/game/OutfitMembershipRequestTest.scala +++ b/src/test/scala/game/OutfitMembershipRequestTest.scala @@ -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 + } + } diff --git a/src/test/scala/game/OutfitMembershipResponseTest.scala b/src/test/scala/game/OutfitMembershipResponseTest.scala new file mode 100644 index 00000000..dae406cd --- /dev/null +++ b/src/test/scala/game/OutfitMembershipResponseTest.scala @@ -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.YouGotKicked + 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.YouGotKicked, 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.YouKicked + 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.YouKicked, 0, 1, 41593365, 41605263, "PSFoutfittest1", "", flag = true) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual unk5 + } +} diff --git a/src/test/scala/game/OutfitRequesTest.scala b/src/test/scala/game/OutfitRequesTest.scala deleted file mode 100644 index eea16519..00000000 --- a/src/test/scala/game/OutfitRequesTest.scala +++ /dev/null @@ -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 - } -} diff --git a/src/test/scala/game/OutfitRequestTest.scala b/src/test/scala/game/OutfitRequestTest.scala new file mode 100644 index 00000000..67cbd62a --- /dev/null +++ b/src/test/scala/game/OutfitRequestTest.scala @@ -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.OutfitWindowOpen(value)) => + id mustEqual 1176612L + value mustEqual true + case _ => + ko + } + } + + "decode Unk4" in { + PacketCoding.decodePacket(string8).require match { + case OutfitRequest(id, OutfitRequestAction.OutfitListWindowOpen(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.OutfitWindowOpen(true)) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual string6 + } + + "encode Unk4" in { + val msg = OutfitRequest(41588237L, OutfitRequestAction.OutfitListWindowOpen(true)) + val pkt = PacketCoding.encodePacket(msg).require.toByteVector + + pkt mustEqual string8 + } +} diff --git a/src/test/scala/game/SquadMemberEventTest.scala b/src/test/scala/game/SquadMemberEventTest.scala index d0ae6d78..a8e2cfef 100644 --- a/src/test/scala/game/SquadMemberEventTest.scala +++ b/src/test/scala/game/SquadMemberEventTest.scala @@ -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 }