Wearing Your Accomplishments on Your Sleeve (#988)

* ability to swap merit commendation ribbons on shoulder and have other players see it

* ability to swap merit commendation ribbons on shoulder and have other players see it

* VehicleControlTest from elsewhere

* giver all non-Exclusive ribbons that would become available to faction/sex and allow modification of the ribbon bars

* awards only need to load during login activities; fixing a few awards that were not being allocated correctly

* wrong conditional for sex check
This commit is contained in:
Fate-JH 2022-04-02 17:19:52 -04:00 committed by GitHub
parent 0d8c717b73
commit e5fe6cf89a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1470 additions and 90 deletions

View file

@ -1,3 +1,4 @@
// Copyright (c) 2019 PSForever
package net.psforever.actors.session
import java.util.concurrent.atomic.AtomicInteger
@ -16,7 +17,7 @@ import net.psforever.objects._
import net.psforever.objects.ballistics.PlayerSource
import net.psforever.objects.locker.LockerContainer
import net.psforever.objects.vital.HealFromImplant
import net.psforever.packet.game.objectcreate.ObjectClass
import net.psforever.packet.game.objectcreate.{ObjectClass, RibbonBars}
import net.psforever.packet.game._
import net.psforever.types._
import net.psforever.util.Database._
@ -178,6 +179,8 @@ object AvatarActor {
/** Set cosmetics. Only allowed for BR24 or higher. */
final case class SetCosmetics(personalStyles: Set[Cosmetic]) extends Command
final case class SetRibbon(ribbon: MeritCommendation.Value, bar: RibbonBarSlot.Value) extends Command
private case class ServiceManagerLookupResult(result: ServiceManager.LookupResult) extends Command
final case class SetStamina(stamina: Int) extends Command
@ -188,6 +191,14 @@ object AvatarActor {
final case class AvatarLoginResponse(avatar: Avatar)
def changeRibbons(ribbons: RibbonBars, ribbon: MeritCommendation.Value, bar: RibbonBarSlot.Value): RibbonBars = {
bar match {
case RibbonBarSlot.Top => ribbons.copy(upper = ribbon)
case RibbonBarSlot.Middle => ribbons.copy(middle = ribbon)
case RibbonBarSlot.Bottom => ribbons.copy(lower = ribbon)
case RibbonBarSlot.TermOfService => ribbons.copy(tos = ribbon)
}
}
}
class AvatarActor(
@ -379,7 +390,7 @@ class AvatarActor(
))
// if we need to start stamina regeneration
tryRestoreStaminaForSession(stamina = 1) match {
case Some(sess) =>
case Some(_) =>
defaultStaminaRegen(initialDelay = 0.5f seconds)
case _ => ;
}
@ -1003,6 +1014,22 @@ class AvatarActor(
case SetCosmetics(cosmetics) =>
setCosmetics(cosmetics)
Behaviors.same
case SetRibbon(ribbon, bar) =>
val previousRibbonBars = avatar.ribbonBars
val useRibbonBars = Seq(previousRibbonBars.upper, previousRibbonBars.middle, previousRibbonBars.lower)
.indexWhere { _ == ribbon } match {
case -1 => previousRibbonBars
case n => AvatarActor.changeRibbons(previousRibbonBars, MeritCommendation.None, RibbonBarSlot(n))
}
replaceAvatar(avatar.copy(ribbonBars = AvatarActor.changeRibbons(useRibbonBars, ribbon, bar)))
val player = session.get.player
val zone = player.Zone
zone.AvatarEvents ! AvatarServiceMessage(
zone.id,
AvatarAction.SendResponse(Service.defaultPlayerGUID, DisplayedAwardMessage(player.GUID, ribbon, bar))
)
Behaviors.same
}
.receiveSignal {
case (_, PostStop) =>

View file

@ -159,6 +159,8 @@ object SessionActor {
tool: ConstructionItem,
index: Int
)
private final case class AvatarAwardMessageBundle(bundle: Iterable[Iterable[PlanetSidePacket]], delay: Long)
}
class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], connectionId: String, sessionId: Long)
@ -279,6 +281,7 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
var heightTrend: Boolean = false //up = true, down = false
var heightHistory: Float = 0f
val collisionHistory: mutable.HashMap[ActorRef, Long] = mutable.HashMap()
var populateAvatarAwardRibbonsFunc: (Int, Long) => Unit = setupAvatarAwardMessageDelivery
var clientKeepAlive: Cancellable = Default.Cancellable
var progressBarUpdate: Cancellable = Default.Cancellable
@ -1347,6 +1350,9 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
}
}
case SessionActor.AvatarAwardMessageBundle(pkts, delay) =>
performAvatarAwardMessageDelivery(pkts, delay)
case ResponseToSelf(pkt) =>
sendResponse(pkt)
@ -3157,8 +3163,9 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
if (tplayer.ExoSuit == ExoSuitType.MAX) {
sendResponse(PlanetsideAttributeMessage(guid, 7, tplayer.Capacitor.toLong))
}
//AvatarAwardMessage
//DisplayAwardMessage
// AvatarAwardMessage
populateAvatarAwardRibbonsFunc(1, 20L)
sendResponse(PlanetsideStringAttributeMessage(guid, 0, "Outfit Name"))
//squad stuff (loadouts, assignment)
squadSetup()
@ -3251,6 +3258,83 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
HandleSetCurrentAvatar(tplayer)
}
/**
* Don't extract the award advancement information from a player character upon respawning or zoning.
* You only need to perform that population once at login.
* @param bundleSize it doesn't matter
* @param delay it doesn't matter
*/
def skipAvatarAwardMessageDelivery(bundleSize: Int, delay: Long): Unit = { }
/**
* Extract the award advancement information from a player character, and
* coordinate timed dispatches of groups of packets.
* @param bundleSize divide packets into groups of this size
* @param delay dispatch packet divisions in intervals
*/
def setupAvatarAwardMessageDelivery(bundleSize: Int, delay: Long): Unit = {
setupAvatarAwardMessageDelivery(player, bundleSize, delay)
populateAvatarAwardRibbonsFunc = skipAvatarAwardMessageDelivery
}
/**
* Extract the merit commendation advancement information from a player character,
* filter unnecessary or not applicable statistics,
* translate the information into packet data, and
* coordinate timed dispatches of groups of packets.
* @param tplayer the player character
* @param bundleSize divide packets into groups of this size
* @param delay dispatch packet divisions in intervals
*/
def setupAvatarAwardMessageDelivery(tplayer: Player, bundleSize: Int, delay: Long): Unit = {
val date: Int = (System.currentTimeMillis() / 1000L).toInt - 604800 //last week, in seconds
performAvatarAwardMessageDelivery(
Award
.values
.filter { merit =>
val label = merit.value
val alignment = merit.alignment
if (merit.category == AwardCategory.Exclusive) false
else if (alignment != PlanetSideEmpire.NEUTRAL && alignment != player.Faction) false
else if (label.contains("Male") && player.Sex != CharacterSex.Male) false
else if (label.contains("Female") && player.Sex != CharacterSex.Female) false
else true
}
.flatMap { merit =>
merit.progression.map { level =>
AvatarAwardMessage(level.commendation, AwardCompletion(date))
}
}
.grouped(bundleSize)
.iterator
.to(Iterable),
delay
)
}
/**
* Coordinate timed dispatches of groups of packets.
* @param messageBundles groups of packets to be dispatched
* @param delay dispatch packet divisions in intervals
*/
def performAvatarAwardMessageDelivery(
messageBundles: Iterable[Iterable[PlanetSidePacket]],
delay: Long
): Unit = {
messageBundles match {
case Nil => ;
case x :: Nil =>
x.foreach { sendResponse }
case x :: xs =>
x.foreach { sendResponse }
context.system.scheduler.scheduleOnce(
delay.milliseconds,
self,
SessionActor.AvatarAwardMessageBundle(xs, delay)
)
}
}
/**
* These messages are dispatched when first starting up the client and connecting to the server for the first time.
* While many of these messages will be reused for other situations, they appear in this order only during startup.
@ -5966,12 +6050,16 @@ class SessionActor(middlewareActor: typed.ActorRef[MiddlewareActor.Command], con
log.error(s"InvalidTerrain: ${player.Name} is complaining about a thing@$vehicle_guid that can not be found")
}
case msg @ ActionCancelMessage(u1, u2, u3) =>
case ActionCancelMessage(_, _, _) =>
progressBarUpdate.cancel()
progressBarValue = None
case TradeMessage(trade) =>
log.info(s"${player.Name} wants to trade, for some reason - $trade")
log.trace(s"${player.Name} wants to trade for some reason - $trade")
case DisplayedAwardMessage(_, ribbon, bar) =>
log.trace(s"${player.Name} changed the $bar displayed award ribbon to $ribbon")
avatarActor ! AvatarActor.SetRibbon(ribbon, bar)
case _ =>
log.warn(s"Unhandled GamePacket $pkt")

View file

@ -1,3 +1,4 @@
// Copyright (c) 2017 PSForever
package net.psforever.objects.avatar
import net.psforever.objects.definition.{AvatarDefinition, BasicDefinition}
@ -6,6 +7,7 @@ import net.psforever.objects.inventory.LocallyRegisteredInventory
import net.psforever.objects.loadouts.{Loadout, SquadLoadout}
import net.psforever.objects.locker.{LockerContainer, LockerEquipment}
import net.psforever.objects.{GlobalDefinitions, OffhandEquipmentSlot}
import net.psforever.packet.game.objectcreate.RibbonBars
import net.psforever.types._
import org.joda.time.{Duration, LocalDateTime, Seconds}
@ -94,6 +96,7 @@ case class Avatar(
stamina: Int = 100,
fatigued: Boolean = false,
cosmetics: Option[Set[Cosmetic]] = None,
ribbonBars: RibbonBars = RibbonBars(),
certifications: Set[Certification] = Set(),
loadouts: Seq[Option[Loadout]] = Seq.fill(20)(None),
squadLoadouts: Seq[Option[SquadLoadout]] = Seq.fill(10)(None),

File diff suppressed because it is too large Load diff

View file

@ -105,7 +105,7 @@ object AvatarConverter {
unk7 = false,
on_zipline = None
)
CharacterAppearanceData(aa, ab, RibbonBars())
CharacterAppearanceData(aa, ab, obj.avatar.ribbonBars)
}
def MakeCharacterData(obj: Player): (Boolean, Boolean) => CharacterData = {

View file

@ -79,7 +79,7 @@ class CharacterSelectConverter extends AvatarConverter {
unk7 = false,
on_zipline = None
)
CharacterAppearanceData(aa, ab, RibbonBars())
CharacterAppearanceData(aa, ab, obj.avatar.ribbonBars)
}
private def MakeDetailedCharacterData(obj: Player): Option[Int] => DetailedCharacterData = {

View file

@ -70,7 +70,7 @@ class CorpseConverter extends AvatarConverter {
unk7 = false,
on_zipline = None
)
CharacterAppearanceData(aa, ab, RibbonBars())
CharacterAppearanceData(aa, ab, obj.avatar.ribbonBars)
}
private def MakeDetailedCharacterData(obj: Player): Option[Int] => DetailedCharacterData = {

View file

@ -2,37 +2,73 @@
package net.psforever.packet.game
import net.psforever.packet.{GamePacketOpcode, Marshallable, PlanetSideGamePacket}
import net.psforever.types.MeritCommendation
import scodec.Codec
import scodec.codecs._
import shapeless.{::, HNil}
import scala.annotation.switch
abstract class AwardOption(val code: Int) {
def unk1: Long
def unk2: Long
/**
* Base class for all merit commendation advancement stages.
*/
sealed trait AwardOption {
def value: Long
def completion: Long
}
final case class AwardOptionZero(unk1: Long, unk2: Long) extends AwardOption(code = 0)
final case class AwardOptionOne(unk1: Long) extends AwardOption(code = 1) {
def unk2: Long = 0L
/**
* Display this award's development progress.
* @param value the current count towards this award
* @param completion the target (maximum) count
*/
final case class AwardProgress(value: Long, completion: Long) extends AwardOption
/**
* Display this award's qualification progress.
* The process is the penultimate conditions necessary for award completion,
* separate from the aforementioned progress.
* This is almost always a kill streak,
* where the user must terminate a certain numbers of enemies without dying.
* @param value the current count towards this award
*/
final case class AwardQualificationProgress(value: Long) extends AwardOption {
/** zero'd as the value is not reported here */
def completion: Long = 0L
}
final case class AwardOptionTwo(unk1: Long) extends AwardOption(code = 3) {
def unk2: Long = 0L
/**
* Display this award as completed.
* @param value the date (mm/dd/yyyy) that the award was achieved in POSIX seconds;
* that's `System.currentTimeMillis() / 1000`
*/
final case class AwardCompletion(value: Long) extends AwardOption {
/** same as the parameter value */
def completion: Long = value
}
/**
* na
* @param unk1 na
* @param unk2 na
* @param unk3 na
* Dispatched from the server to load information about a character's merit commendation awards progress.<br>
* <br>
* The three stages of a merit commendation award are: progress, qualification, and completion.
* The progress stage and the qualification stage have their own development conditions.
* Ocassionally, the development is nonexistent and the award is merely an on/off switch.
* Occasionally, there is no qualification requirement and the award merely advances in the progress stage
* then transitions directly from progress to completion.
* Completion information is available from the character info / achievements tab
* and takes the form of ribbons associated with the merit commendation at a given rank
* and the date that rank was attained.
* Progress and qualification information are visible from the character info / achievements / award progress window
* and take the form of the name and rank of the merit commendation
* and two numbers that indicate the current and the goal towards the next stage.
* The completion stage is also visible from this window
* and will take the form of the same name and rank of the merit commendation indicated as "Completed" as of a date.
* @see `MeritCommendation.Value`
* @param merit_commendation the award and rank
* @param state the current state of the award advancement
* @param unk na;
* 0 and 1 are the possible values;
* 0 is the common value
*/
final case class AvatarAwardMessage(
unk1: Long,
unk2: AwardOption,
unk3: Int
merit_commendation: MeritCommendation.Value,
state: AwardOption,
unk: Int
)
extends PlanetSideGamePacket {
type Packet = AvatarAwardMessage
@ -41,61 +77,67 @@ final case class AvatarAwardMessage(
}
object AvatarAwardMessage extends Marshallable[AvatarAwardMessage] {
private val codec_one: Codec[AwardOptionOne] = {
def apply(meritCommendation: MeritCommendation.Value, state: AwardOption):AvatarAwardMessage =
AvatarAwardMessage(meritCommendation, state, unk = 0)
private val qualification_codec: Codec[AwardOption] = {
uint32L.hlist
}.xmap[AwardOptionOne](
}.xmap[AwardOption](
{
case a :: HNil => AwardOptionOne(a)
case a :: HNil => AwardQualificationProgress(a)
},
{
case AwardOptionOne(a) => a :: HNil
case AwardQualificationProgress(a) => a :: HNil
}
)
private val codec_two: Codec[AwardOptionTwo] = {
private val completion_codec: Codec[AwardOption] = {
uint32L.hlist
}.xmap[AwardOptionTwo](
}.xmap[AwardOption](
{
case a :: HNil => AwardOptionTwo(a)
case a :: HNil => AwardCompletion(a)
},
{
case AwardOptionTwo(a) => a :: HNil
case AwardCompletion(a) => a :: HNil
}
)
private val codec_zero: Codec[AwardOptionZero] = {
private val progress_codec: Codec[AwardOption] = {
uint32L :: uint32L
}.xmap[AwardOptionZero](
}.xmap[AwardOption](
{
case a :: b :: HNil => AwardOptionZero(a, b)
case a :: b :: HNil => AwardProgress(a, b)
},
{
case AwardOptionZero(a, b) => a :: b :: HNil
case AwardProgress(a, b) => a :: b :: HNil
}
)
private def selectAwardOption(code: Int): Codec[AwardOption] = {
((code: @switch) match {
case 2 | 3 => codec_two
case 1 => codec_one
case 0 => codec_zero
}).asInstanceOf[Codec[AwardOption]]
}
implicit val codec: Codec[AvatarAwardMessage] = (
("unk1" | uint32L) ::
(uint2 >>:~ { code =>
("unk2" | selectAwardOption(code)) ::
("unk3" | uint8L)
})
).xmap[AvatarAwardMessage](
{
case unk1 :: _ :: unk2 :: unk3 :: HNil =>
AvatarAwardMessage(unk1, unk2, unk3)
},
{
case AvatarAwardMessage(unk1, unk2, unk3) =>
unk1 :: unk2.code :: unk2 :: unk3 :: HNil
}
)
("merit_commendation" | MeritCommendation.codec) ::
("state" | either(bool,
either(bool, progress_codec, qualification_codec).xmap[AwardOption](
{
case Left(d) => d
case Right(d) => d
},
{
case d: AwardProgress => Left(d)
case d: AwardQualificationProgress => Right(d)
}
),
completion_codec
).xmap[AwardOption](
{
case Left(d) => d
case Right(d) => d
},
{
case d: AwardProgress => Left(d)
case d: AwardQualificationProgress => Left(d)
case d: AwardCompletion => Right(d)
}
)) ::
("unk" | uint8L)
).as[AvatarAwardMessage]
}

View file

@ -9,7 +9,7 @@ import scodec.codecs._
/**
* An `Enumeration` of the slots for award ribbons on a player's `RibbonBars`.
*/
object RibbonBarsSlot extends Enumeration {
object RibbonBarSlot extends Enumeration {
type Type = Value
val Top, Middle, Bottom, TermOfService //technically,the slot above "Top"
@ -21,29 +21,28 @@ object RibbonBarsSlot extends Enumeration {
/**
* Dispatched to configure a player's merit commendation ribbons.<br>
* <br>
* Normally, this packet is dispatched by the client when managing merit commendations through the "Character Info/Achievements" tab.
* Normally, this packet is dispatched by the client when managing merit commendations
* through the "Character Info/Achievements" tab.
* On Gemini Live, this packet was also always dispatched once by the server during character login.
* It set the term of service ribbon explicitly.
* Generally, this was unnecessary, as the encoded character data maintains information about displayed ribbons.
* This behavior was probably a routine that ensured that correct yearly progression was tracked if the player earned it while offline.
* This behavior was probably a routine that ensured that correct yearly progression was tracked
* if the player earned it while offline.
* It never set any of the other ribbon slot positions during login.<br>
* <br>
* A specific ribbon may only be set once to one slot.
* The last set slot is considered the valid position to which that ribbon will be placed/moved.
* @param player_guid the player
* @param ribbon the award to be displayed;
* defaults to `MeritCommendation.None`;
* use `MeritCommendation.None` when indicating "no ribbon"
* @param bar any of the four positions where the award ribbon is to be displayed;
* defaults to `TermOfService`
* @param ribbon the award to be displayed
* @param bar any of the four positions where the award ribbon is to be displayed
* @see `RibbonBars`
* @see `MeritCommendation`
*/
final case class DisplayedAwardMessage(
player_guid: PlanetSideGUID,
ribbon: MeritCommendation.Value = MeritCommendation.None,
bar: RibbonBarsSlot.Value = RibbonBarsSlot.TermOfService
) extends PlanetSideGamePacket {
player_guid: PlanetSideGUID,
ribbon: MeritCommendation.Value,
bar: RibbonBarSlot.Value
) extends PlanetSideGamePacket {
type Packet = DisplayedAwardMessage
def opcode = GamePacketOpcode.DisplayedAwardMessage
def encode = DisplayedAwardMessage.encode(this)
@ -52,7 +51,7 @@ final case class DisplayedAwardMessage(
object DisplayedAwardMessage extends Marshallable[DisplayedAwardMessage] {
implicit val codec: Codec[DisplayedAwardMessage] = (
("player_guid" | PlanetSideGUID.codec) ::
("ribbon" | MeritCommendation.codec) ::
("bar" | RibbonBarsSlot.codec)
("ribbon" | MeritCommendation.codec) ::
("bar" | RibbonBarSlot.codec)
).as[DisplayedAwardMessage]
}

View file

@ -4,18 +4,20 @@ package game
import org.specs2.mutable._
import net.psforever.packet._
import net.psforever.packet.game._
import net.psforever.types.MeritCommendation
import scodec.bits._
class AvatarAwardMessageTest extends Specification {
val string0 = hex"cf 15010000014000003d0040000000"
val string1 = hex"cf 2a010000c717b12a0000"
val string2 = hex"cf a6010000e9058cab0080"
val string3 = hex"cf 7a010000400000000000"
"decode (0)" in {
PacketCoding.decodePacket(string0).require match {
case AvatarAwardMessage(unk1, unk2, unk3) =>
unk1 mustEqual 277
unk2 mustEqual AwardOptionZero(5, 500)
unk1 mustEqual MeritCommendation.Max1
unk2 mustEqual AwardProgress(5, 500)
unk3 mustEqual 0
case _ =>
ko
@ -25,8 +27,8 @@ class AvatarAwardMessageTest extends Specification {
"decode (1)" in {
PacketCoding.decodePacket(string1).require match {
case AvatarAwardMessage(unk1, unk2, unk3) =>
unk1 mustEqual 298
unk2 mustEqual AwardOptionTwo(2831441436L)
unk1 mustEqual MeritCommendation.OneYearVS
unk2 mustEqual AwardCompletion(1415720846L)
unk3 mustEqual 0
case _ =>
ko
@ -36,32 +38,50 @@ class AvatarAwardMessageTest extends Specification {
"decode (2)" in {
PacketCoding.decodePacket(string2).require match {
case AvatarAwardMessage(unk1, unk2, unk3) =>
unk1 mustEqual 422
unk2 mustEqual AwardOptionTwo(2888963748L)
unk3 mustEqual 2
unk1 mustEqual MeritCommendation.TwoYearVS
unk2 mustEqual AwardCompletion(1444482002L)
unk3 mustEqual 1
case _ =>
ko
}
}
"decode (3)" in {
PacketCoding.decodePacket(string3).require match {
case AvatarAwardMessage(unk1, unk2, unk3) =>
unk1 mustEqual MeritCommendation.StandardAssault3
unk2 mustEqual AwardQualificationProgress(0)
unk3 mustEqual 0
case _ =>
ko
}
}
"encode (0)" in {
val msg = AvatarAwardMessage(277, AwardOptionZero(5, 500), 0)
val msg = AvatarAwardMessage(MeritCommendation.Max1, AwardProgress(5, 500))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string0
}
"encode (1)" in {
val msg = AvatarAwardMessage(298, AwardOptionTwo(2831441436L), 0)
val msg = AvatarAwardMessage(MeritCommendation.OneYearVS, AwardCompletion(1415720846L))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string1
}
"encode (2)" in {
val msg = AvatarAwardMessage(422, AwardOptionTwo(2888963748L), 2)
val msg = AvatarAwardMessage(MeritCommendation.TwoYearVS, AwardCompletion(1444482002L), 1)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string2
}
"encode (3)" in {
val msg = AvatarAwardMessage(MeritCommendation.StandardAssault3, AwardQualificationProgress(0))
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string3
}
}

View file

@ -15,14 +15,14 @@ class DisplayedAwardMessageTest extends Specification {
case DisplayedAwardMessage(player_guid, ribbon, bar) =>
player_guid mustEqual PlanetSideGUID(1695)
ribbon mustEqual MeritCommendation.TwoYearVS
bar mustEqual RibbonBarsSlot.TermOfService
bar mustEqual RibbonBarSlot.TermOfService
case _ =>
ko
}
}
"encode" in {
val msg = DisplayedAwardMessage(PlanetSideGUID(1695), MeritCommendation.TwoYearVS, RibbonBarsSlot.TermOfService)
val msg = DisplayedAwardMessage(PlanetSideGUID(1695), MeritCommendation.TwoYearVS, RibbonBarSlot.TermOfService)
val pkt = PacketCoding.encodePacket(msg).require.toByteVector
pkt mustEqual string