feat: add example mine config, &#RRGGBB color support, up to 100 selection points
This commit is contained in:
parent
0f2735f171
commit
f2be6d6ece
6 changed files with 445 additions and 28 deletions
182
mines/example_mine.yml
Normal file
182
mines/example_mine.yml
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# ============================================
|
||||
# LoMines - Пример конфигурации шахты
|
||||
# ============================================
|
||||
|
||||
# Название шахты (для отображения в сообщениях и голограммах)
|
||||
# Поддерживает цвета в формате &#RRGGBB (HEX)
|
||||
# Пример: &#FF0000Красная �FF00шахта
|
||||
name: "&#FFD700Золотая шахта&#FFFFFF"
|
||||
|
||||
# ============================================
|
||||
# РЕГИОНЫ (области спавна блоков)
|
||||
# ============================================
|
||||
# Можно указать несколько регионов (до 100)
|
||||
# Каждый регион = пара точек (от угла до угла)
|
||||
# Все регионы должны быть в ОДНОМ мире
|
||||
#
|
||||
# Формат: мир;x;y;z;yaw;pitch
|
||||
# yaw/pitch можно опустить (будет 0;0)
|
||||
|
||||
region:
|
||||
selection:
|
||||
'1': "world;-100.0;64.0;-100.0"
|
||||
'2': "world;-90.0;74.0;-90.0"
|
||||
# Можно добавить больше регионов:
|
||||
# '3': "world;-80.0;64.0;-100.0"
|
||||
# '4': "world;-70.0;74.0;-90.0"
|
||||
|
||||
# ============================================
|
||||
# БЛОКИ И ШАНСЫ
|
||||
# ============================================
|
||||
# Шансы суммируются и нормализуются автоматически
|
||||
# Можно использовать материалы из Minecraft
|
||||
# Для Oraxen: oraxen:id_предмета
|
||||
# Для ItemsAdder: ia:id_предмета
|
||||
|
||||
blocks:
|
||||
contents:
|
||||
stone: 50.0
|
||||
coal_ore: 20.0
|
||||
iron_ore: 15.0
|
||||
gold_ore: 10.0
|
||||
diamond_ore: 4.0
|
||||
emerald_ore: 1.0
|
||||
|
||||
# Режим заполнения:
|
||||
# - cuboid: заполнить весь кубоид блоками
|
||||
# - mask: только определённые позиции (см. ниже)
|
||||
fill-mode: cuboid
|
||||
|
||||
# Для режима mask (опционально):
|
||||
# mask:
|
||||
# marker: pink_concrete # Блок-маркер для позиций
|
||||
# positions: [] # Позиции будут заполнены при сканировании
|
||||
|
||||
# ============================================
|
||||
# СБРОС ШАХТЫ
|
||||
# ============================================
|
||||
reset:
|
||||
# Интервал сброса
|
||||
# Форматы: 30s (секунды), 5m (минуты), 2h (часы), 1d (дни)
|
||||
interval: "10m"
|
||||
|
||||
# Процентный триггер (сброс при % заполненности)
|
||||
# -1 или 0 = отключено
|
||||
percent-trigger: -1.0
|
||||
percent-enabled: false
|
||||
|
||||
# Сообщение при сбросе (пусто = стандартное)
|
||||
# Поддерживает плейсхолдеры: {mine}, {time}
|
||||
broadcast: "�FF00Шахта &#FFFF00{mine} �FF00сброшена!"
|
||||
|
||||
# Команды при сбросе (опционально)
|
||||
commands: []
|
||||
# - "eco give <player> 100"
|
||||
# - "broadcast Шахта {mine} сброшена!"
|
||||
|
||||
# ============================================
|
||||
# ТЕЛЕПОРТАЦИЯ ПРИ СБРОСЕ
|
||||
# ============================================
|
||||
teleport:
|
||||
enabled: false
|
||||
location: "" # Формат: мир;x;y;z;yaw;pitch
|
||||
|
||||
# Точка спавна для "застрявших" игроков
|
||||
# (если игрок оказался внутри блока после сброса)
|
||||
spawn:
|
||||
enabled: false
|
||||
location: "" # Если не задано - используется teleport.location
|
||||
|
||||
# ============================================
|
||||
# НАГРАДЫ ПРИ ДОБЫЧЕ
|
||||
# ============================================
|
||||
# Выдаются с определённым шансом при ломании блоков
|
||||
|
||||
rewards: []
|
||||
# Пример награды:
|
||||
# - chance: 0.001 # 0.1% шанс
|
||||
# prevent-drops: false # Отменить дроп блока?
|
||||
# blocks: # На каких блоках работает
|
||||
# - diamond_ore
|
||||
# - emerald_ore
|
||||
# items: # Предметы (опционально)
|
||||
# - type: diamond
|
||||
# amount: 1
|
||||
# name: "�FFFFРедкий алмаз"
|
||||
# commands: # Команды (опционально)
|
||||
# - "eco give <player> 1000"
|
||||
|
||||
# ============================================
|
||||
# ИНТЕРФЕЙС (UI)
|
||||
# ============================================
|
||||
ui:
|
||||
# ActionBar (полоска над хотбаром)
|
||||
actionbar:
|
||||
enabled: true
|
||||
# Дистанция показа (в блоках)
|
||||
range: 50.0
|
||||
# Формат строки
|
||||
# Плейсхолдеры: {mine}, {percent}, {time}, {blocks}, {total}
|
||||
format: "&#FFD700{mine} &#AAAAAA| �FF00{percent}% &#AAAAAA| &#FFFF00{time}"
|
||||
|
||||
# Голограмма над шахтой (требует HolographicDisplays или DecentHolograms)
|
||||
hologram:
|
||||
enabled: false
|
||||
# Высота голограммы от центра региона
|
||||
height: 3.0
|
||||
format:
|
||||
- "&#FFD700★ {mine} ★"
|
||||
- "&#AAAAAAЗаполнено: �FF00{percent}%"
|
||||
- "&#AAAAAAСброс через: &#FFFF00{time}"
|
||||
|
||||
# Формат времени для плейсхолдеров
|
||||
# 1 = HH:MM:SS (01:25:35)
|
||||
# 2 = короткий (25m 35s)
|
||||
# 3 = текстовый (1ч 25м 35с)
|
||||
timer-format: 2
|
||||
|
||||
# ============================================
|
||||
# WORLDGUARD ИНТЕГРАЦИЯ (опционально)
|
||||
# ============================================
|
||||
worldguard:
|
||||
enabled: false
|
||||
# Шаблон названия региона
|
||||
# {mine_name} = название шахты
|
||||
# {random_4} = 4 случайных символа
|
||||
region-template: "mine_{mine_name}_{random_4}"
|
||||
|
||||
# Владельцы региона (uuid:xxx или имя)
|
||||
owners: []
|
||||
|
||||
# Участники региона
|
||||
members: []
|
||||
|
||||
# Флаги WorldGuard
|
||||
flags:
|
||||
- "passthrough deny"
|
||||
- "build -g members allow"
|
||||
- "pvp deny"
|
||||
|
||||
# ============================================
|
||||
# ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ
|
||||
# ============================================
|
||||
|
||||
# Задержка между установкой блоков (в тиках, 20 тиков = 1 сек)
|
||||
# 0 = мгновенно (может лагать на больших шахтах)
|
||||
# Больше = плавнее, но медленнее
|
||||
block-place-delay: 0
|
||||
|
||||
# Тип установки блоков:
|
||||
# - fast: быстрый (рекомендуется)
|
||||
# - bukkit: стандартный (медленнее, но стабильнее)
|
||||
# - parallel: параллельный (экспериментально)
|
||||
setter: fast
|
||||
|
||||
# Разрешить физику блоков (песок, гравий падают)?
|
||||
block-physics: false
|
||||
|
||||
# Разрешить спавн сущностей в шахте?
|
||||
entity-spawning: false
|
||||
|
||||
# Разрешить течение жидкостей?
|
||||
liquid-flow: false
|
||||
|
|
@ -17,32 +17,17 @@ public final class RegionConfigLoader {
|
|||
|
||||
/**
|
||||
* Parses region configuration from YAML.
|
||||
* Supports up to 100 selection points (50 regions).
|
||||
*/
|
||||
public RegionConfig parse(YamlConfiguration yaml) {
|
||||
List<Location> selections = new ArrayList<>();
|
||||
|
||||
// Try new format first
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
String key = "region.selection." + i;
|
||||
if (yaml.contains(key)) {
|
||||
String locStr = yaml.getString(key);
|
||||
if (locStr != null && !locStr.isBlank()) {
|
||||
selections.add(parseLocation(locStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try new format first (region.selection.X)
|
||||
selections.addAll(parseSelections(yaml, "region.selection.", 100));
|
||||
|
||||
// Legacy fallback
|
||||
// Legacy fallback (selection.X)
|
||||
if (selections.isEmpty()) {
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
String key = "selection." + i;
|
||||
if (yaml.contains(key)) {
|
||||
String locStr = yaml.getString(key);
|
||||
if (locStr != null && !locStr.isBlank()) {
|
||||
selections.add(parseLocation(locStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
selections.addAll(parseSelections(yaml, "selection.", 100));
|
||||
}
|
||||
|
||||
if (selections.size() < 2) {
|
||||
|
|
@ -52,6 +37,43 @@ public final class RegionConfigLoader {
|
|||
return RegionConfig.fromSelections(selections);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses selection points from YAML with given prefix.
|
||||
* Supports indexed format: prefix.1, prefix.2, etc.
|
||||
*/
|
||||
private List<Location> parseSelections(YamlConfiguration yaml, String prefix, int maxPoints) {
|
||||
List<Location> selections = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i <= maxPoints; i++) {
|
||||
String key = prefix + i;
|
||||
if (yaml.contains(key)) {
|
||||
String locStr = yaml.getString(key);
|
||||
if (locStr != null && !locStr.isBlank()) {
|
||||
try {
|
||||
selections.add(parseLocation(locStr));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Skip invalid locations but continue parsing
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Stop at first missing index (assuming sequential)
|
||||
// But check at least a few more for gaps
|
||||
if (i > 20) {
|
||||
boolean hasMore = false;
|
||||
for (int j = i + 1; j <= Math.min(i + 5, maxPoints); j++) {
|
||||
if (yaml.contains(prefix + j)) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasMore) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves region configuration to YAML.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package dev.loki.lomines.data.config.ui;
|
||||
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import dev.loki.lomines.util.format.ColorUtils;
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
|
@ -36,6 +36,7 @@ public record UIConfig(
|
|||
/**
|
||||
* Parses the action bar format with placeholders.
|
||||
* Placeholders: {mine}, {percent}, {time}, {blocks}, {total}
|
||||
* Supports colors: &#RRGGBB, &a..&f, <color>
|
||||
*/
|
||||
public Component formatActionBar(String mineName, double percent, String timeStr, int blocks, int total) {
|
||||
String parsed = actionBarFormat
|
||||
|
|
@ -45,7 +46,7 @@ public record UIConfig(
|
|||
.replace("{blocks}", String.valueOf(blocks))
|
||||
.replace("{total}", String.valueOf(total));
|
||||
|
||||
return MiniMessage.miniMessage().deserialize(parsed);
|
||||
return ColorUtils.format(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import dev.loki.lomines.core.mine.Mine;
|
|||
import dev.loki.lomines.data.config.ui.HologramConfig;
|
||||
import dev.loki.lomines.integration.hologram.provider.DecentHologramsProvider;
|
||||
import dev.loki.lomines.integration.hologram.provider.HolographicDisplaysProvider;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import dev.loki.lomines.util.format.ColorUtils;
|
||||
import org.bukkit.Location;
|
||||
|
||||
import java.util.*;
|
||||
|
|
@ -144,7 +144,6 @@ public final class HologramManager {
|
|||
|
||||
private List<String> formatLines(List<String> format, Mine mine) {
|
||||
List<String> result = new ArrayList<>();
|
||||
MiniMessage miniMessage = MiniMessage.miniMessage();
|
||||
|
||||
for (String line : format) {
|
||||
String formatted = line
|
||||
|
|
@ -153,7 +152,8 @@ public final class HologramManager {
|
|||
.replace("{bar}", createProgressBar(mine.getPercentFilled()))
|
||||
.replace("{time}", formatResetTime(mine));
|
||||
|
||||
result.add(formatted);
|
||||
// Convert &#RRGGBB and &codes to legacy for hologram plugins
|
||||
result.add(ColorUtils.toLegacy(formatted));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -163,12 +163,12 @@ public final class HologramManager {
|
|||
int filled = (int) Math.round(percent / 10.0);
|
||||
int empty = 10 - filled;
|
||||
|
||||
// Use &#RRGGBB format which works with most hologram plugins
|
||||
StringBuilder bar = new StringBuilder();
|
||||
bar.append("<green>");
|
||||
bar.append("�FF00"); // Green for filled
|
||||
bar.append("█".repeat(Math.max(0, filled)));
|
||||
bar.append("<gray>");
|
||||
bar.append("󅒐"); // Gray for empty
|
||||
bar.append("░".repeat(Math.max(0, empty)));
|
||||
bar.append("</gray></green>");
|
||||
|
||||
return bar.toString();
|
||||
}
|
||||
|
|
|
|||
207
src/main/java/dev/loki/lomines/util/format/ColorUtils.java
Normal file
207
src/main/java/dev/loki/lomines/util/format/ColorUtils.java
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package dev.loki.lomines.util.format;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Utility for converting color formats.
|
||||
* Supports: &#RRGGBB (HEX), &x&R&R&G&G&B&B (Bukkit HEX), MiniMessage
|
||||
*/
|
||||
public final class ColorUtils {
|
||||
|
||||
private static final Pattern HEX_PATTERN = Pattern.compile("&#([0-9A-Fa-f]{6})");
|
||||
private static final Pattern LEGACY_HEX_PATTERN = Pattern.compile("&x([&0-9A-Fa-f]){12}");
|
||||
|
||||
private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();
|
||||
private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.builder()
|
||||
.hexColors()
|
||||
.useUnusualXRepeatedCharacterHexFormat()
|
||||
.build();
|
||||
|
||||
private ColorUtils() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string with color codes to a Component.
|
||||
* Supports:
|
||||
* - &#RRGGBB - HEX colors
|
||||
* - &x&R&R&G&G&B&B - Legacy Bukkit HEX
|
||||
* - &a, &b, ... - Legacy colors
|
||||
* - <color>, <#RRGGBB> - MiniMessage (pass-through)
|
||||
*/
|
||||
public static Component format(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return Component.empty();
|
||||
}
|
||||
|
||||
// Check if it's already MiniMessage format
|
||||
if (text.contains("<") && text.contains(">")) {
|
||||
try {
|
||||
return MINI_MESSAGE.deserialize(text);
|
||||
} catch (Exception e) {
|
||||
// Fall through to legacy conversion
|
||||
}
|
||||
}
|
||||
|
||||
// Convert &#RRGGBB to MiniMessage <#RRGGBB>
|
||||
String miniMessageText = convertHexToMiniMessage(text);
|
||||
|
||||
// Convert legacy &x format if present
|
||||
miniMessageText = convertLegacyHexToMiniMessage(miniMessageText);
|
||||
|
||||
// Convert remaining legacy codes (&a, &l, etc.) to MiniMessage
|
||||
miniMessageText = convertLegacyToMiniMessage(miniMessageText);
|
||||
|
||||
try {
|
||||
return MINI_MESSAGE.deserialize(miniMessageText);
|
||||
} catch (Exception e) {
|
||||
// Fallback: use legacy serializer
|
||||
return LEGACY_SERIALIZER.deserialize(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts &#RRGGBB to <#RRGGBB> for MiniMessage.
|
||||
*/
|
||||
private static String convertHexToMiniMessage(String text) {
|
||||
Matcher matcher = HEX_PATTERN.matcher(text);
|
||||
StringBuffer result = new StringBuffer();
|
||||
|
||||
while (matcher.find()) {
|
||||
String hex = matcher.group(1);
|
||||
matcher.appendReplacement(result, "<#" + hex + ">");
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts &x&R&R&G&G&B&B to <#RRGGBB> for MiniMessage.
|
||||
*/
|
||||
private static String convertLegacyHexToMiniMessage(String text) {
|
||||
if (!text.contains("&x")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Pattern for &x&R&R&G&G&B&B
|
||||
Pattern bukkitHex = Pattern.compile("&x(&[0-9A-Fa-f])(&[0-9A-Fa-f])(&[0-9A-Fa-f])(&[0-9A-Fa-f])(&[0-9A-Fa-f])(&[0-9A-Fa-f])");
|
||||
Matcher matcher = bukkitHex.matcher(text);
|
||||
StringBuffer result = new StringBuffer();
|
||||
|
||||
while (matcher.find()) {
|
||||
String r1 = matcher.group(1).substring(1);
|
||||
String r2 = matcher.group(2).substring(1);
|
||||
String g1 = matcher.group(3).substring(1);
|
||||
String g2 = matcher.group(4).substring(1);
|
||||
String b1 = matcher.group(5).substring(1);
|
||||
String b2 = matcher.group(6).substring(1);
|
||||
String hex = r1 + r2 + g1 + g2 + b1 + b2;
|
||||
matcher.appendReplacement(result, "<#" + hex + ">");
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts legacy & codes to MiniMessage tags.
|
||||
*/
|
||||
private static String convertLegacyToMiniMessage(String text) {
|
||||
return text
|
||||
.replace("&0", "<black>")
|
||||
.replace("&1", "<dark_blue>")
|
||||
.replace("&2", "<dark_green>")
|
||||
.replace("&3", "<dark_aqua>")
|
||||
.replace("&4", "<dark_red>")
|
||||
.replace("&5", "<dark_purple>")
|
||||
.replace("&6", "<gold>")
|
||||
.replace("&7", "<gray>")
|
||||
.replace("&8", "<dark_gray>")
|
||||
.replace("&9", "<blue>")
|
||||
.replace("&a", "<green>")
|
||||
.replace("&b", "<aqua>")
|
||||
.replace("&c", "<red>")
|
||||
.replace("&d", "<light_purple>")
|
||||
.replace("&e", "<yellow>")
|
||||
.replace("&f", "<white>")
|
||||
.replace("&k", "<obfuscated>")
|
||||
.replace("&l", "<bold>")
|
||||
.replace("&m", "<strikethrough>")
|
||||
.replace("&n", "<underlined>")
|
||||
.replace("&o", "<italic>")
|
||||
.replace("&r", "<reset>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips all color codes from text.
|
||||
*/
|
||||
public static String stripColors(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return text.replaceAll("&#[0-9A-Fa-f]{6}", "")
|
||||
.replaceAll("&[0-9A-Fa-fk-orK-OR]", "")
|
||||
.replaceAll("<[^>]+>", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts to legacy color format (&a, &c, &x&R&R&G&G&B&B).
|
||||
* Useful for plugins that don't support MiniMessage.
|
||||
*/
|
||||
public static String toLegacy(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String result = text;
|
||||
|
||||
// Convert MiniMessage <#RRGGBB> to &x&R&R&G&G&B&B
|
||||
Pattern hexTag = Pattern.compile("<#([0-9A-Fa-f]{6})>");
|
||||
Matcher hexMatcher = hexTag.matcher(result);
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (hexMatcher.find()) {
|
||||
String hex = hexMatcher.group(1);
|
||||
StringBuilder legacy = new StringBuilder("&x");
|
||||
for (char c : hex.toCharArray()) {
|
||||
legacy.append("&").append(c);
|
||||
}
|
||||
hexMatcher.appendReplacement(sb, legacy.toString());
|
||||
}
|
||||
hexMatcher.appendTail(sb);
|
||||
result = sb.toString();
|
||||
|
||||
// Convert MiniMessage named colors to legacy
|
||||
result = result
|
||||
.replace("<black>", "&0")
|
||||
.replace("<dark_blue>", "&1")
|
||||
.replace("<dark_green>", "&2")
|
||||
.replace("<dark_aqua>", "&3")
|
||||
.replace("<dark_red>", "&4")
|
||||
.replace("<dark_purple>", "&5")
|
||||
.replace("<gold>", "&6")
|
||||
.replace("<gray>", "&7")
|
||||
.replace("<dark_gray>", "&8")
|
||||
.replace("<blue>", "&9")
|
||||
.replace("<green>", "&a")
|
||||
.replace("<aqua>", "&b")
|
||||
.replace("<red>", "&c")
|
||||
.replace("<light_purple>", "&d")
|
||||
.replace("<yellow>", "&e")
|
||||
.replace("<white>", "&f")
|
||||
.replace("<obfuscated>", "&k")
|
||||
.replace("<bold>", "&l")
|
||||
.replace("<strikethrough>", "&m")
|
||||
.replace("<underlined>", "&n")
|
||||
.replace("<italic>", "&o")
|
||||
.replace("<reset>", "&r");
|
||||
|
||||
// &#RRGGBB is already in a good format for most plugins
|
||||
return result;
|
||||
}
|
||||
}
|
||||
5
todo.md
5
todo.md
|
|
@ -454,6 +454,11 @@ dev.loki.lomines/
|
|||
- `/lm regions <mine>` — список регионов
|
||||
- `/lm addregion <mine>` — добавить регион
|
||||
- `/lm removeregion <mine> <index>` — удалить регион
|
||||
- [x] Улучшить формат конфигурации ✅
|
||||
- Пример конфига с комментариями (mines/example_mine.yml)
|
||||
- Поддержка &#RRGGBB цветов
|
||||
- До 100 точек селекшна (50 регионов)
|
||||
- Чистый читаемый YAML формат
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue