Merge pull request #70 from LmeSzinc/dev

Add Dashboard
This commit is contained in:
LmeSzinc 2023-08-30 00:59:49 +08:00 committed by GitHub
commit 72f1842e44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 672 additions and 83 deletions

View File

@ -59,4 +59,11 @@
#pywebio-scope-waiting,
#pywebio-scope-log {
overflow-y: auto;
}
}
[id^="pywebio-scope-dashboard-row-"] {
display: flex;
flex-grow: 1;
min-width: 4rem;
max-width: 50%;
}

View File

@ -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 {
@ -45,4 +54,4 @@
display: grid;
grid-auto-flow: column;
grid-template-columns: auto auto;
}
}

View File

@ -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;

View File

@ -152,4 +152,8 @@ pre.rich-traceback-code {
*[style*="--arg-help--"],
[id^="pywebio-scope-group_"] > p + p {
color: #adb5bd;
}
*[style*="--dashboard-time--"] {
color: #adb5bd;
}

View File

@ -151,4 +151,8 @@ pre.rich-traceback-code {
*[style*="--arg-help--"],
[id^="pywebio-scope-group_"] > p + p {
color: #777777;
}
*[style*="--dashboard-time--"] {
color: #777777;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

View File

@ -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": {}
}
}
}

View File

@ -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"
}
}
}

View File

@ -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
@ -143,4 +151,8 @@ Assignment:
option: [ Nameless_Land_Nameless_People, ]
Duration:
value: 20
option: [ 4, 8, 12, 20 ]
option: [ 4, 8, 12, 20 ]
Assignment:
stored: StoredAssignment
order: 3
color: "#deba95"

View File

@ -52,6 +52,16 @@ Overview:
Waiting:
NoTask:
Dashboard:
# From lang.readable_time()
NoData:
TimeError:
JustNow:
MinutesAgo:
HoursAgo:
DaysAgo:
LongTimeAgo:
AddAlas:
PopupTitle:
NewName:

View 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"
}
}

View File

@ -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 = {}

View File

@ -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:

View File

@ -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",

View File

@ -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": "コンフィグ名",

View File

@ -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": "新的配置文件名",

View File

@ -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": "新設定的檔案名",

View File

@ -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 = ''

View File

@ -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")

View File

@ -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_scroll_btn"),
],
),
],
),
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,27 +560,39 @@ class AlasGUI(Frame):
color="off",
)
clear("running_tasks")
clear("pending_tasks")
clear("waiting_tasks")
with use_scope("running_tasks"):
if running:
for task in running:
put_task(task)
else:
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
with use_scope("pending_tasks"):
if pending:
for task in pending:
put_task(task)
else:
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
with use_scope("waiting_tasks"):
if waiting:
for task in waiting:
put_task(task)
else:
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
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")
with use_scope("running_tasks"):
if running:
for task in running:
put_task(task)
else:
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
with use_scope("pending_tasks"):
if pending:
for task in pending:
put_task(task)
else:
put_text(t("Gui.Overview.NoTask")).style("--overview-notask-text--")
with use_scope("waiting_tasks"):
if waiting:
for task in waiting:
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:

View File

@ -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:

View File

@ -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")

View File

@ -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:

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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)`