Trying a new approach. Introduced DefaultObject.get_object_typeclass() and cleaned up .create() hooks. Building commands now use the new logic.
This commit is contained in:
parent
7746ff1663
commit
0da7f962c2
2 changed files with 178 additions and 150 deletions
|
|
@ -579,10 +579,6 @@ class CmdCreate(ObjManipCommand):
|
||||||
locks = "cmd:perm(create) or perm(Builder)"
|
locks = "cmd:perm(create) or perm(Builder)"
|
||||||
help_category = "Building"
|
help_category = "Building"
|
||||||
|
|
||||||
# lockstring of newly created objects, for easy overloading.
|
|
||||||
# Will be formatted with the {id} of the creating object.
|
|
||||||
new_obj_lockstring = "control:id({id}) or perm(Admin);delete:id({id}) or perm(Admin)"
|
|
||||||
|
|
||||||
def func(self):
|
def func(self):
|
||||||
"""
|
"""
|
||||||
Creates the object.
|
Creates the object.
|
||||||
|
|
@ -600,26 +596,26 @@ class CmdCreate(ObjManipCommand):
|
||||||
string = ""
|
string = ""
|
||||||
name = objdef["name"]
|
name = objdef["name"]
|
||||||
aliases = objdef["aliases"]
|
aliases = objdef["aliases"]
|
||||||
typeclass = objdef["option"]
|
|
||||||
|
|
||||||
# create object (if not a valid typeclass, the default
|
obj_typeclass, errors = caller.get_object_typeclass(obj_type="object", typeclass=objdef["option"])
|
||||||
# object typeclass will automatically be used)
|
if errors:
|
||||||
lockstring = self.new_obj_lockstring.format(id=caller.id)
|
self.msg(errors)
|
||||||
if (err := caller.can_build_object()):
|
if not obj_typeclass:
|
||||||
caller.msg(err)
|
continue
|
||||||
return
|
|
||||||
obj = create.create_object(
|
obj, errors = obj_typeclass.create(
|
||||||
typeclass,
|
|
||||||
name,
|
name,
|
||||||
caller,
|
caller,
|
||||||
home=caller,
|
home=caller,
|
||||||
aliases=aliases,
|
aliases=aliases,
|
||||||
locks=lockstring,
|
|
||||||
report_to=caller,
|
report_to=caller,
|
||||||
|
creator=caller
|
||||||
)
|
)
|
||||||
|
if errors:
|
||||||
|
self.msg(errors)
|
||||||
if not obj:
|
if not obj:
|
||||||
continue
|
continue
|
||||||
obj.at_object_constructed(caller)
|
|
||||||
if aliases:
|
if aliases:
|
||||||
string = (
|
string = (
|
||||||
f"You create a new {obj.typename}: {obj.name} (aliases: {', '.join(aliases)})."
|
f"You create a new {obj.typename}: {obj.name} (aliases: {', '.join(aliases)})."
|
||||||
|
|
@ -928,25 +924,27 @@ class CmdDig(ObjManipCommand):
|
||||||
location = caller.location
|
location = caller.location
|
||||||
|
|
||||||
# Create the new room
|
# Create the new room
|
||||||
typeclass = room["option"]
|
room_typeclass, errors = caller.get_object_typeclass(obj_type="room", typeclass=room["option"], method="dig")
|
||||||
if not typeclass:
|
if errors:
|
||||||
typeclass = settings.BASE_ROOM_TYPECLASS
|
self.msg("|rError creating room:|n %s" % errors)
|
||||||
|
if not room_typeclass:
|
||||||
|
return
|
||||||
|
|
||||||
# create room
|
# create room
|
||||||
if (err := caller.can_build_object()):
|
new_room, errors = room_typeclass.create(
|
||||||
caller.msg(err)
|
room["name"], aliases=room["aliases"], report_to=caller, creator=caller, method="dig"
|
||||||
return
|
|
||||||
new_room = create.create_object(
|
|
||||||
typeclass, room["name"], aliases=room["aliases"], report_to=caller
|
|
||||||
)
|
)
|
||||||
lockstring = self.new_room_lockstring.format(id=caller.id)
|
if errors:
|
||||||
new_room.locks.add(lockstring)
|
self.msg("|rError creating room:|n %s" % errors)
|
||||||
|
if not new_room:
|
||||||
|
return
|
||||||
|
|
||||||
alias_string = ""
|
alias_string = ""
|
||||||
if new_room.aliases.all():
|
if new_room.aliases.all():
|
||||||
alias_string = " (%s)" % ", ".join(new_room.aliases.all())
|
alias_string = " (%s)" % ", ".join(new_room.aliases.all())
|
||||||
new_room.at_object_constructed(caller)
|
|
||||||
room_string = (
|
room_string = (
|
||||||
f"Created room {new_room}({new_room.dbref}){alias_string} of type {typeclass}."
|
f"Created room {new_room}({new_room.dbref}){alias_string} of type {new_room}."
|
||||||
)
|
)
|
||||||
|
|
||||||
# create exit to room
|
# create exit to room
|
||||||
|
|
@ -962,21 +960,27 @@ class CmdDig(ObjManipCommand):
|
||||||
exit_to_string = "\nYou cannot create an exit from a None-location."
|
exit_to_string = "\nYou cannot create an exit from a None-location."
|
||||||
else:
|
else:
|
||||||
# Build the exit to the new room from the current one
|
# Build the exit to the new room from the current one
|
||||||
typeclass = to_exit["option"]
|
exit_typeclass, errors = caller.get_object_typeclass(obj_type="exit", typeclass=to_exit["option"],
|
||||||
if not typeclass:
|
method="dig")
|
||||||
typeclass = settings.BASE_EXIT_TYPECLASS
|
if errors:
|
||||||
if (err := caller.can_build_object()):
|
self.msg("|rError creating exit:|n %s" % errors)
|
||||||
caller.msg(err)
|
if not exit_typeclass:
|
||||||
return
|
return
|
||||||
new_to_exit = create.create_object(
|
|
||||||
typeclass,
|
new_to_exit, errors = exit_typeclass.create(
|
||||||
to_exit["name"],
|
to_exit["name"],
|
||||||
location,
|
location=location,
|
||||||
aliases=to_exit["aliases"],
|
|
||||||
locks=lockstring,
|
|
||||||
destination=new_room,
|
destination=new_room,
|
||||||
|
aliases=to_exit["aliases"],
|
||||||
report_to=caller,
|
report_to=caller,
|
||||||
|
creator=caller,
|
||||||
|
method="dig"
|
||||||
)
|
)
|
||||||
|
if errors:
|
||||||
|
self.msg("|rError creating exit:|n %s" % errors)
|
||||||
|
if not new_to_exit:
|
||||||
|
return
|
||||||
|
|
||||||
alias_string = ""
|
alias_string = ""
|
||||||
if new_to_exit.aliases.all():
|
if new_to_exit.aliases.all():
|
||||||
alias_string = " (%s)" % ", ".join(new_to_exit.aliases.all())
|
alias_string = " (%s)" % ", ".join(new_to_exit.aliases.all())
|
||||||
|
|
@ -984,7 +988,6 @@ class CmdDig(ObjManipCommand):
|
||||||
f"\nCreated Exit from {location.name} to {new_room.name}:"
|
f"\nCreated Exit from {location.name} to {new_room.name}:"
|
||||||
f" {new_to_exit}({new_to_exit.dbref}){alias_string}."
|
f" {new_to_exit}({new_to_exit.dbref}){alias_string}."
|
||||||
)
|
)
|
||||||
new_to_exit.at_object_constructed(caller)
|
|
||||||
|
|
||||||
# Create exit back from new room
|
# Create exit back from new room
|
||||||
|
|
||||||
|
|
@ -996,21 +999,25 @@ class CmdDig(ObjManipCommand):
|
||||||
elif not location:
|
elif not location:
|
||||||
exit_back_string = "\nYou cannot create an exit back to a None-location."
|
exit_back_string = "\nYou cannot create an exit back to a None-location."
|
||||||
else:
|
else:
|
||||||
typeclass = back_exit["option"]
|
exit_typeclass, errors = caller.get_object_typeclass(obj_type="exit", typeclass=back_exit["option"],
|
||||||
if not typeclass:
|
method="dig")
|
||||||
typeclass = settings.BASE_EXIT_TYPECLASS
|
if errors:
|
||||||
if (err := caller.can_build_object()):
|
self.msg("|rError creating exit:|n %s" % errors)
|
||||||
caller.msg(err)
|
if not exit_typeclass:
|
||||||
return
|
return
|
||||||
new_back_exit = create.create_object(
|
new_back_exit, errors = exit_typeclass.create(
|
||||||
typeclass,
|
|
||||||
back_exit["name"],
|
back_exit["name"],
|
||||||
new_room,
|
location=new_room,
|
||||||
aliases=back_exit["aliases"],
|
|
||||||
locks=lockstring,
|
|
||||||
destination=location,
|
destination=location,
|
||||||
|
aliases=back_exit["aliases"],
|
||||||
report_to=caller,
|
report_to=caller,
|
||||||
|
creator=caller,
|
||||||
|
method="dig"
|
||||||
)
|
)
|
||||||
|
if errors:
|
||||||
|
self.msg("|rError creating exit:|n %s" % errors)
|
||||||
|
if not new_back_exit:
|
||||||
|
return
|
||||||
alias_string = ""
|
alias_string = ""
|
||||||
if new_back_exit.aliases.all():
|
if new_back_exit.aliases.all():
|
||||||
alias_string = " (%s)" % ", ".join(new_back_exit.aliases.all())
|
alias_string = " (%s)" % ", ".join(new_back_exit.aliases.all())
|
||||||
|
|
@ -1018,7 +1025,6 @@ class CmdDig(ObjManipCommand):
|
||||||
f"\nCreated Exit back from {new_room.name} to {location.name}:"
|
f"\nCreated Exit back from {new_room.name} to {location.name}:"
|
||||||
f" {new_back_exit}({new_back_exit.dbref}){alias_string}."
|
f" {new_back_exit}({new_back_exit.dbref}){alias_string}."
|
||||||
)
|
)
|
||||||
new_back_exit.at_object_constructed(caller)
|
|
||||||
caller.msg(f"{room_string}{exit_to_string}{exit_back_string}")
|
caller.msg(f"{room_string}{exit_to_string}{exit_back_string}")
|
||||||
if new_room and "teleport" in self.switches:
|
if new_room and "teleport" in self.switches:
|
||||||
caller.move_to(new_room, move_type="teleport")
|
caller.move_to(new_room, move_type="teleport")
|
||||||
|
|
@ -1489,20 +1495,23 @@ class CmdOpen(ObjManipCommand):
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# exit does not exist before. Create a new one.
|
# exit does not exist before. Create a new one.
|
||||||
lockstring = self.new_obj_lockstring.format(id=caller.id)
|
exit_typeclass, errors = caller.get_object_typeclass(obj_type="exit", typeclass=typeclass, method="open")
|
||||||
if not typeclass:
|
if errors:
|
||||||
typeclass = settings.BASE_EXIT_TYPECLASS
|
self.msg("|rError creating exit:|n %s" % errors)
|
||||||
if (err := caller.can_build_object()):
|
if not exit_typeclass:
|
||||||
caller.msg(err)
|
|
||||||
return
|
return
|
||||||
exit_obj = create.create_object(
|
exit_obj, errors = exit_typeclass.create(
|
||||||
typeclass,
|
exit_name,
|
||||||
key=exit_name,
|
|
||||||
location=location,
|
location=location,
|
||||||
aliases=exit_aliases,
|
aliases=exit_aliases,
|
||||||
locks=lockstring,
|
|
||||||
report_to=caller,
|
report_to=caller,
|
||||||
|
creator=caller,
|
||||||
|
method="open"
|
||||||
)
|
)
|
||||||
|
if errors:
|
||||||
|
self.msg("|rError creating exit:|n %s" % errors)
|
||||||
|
if not exit_obj:
|
||||||
|
return
|
||||||
if exit_obj:
|
if exit_obj:
|
||||||
# storing a destination is what makes it an exit!
|
# storing a destination is what makes it an exit!
|
||||||
exit_obj.destination = destination
|
exit_obj.destination = destination
|
||||||
|
|
@ -1515,7 +1524,6 @@ class CmdOpen(ObjManipCommand):
|
||||||
f"Created new Exit '{exit_name}' from {location.name} to"
|
f"Created new Exit '{exit_name}' from {location.name} to"
|
||||||
f" {destination.name}{string}."
|
f" {destination.name}{string}."
|
||||||
)
|
)
|
||||||
exit_obj.at_object_constructed(caller)
|
|
||||||
else:
|
else:
|
||||||
string = f"Error: Exit '{exit.name}' not created."
|
string = f"Error: Exit '{exit.name}' not created."
|
||||||
# emit results
|
# emit results
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ This is the v1.0 develop version (for ref in doc building).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
import typing
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
import inflect
|
import inflect
|
||||||
|
|
@ -222,6 +223,13 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
{footer}
|
{footer}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
default_typeclasses = {
|
||||||
|
"object": settings.BASE_OBJECT_TYPECLASS,
|
||||||
|
"character": settings.BASE_CHARACTER_TYPECLASS,
|
||||||
|
"room": settings.BASE_ROOM_TYPECLASS,
|
||||||
|
"exit": settings.BASE_EXIT_TYPECLASS,
|
||||||
|
}
|
||||||
|
|
||||||
# on-object properties
|
# on-object properties
|
||||||
|
|
||||||
@lazy_property
|
@lazy_property
|
||||||
|
|
@ -1012,7 +1020,8 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
obj.move_to(home, move_type="teleport")
|
obj.move_to(home, move_type="teleport")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, key, account=None, **kwargs):
|
def create(cls, key: str, account: "DefaultAccount" = None, creator: "DefaultObject" = None, method: str = "create",
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
Creates a basic object with default parameters, unless otherwise
|
Creates a basic object with default parameters, unless otherwise
|
||||||
specified or extended.
|
specified or extended.
|
||||||
|
|
@ -1021,11 +1030,14 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key (str): Name of the new object.
|
key (str): Name of the new object.
|
||||||
account (Account): Account to attribute this object to.
|
|
||||||
|
|
||||||
Keyword Args:
|
Keyword Args:
|
||||||
|
account (Account): Account to attribute this object to.
|
||||||
|
creator (DefaultObject): The object which is creating this one.
|
||||||
description (str): Brief description for this object.
|
description (str): Brief description for this object.
|
||||||
ip (str): IP address of creator (for object auditing).
|
ip (str): IP address of creator (for object auditing).
|
||||||
|
method (str): The method of creation. Defaults to "create".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
object (Object): A newly created object of the given typeclass.
|
object (Object): A newly created object of the given typeclass.
|
||||||
|
|
@ -1598,37 +1610,42 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def can_build_object(self):
|
def get_object_typeclass(self, obj_type: str = "object", typeclass: str = None, method: str = "create", **kwargs) -> tuple[
|
||||||
|
typing.Optional["Builder"], list[str]]:
|
||||||
"""
|
"""
|
||||||
This hook is called by the build command to determine if
|
This hook is called by build commands to determine which typeclass to use for a specific purpose. For instance,
|
||||||
self can build a new object. This is called before the
|
when using dig, the system can use this to autodetect which kind of Room typeclass to use based on where the
|
||||||
object is created. As it receives no arguments, it
|
builder is currently located.
|
||||||
can only be used to determine if self is allowed to build
|
|
||||||
anything in the current context.
|
|
||||||
|
|
||||||
For instance, a room may want to limit its number of exits,
|
Note: Although intended to be used with typeclasses, as long as this hook returns a class with a create method,
|
||||||
or the builder might have an enforced quota limit for rooms
|
which accepts the same API as DefaultObject.create(), build commands and other places should take it.
|
||||||
they can add to their custom dungeon.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
can_build (str or None): If self is allowed to build objects.
|
|
||||||
return a string as the error if there is a problem. If
|
|
||||||
this returns True, the build will abort.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def at_object_constructed(self, builder):
|
|
||||||
"""
|
|
||||||
Called when the object is constructed by a builder.
|
|
||||||
This is used to implement custom logic for building,
|
|
||||||
such as quota tracking systems, auto-tagging of rooms,
|
|
||||||
creation of logical groups of rooms like Zones or
|
|
||||||
dungeons, etc.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
builder (Object): The object that constructed this object.
|
obj_type (str, optional): The type of object that is being created. Defaults to "object". Evennia provides
|
||||||
|
"room", "exit", and "character" by default, but this can be extended.
|
||||||
|
typeclass (str, optional): The typeclass that was requested by the player. Defaults to None.
|
||||||
|
Can also be an actual class.
|
||||||
|
method (str, optional): The method that is calling this hook. Defaults to "create". Others are "dig", "open",
|
||||||
|
"tunnel", etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
results_tuple (tuple[Optional[Builder], list[str]]): A tuple containing the typeclass to use and a list of
|
||||||
|
errors. (which might be empty.)
|
||||||
"""
|
"""
|
||||||
pass
|
|
||||||
|
found_typeclass = typeclass or self.default_typeclasses.get(obj_type, None)
|
||||||
|
if not found_typeclass:
|
||||||
|
return None, [f"No typeclass found for object type '{obj_type}'."]
|
||||||
|
|
||||||
|
try:
|
||||||
|
type_class = class_from_module(found_typeclass) if isinstance(found_typeclass, str) else found_typeclass
|
||||||
|
except ImportError:
|
||||||
|
return None, [f"Typeclass '{found_typeclass}' could not be imported."]
|
||||||
|
|
||||||
|
if not hasattr(type_class, "create"):
|
||||||
|
return None, [f"Typeclass '{found_typeclass}' is not creatable."]
|
||||||
|
|
||||||
|
return type_class, []
|
||||||
|
|
||||||
def at_pre_puppet(self, account, session=None, **kwargs):
|
def at_pre_puppet(self, account, session=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1696,7 +1713,6 @@ class DefaultObject(ObjectDB, metaclass=TypeclassBase):
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def at_server_reload(self):
|
def at_server_reload(self):
|
||||||
"""
|
"""
|
||||||
This hook is called whenever the server is shutting down for
|
This hook is called whenever the server is shutting down for
|
||||||
|
|
@ -2752,7 +2768,6 @@ class DefaultCharacter(DefaultObject):
|
||||||
if not self.sessions.count():
|
if not self.sessions.count():
|
||||||
# only remove this char from grid if no sessions control it anymore.
|
# only remove this char from grid if no sessions control it anymore.
|
||||||
if self.location:
|
if self.location:
|
||||||
|
|
||||||
def message(obj, from_obj):
|
def message(obj, from_obj):
|
||||||
obj.msg(
|
obj.msg(
|
||||||
_("{name} has left the game{reason}.").format(
|
_("{name} has left the game{reason}.").format(
|
||||||
|
|
@ -2814,7 +2829,8 @@ class DefaultRoom(DefaultObject):
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, key, account=None, **kwargs):
|
def create(cls, key: str, account: "DefaultAccount" = None, creator: DefaultObject = None, method: str = "create",
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
Creates a basic Room with default parameters, unless otherwise
|
Creates a basic Room with default parameters, unless otherwise
|
||||||
specified or extended.
|
specified or extended.
|
||||||
|
|
@ -2823,13 +2839,15 @@ class DefaultRoom(DefaultObject):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key (str): Name of the new Room.
|
key (str): Name of the new Room.
|
||||||
account (obj, optional): Account to associate this Room with. If
|
|
||||||
given, it will be given specific control/edit permissions to this
|
|
||||||
object (along with normal Admin perms). If not given, default
|
|
||||||
|
|
||||||
Keyword Args:
|
Keyword Args:
|
||||||
|
account (DefaultAccount, optional): Account to associate this Room with. If
|
||||||
|
given, it will be given specific control/edit permissions to this
|
||||||
|
object (along with normal Admin perms). If not given, default
|
||||||
|
creator (DefaultObject): The object which is creating this one.
|
||||||
description (str): Brief description for this object.
|
description (str): Brief description for this object.
|
||||||
ip (str): IP address of creator (for object auditing).
|
ip (str): IP address of creator (for object auditing).
|
||||||
|
method (str): The method used to create the room. Defaults to "create".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
room (Object): A newly created Room of the given typeclass.
|
room (Object): A newly created Room of the given typeclass.
|
||||||
|
|
@ -3020,7 +3038,8 @@ class DefaultExit(DefaultObject):
|
||||||
# Command hooks
|
# Command hooks
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, key, source, dest, account=None, **kwargs):
|
def create(cls, key: str, location: DefaultRoom = None, destination: DefaultRoom = None, account: "DefaultAccount" = None, creator: DefaultObject = None,
|
||||||
|
method: str = "create", **kwargs) -> tuple[typing.Optional["DefaultExit"], list[str]]:
|
||||||
"""
|
"""
|
||||||
Creates a basic Exit with default parameters, unless otherwise
|
Creates a basic Exit with default parameters, unless otherwise
|
||||||
specified or extended.
|
specified or extended.
|
||||||
|
|
@ -3030,13 +3049,14 @@ class DefaultExit(DefaultObject):
|
||||||
Args:
|
Args:
|
||||||
key (str): Name of the new Exit, as it should appear from the
|
key (str): Name of the new Exit, as it should appear from the
|
||||||
source room.
|
source room.
|
||||||
account (obj): Account to associate this Exit with.
|
location (Room): The room to create this exit in.
|
||||||
source (Room): The room to create this exit in.
|
|
||||||
dest (Room): The room to which this exit should go.
|
|
||||||
|
|
||||||
Keyword Args:
|
Keyword Args:
|
||||||
|
account (obj): Account to associate this Exit with.
|
||||||
|
creator (ObjectDB): the Object creating this Object.
|
||||||
description (str): Brief description for this object.
|
description (str): Brief description for this object.
|
||||||
ip (str): IP address of creator (for object auditing).
|
ip (str): IP address of creator (for object auditing).
|
||||||
|
destination (Room): The room to which this exit should go.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
exit (Object): A newly created Room of the given typeclass.
|
exit (Object): A newly created Room of the given typeclass.
|
||||||
|
|
@ -3059,8 +3079,8 @@ class DefaultExit(DefaultObject):
|
||||||
kwargs["report_to"] = kwargs.pop("report_to", account)
|
kwargs["report_to"] = kwargs.pop("report_to", account)
|
||||||
|
|
||||||
# Set to/from rooms
|
# Set to/from rooms
|
||||||
kwargs["location"] = source
|
kwargs["location"] = location
|
||||||
kwargs["destination"] = dest
|
kwargs["destination"] = destination
|
||||||
|
|
||||||
description = kwargs.pop("description", "")
|
description = kwargs.pop("description", "")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue