From 18dd426d13cd5294bd5ff8c798bd2226a2e485f5 Mon Sep 17 00:00:00 2001 From: Resaec Date: Sat, 30 Aug 2025 01:35:51 +0200 Subject: [PATCH] fix outfit rank names not representing DB values add MOTD handling renaming OMR packet types with known uses handling outfit promotions (setrank) handle outfit owner changes changing the migration to change to unique index. allows concurrent refresh of MV --- .../db/migration/V015__OutfitStructure.sql | 2 +- .../actors/session/normal/GeneralLogic.scala | 7 +- .../support/SessionOutfitHandlers.scala | 231 ++++++++++++++++-- .../game/OutfitMembershipResponse.scala | 6 +- .../psforever/packet/game/OutfitRequest.scala | 2 +- .../game/OutfitMembershipResponseTest.scala | 16 +- 6 files changed, 227 insertions(+), 37 deletions(-) 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