feat(*): 优化培育开始流程
1. SimpleDispatcher 类新增支持 click() 点击指定区域,与 until() 退出条件 2. Context* 类中的 wait_* 系列方法支持在手动截图模式中使用 3. 新增 button_state() 函数,用于判断一个游戏 UI 按钮是否禁用 4. 优化培育开始流程
This commit is contained in:
parent
4bddee0959
commit
f1a05e8cfb
|
@ -159,6 +159,13 @@ def warn_manual_screenshot_mode(name: str, alternative: str):
|
|||
KotonebotWarning
|
||||
)
|
||||
|
||||
def is_manual_screenshot_mode() -> bool:
|
||||
"""
|
||||
检查当前是否处于手动截图模式。
|
||||
"""
|
||||
mode = ContextStackVars.ensure_current().screenshot_mode
|
||||
return mode == 'manual' or mode == 'manual-inherit'
|
||||
|
||||
class ContextGlobalVars:
|
||||
def __init__(self):
|
||||
self.auto_collect: bool = False
|
||||
|
@ -317,10 +324,12 @@ class ContextOcr:
|
|||
"""
|
||||
等待指定文本出现。
|
||||
"""
|
||||
warn_manual_screenshot_mode("expect_wait", "find()")
|
||||
is_manual = is_manual_screenshot_mode()
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if is_manual:
|
||||
device.screenshot()
|
||||
result = self.find(pattern, rect=rect, hint=hint)
|
||||
|
||||
if result is not None:
|
||||
|
@ -342,10 +351,12 @@ class ContextOcr:
|
|||
"""
|
||||
等待指定文本出现。
|
||||
"""
|
||||
warn_manual_screenshot_mode("wait_for", "find()")
|
||||
is_manual = is_manual_screenshot_mode()
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if is_manual:
|
||||
device.screenshot()
|
||||
result = self.find(pattern, rect=rect, hint=hint)
|
||||
if result is not None:
|
||||
self.context.device.last_find = result.original_rect if result else None
|
||||
|
@ -378,10 +389,12 @@ class ContextImage:
|
|||
"""
|
||||
等待指定图像出现。
|
||||
"""
|
||||
warn_manual_screenshot_mode("wait_for", "find()")
|
||||
is_manual = is_manual_screenshot_mode()
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if is_manual:
|
||||
device.screenshot()
|
||||
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
|
||||
if ret is not None:
|
||||
self.context.device.last_find = ret
|
||||
|
@ -404,7 +417,7 @@ class ContextImage:
|
|||
"""
|
||||
等待指定图像中的任意一个出现。
|
||||
"""
|
||||
warn_manual_screenshot_mode("wait_for_any", "find()")
|
||||
is_manual = is_manual_screenshot_mode()
|
||||
|
||||
if masks is None:
|
||||
_masks = [None] * len(templates)
|
||||
|
@ -412,6 +425,8 @@ class ContextImage:
|
|||
_masks = masks
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if is_manual:
|
||||
device.screenshot()
|
||||
for template, mask in zip(templates, _masks):
|
||||
if self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored):
|
||||
return True
|
||||
|
@ -433,10 +448,12 @@ class ContextImage:
|
|||
"""
|
||||
等待指定图像出现。
|
||||
"""
|
||||
warn_manual_screenshot_mode("expect_wait", "find()")
|
||||
is_manual = is_manual_screenshot_mode()
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if is_manual:
|
||||
device.screenshot()
|
||||
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
|
||||
if ret is not None:
|
||||
self.context.device.last_find = ret
|
||||
|
@ -459,7 +476,7 @@ class ContextImage:
|
|||
"""
|
||||
等待指定图像中的任意一个出现。
|
||||
"""
|
||||
warn_manual_screenshot_mode("expect_wait_any", "find()")
|
||||
is_manual = is_manual_screenshot_mode()
|
||||
|
||||
if masks is None:
|
||||
_masks = [None] * len(templates)
|
||||
|
@ -467,6 +484,8 @@ class ContextImage:
|
|||
_masks = masks
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if is_manual:
|
||||
device.screenshot()
|
||||
for template, mask in zip(templates, _masks):
|
||||
ret = self.find(template, mask, transparent=transparent, threshold=threshold, colored=colored)
|
||||
if ret is not None:
|
||||
|
|
|
@ -5,12 +5,13 @@ import inspect
|
|||
from logging import Logger
|
||||
from types import CodeType
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Callable, Concatenate, TypeVar, ParamSpec, Literal, Protocol, cast
|
||||
from typing import Annotated, Any, Callable, Concatenate, Sequence, TypeVar, ParamSpec, Literal, Protocol, cast
|
||||
from typing_extensions import Self
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from kotonebot.backend.ocr import StringMatchFunction
|
||||
from kotonebot.backend.util import Rect, is_rect
|
||||
|
||||
from .core import Image
|
||||
|
||||
|
@ -118,7 +119,7 @@ class ClickParams:
|
|||
finish: bool = False
|
||||
log: str | None = None
|
||||
|
||||
class Click:
|
||||
class ClickCenter:
|
||||
def __init__(self, sd: 'SimpleDispatcher', target: Image | str | StringMatchFunction | Literal['center'], *, params: ClickParams = ClickParams()):
|
||||
self.target = target
|
||||
self.params = params
|
||||
|
@ -182,6 +183,56 @@ class ClickText:
|
|||
if self.params.finish:
|
||||
self.sd.finished = True
|
||||
|
||||
class ClickRect:
|
||||
def __init__(self, sd: 'SimpleDispatcher', rect: Rect, *, params: ClickParams = ClickParams()):
|
||||
self.rect = rect
|
||||
self.params = params
|
||||
self.sd = sd
|
||||
|
||||
def __call__(self):
|
||||
from kotonebot import device
|
||||
if device.click(self.rect):
|
||||
if self.params.log:
|
||||
self.sd.logger.info(self.params.log)
|
||||
if self.params.finish:
|
||||
self.sd.finished = True
|
||||
|
||||
class UntilText:
|
||||
def __init__(
|
||||
self,
|
||||
sd: 'SimpleDispatcher',
|
||||
text: str | StringMatchFunction,
|
||||
*,
|
||||
rect: Rect | None = None
|
||||
):
|
||||
self.text = text
|
||||
self.sd = sd
|
||||
self.rect = rect
|
||||
|
||||
def __call__(self):
|
||||
from kotonebot import ocr
|
||||
if ocr.find(self.text, rect=self.rect):
|
||||
self.sd.finished = True
|
||||
|
||||
class UntilImage:
|
||||
def __init__(
|
||||
self,
|
||||
sd: 'SimpleDispatcher',
|
||||
image: Image,
|
||||
*,
|
||||
rect: Rect | None = None
|
||||
):
|
||||
self.image = image
|
||||
self.sd = sd
|
||||
self.rect = rect
|
||||
|
||||
def __call__(self):
|
||||
from kotonebot import image
|
||||
if self.rect:
|
||||
logger.warning(f'UntilImage with rect is deprecated. Use UntilText instead.')
|
||||
if image.find(self.image):
|
||||
self.sd.finished = True
|
||||
|
||||
class SimpleDispatcher:
|
||||
def __init__(self, name: str, *, interval: float = 0.2):
|
||||
self.name = name
|
||||
|
@ -193,7 +244,7 @@ class SimpleDispatcher:
|
|||
|
||||
def click(
|
||||
self,
|
||||
target: Image | str | StringMatchFunction | Literal['center'],
|
||||
target: Image | StringMatchFunction | Literal['center'] | Rect,
|
||||
*,
|
||||
finish: bool = False,
|
||||
log: str | None = None
|
||||
|
@ -201,8 +252,14 @@ class SimpleDispatcher:
|
|||
params = ClickParams(finish=finish, log=log)
|
||||
if isinstance(target, Image):
|
||||
self.blocks.append(ClickImage(self, target, params=params))
|
||||
else:
|
||||
elif is_rect(target):
|
||||
self.blocks.append(ClickRect(self, target, params=params))
|
||||
elif callable(target):
|
||||
self.blocks.append(ClickText(self, target, params=params))
|
||||
elif target == 'center':
|
||||
self.blocks.append(ClickCenter(self, target='center', params=params))
|
||||
else:
|
||||
raise ValueError(f'Invalid target: {target}')
|
||||
return self
|
||||
|
||||
def click_any(
|
||||
|
@ -216,6 +273,18 @@ class SimpleDispatcher:
|
|||
self.blocks.append(ClickImageAny(self, target, params))
|
||||
return self
|
||||
|
||||
def until(
|
||||
self,
|
||||
text: StringMatchFunction | Image,
|
||||
*,
|
||||
rect: Rect | None = None
|
||||
):
|
||||
if isinstance(text, Image):
|
||||
self.blocks.append(UntilImage(self, text, rect=rect))
|
||||
else:
|
||||
self.blocks.append(UntilText(self, text, rect=rect))
|
||||
return self
|
||||
|
||||
def run(self):
|
||||
from kotonebot import device, sleep
|
||||
while True:
|
||||
|
|
|
@ -6,7 +6,7 @@ import logging
|
|||
import cProfile
|
||||
from importlib import resources
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Callable, TYPE_CHECKING
|
||||
from typing import Literal, Callable, TYPE_CHECKING, TypeGuard
|
||||
|
||||
import cv2
|
||||
from cv2.typing import MatLike
|
||||
|
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||
Rect = typing.Sequence[int]
|
||||
"""左上X, 左上Y, 宽度, 高度"""
|
||||
|
||||
def is_rect(rect: typing.Any) -> bool:
|
||||
def is_rect(rect: typing.Any) -> TypeGuard[Rect]:
|
||||
return isinstance(rect, typing.Sequence) and len(rect) == 4 and all(isinstance(i, int) for i in rect)
|
||||
|
||||
def crop(img: MatLike, /, x1: float = 0, y1: float = 0, x2: float = 1, y2: float = 1) -> MatLike:
|
||||
|
@ -210,7 +210,7 @@ class Countdown:
|
|||
self.start_time = time.time()
|
||||
|
||||
class Interval:
|
||||
def __init__(self, seconds: float):
|
||||
def __init__(self, seconds: float = 0.3):
|
||||
self.seconds = seconds
|
||||
self.start_time = time.time()
|
||||
self.last_wait_time = 0
|
||||
|
@ -223,6 +223,9 @@ class Interval:
|
|||
self.last_wait_time = time.time() - self.start_time
|
||||
self.start_time = time.time()
|
||||
|
||||
def reset(self):
|
||||
self.start_time = time.time()
|
||||
|
||||
package_mode: Literal['wheel', 'standalone'] | None = None
|
||||
def res_path(path: str) -> str:
|
||||
"""
|
||||
|
|
|
@ -429,7 +429,8 @@ def practice():
|
|||
# 结束动画
|
||||
logger.info("CLEAR/PERFECT not found. Practice finished.")
|
||||
(SimpleDispatcher('practice.end')
|
||||
.click(contains("上昇"), finish=True, log="Click to finish 上昇 ")
|
||||
.click(contains("上昇"), finish=True, log="Click to finish 上昇")
|
||||
.until(contains("審査基準"))
|
||||
.click('center')
|
||||
).run()
|
||||
|
||||
|
@ -846,14 +847,14 @@ if __name__ == '__main__':
|
|||
|
||||
# practice()
|
||||
# week_final_exam()
|
||||
exam('final')
|
||||
produce_end()
|
||||
# exam('final')
|
||||
# produce_end()
|
||||
|
||||
|
||||
# hajime_pro(start_from=15)
|
||||
# exam('mid')
|
||||
# stage = (detect_regular_produce_scene())
|
||||
# hajime_regular_from_stage(stage)
|
||||
stage = (detect_regular_produce_scene())
|
||||
hajime_regular_from_stage(stage)
|
||||
|
||||
# click_recommended_card(card_count=skill_card_count())
|
||||
# exam('mid')
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
from typing import Literal
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot import action, device, color, image
|
||||
from kotonebot.backend.util import Rect
|
||||
from kotonebot.backend.core import Image
|
||||
|
||||
@action('按钮是否禁用', screenshot_mode='manual-inherit')
|
||||
def button_state(*, target: Image | None = None, rect: Rect | None = None) -> bool | None:
|
||||
"""
|
||||
判断按钮是否处于禁用状态。
|
||||
|
||||
:param rect: 按钮的矩形区域。必须包括文字或图标部分。
|
||||
:param target: 按钮目标模板。
|
||||
"""
|
||||
img = device.screenshot()
|
||||
if rect is not None:
|
||||
_rect = rect
|
||||
elif target is not None:
|
||||
result = image.find(target)
|
||||
if result is None:
|
||||
return None
|
||||
_rect = result.rect
|
||||
else:
|
||||
raise ValueError('Either rect or target must be provided.')
|
||||
if color.find_rgb('#babcbd', rect=_rect):
|
||||
return False
|
||||
elif color.find_rgb('#ffffff', rect=_rect):
|
||||
return True
|
||||
else:
|
||||
raise ValueError(f'Unknown button state: {img}')
|
|
@ -2,14 +2,16 @@ import logging
|
|||
from itertools import cycle
|
||||
from typing import Optional
|
||||
|
||||
from kotonebot.backend.dispatch import SimpleDispatcher
|
||||
from kotonebot.backend.util import Countdown, Interval
|
||||
from kotonebot.ui import user
|
||||
|
||||
from . import R
|
||||
from .common import conf, PIdol
|
||||
from .actions.loading import wait_loading_end
|
||||
from .game_ui import button_state
|
||||
from .actions.scenes import at_home, goto_home
|
||||
from .actions.in_purodyuusu import hajime_regular
|
||||
from kotonebot import device, image, ocr, task, action, sleep, equals, contains
|
||||
from .actions.scenes import loading, at_home, goto_home
|
||||
from kotonebot import device, image, ocr, task, action, sleep, equals, contains, Rect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -33,7 +35,7 @@ def unify(arr: list[int]):
|
|||
i = j
|
||||
return result
|
||||
|
||||
@action('选择P偶像')
|
||||
@action('选择P偶像', screenshot_mode='manual-inherit')
|
||||
def select_idol(target_titles: list[str] | PIdol):
|
||||
"""
|
||||
选择目标P偶像
|
||||
|
@ -113,8 +115,8 @@ def resume_produce():
|
|||
logger.info('Click resume button.')
|
||||
device.click(image.expect_wait(R.Produce.ButtonResume))
|
||||
|
||||
@action('执行培育')
|
||||
def do_produce(idol: PIdol | None = None):
|
||||
@action('执行培育', screenshot_mode='manual-inherit')
|
||||
def do_produce(idol: PIdol):
|
||||
"""
|
||||
进行培育流程
|
||||
|
||||
|
@ -125,68 +127,51 @@ def do_produce(idol: PIdol | None = None):
|
|||
"""
|
||||
if not at_home():
|
||||
goto_home()
|
||||
|
||||
device.screenshot()
|
||||
# 有进行中培育的情况
|
||||
if ocr.find(contains('プロデュース中'), rect=R.Produce.BoxProduceOngoing):
|
||||
logger.info('Ongoing produce found. Try to resume produce.')
|
||||
resume_produce()
|
||||
return
|
||||
# [screenshots/produce/home.png]
|
||||
device.click(image.expect_wait(R.Produce.ButtonProduce))
|
||||
sleep(0.3)
|
||||
wait_loading_end()
|
||||
# [screenshots/produce/regular_or_pro.png]
|
||||
# device.click(image.expect_wait(R.Produce.ButtonRegular))
|
||||
# 解锁 PRO 和解锁 PRO 前的 REGULAR 字体大小好像不一样。这里暂时用 OCR 代替
|
||||
# TODO: 截图比较解锁 PRO 前和解锁后 REGULAR 的文字图像
|
||||
device.click(ocr.expect_wait('REGULAR'))
|
||||
sleep(0.3)
|
||||
wait_loading_end()
|
||||
# 选择 PIdol [screenshots/produce/select_p_idol.png]
|
||||
if idol:
|
||||
select_idol(idol.value)
|
||||
elif conf().produce.idols:
|
||||
select_idol(conf().produce.idols[0].value) # TODO: 支持多次培育
|
||||
else:
|
||||
logger.warning('No PIdol specified. Using default idol.')
|
||||
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
|
||||
|
||||
sleep(0.1)
|
||||
# 选择支援卡 自动编成 [screenshots/produce/select_support_card.png]
|
||||
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
|
||||
sleep(0.1)
|
||||
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
|
||||
sleep(1.3)
|
||||
# 0. 进入培育页面
|
||||
(SimpleDispatcher('enter_produce')
|
||||
.click(R.Produce.ButtonProduce)
|
||||
.click(contains('REGULAR'))
|
||||
.until(R.Produce.ButtonPIdolOverview)
|
||||
).run()
|
||||
# 1. 选择 PIdol [screenshots/produce/select_p_idol.png]
|
||||
select_idol(idol.value)
|
||||
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
|
||||
# 选择回忆 自动编成 [screenshots/produce/select_memory.png]
|
||||
# 2. 选择支援卡 自动编成 [screenshots/produce/select_support_card.png]
|
||||
ocr.expect_wait(contains('サポート'), rect=R.Produce.BoxStepIndicator)
|
||||
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
|
||||
sleep(1.3)
|
||||
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
|
||||
sleep(0.1)
|
||||
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
|
||||
sleep(0.6)
|
||||
# 不租赁回忆提示弹窗 [screenshots/produce/no_rent_memory_dialog.png]
|
||||
with device.pinned():
|
||||
if image.find(R.Produce.TextRentAvailable):
|
||||
device.click(image.expect(R.Common.ButtonNextNoIcon))
|
||||
sleep(0.3)
|
||||
# 选择道具 [screenshots/produce/select_end.png]
|
||||
# 3. 选择回忆 自动编成 [screenshots/produce/select_memory.png]
|
||||
ocr.expect_wait(contains('メモリー'), rect=R.Produce.BoxStepIndicator)
|
||||
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
|
||||
device.screenshot()
|
||||
(SimpleDispatcher('do_produce.step_3')
|
||||
.click(R.Common.ButtonNextNoIcon)
|
||||
.click(R.Common.ButtonConfirm)
|
||||
.until(contains('開始確認'), rect=R.Produce.BoxStepIndicator)
|
||||
).run()
|
||||
# 4. 选择道具 [screenshots/produce/select_end.png]
|
||||
if conf().produce.use_note_boost:
|
||||
device.click(image.expect_wait(R.Produce.CheckboxIconNoteBoost))
|
||||
sleep(0.2)
|
||||
if conf().produce.use_pt_boost:
|
||||
device.click(image.expect_wait(R.Produce.CheckboxIconSupportPtBoost))
|
||||
sleep(0.2)
|
||||
device.click(image.expect_wait(R.Produce.ButtonProduceStart))
|
||||
sleep(0.5)
|
||||
# while not loading():
|
||||
# # 跳过交流设置 [screenshots/produce/skip_commu.png]
|
||||
# with device.pinned():
|
||||
# if image.find(R.Produce.RadioTextSkipCommu):
|
||||
# device.click()
|
||||
# sleep(0.2)
|
||||
# if image.find(R.Common.ButtonConfirmNoIcon):
|
||||
# device.click()
|
||||
# wait_loading_end()
|
||||
# 5. 相关设置弹窗 [screenshots/produce/skip_commu.png]
|
||||
cd = Countdown(5)
|
||||
while not cd.expired():
|
||||
device.screenshot()
|
||||
if image.find(R.Produce.RadioTextSkipCommu):
|
||||
device.click()
|
||||
if image.find(R.Common.ButtonConfirmNoIcon):
|
||||
device.click()
|
||||
hajime_regular()
|
||||
|
||||
@task('培育')
|
||||
|
@ -219,10 +204,18 @@ def produce_task(count: Optional[int] = None, idols: Optional[list[PIdol]] = Non
|
|||
end_time = time.time()
|
||||
logger.info(f"Produce time used: {format_time(end_time - start_time)}")
|
||||
|
||||
@action('测试')
|
||||
def a():
|
||||
ocr.expect_wait(contains('メモリー'), rect=R.Produce.BoxStepIndicator)
|
||||
device.click(image.expect_wait(R.Produce.ButtonAutoSet))
|
||||
device.click(image.expect_wait(R.Common.ButtonConfirm, colored=True))
|
||||
device.click(image.expect_wait(R.Common.ButtonNextNoIcon))
|
||||
|
||||
if __name__ == '__main__':
|
||||
import logging
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
logging.getLogger('kotonebot').setLevel(logging.DEBUG)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
do_produce()
|
||||
do_produce(conf().produce.idols[0])
|
||||
# a()
|
||||
# select_idol(PIdol.藤田ことね_学園生活)
|
Binary file not shown.
After Width: | Height: | Size: 790 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe":{"name":"Produce.BoxModeButtons","displayName":"培育模式选择按钮","type":"hint-box","annotationId":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","useHintRect":false}},"annotations":[{"id":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","type":"rect","data":{"x1":7,"y1":818,"x2":713,"y2":996}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"}]}
|
||||
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.BoxStepIndicator","displayName":"培育准备页面 当前步骤","type":"hint-box","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":4,"y1":11,"x2":405,"y2":99}}]}
|
Loading…
Reference in New Issue