Add documentation, move ByteBuf extensions into an object and generify SessionContainer
This commit is contained in:
parent
3c85b6c105
commit
6daa459fa6
23 changed files with 215 additions and 132 deletions
|
@ -1,14 +1,20 @@
|
|||
package space.blokk
|
||||
|
||||
import space.blokk.net.SessionContainer
|
||||
import space.blokk.events.EventTargetGroup
|
||||
import space.blokk.net.Session
|
||||
import space.blokk.server.Server
|
||||
|
||||
interface BlokkProvider {
|
||||
val server: Server
|
||||
val sessions: SessionContainer
|
||||
|
||||
/**
|
||||
* [EventTargetGroup] instance containing all sessions connected to the server.
|
||||
*/
|
||||
val sessions: EventTargetGroup<Session>
|
||||
}
|
||||
|
||||
object Blokk: BlokkProvider {
|
||||
// Is assigned by BlokkServer using reflection
|
||||
private var provider: BlokkProvider? = null
|
||||
override val server get() = provider!!.server
|
||||
override val sessions get() = provider!!.sessions
|
||||
|
|
|
@ -19,6 +19,9 @@ sealed class ChatComponent {
|
|||
|
||||
data class TextComponent(val text: String, override val extra: ChatComponent? = null): ChatComponent() {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a new [TextComponent] instance using [text] and returns it.
|
||||
*/
|
||||
infix fun of(text: String) = TextComponent(text)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package space.blokk.chat
|
||||
|
||||
/**
|
||||
* Legacy formatting codes. You should use [space.blokk.chat.ChatComponent] whenever it it possible, but sometimes
|
||||
* these codes are required, for example in the name of a player in the server list sample
|
||||
* ([space.blokk.net.protocols.status.ResponsePacket.Players.SampleEntry]).
|
||||
* Legacy formatting codes. You should use [ChatComponent][space.blokk.chat.ChatComponent] whenever it's possible, but sometimes
|
||||
* these codes are required, for example in
|
||||
* [the name of a player in a server list sample][space.blokk.net.protocols.status.ResponsePacket.Players.SampleEntry.name].
|
||||
*/
|
||||
enum class FormattingCode(private val char: Char) {
|
||||
BLACK('0'),
|
||||
|
|
|
@ -3,3 +3,13 @@ package space.blokk.events
|
|||
interface Cancellable {
|
||||
var isCancelled: Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Only executes [fn] if [isCancelled][Cancellable.isCancelled] is true.
|
||||
*/
|
||||
inline fun Cancellable.ifCancelled(fn: () -> Unit) = if (isCancelled) fn() else Unit
|
||||
|
||||
/**
|
||||
* Only executes [fn] if [isCancelled][Cancellable.isCancelled] is false.
|
||||
*/
|
||||
inline fun Cancellable.ifNotCancelled(fn: () -> Unit) = if (!isCancelled) fn() else Unit
|
||||
|
|
|
@ -8,15 +8,24 @@ class EventBus<EventT: Event>(eventType: KClass<EventT>) {
|
|||
private val eventType: Class<EventT> = eventType.java
|
||||
|
||||
/**
|
||||
* All event handlers, sorted by their priority and the order in which they were inserted
|
||||
* All event handlers, sorted by their priority and the order in which they were registered.
|
||||
*/
|
||||
private val handlers = mutableListOf<Handler>()
|
||||
|
||||
/**
|
||||
* Invokes all previously registered event handlers sorted by their priority
|
||||
* and the order in which they were registered.
|
||||
*/
|
||||
fun <T: EventT> emit(event: T): T {
|
||||
handlers.filter { it.eventType.isInstance(event) }.forEach { it.fn.invoke(it.listener, event) }
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all [event handlers][EventHandler] in [listener] to be invoked when their corresponding event is emitted.
|
||||
*
|
||||
* @throws InvalidEventHandlerException if one of the event handlers does not meet the requirements
|
||||
*/
|
||||
fun register(listener: Listener) {
|
||||
val handlersOfListener = listener::class.java.methods
|
||||
.mapNotNull { method -> method.getAnnotation(EventHandler::class.java)?.let { method to it } }
|
||||
|
@ -39,7 +48,7 @@ class EventBus<EventT: Event>(eventType: KClass<EventT>) {
|
|||
}
|
||||
}
|
||||
|
||||
class InvalidEventHandlerException(message: String): Exception(message)
|
||||
class InvalidEventHandlerException internal constructor(message: String): Exception(message)
|
||||
|
||||
private data class Handler(
|
||||
val eventType: Class<out Event>,
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package space.blokk.events
|
||||
|
||||
interface EventTarget<T: Event> {
|
||||
val eventBus: EventBus<T>
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package space.blokk.events
|
||||
|
||||
interface EventTargetGroup<T: EventTarget<*>> : Iterable<T> {
|
||||
fun registerListener(listener: Listener)
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
package space.blokk.net
|
||||
|
||||
import io.netty.buffer.ByteBuf
|
||||
import java.io.IOException
|
||||
import kotlin.experimental.and
|
||||
|
||||
fun ByteBuf.readVarInt(): Int {
|
||||
var bytesRead = 0
|
||||
var result = 0
|
||||
|
||||
do {
|
||||
if (bytesRead == 5) throw IOException("VarInt is longer than the maximum of 5 bytes")
|
||||
|
||||
val byte = readByte()
|
||||
val value: Int = (byte and 0b01111111).toInt()
|
||||
result = result or (value shl (7 * bytesRead))
|
||||
bytesRead += 1
|
||||
} while ((byte and 0b10000000.toByte()).toInt() != 0)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun ByteBuf.varIntReadable(): Boolean {
|
||||
if (readableBytes() > 5) {
|
||||
// maximum VarInt size
|
||||
return true
|
||||
}
|
||||
|
||||
val initialIndex = readerIndex()
|
||||
do {
|
||||
if (readableBytes() < 1) {
|
||||
readerIndex(initialIndex)
|
||||
return false;
|
||||
}
|
||||
|
||||
val value = readByte().toInt()
|
||||
} while ((value and 0b10000000) != 0);
|
||||
|
||||
readerIndex(initialIndex)
|
||||
return true;
|
||||
}
|
||||
|
||||
fun ByteBuf.writeVarInt(value: Int) {
|
||||
var v = value
|
||||
var part: Byte
|
||||
while (true) {
|
||||
part = (v and 0x7F).toByte()
|
||||
v = v ushr 7
|
||||
if (v != 0) {
|
||||
part = (part.toInt() or 0x80).toByte()
|
||||
}
|
||||
writeByte(part.toInt())
|
||||
if (v == 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ByteBuf.readString(): String {
|
||||
val length = readVarInt()
|
||||
val bytes = ByteArray(length)
|
||||
readBytes(bytes)
|
||||
return bytes.toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun ByteBuf.writeString(value: String) {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8)
|
||||
writeVarInt(bytes.size)
|
||||
writeBytes(bytes)
|
||||
}
|
103
blokk-api/src/main/kotlin/space/blokk/net/MinecraftDataTypes.kt
Normal file
103
blokk-api/src/main/kotlin/space/blokk/net/MinecraftDataTypes.kt
Normal file
|
@ -0,0 +1,103 @@
|
|||
package space.blokk.net
|
||||
|
||||
import io.netty.buffer.ByteBuf
|
||||
import java.io.IOException
|
||||
import kotlin.experimental.and
|
||||
|
||||
/**
|
||||
* Extension functions on [ByteBuf] for reading and writing data types used by the Minecraft protocol
|
||||
*
|
||||
* You can access them using the [with] function (`with(MinecraftDataTypes) { ... }`).
|
||||
*/
|
||||
object MinecraftDataTypes {
|
||||
/**
|
||||
* Gets a variable length integer at the current readerIndex and increases the readerIndex by up to 5 in this buffer.
|
||||
*
|
||||
* @throws IOException if the VarInt is longer than the maximum of 5 bytes
|
||||
* @see <a href="https://wiki.vg/Protocol#VarInt_and_VarLong">https://wiki.vg/Protocol#VarInt_and_VarLong</a>
|
||||
*/
|
||||
fun ByteBuf.readVarInt(): Int {
|
||||
var bytesRead = 0
|
||||
var result = 0
|
||||
|
||||
do {
|
||||
if (bytesRead == 5) throw IOException("VarInt is longer than the maximum of 5 bytes")
|
||||
|
||||
val byte = readByte()
|
||||
val value: Int = (byte and 0b01111111).toInt()
|
||||
result = result or (value shl (7 * bytesRead))
|
||||
bytesRead += 1
|
||||
} while ((byte and 0b10000000.toByte()).toInt() != 0)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a VarInt can be read starting from the current readerIndex.
|
||||
*/
|
||||
fun ByteBuf.varIntReadable(): Boolean {
|
||||
if (readableBytes() > 5) {
|
||||
// maximum VarInt size
|
||||
return true
|
||||
}
|
||||
|
||||
val initialIndex = readerIndex()
|
||||
do {
|
||||
if (readableBytes() < 1) {
|
||||
readerIndex(initialIndex)
|
||||
return false
|
||||
}
|
||||
|
||||
val value = readByte().toInt()
|
||||
} while ((value and 0b10000000) != 0)
|
||||
|
||||
readerIndex(initialIndex)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a variable length integer at the current writerIndex and increases the writerIndex by up to 5 in this buffer.
|
||||
*
|
||||
* @see <a href="https://wiki.vg/Protocol#VarInt_and_VarLong">https://wiki.vg/Protocol#VarInt_and_VarLong</a>
|
||||
*/
|
||||
fun ByteBuf.writeVarInt(value: Int) {
|
||||
var v = value
|
||||
var part: Byte
|
||||
while (true) {
|
||||
part = (v and 0x7F).toByte()
|
||||
v = v ushr 7
|
||||
if (v != 0) {
|
||||
part = (part.toInt() or 0x80).toByte()
|
||||
}
|
||||
writeByte(part.toInt())
|
||||
if (v == 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a variable length UTF-8 encoded string at the current readerIndex and increases the readerIndex in this
|
||||
* buffer by the number of bytes read.
|
||||
*
|
||||
* @see <a href="https://wiki.vg/Protocol#Data_types">https://wiki.vg/Protocol#Data_types</a>
|
||||
*/
|
||||
fun ByteBuf.readString(): String {
|
||||
val length = readVarInt()
|
||||
val bytes = ByteArray(length)
|
||||
readBytes(bytes)
|
||||
return bytes.toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a variable length UTF-8 encoded string at the current writerIndex and increases the writerIndex in this
|
||||
* buffer by the number of bytes written.
|
||||
*
|
||||
* @see <a href="https://wiki.vg/Protocol#Data_types">https://wiki.vg/Protocol#Data_types</a>
|
||||
*/
|
||||
fun ByteBuf.writeString(value: String) {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8)
|
||||
writeVarInt(bytes.size)
|
||||
writeBytes(bytes)
|
||||
}
|
||||
}
|
|
@ -1,16 +1,17 @@
|
|||
package space.blokk.net
|
||||
|
||||
import space.blokk.events.EventBus
|
||||
import space.blokk.events.EventTarget
|
||||
import space.blokk.net.events.SessionEvent
|
||||
import space.blokk.net.protocols.OutgoingPacket
|
||||
import space.blokk.net.protocols.Protocol
|
||||
import java.net.InetAddress
|
||||
|
||||
interface Session {
|
||||
interface Session: EventTarget<SessionEvent> {
|
||||
/**
|
||||
* The protocol this session is currently using.
|
||||
*/
|
||||
var currentProtocol: Protocol
|
||||
val address: InetAddress
|
||||
|
||||
fun send(packet: OutgoingPacket)
|
||||
|
||||
val eventBus: EventBus<SessionEvent>
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
package space.blokk.net
|
||||
|
||||
import space.blokk.events.Listener
|
||||
|
||||
interface SessionContainer: Iterable<Session> {
|
||||
fun registerGlobalListener(listener: Listener)
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package space.blokk.net.events
|
||||
|
||||
import space.blokk.events.Event
|
||||
import space.blokk.net.Session
|
||||
|
||||
abstract class SessionEvent: Event()
|
||||
abstract class SessionEvent(val session: Session): Event()
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
package space.blokk.net.events
|
||||
|
||||
import space.blokk.events.Cancellable
|
||||
import space.blokk.net.Session
|
||||
import space.blokk.net.protocols.IncomingPacket
|
||||
|
||||
class SessionPacketReceivedEvent(var packet: IncomingPacket): SessionEvent(), Cancellable {
|
||||
class SessionPacketReceivedEvent(session: Session, var packet: IncomingPacket): SessionEvent(session), Cancellable {
|
||||
override var isCancelled = false
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
package space.blokk.net.events
|
||||
|
||||
import space.blokk.events.Cancellable
|
||||
import space.blokk.net.Session
|
||||
import space.blokk.net.protocols.OutgoingPacket
|
||||
|
||||
class SessionPacketSendEvent(var packet: OutgoingPacket): SessionEvent(), Cancellable {
|
||||
class SessionPacketSendEvent(session: Session, var packet: OutgoingPacket): SessionEvent(session), Cancellable {
|
||||
override var isCancelled = false
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package space.blokk.net.protocols
|
||||
|
||||
abstract class Protocol(packets: Set<PacketCompanion<*>>) {
|
||||
abstract class Protocol internal constructor(packets: Set<PacketCompanion<*>>) {
|
||||
val incomingPackets = packets.filterIsInstance<IncomingPacketCompanion<*>>()
|
||||
val incomingPacketsByID = incomingPackets.mapToIDMap()
|
||||
val outgoingPackets = packets.filterIsInstance<OutgoingPacketCompanion<*>>()
|
||||
|
|
|
@ -6,5 +6,5 @@ import space.blokk.net.protocols.status.StatusProtocol
|
|||
|
||||
object Protocols {
|
||||
val all = setOf(HandshakingProtocol, StatusProtocol, LoginProtocol)
|
||||
fun validate() = all.forEach(Protocol::validate)
|
||||
internal fun validate() = all.forEach(Protocol::validate)
|
||||
}
|
||||
|
|
|
@ -1,32 +1,33 @@
|
|||
package space.blokk.net.protocols.handshaking
|
||||
|
||||
import io.netty.buffer.ByteBuf
|
||||
import space.blokk.net.MinecraftDataTypes
|
||||
import space.blokk.net.Session
|
||||
import space.blokk.net.protocols.IncomingPacket
|
||||
import space.blokk.net.protocols.IncomingPacketCompanion
|
||||
import space.blokk.net.protocols.login.LoginProtocol
|
||||
import space.blokk.net.protocols.status.StatusProtocol
|
||||
import space.blokk.net.readString
|
||||
import space.blokk.net.readVarInt
|
||||
|
||||
data class HandshakePacket(
|
||||
val protocolVersion: Int,
|
||||
val serverAddress: String,
|
||||
val serverPort: Int,
|
||||
val login: Boolean
|
||||
val isLoginAttempt: Boolean
|
||||
): IncomingPacket() {
|
||||
override fun handle(session: Session) {
|
||||
session.currentProtocol = if (login) LoginProtocol else StatusProtocol
|
||||
session.currentProtocol = if (isLoginAttempt) LoginProtocol else StatusProtocol
|
||||
}
|
||||
|
||||
companion object: IncomingPacketCompanion<HandshakePacket>(0x00, HandshakePacket::class) {
|
||||
override fun decode(msg: ByteBuf): HandshakePacket {
|
||||
return HandshakePacket(
|
||||
protocolVersion = msg.readVarInt(),
|
||||
serverAddress = msg.readString(),
|
||||
serverPort = msg.readUnsignedShort(),
|
||||
login = msg.readVarInt() == 2
|
||||
)
|
||||
return with(MinecraftDataTypes) {
|
||||
HandshakePacket(
|
||||
protocolVersion = msg.readVarInt(),
|
||||
serverAddress = msg.readString(),
|
||||
serverPort = msg.readUnsignedShort(),
|
||||
isLoginAttempt = msg.readVarInt() == 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,9 @@ package space.blokk.net.protocols.status
|
|||
import com.google.gson.GsonBuilder
|
||||
import io.netty.buffer.ByteBuf
|
||||
import space.blokk.chat.TextComponent
|
||||
import space.blokk.net.MinecraftDataTypes
|
||||
import space.blokk.net.protocols.OutgoingPacket
|
||||
import space.blokk.net.protocols.OutgoingPacketCompanion
|
||||
import space.blokk.net.writeString
|
||||
import java.util.*
|
||||
|
||||
data class ResponsePacket(
|
||||
|
@ -29,18 +29,24 @@ data class ResponsePacket(
|
|||
}
|
||||
|
||||
override fun encode(dst: ByteBuf) {
|
||||
dst.writeString(gson.toJson(mapOf(
|
||||
"version" to mapOf(
|
||||
"name" to versionName,
|
||||
"protocol" to protocolVersion
|
||||
),
|
||||
"players" to players,
|
||||
"description" to description,
|
||||
"favicon" to favicon
|
||||
)))
|
||||
with(MinecraftDataTypes) {
|
||||
dst.writeString(gson.toJson(mapOf(
|
||||
"version" to mapOf(
|
||||
"name" to versionName,
|
||||
"protocol" to protocolVersion
|
||||
),
|
||||
"players" to players,
|
||||
"description" to description,
|
||||
"favicon" to favicon
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
data class Players(val max: Int, val online: Int, val sample: List<SampleEntry>) {
|
||||
/**
|
||||
* @param name The name of the player. You can use [FormattingCode](space.blokk.chat.FormattingCode)s
|
||||
* @param id The UUID of the player as a string. You can use a random UUID as it doesn't get validated by the Minecraft client
|
||||
*/
|
||||
data class SampleEntry(val name: String, val id: String)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
package space.blokk.server
|
||||
|
||||
import space.blokk.events.EventBus
|
||||
import space.blokk.events.EventTarget
|
||||
import space.blokk.server.events.ServerEvent
|
||||
import space.blokk.utils.Logger
|
||||
|
||||
interface Server {
|
||||
interface Server: EventTarget<ServerEvent> {
|
||||
val logger: Logger
|
||||
val eventBus: EventBus<ServerEvent>
|
||||
|
||||
override val eventBus: EventBus<ServerEvent>
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
package space.blokk.net
|
||||
|
||||
import space.blokk.events.EventTargetGroup
|
||||
import space.blokk.events.Listener
|
||||
|
||||
class BlokkSessionContainer: SessionContainer {
|
||||
class BlokkSessionContainer: EventTargetGroup<Session> {
|
||||
private val sessions = mutableSetOf<Session>()
|
||||
private val listeners = mutableSetOf<Listener>()
|
||||
|
||||
override fun registerGlobalListener(listener: Listener) {
|
||||
override fun registerListener(listener: Listener) {
|
||||
sessions.forEach { it.eventBus.register(listener) }
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
|
|
@ -8,24 +8,27 @@ class FramingCodec: ByteToMessageCodec<ByteBuf>() {
|
|||
private var currentLength: Int? = null
|
||||
|
||||
override fun encode(ctx: ChannelHandlerContext, msg: ByteBuf, out: ByteBuf) {
|
||||
out.writeVarInt(msg.readableBytes())
|
||||
with(MinecraftDataTypes) { out.writeVarInt(msg.readableBytes()) }
|
||||
msg.readerIndex(0)
|
||||
out.writeBytes(msg)
|
||||
}
|
||||
|
||||
override fun decode(ctx: ChannelHandlerContext, msg: ByteBuf, out: MutableList<Any>) {
|
||||
var length = currentLength
|
||||
if (length == null) {
|
||||
if (msg.varIntReadable()) {
|
||||
length = msg.readVarInt()
|
||||
currentLength = length
|
||||
}
|
||||
else return
|
||||
}
|
||||
with(MinecraftDataTypes) {
|
||||
var length = currentLength
|
||||
|
||||
if (msg.readableBytes() >= length) {
|
||||
out.add(msg.readBytes(length))
|
||||
currentLength = null
|
||||
if (length == null) {
|
||||
if (msg.varIntReadable()) {
|
||||
length = msg.readVarInt()
|
||||
currentLength = length
|
||||
}
|
||||
else return
|
||||
}
|
||||
|
||||
if (msg.readableBytes() >= length) {
|
||||
out.add(msg.readBytes(length))
|
||||
currentLength = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,13 +25,13 @@ class PacketCodec(private val blokkSocketServer: BlokkSocketServer): MessageToMe
|
|||
override fun encode(ctx: ChannelHandlerContext, msg: PacketMessage, out: MutableList<Any>) {
|
||||
if (msg.packet !is OutgoingPacket) throw Error("Only clientbound packets can be sent")
|
||||
val buffer = ctx.alloc().buffer()
|
||||
buffer.writeVarInt(msg.packetCompanion.id)
|
||||
with(MinecraftDataTypes) { buffer.writeVarInt(msg.packetCompanion.id) }
|
||||
msg.packet.encode(buffer)
|
||||
out.add(buffer)
|
||||
}
|
||||
|
||||
override fun decode(ctx: ChannelHandlerContext, msg: ByteBuf, out: MutableList<Any>) {
|
||||
val packetID = msg.readVarInt()
|
||||
val packetID = with(MinecraftDataTypes) { msg.readVarInt() }
|
||||
val data = msg.readBytes(msg.readableBytes())
|
||||
|
||||
val packetCompanion = session.currentProtocol.incomingPacketsByID[packetID]
|
||||
|
|
|
@ -3,6 +3,7 @@ package space.blokk.net
|
|||
import io.netty.channel.ChannelHandlerContext
|
||||
import io.netty.channel.SimpleChannelInboundHandler
|
||||
import space.blokk.BlokkServer
|
||||
import space.blokk.events.ifNotCancelled
|
||||
import space.blokk.net.events.SessionPacketReceivedEvent
|
||||
import space.blokk.net.protocols.IncomingPacket
|
||||
|
||||
|
@ -11,8 +12,9 @@ class PacketMessageHandler: SimpleChannelInboundHandler<PacketMessage>() {
|
|||
if (msg.packet !is IncomingPacket) throw Error("Only serverbound packets can be handled")
|
||||
BlokkServer.instance.blokkSocketServer.logger.debug { "Packet received: ${msg.packet}" }
|
||||
|
||||
if (!msg.session.eventBus.emit(SessionPacketReceivedEvent(msg.packet)).isCancelled)
|
||||
msg.session.eventBus.emit(SessionPacketReceivedEvent(msg.session, msg.packet)).ifNotCancelled {
|
||||
msg.packet.handle(msg.session)
|
||||
}
|
||||
|
||||
// TODO: Disconnect when invalid data is received
|
||||
}
|
||||
|
|
Reference in a new issue