mirror of
https://github.com/LmeSzinc/StarRailCopilot.git
synced 2024-11-22 00:35:34 +00:00
commit
72f1842e44
@ -60,3 +60,10 @@
|
||||
#pywebio-scope-log {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-row-"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 4rem;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
@ -26,7 +26,16 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 11.5rem;
|
||||
}
|
||||
|
||||
#pywebio-scope-dashboard {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#pywebio-scope-log-bar,
|
||||
#pywebio-scope-log,
|
||||
#pywebio-scope-daemon-overview #pywebio-scope-groups {
|
||||
|
@ -383,17 +383,68 @@ pre.rich-traceback-code {
|
||||
}
|
||||
|
||||
#pywebio-scope-scheduler-bar,
|
||||
#pywebio-scope-log-bar {
|
||||
#pywebio-scope-log-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#pywebio-scope-log-bar-btns {
|
||||
#pywebio-scope-log-title-btns {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
#pywebio-scope-dashboard {
|
||||
display: flex;
|
||||
align-content: space-between;
|
||||
justify-content: flex-start;
|
||||
flex-flow: row wrap;
|
||||
overflow: auto;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
#pywebio-scope-dashboard > i {
|
||||
flex-grow: 1;
|
||||
align-self: flex-end;
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-row-"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.dashboard-icon {
|
||||
margin: .6rem .8rem 0 .6rem;
|
||||
width: .5rem;
|
||||
height: .5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
*[style*="--dashboard-value--"] {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
*[style*="--dashboard-time--"] {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-row-"] p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
[id^="pywebio-scope-dashboard-value-"] {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
#pywebio-scope-log {
|
||||
line-height: 1.2;
|
||||
font-size: 0.85rem;
|
||||
|
@ -153,3 +153,7 @@ pre.rich-traceback-code {
|
||||
[id^="pywebio-scope-group_"] > p + p {
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
*[style*="--dashboard-time--"] {
|
||||
color: #adb5bd;
|
||||
}
|
@ -152,3 +152,7 @@ pre.rich-traceback-code {
|
||||
[id^="pywebio-scope-group_"] > p + p {
|
||||
color: #777777;
|
||||
}
|
||||
|
||||
*[style*="--dashboard-time--"] {
|
||||
color: #777777;
|
||||
}
|
BIN
assets/share/dungeon/ui/OCR_SIMUNI_POINT.png
Normal file
BIN
assets/share/dungeon/ui/OCR_SIMUNI_POINT.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
BIN
assets/support/selected_character_arrow.png
Normal file
BIN
assets/support/selected_character_arrow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 668 B |
@ -57,7 +57,8 @@
|
||||
},
|
||||
"DungeonStorage": {
|
||||
"TrailblazePower": {},
|
||||
"DungeonDouble": {}
|
||||
"DungeonDouble": {},
|
||||
"SimulatedUniverse": {}
|
||||
}
|
||||
},
|
||||
"DailyQuest": {
|
||||
@ -119,7 +120,8 @@
|
||||
"Name_2": "Akashic_Records",
|
||||
"Name_3": "The_Invisible_Hand",
|
||||
"Name_4": "Nine_Billion_Names",
|
||||
"Duration": 20
|
||||
"Duration": 20,
|
||||
"Assignment": {}
|
||||
}
|
||||
}
|
||||
}
|
@ -364,13 +364,23 @@
|
||||
"type": "stored",
|
||||
"value": {},
|
||||
"display": "hide",
|
||||
"stored": "StoredTrailblazePower"
|
||||
"stored": "StoredTrailblazePower",
|
||||
"order": 1,
|
||||
"color": "#eb8efe"
|
||||
},
|
||||
"DungeonDouble": {
|
||||
"type": "stored",
|
||||
"value": {},
|
||||
"display": "hide",
|
||||
"stored": "StoredDungeonDouble"
|
||||
},
|
||||
"SimulatedUniverse": {
|
||||
"type": "stored",
|
||||
"value": {},
|
||||
"display": "hide",
|
||||
"stored": "StoredSimulatedUniverse",
|
||||
"order": 5,
|
||||
"color": "#8fb5fe"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -782,7 +792,9 @@
|
||||
"type": "stored",
|
||||
"value": {},
|
||||
"display": "hide",
|
||||
"stored": "StoredDailyActivity"
|
||||
"stored": "StoredDailyActivity",
|
||||
"order": 2,
|
||||
"color": "#ffcf70"
|
||||
},
|
||||
"DailyQuest": {
|
||||
"type": "stored",
|
||||
@ -931,6 +943,14 @@
|
||||
12,
|
||||
20
|
||||
]
|
||||
},
|
||||
"Assignment": {
|
||||
"type": "stored",
|
||||
"value": {},
|
||||
"display": "hide",
|
||||
"stored": "StoredAssignment",
|
||||
"order": 3,
|
||||
"color": "#deba95"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -111,8 +111,14 @@ DungeonSupport:
|
||||
DungeonStorage:
|
||||
TrailblazePower:
|
||||
stored: StoredTrailblazePower
|
||||
order: 1
|
||||
color: "#eb8efe"
|
||||
DungeonDouble:
|
||||
stored: StoredDungeonDouble
|
||||
SimulatedUniverse:
|
||||
stored: StoredSimulatedUniverse
|
||||
order: 5
|
||||
color: "#8fb5fe"
|
||||
|
||||
AchievableQuest:
|
||||
# Quests will be injected in config updater
|
||||
@ -124,6 +130,8 @@ AchievableQuest:
|
||||
DailyStorage:
|
||||
DailyActivity:
|
||||
stored: StoredDailyActivity
|
||||
order: 2
|
||||
color: "#ffcf70"
|
||||
DailyQuest:
|
||||
stored: StoredDaily
|
||||
|
||||
@ -144,3 +152,7 @@ Assignment:
|
||||
Duration:
|
||||
value: 20
|
||||
option: [ 4, 8, 12, 20 ]
|
||||
Assignment:
|
||||
stored: StoredAssignment
|
||||
order: 3
|
||||
color: "#deba95"
|
||||
|
@ -52,6 +52,16 @@ Overview:
|
||||
Waiting:
|
||||
NoTask:
|
||||
|
||||
Dashboard:
|
||||
# From lang.readable_time()
|
||||
NoData:
|
||||
TimeError:
|
||||
JustNow:
|
||||
MinutesAgo:
|
||||
HoursAgo:
|
||||
DaysAgo:
|
||||
LongTimeAgo:
|
||||
|
||||
AddAlas:
|
||||
PopupTitle:
|
||||
NewName:
|
||||
|
84
module/config/argument/stored.json
Normal file
84
module/config/argument/stored.json
Normal file
@ -0,0 +1,84 @@
|
||||
{
|
||||
"TrailblazePower": {
|
||||
"name": "TrailblazePower",
|
||||
"path": "Dungeon.DungeonStorage.TrailblazePower",
|
||||
"i18n": "DungeonStorage.TrailblazePower.name",
|
||||
"stored": "StoredTrailblazePower",
|
||||
"attrs": {
|
||||
"time": "2020-01-01 00:00:00",
|
||||
"total": 180,
|
||||
"value": 0
|
||||
},
|
||||
"order": 1,
|
||||
"color": "#eb8efe"
|
||||
},
|
||||
"DailyActivity": {
|
||||
"name": "DailyActivity",
|
||||
"path": "DailyQuest.DailyStorage.DailyActivity",
|
||||
"i18n": "DailyStorage.DailyActivity.name",
|
||||
"stored": "StoredDailyActivity",
|
||||
"attrs": {
|
||||
"time": "2020-01-01 00:00:00",
|
||||
"total": 500,
|
||||
"value": 0
|
||||
},
|
||||
"order": 2,
|
||||
"color": "#ffcf70"
|
||||
},
|
||||
"Assignment": {
|
||||
"name": "Assignment",
|
||||
"path": "Assignment.Assignment.Assignment",
|
||||
"i18n": "Assignment.Assignment.name",
|
||||
"stored": "StoredAssignment",
|
||||
"attrs": {
|
||||
"time": "2020-01-01 00:00:00",
|
||||
"total": 0,
|
||||
"value": 0
|
||||
},
|
||||
"order": 3,
|
||||
"color": "#deba95"
|
||||
},
|
||||
"SimulatedUniverse": {
|
||||
"name": "SimulatedUniverse",
|
||||
"path": "Dungeon.DungeonStorage.SimulatedUniverse",
|
||||
"i18n": "DungeonStorage.SimulatedUniverse.name",
|
||||
"stored": "StoredSimulatedUniverse",
|
||||
"attrs": {
|
||||
"time": "2020-01-01 00:00:00",
|
||||
"total": 0,
|
||||
"value": 0
|
||||
},
|
||||
"order": 5,
|
||||
"color": "#8fb5fe"
|
||||
},
|
||||
"DungeonDouble": {
|
||||
"name": "DungeonDouble",
|
||||
"path": "Dungeon.DungeonStorage.DungeonDouble",
|
||||
"i18n": "DungeonStorage.DungeonDouble.name",
|
||||
"stored": "StoredDungeonDouble",
|
||||
"attrs": {
|
||||
"time": "2020-01-01 00:00:00",
|
||||
"calyx": 0,
|
||||
"relic": 0
|
||||
},
|
||||
"order": 0,
|
||||
"color": "#777777"
|
||||
},
|
||||
"DailyQuest": {
|
||||
"name": "DailyQuest",
|
||||
"path": "DailyQuest.DailyStorage.DailyQuest",
|
||||
"i18n": "DailyStorage.DailyQuest.name",
|
||||
"stored": "StoredDaily",
|
||||
"attrs": {
|
||||
"time": "2020-01-01 00:00:00",
|
||||
"quest1": "",
|
||||
"quest2": "",
|
||||
"quest3": "",
|
||||
"quest4": "",
|
||||
"quest5": "",
|
||||
"quest6": ""
|
||||
},
|
||||
"order": 0,
|
||||
"color": "#777777"
|
||||
}
|
||||
}
|
@ -57,6 +57,7 @@ class GeneratedConfig:
|
||||
# Group `DungeonStorage`
|
||||
DungeonStorage_TrailblazePower = {}
|
||||
DungeonStorage_DungeonDouble = {}
|
||||
DungeonStorage_SimulatedUniverse = {}
|
||||
|
||||
# Group `AchievableQuest`
|
||||
AchievableQuest_Complete_1_Daily_Mission = 'achievable' # achievable, not_set, not_supported
|
||||
@ -95,3 +96,4 @@ class GeneratedConfig:
|
||||
Assignment_Name_3 = 'The_Invisible_Hand' # Nine_Billion_Names, Destruction_of_the_Destroyer, Winter_Soldiers, Born_to_Obey, Root_Out_the_Turpitude, Fire_Lord_Inflames_Blades_of_War, Nameless_Land_Nameless_People, Akashic_Records, The_Invisible_Hand, Abandoned_and_Insulted, Spring_of_Life, The_Land_of_Gold, The_Blossom_in_the_Storm
|
||||
Assignment_Name_4 = 'Nine_Billion_Names' # Nine_Billion_Names, Destruction_of_the_Destroyer, Winter_Soldiers, Born_to_Obey, Root_Out_the_Turpitude, Fire_Lord_Inflames_Blades_of_War, Nameless_Land_Nameless_People, Akashic_Records, The_Invisible_Hand, Abandoned_and_Insulted, Spring_of_Life, The_Land_of_Gold, The_Blossom_in_the_Storm
|
||||
Assignment_Duration = 20 # 4, 8, 12, 20
|
||||
Assignment_Assignment = {}
|
||||
|
@ -441,6 +441,32 @@ class ConfigGenerator:
|
||||
|
||||
return data
|
||||
|
||||
@cached_property
|
||||
def stored(self):
|
||||
import module.config.stored.classes as classes
|
||||
data = {}
|
||||
for path, value in deep_iter(self.args, depth=3):
|
||||
if value.get('type') != 'stored':
|
||||
continue
|
||||
name = path[-1]
|
||||
stored = value.get('stored')
|
||||
stored_class = getattr(classes, stored)
|
||||
row = {
|
||||
'name': name,
|
||||
'path': '.'.join(path),
|
||||
'i18n': f'{path[1]}.{path[2]}.name',
|
||||
'stored': stored,
|
||||
'attrs': stored_class('')._attrs,
|
||||
'order': value.get('order', 0),
|
||||
'color': value.get('color', '#777777')
|
||||
}
|
||||
data[name] = row
|
||||
|
||||
# sort by `order` ascending, but `order`==0 at last
|
||||
data = sorted(data.items(), key=lambda kv: (kv[1]['order'] == 0, kv[1]['order']))
|
||||
data = {k: v for k, v in data}
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def generate_deploy_template():
|
||||
template = poor_yaml_read(DEPLOY_TEMPLATE)
|
||||
@ -495,12 +521,14 @@ class ConfigGenerator:
|
||||
def generate(self):
|
||||
_ = self.args
|
||||
_ = self.menu
|
||||
_ = self.stored
|
||||
# _ = self.event
|
||||
self.insert_assignment()
|
||||
self.insert_package()
|
||||
# self.insert_server()
|
||||
write_file(filepath_args(), self.args)
|
||||
write_file(filepath_args('menu'), self.menu)
|
||||
write_file(filepath_args('stored'), self.stored)
|
||||
self.generate_code()
|
||||
self.generate_stored()
|
||||
for lang in LANGUAGES:
|
||||
|
@ -364,6 +364,10 @@
|
||||
"DungeonDouble": {
|
||||
"name": "Dungeon Double",
|
||||
"help": ""
|
||||
},
|
||||
"SimulatedUniverse": {
|
||||
"name": "Sim.Uni.",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"AchievableQuest": {
|
||||
@ -641,6 +645,10 @@
|
||||
"8": "8 Hours",
|
||||
"12": "12 Hours",
|
||||
"20": "20 Hours"
|
||||
},
|
||||
"Assignment": {
|
||||
"name": "Assignment",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Gui": {
|
||||
@ -695,6 +703,15 @@
|
||||
"Waiting": "Waiting",
|
||||
"NoTask": "No Task"
|
||||
},
|
||||
"Dashboard": {
|
||||
"NoData": "no data",
|
||||
"TimeError": "time error",
|
||||
"JustNow": "just now",
|
||||
"MinutesAgo": "{time}min ago",
|
||||
"HoursAgo": "{time}h ago",
|
||||
"DaysAgo": "{time}d ago",
|
||||
"LongTimeAgo": "long time ago"
|
||||
},
|
||||
"AddAlas": {
|
||||
"PopupTitle": "Add new config",
|
||||
"NewName": "New name",
|
||||
|
@ -364,6 +364,10 @@
|
||||
"DungeonDouble": {
|
||||
"name": "DungeonStorage.DungeonDouble.name",
|
||||
"help": "DungeonStorage.DungeonDouble.help"
|
||||
},
|
||||
"SimulatedUniverse": {
|
||||
"name": "DungeonStorage.SimulatedUniverse.name",
|
||||
"help": "DungeonStorage.SimulatedUniverse.help"
|
||||
}
|
||||
},
|
||||
"AchievableQuest": {
|
||||
@ -641,6 +645,10 @@
|
||||
"8": "8",
|
||||
"12": "12",
|
||||
"20": "20"
|
||||
},
|
||||
"Assignment": {
|
||||
"name": "Assignment.Assignment.name",
|
||||
"help": "Assignment.Assignment.help"
|
||||
}
|
||||
},
|
||||
"Gui": {
|
||||
@ -695,6 +703,15 @@
|
||||
"Waiting": "Waiting",
|
||||
"NoTask": "No Task"
|
||||
},
|
||||
"Dashboard": {
|
||||
"NoData": "Gui.Dashboard.NoData",
|
||||
"TimeError": "Gui.Dashboard.TimeError",
|
||||
"JustNow": "Gui.Dashboard.JustNow",
|
||||
"MinutesAgo": "Gui.Dashboard.MinutesAgo",
|
||||
"HoursAgo": "Gui.Dashboard.HoursAgo",
|
||||
"DaysAgo": "Gui.Dashboard.DaysAgo",
|
||||
"LongTimeAgo": "Gui.Dashboard.LongTimeAgo"
|
||||
},
|
||||
"AddAlas": {
|
||||
"PopupTitle": "新しいコンフィグを追加",
|
||||
"NewName": "コンフィグ名",
|
||||
|
@ -364,6 +364,10 @@
|
||||
"DungeonDouble": {
|
||||
"name": "副本双倍",
|
||||
"help": ""
|
||||
},
|
||||
"SimulatedUniverse": {
|
||||
"name": "模拟宇宙",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"AchievableQuest": {
|
||||
@ -641,6 +645,10 @@
|
||||
"8": "8小时",
|
||||
"12": "12小时",
|
||||
"20": "20小时"
|
||||
},
|
||||
"Assignment": {
|
||||
"name": "委托",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Gui": {
|
||||
@ -695,6 +703,15 @@
|
||||
"Waiting": "等待中",
|
||||
"NoTask": "无任务"
|
||||
},
|
||||
"Dashboard": {
|
||||
"NoData": "无数据",
|
||||
"TimeError": "时间错误",
|
||||
"JustNow": "刚刚",
|
||||
"MinutesAgo": "{time}分钟前",
|
||||
"HoursAgo": "{time}小时前",
|
||||
"DaysAgo": "{time}天前",
|
||||
"LongTimeAgo": "很久以前"
|
||||
},
|
||||
"AddAlas": {
|
||||
"PopupTitle": "添加新配置",
|
||||
"NewName": "新的配置文件名",
|
||||
|
@ -364,6 +364,10 @@
|
||||
"DungeonDouble": {
|
||||
"name": "副本雙倍",
|
||||
"help": ""
|
||||
},
|
||||
"SimulatedUniverse": {
|
||||
"name": "模擬宇宙",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"AchievableQuest": {
|
||||
@ -641,6 +645,10 @@
|
||||
"8": "8小時",
|
||||
"12": "12小時",
|
||||
"20": "20小時"
|
||||
},
|
||||
"Assignment": {
|
||||
"name": "委託",
|
||||
"help": ""
|
||||
}
|
||||
},
|
||||
"Gui": {
|
||||
@ -695,6 +703,15 @@
|
||||
"Waiting": "等待中",
|
||||
"NoTask": "無任務"
|
||||
},
|
||||
"Dashboard": {
|
||||
"NoData": "無數據",
|
||||
"TimeError": "時間錯誤",
|
||||
"JustNow": "剛剛",
|
||||
"MinutesAgo": "{time}分鐘前",
|
||||
"HoursAgo": "{time}小時前",
|
||||
"DaysAgo": "{time}天前",
|
||||
"LongTimeAgo": "很久以前"
|
||||
},
|
||||
"AddAlas": {
|
||||
"PopupTitle": "添加新的設定",
|
||||
"NewName": "新設定的檔案名",
|
||||
|
@ -1,4 +1,3 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import cached_property as functools_cached_property
|
||||
|
||||
@ -111,29 +110,6 @@ class StoredBase:
|
||||
from module.logger import logger
|
||||
logger.attr(self._name, self._stored)
|
||||
|
||||
def dashboard(self) -> str:
|
||||
"""
|
||||
Return a string to show on GUI
|
||||
"""
|
||||
return 'None'
|
||||
|
||||
def readable_time(self):
|
||||
diff = self.time.timestamp() - time.time()
|
||||
if diff < -1:
|
||||
return '', 'TimeError'
|
||||
elif diff < 60:
|
||||
# < 1 min
|
||||
return '', 'JustNow'
|
||||
elif diff < 3600:
|
||||
return str(int(diff // 60)), 'MinutesAgo'
|
||||
elif diff < 86400:
|
||||
return str(int(diff // 86400)), 'HoursAgo'
|
||||
elif diff < 129600:
|
||||
return str(int(diff // 129600)), 'DaysAgo'
|
||||
else:
|
||||
# > 15 days
|
||||
return '', 'LongTimeAgo'
|
||||
|
||||
|
||||
class StoredExpiredAt0400(StoredBase):
|
||||
def is_expired(self):
|
||||
@ -154,11 +130,11 @@ class StoredCounter(StoredBase):
|
||||
|
||||
FIXED_TOTAL = 0
|
||||
|
||||
def set(self, current, total=0):
|
||||
def set(self, value, total=0):
|
||||
if self.FIXED_TOTAL:
|
||||
total = self.FIXED_TOTAL
|
||||
with self._config.multi_set():
|
||||
self.value = current
|
||||
self.value = value
|
||||
self.total = total
|
||||
|
||||
def to_counter(self) -> str:
|
||||
@ -170,6 +146,13 @@ class StoredCounter(StoredBase):
|
||||
def get_remain(self) -> int:
|
||||
return self.total - self.value
|
||||
|
||||
@cached_property
|
||||
def _attrs(self) -> dict:
|
||||
attrs = super()._attrs
|
||||
if self.FIXED_TOTAL:
|
||||
attrs['total'] = self.FIXED_TOTAL
|
||||
return attrs
|
||||
|
||||
@functools_cached_property
|
||||
def _stored(self):
|
||||
stored = super()._stored
|
||||
@ -186,6 +169,14 @@ class StoredTrailblazePower(StoredCounter):
|
||||
FIXED_TOTAL = 180
|
||||
|
||||
|
||||
class StoredSimulatedUniverse(StoredCounter, StoredExpiredAt0400):
|
||||
pass
|
||||
|
||||
|
||||
class StoredAssignment(StoredCounter):
|
||||
pass
|
||||
|
||||
|
||||
class StoredDaily(StoredExpiredAt0400):
|
||||
quest1 = ''
|
||||
quest2 = ''
|
||||
|
@ -1,4 +1,5 @@
|
||||
from module.config.stored.classes import (
|
||||
StoredAssignment,
|
||||
StoredBase,
|
||||
StoredCounter,
|
||||
StoredDaily,
|
||||
@ -6,6 +7,7 @@ from module.config.stored.classes import (
|
||||
StoredDungeonDouble,
|
||||
StoredExpiredAt0400,
|
||||
StoredInt,
|
||||
StoredSimulatedUniverse,
|
||||
StoredTrailblazePower,
|
||||
)
|
||||
|
||||
@ -16,5 +18,7 @@ from module.config.stored.classes import (
|
||||
class StoredGenerated:
|
||||
TrailblazePower = StoredTrailblazePower("Dungeon.DungeonStorage.TrailblazePower")
|
||||
DungeonDouble = StoredDungeonDouble("Dungeon.DungeonStorage.DungeonDouble")
|
||||
SimulatedUniverse = StoredSimulatedUniverse("Dungeon.DungeonStorage.SimulatedUniverse")
|
||||
DailyActivity = StoredDailyActivity("DailyQuest.DailyStorage.DailyActivity")
|
||||
DailyQuest = StoredDaily("DailyQuest.DailyStorage.DailyQuest")
|
||||
Assignment = StoredAssignment("Assignment.Assignment.Assignment")
|
||||
|
@ -88,11 +88,13 @@ task_handler = TaskHandler()
|
||||
class AlasGUI(Frame):
|
||||
ALAS_MENU: Dict[str, Dict[str, List[str]]]
|
||||
ALAS_ARGS: Dict[str, Dict[str, Dict[str, Dict[str, str]]]]
|
||||
ALAS_STORED: Dict[str, Dict[str, Dict[str, str]]]
|
||||
theme = "default"
|
||||
|
||||
def initial(self) -> None:
|
||||
self.ALAS_MENU = read_file(filepath_args("menu", self.alas_mod))
|
||||
self.ALAS_ARGS = read_file(filepath_args("args", self.alas_mod))
|
||||
self.ALAS_STORED = read_file(filepath_args("stored", self.alas_mod))
|
||||
self._init_alas_config_watcher()
|
||||
|
||||
def __init__(self) -> None:
|
||||
@ -318,6 +320,35 @@ class AlasGUI(Frame):
|
||||
color="navigator",
|
||||
)
|
||||
|
||||
def set_dashboard(self, arg, arg_dict, config):
|
||||
i18n = arg_dict.get('i18n')
|
||||
if i18n:
|
||||
name = t(i18n)
|
||||
else:
|
||||
name = arg
|
||||
color = arg_dict.get("color", "#777777")
|
||||
nodata = t("Gui.Dashboard.NoData")
|
||||
|
||||
def set_value(dic):
|
||||
if "total" in dic.get("attrs", []) and config.get("total") is not None:
|
||||
return [
|
||||
put_text(config.get("value", nodata)).style("--dashboard-value--"),
|
||||
put_text(f' / {config.get("total", "")}').style("--dashboard-time--"),
|
||||
]
|
||||
else:
|
||||
return [
|
||||
put_text(config.get("value", nodata)).style("--dashboard-value--"),
|
||||
]
|
||||
|
||||
with use_scope(f"dashboard-row-{arg}", clear=True):
|
||||
put_html(f'<div><div class="dashboard-icon" style="background-color:{color}"></div>'),
|
||||
put_scope(f"dashboard-content-{arg}", [
|
||||
put_scope(f"dashboard-value-{arg}", set_value(arg_dict)),
|
||||
put_scope(f"dashboard-time-{arg}", [
|
||||
put_text(f"{name} - {lang.readable_time(config.get('time', ''))}").style("--dashboard-time--"),
|
||||
])
|
||||
])
|
||||
|
||||
@use_scope("content", clear=True)
|
||||
def alas_overview(self) -> None:
|
||||
self.init_menu(name="Overview")
|
||||
@ -374,20 +405,20 @@ class AlasGUI(Frame):
|
||||
log = RichLog("log")
|
||||
|
||||
with use_scope("logs"):
|
||||
put_scope(
|
||||
"log-bar",
|
||||
[
|
||||
put_text(t("Gui.Overview.Log")).style(
|
||||
"font-size: 1.25rem; margin: auto .5rem auto;"
|
||||
),
|
||||
put_scope(
|
||||
"log-bar-btns",
|
||||
[
|
||||
put_scope("log-bar", [
|
||||
put_scope("log-title", [
|
||||
put_text(t("Gui.Overview.Log")).style("font-size: 1.25rem; margin: auto .5rem auto;"),
|
||||
put_scope("log-title-btns", [
|
||||
put_scope("log_scroll_btn"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
put_html('<hr class="hr-group">'),
|
||||
put_scope("dashboard", [
|
||||
# Empty dashboard, values will be updated in alas_update_overview_task()
|
||||
put_scope(f"dashboard-row-{arg}", []) for arg in self.ALAS_STORED.keys()
|
||||
# Empty content to left-align last row
|
||||
] + [put_html("<i></i>")] * len(self.ALAS_STORED))
|
||||
])
|
||||
put_scope("log", [put_html("")])
|
||||
|
||||
log.console.width = log.get_width()
|
||||
@ -501,6 +532,7 @@ class AlasGUI(Frame):
|
||||
self.alas_config.load()
|
||||
self.alas_config.get_next_task()
|
||||
|
||||
alive = self.alas.alive
|
||||
if len(self.alas_config.pending_task) >= 1:
|
||||
if self.alas.alive:
|
||||
running = self.alas_config.pending_task[:1]
|
||||
@ -528,6 +560,8 @@ class AlasGUI(Frame):
|
||||
color="off",
|
||||
)
|
||||
|
||||
if self.scope_expired("scheduler_alive", alive) \
|
||||
or self.scope_expired("pending_task", self.alas_config.pending_task):
|
||||
clear("running_tasks")
|
||||
clear("pending_tasks")
|
||||
clear("waiting_tasks")
|
||||
@ -549,6 +583,16 @@ class AlasGUI(Frame):
|
||||
put_task(task)
|
||||
else:
|
||||
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
|
||||
self.scope_add("scheduler_alive", alive)
|
||||
self.scope_add("pending_task", self.alas_config.pending_task)
|
||||
|
||||
for arg, arg_dict in self.ALAS_STORED.items():
|
||||
path = arg_dict["path"]
|
||||
if self.scope_expired_then_add(
|
||||
key=f"dashboard-time-value-{arg}",
|
||||
value=lang.readable_time(deep_get(self.alas_config.data, keys=f"{path}.time"))
|
||||
):
|
||||
self.set_dashboard(arg, arg_dict, deep_get(self.alas_config.data, keys=path, default={}))
|
||||
|
||||
@use_scope("content", clear=True)
|
||||
def alas_daemon_overview(self, task: str) -> None:
|
||||
|
@ -1,3 +1,5 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from pywebio.output import clear, put_html, put_scope, put_text, use_scope
|
||||
from pywebio.session import defer_call, info, run_js
|
||||
|
||||
@ -13,12 +15,34 @@ class Base:
|
||||
self.is_mobile = info.user_agent.is_mobile
|
||||
# Task handler
|
||||
self.task_handler = WebIOTaskHandler()
|
||||
# Record scopes to reduce data transfer to frontend
|
||||
# Key: scope name, value: last update time
|
||||
self.scope: Dict[str, Any] = {}
|
||||
defer_call(self.stop)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.alive = False
|
||||
self.task_handler.stop()
|
||||
|
||||
def scope_clear(self):
|
||||
self.scope = {}
|
||||
|
||||
def scope_add(self, key, value):
|
||||
self.scope[key] = value
|
||||
|
||||
def scope_expired(self, key, value) -> bool:
|
||||
try:
|
||||
return self.scope[key] != value
|
||||
except KeyError:
|
||||
return True
|
||||
|
||||
def scope_expired_then_add(self, key, value) -> bool:
|
||||
if self.scope_expired(key, value):
|
||||
self.scope_add(key, value)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class Frame(Base):
|
||||
def __init__(self) -> None:
|
||||
@ -33,6 +57,7 @@ class Frame(Base):
|
||||
name: button name(label) to be highlight
|
||||
"""
|
||||
self.visible = True
|
||||
self.scope_clear()
|
||||
self.task_handler.remove_pending_task()
|
||||
clear("menu")
|
||||
if expand_menu:
|
||||
@ -50,6 +75,7 @@ class Frame(Base):
|
||||
"""
|
||||
self.visible = True
|
||||
self.page = name
|
||||
self.scope_clear()
|
||||
self.task_handler.remove_pending_task()
|
||||
clear("content")
|
||||
if collapse_menu:
|
||||
|
@ -1,8 +1,9 @@
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from module.config.utils import *
|
||||
from module.webui.setting import State
|
||||
from module.webui.fake import list_mod
|
||||
from module.webui.setting import State
|
||||
|
||||
LANG = "zh-CN"
|
||||
TRANSLATE_MODE = False
|
||||
@ -67,3 +68,33 @@ def reload():
|
||||
for key in dic_lang["ja-JP"].keys():
|
||||
if dic_lang["ja-JP"][key] == key:
|
||||
dic_lang["ja-JP"][key] = dic_lang["en-US"][key]
|
||||
|
||||
|
||||
def readable_time(before: str) -> str:
|
||||
"""
|
||||
Convert "2023-08-29 12:30:53" to "3 Minutes Ago"
|
||||
"""
|
||||
if not before:
|
||||
return t("Gui.Dashboard.NoData")
|
||||
try:
|
||||
ti = datetime.fromisoformat(before)
|
||||
except ValueError:
|
||||
return t("Gui.Dashboard.TimeError")
|
||||
if ti == DEFAULT_TIME:
|
||||
return t("Gui.Dashboard.NoData")
|
||||
|
||||
diff = time.time() - ti.timestamp()
|
||||
if diff < -1:
|
||||
return t("Gui.Dashboard.TimeError")
|
||||
elif diff < 60:
|
||||
# < 1 min
|
||||
return t("Gui.Dashboard.JustNow")
|
||||
elif diff < 3600:
|
||||
return t("Gui.Dashboard.MinutesAgo", time=int(diff // 60))
|
||||
elif diff < 86400:
|
||||
return t("Gui.Dashboard.HoursAgo", time=int(diff // 3600))
|
||||
elif diff < 129600:
|
||||
return t("Gui.Dashboard.DaysAgo", time=int(diff // 86400))
|
||||
else:
|
||||
# > 15 days
|
||||
return t("Gui.Dashboard.LongTimeAgo")
|
||||
|
@ -145,7 +145,14 @@ class AssignmentUI(UI):
|
||||
@property
|
||||
def _limit_status(self) -> tuple[int, int, int]:
|
||||
self.device.screenshot()
|
||||
return DigitCounter(OCR_ASSIGNMENT_LIMIT).ocr_single_line(self.device.image)
|
||||
current, remain, total = DigitCounter(OCR_ASSIGNMENT_LIMIT).ocr_single_line(self.device.image)
|
||||
if total and current <= total:
|
||||
logger.attr('Assignment', f'{current}/{total}')
|
||||
self.config.stored.Assignment.set(current, total)
|
||||
else:
|
||||
logger.warning(f'Invalid assignment limit: {current}/{total}')
|
||||
self.config.stored.Assignment.set(0, 0)
|
||||
return current, remain, total
|
||||
|
||||
def _iter_groups(self) -> Iterator[AssignmentGroup]:
|
||||
for state in ASSIGNMENT_TOP_SWITCH.state_list:
|
||||
|
@ -1,23 +1,36 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
from module.base.button import Button, ButtonWrapper
|
||||
|
||||
from module.base.timer import Timer
|
||||
from module.base.utils import area_size, crop, rgb2luma, load_image
|
||||
from module.base.utils import area_size, crop, rgb2luma, load_image, crop
|
||||
from module.logger import logger
|
||||
from module.ui.scroll import Scroll
|
||||
from tasks.base.assets.assets_base_popup import CANCEL_POPUP
|
||||
from tasks.base.ui import UI
|
||||
from tasks.combat.assets.assets_combat_support import COMBAT_SUPPORT_ADD, COMBAT_SUPPORT_LIST, \
|
||||
COMBAT_SUPPORT_LIST_SCROLL, COMBAT_SUPPORT_SELECTED
|
||||
COMBAT_SUPPORT_LIST_SCROLL, COMBAT_SUPPORT_SELECTED, COMBAT_SUPPORT_LIST_GRID
|
||||
from tasks.combat.assets.assets_combat_team import COMBAT_TEAM_SUPPORT, COMBAT_TEAM_DISMISSSUPPORT
|
||||
|
||||
|
||||
def get_position_in_original_image(position_in_croped_image, crop_area):
|
||||
"""
|
||||
Returns:
|
||||
tuple: (x, y) of position in original image
|
||||
"""
|
||||
return (
|
||||
position_in_croped_image[0] + crop_area[0], position_in_croped_image[1] + crop_area[1]) if position_in_croped_image else None
|
||||
|
||||
|
||||
class SupportCharacter:
|
||||
_image_cache = {}
|
||||
_crop_area = COMBAT_SUPPORT_LIST_GRID.matched_button.area
|
||||
|
||||
def __init__(self, name, screenshot, similarity=0.85):
|
||||
self.name = name
|
||||
self.image = self._scale_character()
|
||||
self.screenshot = screenshot
|
||||
self.screenshot = crop(screenshot, SupportCharacter._crop_area)
|
||||
self.similarity = similarity
|
||||
self.button = self._find_character()
|
||||
|
||||
@ -49,10 +62,12 @@ class SupportCharacter:
|
||||
def _find_character(self):
|
||||
character = np.array(self.image)
|
||||
support_list_img = self.screenshot
|
||||
res = cv2.matchTemplate(character, support_list_img, cv2.TM_CCOEFF_NORMED)
|
||||
res = cv2.matchTemplate(
|
||||
character, support_list_img, cv2.TM_CCOEFF_NORMED)
|
||||
|
||||
_, max_val, _, max_loc = cv2.minMaxLoc(res)
|
||||
|
||||
max_loc = get_position_in_original_image(
|
||||
max_loc, SupportCharacter._crop_area)
|
||||
character_width = character.shape[1]
|
||||
character_height = character.shape[0]
|
||||
|
||||
@ -68,6 +83,66 @@ class SupportCharacter:
|
||||
self.button[0], self.button[1] - 5, self.button[0] + 30, self.button[1]) if self.button else None
|
||||
|
||||
|
||||
class ArrowWrapper(ButtonWrapper):
|
||||
|
||||
def find_center(self, image):
|
||||
res = cv2.matchTemplate(
|
||||
self.matched_button.image, image, cv2.TM_CCOEFF_NORMED)
|
||||
_, max_val, _, max_loc = cv2.minMaxLoc(res)
|
||||
return (
|
||||
(
|
||||
(max_loc[0] + self.matched_button.image.shape[1] / 2),
|
||||
(max_loc[1] + self.matched_button.image.shape[0] / 2),
|
||||
)
|
||||
if max_val > 0.75
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
class NextSupportCharacter:
|
||||
_arrow = ArrowWrapper(
|
||||
name="NextSupportCharacterArrow",
|
||||
share=Button(
|
||||
file='./assets/support/selected_character_arrow.png',
|
||||
area=None,
|
||||
search=None,
|
||||
color=None,
|
||||
button=None,
|
||||
)
|
||||
)
|
||||
_crop_area = (290, 115, 435, 634)
|
||||
|
||||
def __init__(self, screenshot):
|
||||
self.name = "SupportCharacterArrow"
|
||||
self.screenshot = crop(screenshot, NextSupportCharacter._crop_area)
|
||||
self.arrow_center = self._find_center()
|
||||
self.button = self._get_next_support_character_button()
|
||||
|
||||
def __bool__(self):
|
||||
return self.button is not None
|
||||
|
||||
def _find_center(self):
|
||||
center = NextSupportCharacter._arrow.find_center(self.screenshot)
|
||||
center = get_position_in_original_image(
|
||||
center, NextSupportCharacter._crop_area) if center else None
|
||||
return center
|
||||
|
||||
def _get_next_support_character_button(self):
|
||||
area = (self.arrow_center[0] - 200, min(self.arrow_center[1] + 65, 615), self.arrow_center[0] + 10, min(
|
||||
self.arrow_center[1] + 80, 620)) if self.arrow_center and self.arrow_center[1] < 510 else None
|
||||
return ButtonWrapper(
|
||||
name="NextSupportCharacterButton",
|
||||
share=Button(
|
||||
file='./assets/support/selected_character_arrow.png',
|
||||
area=area,
|
||||
search=area,
|
||||
# if next support was selected, the average color of the button will larger than 220
|
||||
color=(220, 220, 220),
|
||||
button=area,
|
||||
)
|
||||
) if self.arrow_center and self.arrow_center[1] < 510 else None
|
||||
|
||||
|
||||
class SupportListScroll(Scroll):
|
||||
def cal_position(self, main):
|
||||
"""
|
||||
@ -130,11 +205,18 @@ class CombatSupport(UI):
|
||||
return True
|
||||
|
||||
# Click
|
||||
if self.appear(COMBAT_TEAM_SUPPORT, interval=2):
|
||||
if self.appear(COMBAT_TEAM_SUPPORT, interval=1):
|
||||
self.device.click(COMBAT_TEAM_SUPPORT)
|
||||
self.interval_reset(COMBAT_TEAM_SUPPORT)
|
||||
continue
|
||||
if self.appear(COMBAT_SUPPORT_LIST, interval=2):
|
||||
if self.appear(CANCEL_POPUP, interval=1):
|
||||
logger.warning(
|
||||
"selected identical character, trying select another")
|
||||
self._cancel_popup()
|
||||
self._select_next_support()
|
||||
self.interval_reset(CANCEL_POPUP)
|
||||
continue
|
||||
if self.appear(COMBAT_SUPPORT_LIST, interval=1):
|
||||
if support_character_name != "FirstCharacter":
|
||||
self._search_support(
|
||||
support_character_name) # Search support
|
||||
@ -167,7 +249,6 @@ class CombatSupport(UI):
|
||||
|
||||
logger.info("Searching support")
|
||||
skip_first_screenshot = False
|
||||
character = None
|
||||
while 1:
|
||||
if skip_first_screenshot:
|
||||
skip_first_screenshot = False
|
||||
@ -175,7 +256,8 @@ class CombatSupport(UI):
|
||||
self.device.screenshot()
|
||||
|
||||
if not support_character_name.startswith("Trailblazer"):
|
||||
character = SupportCharacter(support_character_name, self.device.image)
|
||||
character = SupportCharacter(
|
||||
support_character_name, self.device.image)
|
||||
else:
|
||||
character = SupportCharacter(f"Stelle{support_character_name[11:]}",
|
||||
self.device.image) or SupportCharacter(
|
||||
@ -223,3 +305,73 @@ class CombatSupport(UI):
|
||||
self.device.click(character)
|
||||
interval.reset()
|
||||
continue
|
||||
|
||||
def _cancel_popup(self):
|
||||
"""
|
||||
Pages:
|
||||
in: CANCEL_POPUP
|
||||
out: COMBAT_SUPPORT_LIST
|
||||
"""
|
||||
logger.hr("Combat support cancel popup")
|
||||
skip_first_screenshot = True
|
||||
|
||||
while 1:
|
||||
if skip_first_screenshot:
|
||||
skip_first_screenshot = False
|
||||
else:
|
||||
self.device.screenshot()
|
||||
|
||||
# End
|
||||
if self.appear(COMBAT_SUPPORT_LIST):
|
||||
logger.info("Popup canceled")
|
||||
return
|
||||
|
||||
if self.appear(CANCEL_POPUP):
|
||||
self.device.click(CANCEL_POPUP)
|
||||
continue
|
||||
|
||||
def _select_next_support(self):
|
||||
"""
|
||||
Pages:
|
||||
in: COMBAT_SUPPORT_LIST
|
||||
out: COMBAT_SUPPORT_LIST
|
||||
"""
|
||||
skip_first_screenshot = True
|
||||
scroll = SupportListScroll(area=COMBAT_SUPPORT_LIST_SCROLL.area, color=(194, 196, 205),
|
||||
name=COMBAT_SUPPORT_LIST_SCROLL.name)
|
||||
interval = Timer(1)
|
||||
next_support = None
|
||||
if scroll.appear(main=self):
|
||||
while 1:
|
||||
if skip_first_screenshot:
|
||||
skip_first_screenshot = False
|
||||
else:
|
||||
self.device.screenshot()
|
||||
|
||||
# End
|
||||
if next_support and self._next_support_selected(next_support):
|
||||
return
|
||||
|
||||
if interval.reached():
|
||||
next_support = NextSupportCharacter(self.device.image)
|
||||
if next_support:
|
||||
logger.info("Next support found, clicking")
|
||||
self.device.click(next_support.button)
|
||||
elif not scroll.at_bottom(main=self):
|
||||
scroll.next_page(main=self, page=0.4)
|
||||
else:
|
||||
logger.warning("No more support")
|
||||
return
|
||||
|
||||
interval.reset()
|
||||
continue
|
||||
|
||||
def _next_support_selected(self, next_support: NextSupportCharacter):
|
||||
"""
|
||||
Returns:
|
||||
bool: True if selected else False
|
||||
"""
|
||||
if self.match_color(next_support.button, threshold=20):
|
||||
logger.info("Next support selected")
|
||||
return True
|
||||
return False
|
||||
|
@ -53,6 +53,16 @@ OCR_DUNGEON_NAV = ButtonWrapper(
|
||||
button=(108, 132, 428, 613),
|
||||
),
|
||||
)
|
||||
OCR_SIMUNI_POINT = ButtonWrapper(
|
||||
name='OCR_SIMUNI_POINT',
|
||||
share=Button(
|
||||
file='./assets/share/dungeon/ui/OCR_SIMUNI_POINT.png',
|
||||
area=(580, 190, 880, 235),
|
||||
search=(560, 170, 900, 255),
|
||||
color=(158, 164, 252),
|
||||
button=(580, 190, 880, 235),
|
||||
),
|
||||
)
|
||||
OPERATION_BRIEFING_CHECK = ButtonWrapper(
|
||||
name='OPERATION_BRIEFING_CHECK',
|
||||
share=Button(
|
||||
|
@ -143,6 +143,9 @@ class Dungeon(DungeonUI, DungeonEvent, Combat):
|
||||
with self.config.multi_set():
|
||||
self.config.stored.DungeonDouble.calyx = calyx
|
||||
self.config.stored.DungeonDouble.relic = relic
|
||||
# Update SimulatedUniverse points
|
||||
logger.info('Get simulated universe points')
|
||||
self.dungeon_get_simuni_point()
|
||||
|
||||
# Run double events
|
||||
ran_calyx_golden = False
|
||||
|
@ -5,7 +5,7 @@ from module.base.button import ClickButton
|
||||
from module.base.timer import Timer
|
||||
from module.base.utils import get_color
|
||||
from module.logger import logger
|
||||
from module.ocr.ocr import Ocr, OcrResultButton
|
||||
from module.ocr.ocr import DigitCounter, Ocr, OcrResultButton
|
||||
from module.ocr.utils import split_and_pair_button_attr
|
||||
from module.ui.draggable_list import DraggableList
|
||||
from module.ui.switch import Switch
|
||||
@ -74,6 +74,10 @@ class OcrDungeonList(Ocr):
|
||||
return result
|
||||
|
||||
|
||||
class OcrSimUniPoint(DigitCounter):
|
||||
merge_thres_x = 50
|
||||
|
||||
|
||||
class OcrDungeonListLimitEntrance(OcrDungeonList):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -219,6 +223,22 @@ class DungeonUI(UI):
|
||||
logger.info('DungeonNav row Forgotten_Hall stabled')
|
||||
return True
|
||||
|
||||
def dungeon_get_simuni_point(self) -> int:
|
||||
"""
|
||||
Page:
|
||||
in: page_guide, Survival_Index, Simulated_Universe
|
||||
"""
|
||||
ocr = OcrSimUniPoint(OCR_SIMUNI_POINT)
|
||||
value, _, total = ocr.ocr_single_line(self.device.image)
|
||||
if total and value <= total:
|
||||
logger.attr('SimulatedUniverse', f'{value}/{total}')
|
||||
self.config.stored.SimulatedUniverse.set(value, total)
|
||||
return value
|
||||
else:
|
||||
logger.warning(f'Invalid SimulatedUniverse points: {value}/{total}')
|
||||
self.config.stored.SimulatedUniverse.set(0, 0)
|
||||
return 0
|
||||
|
||||
def _dungeon_nav_goto(self, dungeon: DungeonList, skip_first_screenshot=True):
|
||||
"""
|
||||
Equivalent to `DUNGEON_NAV_LIST.select_row(dungeon.dungeon_nav, main=self)`
|
||||
|
Loading…
Reference in New Issue
Block a user