Sync entity positions
This commit is contained in:
parent
792536ae7a
commit
3767d66065
19 changed files with 245 additions and 16 deletions
|
@ -17,6 +17,7 @@ import space.uranos.player.GameMode
|
||||||
import space.uranos.plugin.Plugin
|
import space.uranos.plugin.Plugin
|
||||||
import space.uranos.testplugin.anvil.AnvilWorld
|
import space.uranos.testplugin.anvil.AnvilWorld
|
||||||
import space.uranos.util.RGBColor
|
import space.uranos.util.RGBColor
|
||||||
|
import space.uranos.util.runInServerThread
|
||||||
import space.uranos.util.secondsToTicks
|
import space.uranos.util.secondsToTicks
|
||||||
import space.uranos.world.*
|
import space.uranos.world.*
|
||||||
import space.uranos.world.block.GreenWoolBlock
|
import space.uranos.world.block.GreenWoolBlock
|
||||||
|
@ -61,7 +62,7 @@ class TestPlugin: Plugin("Test", "1.0.0") {
|
||||||
}
|
}
|
||||||
|
|
||||||
val entity = Uranos.create<CowEntity>()
|
val entity = Uranos.create<CowEntity>()
|
||||||
entity.position = Position(0.0, 10.0, 0.0, 0f, 0f)
|
entity.position = Position(0.5, 10.0, 0.5, 0f, 0f)
|
||||||
entity.setWorld(world)
|
entity.setWorld(world)
|
||||||
|
|
||||||
Uranos.eventBus.on<SessionAfterLoginEvent> { event ->
|
Uranos.eventBus.on<SessionAfterLoginEvent> { event ->
|
||||||
|
@ -80,5 +81,12 @@ class TestPlugin: Plugin("Test", "1.0.0") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var x = 1.0
|
||||||
|
Uranos.scheduler.executeRepeating(1) {
|
||||||
|
x += 0.2
|
||||||
|
runInServerThread { entity.position = Position(x + 0.5, x, 0.5, 0f, 0f) }
|
||||||
|
if (x >= 10.0) x = 0.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,4 @@
|
||||||
|
|
||||||
package space.uranos.entity
|
package space.uranos.entity
|
||||||
|
|
||||||
interface LivingEntity : Entity, Mobile {
|
interface LivingEntity : Entity, Mobile
|
||||||
// TODO: This should probably be headYaw, but wiki.vg says headPitch.
|
|
||||||
// And it is only used in the SpawnLivingEntity packet
|
|
||||||
var headPitch: Float
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,15 +5,17 @@
|
||||||
|
|
||||||
package space.uranos.util
|
package space.uranos.util
|
||||||
|
|
||||||
import java.util.*
|
import java.util.Spliterator
|
||||||
import java.util.function.Predicate
|
import java.util.function.Predicate
|
||||||
import java.util.stream.Stream
|
import java.util.stream.Stream
|
||||||
|
|
||||||
abstract class WatchableSet<T>(private val backingSet: MutableSet<T>) : MutableSet<T> by backingSet {
|
abstract class WatchableSet<T>(private val backingSet: MutableSet<T>) : MutableSet<T> by backingSet {
|
||||||
abstract fun onAdd(element: T)
|
abstract fun onAdd(element: T)
|
||||||
abstract fun onRemove(element: T)
|
abstract fun onRemove(element: T)
|
||||||
|
abstract fun beforeAdd(element: T)
|
||||||
|
|
||||||
override fun add(element: T): Boolean {
|
override fun add(element: T): Boolean {
|
||||||
|
beforeAdd(element)
|
||||||
val added = backingSet.add(element)
|
val added = backingSet.add(element)
|
||||||
if (added) onAdd(element)
|
if (added) onAdd(element)
|
||||||
return added
|
return added
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf
|
||||||
|
import space.uranos.net.MinecraftProtocolDataTypes.writeVarInt
|
||||||
|
import space.uranos.net.packet.OutgoingPacketCodec
|
||||||
|
|
||||||
|
object EntityOrientationPacketCodec :
|
||||||
|
OutgoingPacketCodec<EntityOrientationPacket>(0x29, EntityOrientationPacket::class) {
|
||||||
|
override fun EntityOrientationPacket.encode(dst: ByteBuf) {
|
||||||
|
dst.writeVarInt(entityID)
|
||||||
|
dst.writeByte(yaw.toInt())
|
||||||
|
dst.writeByte(pitch.toInt())
|
||||||
|
dst.writeBoolean(onGround)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf
|
||||||
|
import space.uranos.net.MinecraftProtocolDataTypes.writeVarInt
|
||||||
|
import space.uranos.net.packet.OutgoingPacketCodec
|
||||||
|
|
||||||
|
object EntityRelativeMovePacketCodec :
|
||||||
|
OutgoingPacketCodec<EntityRelativeMovePacket>(0x27, EntityRelativeMovePacket::class) {
|
||||||
|
override fun EntityRelativeMovePacket.encode(dst: ByteBuf) {
|
||||||
|
dst.writeVarInt(entityID)
|
||||||
|
dst.writeShort(deltaX.toInt())
|
||||||
|
dst.writeShort(deltaY.toInt())
|
||||||
|
dst.writeShort(deltaZ.toInt())
|
||||||
|
dst.writeBoolean(onGround)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf
|
||||||
|
import space.uranos.net.MinecraftProtocolDataTypes.writeVarInt
|
||||||
|
import space.uranos.net.packet.OutgoingPacketCodec
|
||||||
|
|
||||||
|
object EntityRelativeMoveWithOrientationPacketCodec :
|
||||||
|
OutgoingPacketCodec<EntityRelativeMoveWithOrientationPacket>(0x28, EntityRelativeMoveWithOrientationPacket::class) {
|
||||||
|
override fun EntityRelativeMoveWithOrientationPacket.encode(dst: ByteBuf) {
|
||||||
|
dst.writeVarInt(entityID)
|
||||||
|
dst.writeShort(deltaX.toInt())
|
||||||
|
dst.writeShort(deltaY.toInt())
|
||||||
|
dst.writeShort(deltaZ.toInt())
|
||||||
|
dst.writeByte(yaw.toInt())
|
||||||
|
dst.writeByte(pitch.toInt())
|
||||||
|
dst.writeBoolean(onGround)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf
|
||||||
|
import space.uranos.net.MinecraftProtocolDataTypes.writeVarInt
|
||||||
|
import space.uranos.net.packet.OutgoingPacketCodec
|
||||||
|
|
||||||
|
object EntityTeleportPacketCodec :
|
||||||
|
OutgoingPacketCodec<EntityTeleportPacket>(0x56, EntityTeleportPacket::class) {
|
||||||
|
override fun EntityTeleportPacket.encode(dst: ByteBuf) {
|
||||||
|
dst.writeVarInt(entityID)
|
||||||
|
dst.writeDouble(position.x)
|
||||||
|
dst.writeDouble(position.y)
|
||||||
|
dst.writeDouble(position.z)
|
||||||
|
dst.writeByte(position.yawIn256Steps.toInt())
|
||||||
|
dst.writeByte(position.pitchIn256Steps.toInt())
|
||||||
|
dst.writeBoolean(onGround)
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,10 @@ object PlayProtocol : Protocol(
|
||||||
DeclareRecipesPacketCodec,
|
DeclareRecipesPacketCodec,
|
||||||
DestroyEntitiesPacketCodec,
|
DestroyEntitiesPacketCodec,
|
||||||
DisconnectPacketCodec,
|
DisconnectPacketCodec,
|
||||||
|
EntityOrientationPacketCodec,
|
||||||
|
EntityRelativeMovePacketCodec,
|
||||||
|
EntityRelativeMoveWithOrientationPacketCodec,
|
||||||
|
EntityTeleportPacketCodec,
|
||||||
IncomingKeepAlivePacketCodec,
|
IncomingKeepAlivePacketCodec,
|
||||||
IncomingPlayerPositionPacketCodec,
|
IncomingPlayerPositionPacketCodec,
|
||||||
IncomingPluginMessagePacketCodec,
|
IncomingPluginMessagePacketCodec,
|
||||||
|
|
|
@ -21,7 +21,7 @@ object SpawnLivingEntityPacketCodec : OutgoingPacketCodec<SpawnLivingEntityPacke
|
||||||
dst.writeDouble(position.z)
|
dst.writeDouble(position.z)
|
||||||
dst.writeByte(position.yawIn256Steps.toInt())
|
dst.writeByte(position.yawIn256Steps.toInt())
|
||||||
dst.writeByte(position.pitchIn256Steps.toInt())
|
dst.writeByte(position.pitchIn256Steps.toInt())
|
||||||
dst.writeByte(((headPitch / 360) * 256).toInt())
|
dst.writeByte(0) // Head pitch; I do not know what this does
|
||||||
dst.writeShort((velocity.x * 8000).toInt().toShort().toInt())
|
dst.writeShort((velocity.x * 8000).toInt().toShort().toInt())
|
||||||
dst.writeShort((velocity.y * 8000).toInt().toShort().toInt())
|
dst.writeShort((velocity.y * 8000).toInt().toShort().toInt())
|
||||||
dst.writeShort((velocity.z * 8000).toInt().toShort().toInt())
|
dst.writeShort((velocity.z * 8000).toInt().toShort().toInt())
|
||||||
|
|
|
@ -8,5 +8,4 @@ package space.uranos.net.packet.play
|
||||||
import space.uranos.net.packet.OutgoingPacket
|
import space.uranos.net.packet.OutgoingPacket
|
||||||
import space.uranos.world.VoxelLocation
|
import space.uranos.world.VoxelLocation
|
||||||
|
|
||||||
// TODO: Remove "Set" prefix for all packet names
|
|
||||||
data class CompassTargetPacket(val target: VoxelLocation) : OutgoingPacket()
|
data class CompassTargetPacket(val target: VoxelLocation) : OutgoingPacket()
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import space.uranos.net.packet.OutgoingPacket
|
||||||
|
|
||||||
|
data class EntityOrientationPacket(
|
||||||
|
val entityID: Int,
|
||||||
|
val yaw: UByte,
|
||||||
|
val pitch: UByte,
|
||||||
|
val onGround: Boolean
|
||||||
|
) : OutgoingPacket()
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import space.uranos.net.packet.OutgoingPacket
|
||||||
|
|
||||||
|
data class EntityRelativeMovePacket(
|
||||||
|
val entityID: Int,
|
||||||
|
val deltaX: Short,
|
||||||
|
val deltaY: Short,
|
||||||
|
val deltaZ: Short,
|
||||||
|
val onGround: Boolean
|
||||||
|
) : OutgoingPacket()
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import space.uranos.net.packet.OutgoingPacket
|
||||||
|
|
||||||
|
data class EntityRelativeMoveWithOrientationPacket(
|
||||||
|
val entityID: Int,
|
||||||
|
val deltaX: Short,
|
||||||
|
val deltaY: Short,
|
||||||
|
val deltaZ: Short,
|
||||||
|
/**
|
||||||
|
* Absolute value.
|
||||||
|
*/
|
||||||
|
val yaw: UByte,
|
||||||
|
/**
|
||||||
|
* Absolute value.
|
||||||
|
*/
|
||||||
|
val pitch: UByte,
|
||||||
|
val onGround: Boolean
|
||||||
|
) : OutgoingPacket()
|
|
@ -0,0 +1,15 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.net.packet.play
|
||||||
|
|
||||||
|
import space.uranos.Position
|
||||||
|
import space.uranos.net.packet.OutgoingPacket
|
||||||
|
|
||||||
|
data class EntityTeleportPacket(
|
||||||
|
val entityID: Int,
|
||||||
|
val position: Position,
|
||||||
|
val onGround: Boolean
|
||||||
|
) : OutgoingPacket()
|
|
@ -19,7 +19,6 @@ data class SpawnLivingEntityPacket(
|
||||||
val uuid: UUID,
|
val uuid: UUID,
|
||||||
val type: EntityType<*>,
|
val type: EntityType<*>,
|
||||||
val position: Position,
|
val position: Position,
|
||||||
val headPitch: Float,
|
|
||||||
/**
|
/**
|
||||||
* Velocity in blocks per tick
|
* Velocity in blocks per tick
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2020-2021 Moritz Ruth and Uranos contributors
|
||||||
|
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file
|
||||||
|
*/
|
||||||
|
|
||||||
|
package space.uranos.util
|
||||||
|
|
||||||
|
import space.uranos.Position
|
||||||
|
import space.uranos.abs
|
||||||
|
import space.uranos.net.packet.OutgoingPacket
|
||||||
|
import space.uranos.net.packet.play.EntityOrientationPacket
|
||||||
|
import space.uranos.net.packet.play.EntityRelativeMovePacket
|
||||||
|
import space.uranos.net.packet.play.EntityRelativeMoveWithOrientationPacket
|
||||||
|
import space.uranos.net.packet.play.EntityTeleportPacket
|
||||||
|
|
||||||
|
fun createEntityMovementPacket(entityID: Int, oldPosition: Position, newPosition: Position): OutgoingPacket? {
|
||||||
|
val delta = abs(newPosition.toVector() - oldPosition.toVector())
|
||||||
|
val orientationChanged = oldPosition.yaw != newPosition.yaw || oldPosition.pitch != newPosition.pitch
|
||||||
|
val onGround = true // TODO: Find out what onGround does
|
||||||
|
|
||||||
|
return if (delta.x + delta.y + delta.z == 0.0) {
|
||||||
|
if (orientationChanged) EntityOrientationPacket(
|
||||||
|
entityID,
|
||||||
|
newPosition.yawIn256Steps,
|
||||||
|
newPosition.pitchIn256Steps,
|
||||||
|
onGround
|
||||||
|
) else null
|
||||||
|
} else if (delta.x > 8 || delta.y > 8 || delta.z > 8) {
|
||||||
|
EntityTeleportPacket(entityID, newPosition, onGround)
|
||||||
|
} else {
|
||||||
|
if (orientationChanged) EntityRelativeMoveWithOrientationPacket(
|
||||||
|
entityID,
|
||||||
|
getDeltaShort(delta.x),
|
||||||
|
getDeltaShort(delta.y),
|
||||||
|
getDeltaShort(delta.z),
|
||||||
|
newPosition.yawIn256Steps,
|
||||||
|
newPosition.pitchIn256Steps,
|
||||||
|
onGround
|
||||||
|
) else EntityRelativeMovePacket(
|
||||||
|
entityID,
|
||||||
|
getDeltaShort(delta.x),
|
||||||
|
getDeltaShort(delta.y),
|
||||||
|
getDeltaShort(delta.z),
|
||||||
|
onGround
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDeltaShort(delta: Double): Short = (delta * 32 * 128).toInt().toShort()
|
|
@ -17,7 +17,6 @@ fun Entity.createSpawnPacket(): OutgoingPacket = when (this) {
|
||||||
uuid,
|
uuid,
|
||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
headPitch,
|
|
||||||
velocity
|
velocity
|
||||||
)
|
)
|
||||||
is ObjectEntity -> SpawnObjectEntityPacket(
|
is ObjectEntity -> SpawnObjectEntityPacket(
|
|
@ -13,6 +13,7 @@ import space.uranos.entity.event.ViewingChangedEvent
|
||||||
import space.uranos.player.Player
|
import space.uranos.player.Player
|
||||||
import space.uranos.util.TickSynchronizationContainer
|
import space.uranos.util.TickSynchronizationContainer
|
||||||
import space.uranos.util.WatchableSet
|
import space.uranos.util.WatchableSet
|
||||||
|
import space.uranos.util.createEntityMovementPacket
|
||||||
import space.uranos.util.memoized
|
import space.uranos.util.memoized
|
||||||
import space.uranos.world.Chunk
|
import space.uranos.world.Chunk
|
||||||
import space.uranos.world.VoxelLocation
|
import space.uranos.world.VoxelLocation
|
||||||
|
@ -31,6 +32,11 @@ sealed class UranosEntity(server: UranosServer) : Entity {
|
||||||
override val uuid: UUID = UUID.randomUUID()
|
override val uuid: UUID = UUID.randomUUID()
|
||||||
|
|
||||||
override val viewers: MutableSet<Player> = object : WatchableSet<Player>(Collections.newSetFromMap(WeakHashMap())) {
|
override val viewers: MutableSet<Player> = object : WatchableSet<Player>(Collections.newSetFromMap(WeakHashMap())) {
|
||||||
|
override fun beforeAdd(element: Player) {
|
||||||
|
if ((this@UranosEntity as? UranosPlayerEntity)?.let { it.player == element } == true)
|
||||||
|
throw IllegalArgumentException("A player cannot be a viewer of it's own entity")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onAdd(element: Player) {
|
override fun onAdd(element: Player) {
|
||||||
Uranos.scope.launch { Uranos.eventBus.emit(ViewingChangedEvent(this@UranosEntity, element, true)) }
|
Uranos.scope.launch { Uranos.eventBus.emit(ViewingChangedEvent(this@UranosEntity, element, true)) }
|
||||||
}
|
}
|
||||||
|
@ -70,10 +76,16 @@ sealed class UranosEntity(server: UranosServer) : Entity {
|
||||||
|
|
||||||
abstract class UranosLivingEntity(server: UranosServer) : UranosEntity(server), LivingEntity {
|
abstract class UranosLivingEntity(server: UranosServer) : UranosEntity(server), LivingEntity {
|
||||||
override var velocity: Vector = Vector.ZERO
|
override var velocity: Vector = Vector.ZERO
|
||||||
override var headPitch: Float = 0f
|
|
||||||
|
|
||||||
|
private var lastSentPosition: Position = Position.ZERO
|
||||||
override var position: Position by container.ifChanged(Position.ZERO) { value ->
|
override var position: Position by container.ifChanged(Position.ZERO) { value ->
|
||||||
// TODO: Broadcast to players
|
if (viewers.isNotEmpty()) createEntityMovementPacket(numericID, lastSentPosition, value)?.let {
|
||||||
|
for (viewer in viewers) {
|
||||||
|
viewer.session.sendNextTick(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSentPosition = value
|
||||||
}
|
}
|
||||||
|
|
||||||
override val chunkKey: Chunk.Key by memoized({ position }) { Chunk.Key.from(position) }
|
override val chunkKey: Chunk.Key by memoized({ position }) { Chunk.Key.from(position) }
|
||||||
|
@ -82,8 +94,13 @@ abstract class UranosLivingEntity(server: UranosServer) : UranosEntity(server),
|
||||||
abstract class UranosObjectEntity(server: UranosServer) : UranosEntity(server), ObjectEntity {
|
abstract class UranosObjectEntity(server: UranosServer) : UranosEntity(server), ObjectEntity {
|
||||||
override var velocity: Vector = Vector.ZERO
|
override var velocity: Vector = Vector.ZERO
|
||||||
|
|
||||||
|
private var lastSentPosition: Position = Position.ZERO
|
||||||
override var position: Position by container.ifChanged(Position.ZERO) { value ->
|
override var position: Position by container.ifChanged(Position.ZERO) { value ->
|
||||||
// TODO: Broadcast to players
|
createEntityMovementPacket(numericID, lastSentPosition, value)?.let {
|
||||||
|
for (viewer in viewers) {
|
||||||
|
viewer.session.sendNextTick(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val chunkKey: Chunk.Key by memoized({ position }) { Chunk.Key.from(position) }
|
override val chunkKey: Chunk.Key by memoized({ position }) { Chunk.Key.from(position) }
|
||||||
|
|
|
@ -62,11 +62,10 @@ class UranosPlayer(
|
||||||
|
|
||||||
override val entity: PlayerEntity = session.server.createPlayerEntity(this).also {
|
override val entity: PlayerEntity = session.server.createPlayerEntity(this).also {
|
||||||
it.position = position
|
it.position = position
|
||||||
it.headPitch = headPitch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun spawnInitially(world: World) {
|
suspend fun spawnInitially(world: World) {
|
||||||
session.server.entities.forEach { if (it.visibleToNewPlayers) it.viewers.add(this) }
|
session.server.entities.forEach { if (it.visibleToNewPlayers && it != entity) it.viewers.add(this) }
|
||||||
entity.setWorld(world)
|
entity.setWorld(world)
|
||||||
updateCurrentlyViewedChunks()
|
updateCurrentlyViewedChunks()
|
||||||
sendChunksAndLight()
|
sendChunksAndLight()
|
||||||
|
|
Reference in a new issue