fixed # 修复头像框资源位置错误的BUG

This commit is contained in:
2026-10-20 11:49:44 +08:00
parent 2b0fca8828
commit 92a3944e43
20 changed files with 754 additions and 74 deletions
+43
View File
@@ -0,0 +1,43 @@
# 网页头像栏与数据目录
## 头像栏
正式渲染器读取 `metadata.json` 的 `panel` 配置。头像、头像框、昵称、
经验条、等级、农场币和点券使用原始 `WebGLPersionInfo` prefab 的矩形、
锚点与文字对齐方式,不再使用旧版 `userInfo.png` 的位置。
来源为已下载公开资源包 `mainscene_053483f584f07436ac0569e2f4f7ed01`。
提取脚本只读取资源,不执行游戏脚本。原始包哈希及六张新增素材的哈希
记录在 `src/farm_render/source_assets/manifest.json`。完整头像框先于文字、
后于头像合成;Gold 的裁切透明边距按资源中的 textureRectOffset 恢复。
坐标以左上角为起点。Unity RectTransform 的锚点、pivot 和父矩形计算出
各元素矩形。渲染器采用宽 1920 的参考画布,输出坐标和尺寸乘以
`输出宽度 / 1920`;1280 像素网页截图按 2/3 对照。1920 是本渲染器的
对照基准,没有把它报告为已经提取到的网页 CanvasScaler 参数。
原始头像矩形为 `[32.2744, 39.7618, 111.3870, 107.7072]`,
头像框为 `[3.0930, 8.8650, 169.7500, 169.5000]`。昵称在头像右侧,
经验条与等级在昵称下方,农场币和点券在第三行。文字按矩形尺寸适配,
经验条裁切填充图片,不通过拉伸改变纹理。
保留本项目的账户与经验规则。网页的 VIP、晶钻、星钻及购买按钮没有
对应业务,不添加假余额或操作入口。渲染时暂时收起底板上原图纵坐标
168 到 208 的空栏区域,将原始下边框上移拼接。头像和现有文字的位置、
字号保持不变,面板可见高度约缩短 19%;原始素材完整保留,便于以后恢复。
## 数据目录
默认路径改为宿主 `DATA_PATH / farm_db`。为保持数据连续,数据库文件名
`farm-preview.sqlite3`、日志文件名和 deployment 标识不变。
更新插件后下次启动时,若默认旧目录 `farm_preview` 中存在本项目 SQLite,
且 `farm_db` 不存在,会把整个旧目录重命名为 `farm_db`,同时保留日志和
SQLite 附属文件。迁移在打开数据库前进行,成功后记录迁移日志。
若两个目录同时存在,启动失败并提示明确选择数据目录,禁止覆盖或默默
创建空账户。文件被占用导致重命名失败时同样终止启动;应停止旧实例再启动。
原有显式 `farm_preview_data_dir` 配置继续生效;该配置用于自定义路径,
不会被自动移动。若显式配置仍指向 `farm_preview`,需自行更改该路径。
本轮只修改开发源并进行隔离测试;没有移动正在使用的宿主数据或重启机器人。
+6 -1
View File
@@ -15,15 +15,20 @@ from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
from .runtime import COMMANDS, Runtime
from .data_paths import data_directories
logger = logging.getLogger(__name__)
driver = get_driver()
data_dir, legacy_data_dir = data_directories(
DATA_PATH, getattr(driver.config, 'farm_preview_data_dir', None),
)
runtime = Runtime(
getattr(driver.config, "farm_preview_data_dir", DATA_PATH / "farm_preview"),
data_dir,
getattr(driver.config, "farm_preview_font", None),
getattr(driver.config, "farm_preview_deployment", "zhenxun-preview"),
str(getattr(driver.config, "farm_preview_image_format", "AUTO")).upper(),
font_dir=FONT_PATH,
legacy_data_dir=legacy_data_dir,
)
driver.on_startup(runtime.start)
driver.on_shutdown(runtime.close)
+30
View File
@@ -0,0 +1,30 @@
"""农场默认目录与旧预览目录的无覆盖迁移。"""
from pathlib import Path
def data_directories(base, configured=None):
if configured is not None:
return Path(configured), None
return Path(base) / 'farm_db', Path(base) / 'farm_preview'
def prepare_directory(target, legacy=None):
target = Path(target).absolute()
if legacy is not None:
legacy = Path(legacy).absolute()
if (target.name != 'farm_db' or legacy.name != 'farm_preview'
or target.parent != legacy.parent):
raise ValueError('Farm migration requires default sibling paths')
if target.is_symlink() or legacy.is_symlink():
raise ValueError('Farm migration does not follow directory links')
if (legacy / 'farm-preview.sqlite3').is_file():
if target.exists():
raise FileExistsError(
'farm_db already exists beside farm_preview; '
'select farm_preview_data_dir explicitly before startup'
)
legacy.rename(target)
return True
target.mkdir(parents=True, exist_ok=True)
return False
+6 -2
View File
@@ -12,6 +12,7 @@ from ._vendor.farm_core.storage import SCHEMA_VERSION
from ._vendor.farm_core.presentation import receipt_text, snapshot_text
from ._vendor.farm_render import FarmRenderer, RenderOptions, RenderService
from ._vendor.farm_render.seasons import season_for
from .data_paths import prepare_directory
logger = logging.getLogger(__name__)
COMMANDS = {
@@ -65,8 +66,9 @@ def operation_id(bot_id, platform, user_id, message_id, action):
class Runtime:
def __init__(self, data_dir, font_path=None, deployment="zhenxun-preview",
image_format="AUTO", *, font_dir=None):
image_format="AUTO", *, font_dir=None, legacy_data_dir=None):
self.data_dir = Path(data_dir).resolve()
self.legacy_data_dir = legacy_data_dir
self.font_path = font_path
self.font_dir = Path(font_dir or "resources/font")
self.deployment = deployment
@@ -80,7 +82,7 @@ class Runtime:
async def start(self):
if self.started:
return
self.data_dir.mkdir(parents=True, exist_ok=True)
migrated = prepare_directory(self.data_dir, self.legacy_data_dir)
self.log_handler = logging.FileHandler(
self.data_dir / "farm-preview.log", encoding="utf-8",
)
@@ -89,6 +91,8 @@ class Runtime:
))
logging.getLogger(__package__).setLevel(logging.INFO)
logging.getLogger(__package__).addHandler(self.log_handler)
if migrated:
logger.info('farm data directory migrated farm_preview -> farm_db')
try:
await self.store.initialize(catalogue=self.service.catalogue)
except Exception:
+2 -1
View File
@@ -44,7 +44,8 @@ def build(root, output):
files = {}
integration = root / "integrations/zhenxun"
for name in (
"__init__.py", "runtime.py", "commands.py", "requirements.txt",
"__init__.py", "runtime.py", "commands.py", "data_paths.py",
"requirements.txt",
):
files[name] = (integration / name).read_bytes()
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
+1
View File
@@ -88,6 +88,7 @@ def export(root):
webgl_source = root / "src/farm_render/source_assets"
manifest = json.loads((webgl_source / "manifest.json").read_bytes())
metadata["pest"] = manifest["pest"]
metadata["panel"] = manifest["panel"]
entries = {path.relative_to(source).as_posix(): path for path in paths}
entries.update({name: webgl_source / name for name in manifest["images"]})
with ZipFile(output, "w", compression=ZIP_STORED) as bundle:
+89 -4
View File
@@ -8,10 +8,78 @@ import re
import sys
def panel_layout(environment):
prefab = next(
entry.asset for obj in environment.objects
if obj.type.name == 'AssetBundle'
for path, entry in obj.read().m_Container
if path.endswith('/webglpersioninfo.prefab')
)
nodes = {}
def visit(pointer, parent=None):
game_object = pointer.read()
components = [c.component for c in game_object.m_Component]
transform = next(c.read() for c in components
if c.type.name == 'RectTransform')
rect = [0, 0, 100, 100]
if parent is not None:
lo, hi = transform.m_AnchorMin, transform.m_AnchorMax
pivot, pos = transform.m_Pivot, transform.m_AnchoredPosition
w = parent[2] * (hi.x - lo.x) + transform.m_SizeDelta.x
h = parent[3] * (hi.y - lo.y) + transform.m_SizeDelta.y
rect = [
parent[0] + parent[2] * (lo.x + (hi.x-lo.x)*pivot.x)
+ pos.x - w*pivot.x,
parent[1] + parent[3] * (1-lo.y-(hi.y-lo.y)*pivot.y)
- pos.y - h*(1-pivot.y), w, h,
]
for component in components:
if component.type.name != 'MonoBehaviour':
continue
data = component.read_typetree()
if 'm_Sprite' in data or 'm_Text' in data:
nodes[game_object.m_Name] = {
'rect': rect, 'data': data,
}
for child in transform.m_Children:
visit(child.read().m_GameObject, rect)
visit(prefab)
sprites = []
sprite_ids = {}
for name in ('PersionInfoBG', 'HeadFrame', 'ExpBar', 'ExpProgress',
'GoldIcon', 'CouponIcon'):
node = nodes[name]
sprite_id = node['data']['m_Sprite']['m_PathID']
path = f'webgl/panel/{name}.png'
sprite_ids[sprite_id] = path
sprites.append({'name': name, 'sprite': path, 'rect': node['rect']})
fields = []
for node_name, field in (('NameT', 'name'), ('text', 'experience'),
('LevelT', 'level'), ('GoldT', 'point'),
('CouponT', 'vip_point')):
node = nodes[node_name]
color = node['data']['m_Color']
fields.append({
'field': field, 'rect': node['rect'],
'alignment': node['data']['m_FontData']['m_Alignment'],
'color': [round(color[c]*255) for c in ('r', 'g', 'b')],
})
return {
'reference_canvas': [1920, 1080],
'source_prefab': 'mainscene#WebGLPersionInfo',
'anchor': 'top_left', 'sprites': sprites,
'avatar': nodes['HeadIcon']['rect'], 'fields': fields,
}, sprite_ids
def extract(bundle, destination):
import UnityPy
from PIL import Image
environment = UnityPy.load(str(bundle))
panel, panel_ids = panel_layout(environment)
destination.mkdir(parents=True, exist_ok=True)
images = {}
for obj in environment.objects:
@@ -19,7 +87,9 @@ def extract(bundle, destination):
continue
sprite = obj.read()
name = sprite.m_Name
if re.fullmatch(r"pest\d+", name):
if obj.path_id in panel_ids:
path = panel_ids[obj.path_id]
elif re.fullmatch(r"pest\d+", name):
path = f"pest/{name}.png"
elif name in ("Icon_PlantSeed", "Icon_PlantWither"):
path = "webgl/" + (
@@ -28,9 +98,23 @@ def extract(bundle, destination):
else:
continue
image = sprite.image
full_size = (round(sprite.m_Rect.width), round(sprite.m_Rect.height))
if obj.path_id in panel_ids and image.size != full_size:
offset = sprite.m_RD.textureRectOffset
position = (round(offset.x),
full_size[1] - round(offset.y) - image.height)
if (min(position) < 0 or position[0]+image.width > full_size[0]
or position[1]+image.height > full_size[1]):
raise ValueError('Panel sprite exceeds its original canvas')
full = Image.new('RGBA', full_size)
full.paste(image, position)
image = full
if image.size != (round(sprite.m_Rect.width),
round(sprite.m_Rect.height)):
raise ValueError("Sprite extraction lost its full canvas")
raise ValueError(
f'Sprite extraction lost its full canvas: {name} '
f'{image.size} vs {sprite.m_Rect}'
)
target = destination / path
target.parent.mkdir(parents=True, exist_ok=True)
image.save(target)
@@ -38,8 +122,8 @@ def extract(bundle, destination):
"size": list(image.size),
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
}
if len(images) != 40:
raise ValueError("Expected 38 pest frames and two common crop sprites")
if len(images) != 46:
raise ValueError("Expected 38 pests, two crop and six panel sprites")
manifest = {
"schema": 1,
"source_url": (
@@ -49,6 +133,7 @@ def extract(bundle, destination):
"source_sha256": hashlib.sha256(bundle.read_bytes()).hexdigest(),
"extractor": "UnityPy 1.25.4 Sprite.image; full sprite rect checked",
"images": images,
"panel": panel,
"pest": {
"frames": [f"pest/pest{i}.png" for i in range(1, 76, 2)],
"sample_rate": 24,
Binary file not shown.
+195
View File
@@ -6195,6 +6195,48 @@
120
]
},
"webgl/panel/CouponIcon.png": {
"sha256": "8f564bf9da5c0fbeb65a3e320cfb47d66648405f09e2309a42825c1f5404caf1",
"size": [
64,
64
]
},
"webgl/panel/ExpBar.png": {
"sha256": "efb13c2dcb1ecd49689ed561d3f816312738f84887e01496385914e86dcf87d5",
"size": [
223,
30
]
},
"webgl/panel/ExpProgress.png": {
"sha256": "fcb1bdc9a568758460e005a1be58c04d8346eaedfcbc3fef59b29ad0dedcfdc0",
"size": [
225,
23
]
},
"webgl/panel/GoldIcon.png": {
"sha256": "5389afb3a3b1245e9fdc8d3a8839c8ff07a653ebf160bb8e43882a461e73ce49",
"size": [
64,
64
]
},
"webgl/panel/HeadFrame.png": {
"sha256": "74e8f716f58f9055d873657eb8fbaea2170ea19365fe83299b8e32f8484b0b62",
"size": [
230,
230
]
},
"webgl/panel/PersionInfoBG.png": {
"sha256": "62971e70743418988982667625f3e6e4e61073e74610c5279919be2e71c1bd10",
"size": [
492,
236
]
},
"webgl/seed.png": {
"sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609",
"size": [
@@ -6210,6 +6252,159 @@
]
}
},
"panel": {
"anchor": "top_left",
"avatar": [
32.2744255065918,
39.76182556152344,
111.38700103759766,
107.70719909667969
],
"fields": [
{
"alignment": 3,
"color": [
74,
31,
0
],
"field": "name",
"rect": [
161.24510192871094,
40.90002250671387,
298.0353088378906,
31.200000762939453
]
},
{
"alignment": 4,
"color": [
91,
110,
3
],
"field": "experience",
"rect": [
177.60003662109375,
89.78792595863342,
223.0,
22.024199962615967
]
},
{
"alignment": 4,
"color": [
216,
112,
0
],
"field": "level",
"rect": [
400.9734191894531,
85.36273193359375,
56.0,
30.0
]
},
{
"alignment": 3,
"color": [
255,
255,
255
],
"field": "point",
"rect": [
178.46239471435547,
128.23919677734375,
123.61039733886719,
30.0
]
},
{
"alignment": 3,
"color": [
255,
255,
255
],
"field": "vip_point",
"rect": [
342.0,
128.23919677734375,
108.0,
30.0
]
}
],
"reference_canvas": [
1920,
1080
],
"source_prefab": "mainscene#WebGLPersionInfo",
"sprites": [
{
"name": "PersionInfoBG",
"rect": [
0.0,
0.0,
492.0,
236.0
],
"sprite": "webgl/panel/PersionInfoBG.png"
},
{
"name": "HeadFrame",
"rect": [
3.092998504638672,
8.865001678466797,
169.75,
169.5
],
"sprite": "webgl/panel/HeadFrame.png"
},
{
"name": "ExpBar",
"rect": [
177.60000610351562,
85.89997100830078,
223.0,
30.0
],
"sprite": "webgl/panel/ExpBar.png"
},
{
"name": "ExpProgress",
"rect": [
179.90000915527344,
89.89997100830078,
215.0,
22.0
],
"sprite": "webgl/panel/ExpProgress.png"
},
{
"name": "GoldIcon",
"rect": [
146.5999984741211,
128.8000030517578,
30.0,
30.0
],
"sprite": "webgl/panel/GoldIcon.png"
},
{
"name": "CouponIcon",
"rect": [
310.5,
128.8000030517578,
30.0,
30.0
],
"sprite": "webgl/panel/CouponIcon.png"
}
]
},
"pest": {
"display_size": [
22,
+53 -33
View File
@@ -17,6 +17,7 @@ from .models import RenderOptions, RenderResult
logger = logging.getLogger(__name__)
MAX_AVATAR_BYTES = 2 * 1024 * 1024
MAX_AVATAR_PIXELS = 4_000_000
PANEL_UNUSED_ROW = (168, 208)
class FarmRenderer:
@@ -218,45 +219,64 @@ class FarmRenderer:
return avatar
def _panel(self, canvas, scene, options, avatar):
s = lambda n: self._scale(n, options)
def paste_asset(name, position):
original = self.assets.metadata["images"][name]["size"]
image = self.assets.image(
name, tuple(max(1, s(n)) for n in original),
)
canvas.paste(image, tuple(s(n) for n in position))
paste_asset("background/userInfo.png", (100, 30))
canvas.paste(
self._avatar(avatar, (max(1, s(140)), max(1, s(150)))),
(s(125), s(85)),
)
paste_asset("background/frame/frame.png", (75, 44))
layout = self.assets.metadata['panel']
s = lambda n: round(n * options.width / layout['reference_canvas'][0])
account = scene.account
level = account.level
current = account.experience - 200 * level * (level + 1) // 2
next_total = 200 * (level + 1) * (level + 2) // 2
progress = min(247, max(0, current * 247 // next_total))
if progress:
canvas.rectangle(
tuple(s(n) for n in (292, 164, 292 + progress, 189)),
(171, 194, 41),
for entry in layout['sprites']:
x, y, w, h = entry['rect']
image = self.assets.image(
entry['sprite'], (max(1, s(w)), max(1, s(h))),
)
lines = (
(account.name, (300, 92), 24, (77, 35, 4), 330),
(f"{current} / {next_total}", (365, 164), 20,
(102, 120, 19), 165),
(str(level), (570, 158), 32, (214, 111, 1), 60),
(str(account.point), (330, 211), 24, (253, 253, 253), 100),
(str(account.vip_point), (570, 211), 24, (253, 253, 253), 40),
)
for value, position, original_size, color, width in lines:
size = max(1, s(original_size))
if entry['name'] == 'PersionInfoBG':
start, end = (s(n) for n in PANEL_UNUSED_ROW)
compact = Image.new('RGBA', (
image.width, image.height - (end - start),
))
compact.paste(image.crop((0, 0, image.width, start)))
compact.paste(image.crop((
0, end, image.width, image.height,
)), (0, start))
image = compact
if entry['name'] == 'ExpProgress':
filled = min(image.width, max(
0, current * image.width // next_total,
))
if not filled:
continue
image = image.crop((0, 0, filled, image.height))
canvas.paste(image, (s(x), s(y)))
if entry['name'] == 'PersionInfoBG':
ax, ay, aw, ah = layout['avatar']
canvas.paste(self._avatar(
avatar, (max(1, s(aw)), max(1, s(ah))),
), (s(ax), s(ay)))
values = {
'name': account.name, 'experience': f'{current} / {next_total}',
'level': str(level), 'point': str(account.point),
'vip_point': str(account.vip_point),
}
for entry in layout['fields']:
x, y, w, h = entry['rect']
value = values[entry['field']]
width, height = max(1, s(w)), max(1, s(h))
size = height
while size > 1:
box = font(self.font_path, size).getbbox(value)
if (box[2]-box[0] <= width
and box[3]-box[1] <= height):
break
size -= 1
text = canvas.fit_text(
value, self.font_path, size, max(1, s(width)),
value, self.font_path, size, width,
)
box = font(self.font_path, size).getbbox(text)
left = s(x) - box[0]
if entry['alignment'] == 4:
left += (width - (box[2]-box[0])) // 2
top = s(y) + (height - (box[3]-box[1])) // 2 - box[1]
canvas.text(
tuple(s(n) for n in position), text,
self.font_path, size, color,
(left, top), text, self.font_path, size, tuple(entry['color']),
)
+195
View File
@@ -25,6 +25,20 @@
],
"sha256": "226c96b60648787fd457d2f968c0eee7c610444f191e6c73fdd071fa6e95bf14"
},
"webgl/panel/GoldIcon.png": {
"size": [
64,
64
],
"sha256": "5389afb3a3b1245e9fdc8d3a8839c8ff07a653ebf160bb8e43882a461e73ce49"
},
"webgl/panel/HeadFrame.png": {
"size": [
230,
230
],
"sha256": "74e8f716f58f9055d873657eb8fbaea2170ea19365fe83299b8e32f8484b0b62"
},
"pest/pest37.png": {
"size": [
44,
@@ -116,6 +130,13 @@
],
"sha256": "1ad3f1bd4521ef462056b88070728853fc93de85457eac1903d8dd11dc175642"
},
"webgl/panel/PersionInfoBG.png": {
"size": [
492,
236
],
"sha256": "62971e70743418988982667625f3e6e4e61073e74610c5279919be2e71c1bd10"
},
"pest/pest69.png": {
"size": [
44,
@@ -172,6 +193,13 @@
],
"sha256": "8a78d0b046991de083334cabba06291fecc7739be501beb2caf3fcc87f5454f7"
},
"webgl/panel/ExpProgress.png": {
"size": [
225,
23
],
"sha256": "fcb1bdc9a568758460e005a1be58c04d8346eaedfcbc3fef59b29ad0dedcfdc0"
},
"pest/pest65.png": {
"size": [
44,
@@ -249,6 +277,13 @@
],
"sha256": "353ff338fd5ab3f7812e9d2d7f98df11d0de4e04af6de7f9d5aa0249d40b3609"
},
"webgl/panel/CouponIcon.png": {
"size": [
64,
64
],
"sha256": "8f564bf9da5c0fbeb65a3e320cfb47d66648405f09e2309a42825c1f5404caf1"
},
"pest/pest27.png": {
"size": [
44,
@@ -263,6 +298,13 @@
],
"sha256": "fc3a21ed236cd2200704d2971c54dcf6f90fe92ada756da6acb70b78d2c9fd82"
},
"webgl/panel/ExpBar.png": {
"size": [
223,
30
],
"sha256": "efb13c2dcb1ecd49689ed561d3f816312738f84887e01496385914e86dcf87d5"
},
"pest/pest61.png": {
"size": [
44,
@@ -285,6 +327,159 @@
"sha256": "5bd1ed7e9db82572119403fce50ff9c7276df33ad4288869aeac3fa5896652e6"
}
},
"panel": {
"reference_canvas": [
1920,
1080
],
"source_prefab": "mainscene#WebGLPersionInfo",
"anchor": "top_left",
"sprites": [
{
"name": "PersionInfoBG",
"sprite": "webgl/panel/PersionInfoBG.png",
"rect": [
0.0,
0.0,
492.0,
236.0
]
},
{
"name": "HeadFrame",
"sprite": "webgl/panel/HeadFrame.png",
"rect": [
3.092998504638672,
8.865001678466797,
169.75,
169.5
]
},
{
"name": "ExpBar",
"sprite": "webgl/panel/ExpBar.png",
"rect": [
177.60000610351562,
85.89997100830078,
223.0,
30.0
]
},
{
"name": "ExpProgress",
"sprite": "webgl/panel/ExpProgress.png",
"rect": [
179.90000915527344,
89.89997100830078,
215.0,
22.0
]
},
{
"name": "GoldIcon",
"sprite": "webgl/panel/GoldIcon.png",
"rect": [
146.5999984741211,
128.8000030517578,
30.0,
30.0
]
},
{
"name": "CouponIcon",
"sprite": "webgl/panel/CouponIcon.png",
"rect": [
310.5,
128.8000030517578,
30.0,
30.0
]
}
],
"avatar": [
32.2744255065918,
39.76182556152344,
111.38700103759766,
107.70719909667969
],
"fields": [
{
"field": "name",
"rect": [
161.24510192871094,
40.90002250671387,
298.0353088378906,
31.200000762939453
],
"alignment": 3,
"color": [
74,
31,
0
]
},
{
"field": "experience",
"rect": [
177.60003662109375,
89.78792595863342,
223.0,
22.024199962615967
],
"alignment": 4,
"color": [
91,
110,
3
]
},
{
"field": "level",
"rect": [
400.9734191894531,
85.36273193359375,
56.0,
30.0
],
"alignment": 4,
"color": [
216,
112,
0
]
},
{
"field": "point",
"rect": [
178.46239471435547,
128.23919677734375,
123.61039733886719,
30.0
],
"alignment": 3,
"color": [
255,
255,
255
]
},
{
"field": "vip_point",
"rect": [
342.0,
128.23919677734375,
108.0,
30.0
],
"alignment": 3,
"color": [
255,
255,
255
]
}
]
},
"pest": {
"frames": [
"pest/pest1.png",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+58
View File
@@ -0,0 +1,58 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class DataDirectoryTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location(
'farm_data_paths', ROOT / 'integrations/zhenxun/data_paths.py',
)
self.module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.module)
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
def test_default_and_explicit_directory(self):
self.assertEqual(self.module.data_directories(self.base), (
self.base / 'farm_db', self.base / 'farm_preview',
))
custom = self.base / 'custom'
self.assertEqual(self.module.data_directories(self.base, custom), (
custom, None,
))
def test_migrate_preserves_database_log_and_sidecars(self):
old = self.base / 'farm_preview'
old.mkdir()
for name in ('farm-preview.sqlite3', 'farm-preview.sqlite3-wal',
'farm-preview.log'):
(old / name).write_bytes(name.encode())
target = self.base / 'farm_db'
self.assertTrue(self.module.prepare_directory(target, old))
self.assertFalse(old.exists())
for path in target.iterdir():
self.assertEqual(path.read_bytes(), path.name.encode())
self.assertFalse(self.module.prepare_directory(target, old))
def test_conflicting_directory_stops_without_overwrite(self):
old, target = self.base / 'farm_preview', self.base / 'farm_db'
old.mkdir()
target.mkdir()
(old / 'farm-preview.sqlite3').write_bytes(b'old')
(target / 'farm-preview.sqlite3').write_bytes(b'new')
with self.assertRaises(FileExistsError):
self.module.prepare_directory(target, old)
self.assertEqual((old / 'farm-preview.sqlite3').read_bytes(), b'old')
self.assertEqual((target / 'farm-preview.sqlite3').read_bytes(), b'new')
def test_migration_rejects_paths_outside_default_siblings(self):
with self.assertRaises(ValueError):
self.module.prepare_directory(
self.base / 'farm_db', self.base / 'other/farm_preview',
)
+51 -32
View File
@@ -115,32 +115,26 @@ class RenderTests(unittest.TestCase):
changed = replace(data, account=replace(data.account, experience=200))
self.assertFalse(self.renderer.render(self.who, changed, 0).cache_hit)
def test_legacy_panel_assets_avatar_size_and_layer_order(self):
def test_web_panel_avatar_uses_prefab_rect_and_frame_overlays_it(self):
avatar = BytesIO()
Image.new("RGB", (80, 40), "red").save(avatar, "PNG")
scene = self.renderer.scene(self.who, snapshot(), 0)
for width in (1024, 1536, 3072):
options = RenderOptions(width=width)
s = lambda n: round(n * width / 3072)
actual = Canvas.new((width, s(1872)))
expected = Canvas.new((width, s(1872)))
name = "background/userInfo.png"
size = self.renderer.assets.metadata["images"][name]["size"]
expected.paste(self.renderer.assets.image(
name, tuple(s(n) for n in size),
), (s(100), s(30)))
expected.paste(Image.new("RGBA", (s(140), s(150)), "red"),
(s(125), s(85)))
name = "background/frame/frame.png"
size = self.renderer.assets.metadata["images"][name]["size"]
expected.paste(self.renderer.assets.image(
name, tuple(s(n) for n in size),
), (s(75), s(44)))
with patch.object(actual, "text"), patch.object(actual, "rectangle"):
s = lambda n: round(n * width / 1920)
actual = Canvas.new((width, round(width * 1872 / 3072)))
with patch.object(actual, "paste", wraps=actual.paste) as paste:
self.renderer._panel(actual, scene, options, avatar.getvalue())
self.assertEqual(actual.image.tobytes(), expected.image.tobytes())
calls = paste.call_args_list
self.assertEqual(calls[1].args[0].size,
(s(111.387001), s(107.707199)))
self.assertEqual(calls[1].args[1], (s(32.274426), s(39.761826)))
self.assertEqual(calls[1].args[0].getpixel((0, 0)),
(255, 0, 0, 255))
self.assertEqual(calls[2].args[1], (s(3.093), s(8.865)))
self.assertEqual(calls[2].args[0].size, (s(169.75), s(169.5)))
def test_legacy_panel_fields_progress_and_level_boundaries(self):
def test_web_panel_fields_fit_their_prefab_rectangles(self):
options = RenderOptions(width=3072)
for exp, level, current, threshold in (
(0, 0, 0, 200), (199, 0, 199, 200), (200, 1, 0, 600),
@@ -154,21 +148,46 @@ class RenderTests(unittest.TestCase):
canvas, "rectangle",
) as rectangle:
self.renderer._panel(canvas, scene, options, None)
values = [(call.args[0], call.args[1])
for call in text.call_args_list]
self.assertEqual(values, [
((300, 92), "农场"),
((365, 164), f"{current} / {threshold}"),
((570, 158), str(level)),
((330, 211), "12580"), ((570, 211), "36"),
self.assertEqual([c.args[1] for c in text.call_args_list], [
'农场', f'{current} / {threshold}', str(level), '12580', '36',
])
progress = current * 247 // threshold
if progress:
rectangle.assert_called_once_with(
(292, 164, 292 + progress, 189), (171, 194, 41),
)
from farm_render.canvas import font
rectangles = ((161.2451, 40.9, 298.0353, 31.2),
(177.6, 89.7879, 223, 22.0242),
(400.9734, 85.3627, 56, 30),
(178.4624, 128.2392, 123.6104, 30),
(342, 128.2392, 108, 30))
for call, rect in zip(text.call_args_list, rectangles):
position, value, path, size, _ = call.args
box = font(path, size).getbbox(value)
left, top = position[0] + box[0], position[1] + box[1]
self.assertGreaterEqual(left, round(rect[0] * 1.6) - 1)
self.assertGreaterEqual(top, round(rect[1] * 1.6) - 1)
self.assertLessEqual(left + box[2] - box[0],
round((rect[0] + rect[2]) * 1.6) + 1)
self.assertLessEqual(top + box[3] - box[1],
round((rect[1] + rect[3]) * 1.6) + 1)
def test_web_experience_fill_clips_texture_without_resizing(self):
options = RenderOptions(width=1536)
original = self.renderer.assets.image(
'webgl/panel/ExpProgress.png', (172, 18),
)
for exp in (0, 100, 199):
scene = self.renderer.scene(
self.who, snapshot(experience=exp), 0,
)
canvas = Canvas.new((1536, 936))
with patch.object(canvas, 'paste') as paste:
self.renderer._panel(canvas, scene, options, None)
calls = paste.call_args_list
if exp == 0:
self.assertEqual(len(calls), 6)
else:
rectangle.assert_not_called()
self.assertEqual(len(calls), 7)
filled = exp * 172 // 200
self.assertEqual(calls[4].args[0].tobytes(),
original.crop((0, 0, filled, 18)).tobytes())
def test_lru_budget_and_shared_image_safety(self):
cache = ByteLRU(10)
+25 -1
View File
@@ -189,10 +189,33 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(before, await self.call("inventory"))
async def test_default_directory_migration_keeps_account_and_inventory(self):
old = self.root / 'migration/farm_preview'
target = old.with_name('farm_db')
previous = self.module.Runtime(old, choose_font())
await previous.start()
try:
await previous.execute('register', '迁移农场', **self.context)
before = await previous.execute('inventory', '', **self.context)
finally:
await previous.close()
current = self.module.Runtime(
target, choose_font(), legacy_data_dir=old,
)
await current.start()
try:
self.assertFalse(old.exists())
self.assertTrue((target / 'farm-preview.sqlite3').is_file())
self.assertEqual(await current.execute(
'inventory', '', **self.context,
), before)
finally:
await current.close()
async def test_mock_host_load_registers_hooks_and_original_matchers(self):
hooks = {"start":[], "close":[], "commands":{}}
driver = SimpleNamespace(
config=SimpleNamespace(farm_preview_data_dir=self.root / "host"),
config=SimpleNamespace(),
on_startup=hooks["start"].append,
on_shutdown=hooks["close"].append,
)
@@ -233,6 +256,7 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
)}
with patch.dict(sys.modules, modules):
imported = importlib.import_module(self.package + ".commands")
self.assertEqual(imported.runtime.data_dir, self.root / 'farm_db')
self.assertEqual(set(hooks["commands"]), set(self.module.COMMANDS))
self.assertEqual(len(hooks["start"]), 1)
await hooks["start"][0]()