diff --git a/server/src/main/resources/db/migration/V015__OutfitStructure.sql b/server/src/main/resources/db/migration/V015__OutfitStructure.sql index f44ee4af0..7a0a11867 100644 --- a/server/src/main/resources/db/migration/V015__OutfitStructure.sql +++ b/server/src/main/resources/db/migration/V015__OutfitStructure.sql @@ -104,4 +104,4 @@ CREATE MATERIALIZED VIEW outfitpoint_mv AS "outfitpoint" GROUP BY "outfit_id"; -CREATE INDEX "outfitpoint_mv_outfit_id_idx" ON "outfitpoint_mv" ("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/normal/GeneralLogic.scala b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala index 3b3794b17..b16386d78 100644 --- a/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala +++ b/src/main/scala/net/psforever/actors/session/normal/GeneralLogic.scala @@ -830,14 +830,13 @@ class GeneralLogic(val ops: GeneralOperations, implicit val context: ActorContex def handleOutfitRequest(pkt: OutfitRequest): Unit = { pkt match { + case OutfitRequest(_, OutfitRequestAction.Motd(message)) => + SessionOutfitHandlers.HandleOutfitMotd(zones, message, player) + case OutfitRequest(_, OutfitRequestAction.Ranks(List(r1, r2, r3, r4, r5, r6, r7, r8))) => // update db //sendResponse(OutfitEvent(6418, Unk2(OutfitInfo(player.outfit_name, 0, 0, 1, OutfitRankNames(r1.getOrElse(""), r2.getOrElse(""), r3.getOrElse(""), r4.getOrElse(""), r5.getOrElse(""), r6.getOrElse(""), r7.getOrElse(""), r8.getOrElse("")), "Welcome to the first PSForever Outfit!", 0, unk11=true, 0, 8888888, 0, 0, 0)))) - case OutfitRequest(_, OutfitRequestAction.Motd(message)) => - // update db - //sendResponse(OutfitEvent(6418, Unk2(OutfitInfo(player.outfit_name, 0, 0, 1, OutfitRankNames("", "", "", "", "", "", "", ""), message, 0, unk11=true, 0, 8888888, 0, 0, 0)))) - case OutfitRequest(_, OutfitRequestAction.Unk3(true)) => SessionOutfitHandlers.HandleViewOutfitWindow(zones, player, player.outfit_id) diff --git a/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala b/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala index 553d6eec8..e9249d6d9 100644 --- a/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala +++ b/src/main/scala/net/psforever/actors/session/support/SessionOutfitHandlers.scala @@ -1,7 +1,7 @@ // Copyright (c) 2025 PSForever package net.psforever.actors.session.support -import io.getquill.{ActionReturning, EntityQuery, Insert, PostgresJAsyncContext, Query, Quoted, SnakeCase} +import io.getquill.{ActionReturning, EntityQuery, Insert, PostgresJAsyncContext, Query, Quoted, SnakeCase, Update} import net.psforever.objects.avatar.PlayerControl import net.psforever.objects.zones.Zone import net.psforever.objects.Player @@ -19,15 +19,23 @@ import scala.util.{Failure, Success} object SessionOutfitHandlers { case class Avatar(id: Long, name: String, faction_id: Int, last_login: java.time.LocalDateTime) - case class Outfit(id: Long, name: String, faction: Int, owner_id: Long, motd: Option[String], created: java.time.LocalDateTime, - rank0: Option[String], - rank1: Option[String], - rank2: Option[String], - rank3: Option[String], - rank4: Option[String], - rank5: Option[String], - rank6: Option[String], - rank7: Option[String]) + case class 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) @@ -126,12 +134,12 @@ object SessionOutfitHandlers { PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name, OutfitMembershipResponse( - OutfitMembershipResponse.PacketType.Unk2, 0, 0, + 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.Unk2, 0, 0, + OutfitMembershipResponse.PacketType.InviteAccepted, 0, 0, invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, outfit.name, flag = true)) PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name, @@ -183,12 +191,12 @@ object SessionOutfitHandlers { PlayerControl.sendResponse(outfitInvite.sentFrom.Zone, outfitInvite.sentFrom.Name, OutfitMembershipResponse( - OutfitMembershipResponse.PacketType.Unk3, 0, 0, + OutfitMembershipResponse.PacketType.InviteRejected, 0, 0, invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, "", flag = false)) PlayerControl.sendResponse(invited.Zone, invited.Name, OutfitMembershipResponse( - OutfitMembershipResponse.PacketType.Unk3, 0, 0, + OutfitMembershipResponse.PacketType.InviteRejected, 0, 0, invited.CharId, outfitInvite.sentFrom.CharId, invited.Name, "", flag = true)) OutfitInviteManager.removeOutfitInvite(invited.CharId) @@ -277,10 +285,60 @@ object SessionOutfitHandlers { } def HandleOutfitPromote(zones: Seq[Zone], promotedId: Long, newRank: Int, promoter: Player): Unit = { - // send to all online players in outfit + + val outfit_id = promoter.outfit_id + findPlayerByIdForOutfitAction(zones, promotedId, promoter).foreach { promoted => - PlayerControl.sendResponse(promoted.Zone, promoted.Name, OutfitMemberEvent(6418, promotedId, OutfitMemberEventAction.Unk0(promoted.Name, newRank, 1032432, 0, OutfitMemberEventAction.PacketType.Padding, 0))) - PlayerControl.sendResponse(promoter.Zone, promoter.Name, OutfitMemberEvent(6418, promotedId, OutfitMemberEventAction.Unk0(promoted.Name, newRank, 1032432, 0, OutfitMemberEventAction.PacketType.Padding, 0))) + + if (newRank == 7) { + + // demote owner to rank 6 + // promote promoted to rank 7 + // update outfit + updateOutfitOwner(outfit_id, promoter.avatar.id, promoted.avatar.id) + + // TODO: does every member get the notification like this? + getOutfitMemberPoints(outfit_id, promoter.avatar.id).map { + owner_points => + // announce owner rank change + zones.foreach(zone => { + zone.AllPlayers.filter(_.outfit_id == outfit_id).foreach(outfitMember => { + PlayerControl.sendResponse( + zone, outfitMember.Name, + OutfitMemberEvent(outfit_id, promoter.avatar.id, + OutfitMemberEventAction.Unk0(promoter.Name, 6, owner_points, 0, OutfitMemberEventAction.PacketType.Padding, 0))) + }) + }) + } + + // update promoter rank + PlayerControl.sendResponse( + promoter.Zone, promoter.Name, + OutfitMemberUpdate(outfit_id, promoter.avatar.id, rank = 6, flag = true)) + } + else { + // promote promoted + updateOutfitMemberRank(outfit_id, promoted.avatar.id, rank = newRank) + } + + // TODO: does every member get the notification like this? + getOutfitMemberPoints(outfit_id, promoted.avatar.id).map { + member_points => + // tell everyone about the new rank of the promoted member + zones.foreach(zone => { + zone.AllPlayers.filter(_.outfit_id == outfit_id).foreach(player => { + PlayerControl.sendResponse( + zone, player.Name, + OutfitMemberEvent(outfit_id, promoted.avatar.id, + OutfitMemberEventAction.Unk0(promoted.Name, newRank, member_points, 0, OutfitMemberEventAction.PacketType.Padding, 0))) + }) + }) + } + + // update promoted rank + PlayerControl.sendResponse( + promoted.Zone, promoted.Name, + OutfitMemberUpdate(outfit_id, promoted.avatar.id, rank = newRank, flag = true)) } } @@ -306,7 +364,16 @@ object SessionOutfitHandlers { totalPoints, totalPoints, memberCount, - OutfitRankNames("", "", "", "", "", "", "", ""), + 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)))) @@ -355,6 +422,71 @@ object SessionOutfitHandlers { } } + def HandleOutfitMotd(zones: Seq[Zone], message: String, player: Player): Unit = { + + val outfit_id = player.outfit_id + + // update MOTD + updateOutfitMotd(outfit_id, message) + + // TODO this does not notify clients with open windows. Do they update in the first place? + val outfitDetails = for { + outfitOpt <- ctx.run(getOutfitById(outfit_id)).map(_.headOption) + memberCount <- ctx.run(query[Outfitmember].filter(_.outfit_id == lift(outfit_id)).size) + pointsTotal <- ctx.run(querySchema[OutfitpointMv]("outfitpoint_mv").filter(_.outfit_id == lift(outfit_id))) + } yield (outfitOpt, memberCount, pointsTotal.headOption.map(_.points).getOrElse(0L)) + + for { + (outfitOpt, memberCount, totalPoints) <- outfitDetails + } yield { + outfitOpt.foreach { outfit => + + // send to all online players in outfit + val outfit_event = OutfitEvent( + outfit_id, + Unk2( + OutfitInfo( + outfit_name = outfit.name, + outfit_points1 = totalPoints, + outfit_points2 = totalPoints, + member_count = memberCount, + outfit_rank_names = OutfitRankNames( + outfit.rank0.getOrElse(""), + outfit.rank1.getOrElse(""), + outfit.rank2.getOrElse(""), + outfit.rank3.getOrElse(""), + outfit.rank4.getOrElse(""), + outfit.rank5.getOrElse(""), + outfit.rank6.getOrElse(""), + outfit.rank7.getOrElse(""), + ), + motd = outfit.motd.getOrElse(""), + unk10 = 0, + unk11 = true, + unk12 = 0, + created_timestamp = outfit.created.atZone(java.time.ZoneOffset.UTC).toInstant.toEpochMilli / 1000, + unk23 = 0, + unk24 = 0, + unk25 = 0 + ) + ) + ) + + zones.foreach(zone => { + zone.AllPlayers.filter(_.outfit_id == outfit_id).foreach(player => { + PlayerControl.sendResponse( + zone, player.Name, + outfit_event + ) + }) + }) + } + } + + // C >> S OutfitRequest(41593365, Motd(Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net)) + // S >> C OutfitEvent(Unk2, 529744, Unk2(OutfitInfo(PlanetSide_Forever_Vanu, 0, 0, 3, OutfitRankNames(, , , , , , , ), Vanu outfit for the planetside forever project! -find out more about the PSEMU project at PSforever.net, 0, 1, 0, 1458331641, 0, 0, 0))) + } + /* supporting functions */ def sanitizeOutfitName(name: String): Option[String] = { @@ -435,12 +567,14 @@ object SessionOutfitHandlers { for { deleted <- ctx.run( query[Outfitmember] - .filter(m => m.outfit_id == lift(outfit_id) && m.avatar_id == lift(avatar_id)) + .filter(_.outfit_id == lift(outfit_id)) + .filter(_.avatar_id == lift(avatar_id)) .delete ) updated <- ctx.run( query[Outfitpoint] - .filter(p => p.outfit_id == lift(outfit_id) && p.avatar_id == lift(avatarOpt)) + .filter(_.outfit_id == lift(outfit_id)) + .filter(_.avatar_id == lift(avatarOpt)) .update(_.avatar_id -> None) ) } yield (deleted, updated) @@ -455,6 +589,18 @@ object SessionOutfitHandlers { 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)) } @@ -490,4 +636,49 @@ object SessionOutfitHandlers { (outfit.id, points.map(_.points).getOrElse(0L), outfit.name, leader.name, memberCounts.map(_._2).getOrElse(0L)) } } + + def updateMemberRankById(outfit_id: Long, avatar_id: Long, rank: Int): Quoted[Update[Outfitmember]] = quote { + query[Outfitmember] + .filter(_.outfit_id == lift(outfit_id)) + .filter(_.avatar_id == lift(avatar_id)) + .update(_.rank -> lift(rank)) + } + + def updateOutfitMemberRank(outfit_id: Long, avatar_id: Long, rank: Int): Future[Unit] = { + ctx.transaction { implicit ec => + for { + _ <- ctx.run(updateMemberRankById(outfit_id, avatar_id, rank)) + } yield () + } + } + + def updateOutfitOwnerById(outfit_id: Long, owner_id: Long): Quoted[Update[Outfit]] = quote { + query[Outfit] + .filter(_.id == lift(outfit_id)) + .update(_.owner_id -> lift(owner_id)) + } + + def updateOutfitOwner(outfit_id: Long, owner_id: Long, new_owner_id: Long): Future[Unit] = { + ctx.transaction { implicit ec => + for { + _ <- ctx.run(updateMemberRankById(outfit_id, owner_id, 6)) + _ <- ctx.run(updateMemberRankById(outfit_id, new_owner_id, 7)) + _ <- ctx.run(updateOutfitOwnerById(outfit_id, new_owner_id)) + } yield () + } + } + + def updateOutfitMotdById(outfit_id: Long, motd: Option[String]): Quoted[Update[Outfit]] = quote { + query[Outfit] + .filter(_.id == lift(outfit_id)) + .update(_.motd -> lift(motd)) + } + + def updateOutfitMotd(outfit_id: Long, motd: String): Future[Unit] = { + ctx.transaction { implicit ec => + for { + _ <- ctx.run(updateOutfitMotdById(outfit_id, Some(motd))) + } yield () + } + } } diff --git a/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala b/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala index ebde82702..90f592954 100644 --- a/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala +++ b/src/main/scala/net/psforever/packet/game/OutfitMembershipResponse.scala @@ -31,9 +31,9 @@ object OutfitMembershipResponse extends Marshallable[OutfitMembershipResponse] { type Type = Value val CreateResponse: PacketType.Value = Value(0) - val Invite: PacketType.Value = Value(1) // Info: Player has been invited / response to OutfitMembershipRequest Unk2 for that player - val Unk2: PacketType.Value = Value(2) // Invited / Accepted / Added - val Unk3: PacketType.Value = Value(3) + val Invite: PacketType.Value = Value(1) // response to OutfitMembershipRequest Unk2 for that player + val InviteAccepted: PacketType.Value = Value(2) + val InviteRejected: PacketType.Value = Value(3) val Unk4: PacketType.Value = Value(4) val Kick: PacketType.Value = Value(5) val Unk6: PacketType.Value = Value(6) // 6 and 7 seen as failed decodes, validity unknown diff --git a/src/main/scala/net/psforever/packet/game/OutfitRequest.scala b/src/main/scala/net/psforever/packet/game/OutfitRequest.scala index 7d6b4cd0c..3e8d5f513 100644 --- a/src/main/scala/net/psforever/packet/game/OutfitRequest.scala +++ b/src/main/scala/net/psforever/packet/game/OutfitRequest.scala @@ -8,7 +8,7 @@ import scodec.codecs._ import shapeless.{::, HNil} final case class OutfitRequest( - id: Long, + outfit_id: Long, action: OutfitRequestAction ) extends PlanetSideGamePacket { type Packet = OutfitRequest diff --git a/src/test/scala/game/OutfitMembershipResponseTest.scala b/src/test/scala/game/OutfitMembershipResponseTest.scala index bd16286a3..948cd41cc 100644 --- a/src/test/scala/game/OutfitMembershipResponseTest.scala +++ b/src/test/scala/game/OutfitMembershipResponseTest.scala @@ -42,7 +42,7 @@ class OutfitMembershipResponseTest extends Specification { "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.Unk1 + packet_type mustEqual PacketType.Invite unk0 mustEqual 0 unk1 mustEqual 0 outfit_id mustEqual 30383325 @@ -56,7 +56,7 @@ class OutfitMembershipResponseTest extends Specification { } "encode unk1" in { - val msg = OutfitMembershipResponse(PacketType.Unk1, 0, 0, 30383325, 41605870, "xNick", "PlanetSide_Forever_TR", flag = false) + 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 @@ -65,7 +65,7 @@ class OutfitMembershipResponseTest extends Specification { "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.Unk2 + packet_type mustEqual PacketType.InviteAccepted unk0 mustEqual 0 unk1 mustEqual 0 outfit_id mustEqual 41605156 @@ -79,7 +79,7 @@ class OutfitMembershipResponseTest extends Specification { } "encode unk2" in { - val msg = OutfitMembershipResponse(PacketType.Unk2, 0, 0, 41605156, 41593365, "Zergling92", "PlanetSide_Forever_Vanu", flag = false) + 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 @@ -88,7 +88,7 @@ class OutfitMembershipResponseTest extends Specification { "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.Unk3 + packet_type mustEqual PacketType.InviteRejected unk0 mustEqual 0 unk1 mustEqual 0 outfit_id mustEqual 41574772 @@ -102,7 +102,7 @@ class OutfitMembershipResponseTest extends Specification { } "encode unk3" in { - val msg = OutfitMembershipResponse(PacketType.Unk3, 0, 0, 41574772, 31156616, "", "", flag = true) + val msg = OutfitMembershipResponse(PacketType.InviteRejected, 0, 0, 41574772, 31156616, "", "", flag = true) val pkt = PacketCoding.encodePacket(msg).require.toByteVector pkt mustEqual unk3 @@ -134,7 +134,7 @@ class OutfitMembershipResponseTest extends Specification { "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.Unk5 + packet_type mustEqual PacketType.Kick unk0 mustEqual 0 unk1 mustEqual 1 outfit_id mustEqual 41593365 @@ -148,7 +148,7 @@ class OutfitMembershipResponseTest extends Specification { } "encode unk5" in { - val msg = OutfitMembershipResponse(PacketType.Unk5, 0, 1, 41593365, 41605263, "PSFoutfittest1", "", flag = true) + val msg = OutfitMembershipResponse(PacketType.Kick, 0, 1, 41593365, 41605263, "PSFoutfittest1", "", flag = true) val pkt = PacketCoding.encodePacket(msg).require.toByteVector pkt mustEqual unk5