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
This commit is contained in:
Resaec 2025-08-30 01:35:51 +02:00
parent ad52c8076c
commit 18dd426d13
6 changed files with 227 additions and 37 deletions

View file

@ -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");

View file

@ -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)

View file

@ -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,7 +19,14 @@ 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,
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],
@ -27,7 +34,8 @@ object SessionOutfitHandlers {
rank4: Option[String],
rank5: Option[String],
rank6: Option[String],
rank7: 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 ()
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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