Land Explorer in HomeCamp with boot loading, journal UI, and CDN publish tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-11-04 16:18:01 +08:00
co-authored by Cursor
parent 9eec364f30
commit 80678ec499
405 changed files with 48053 additions and 420302 deletions
+169
View File
@@ -0,0 +1,169 @@
param(
[Parameter(Mandatory=$true)][ValidateSet('Check','Plan','Prepare','Publish','Rollback')][string]$Action,
[Parameter(Mandatory=$true)][string]$RequestBase64,
[string]$StateRoot = ''
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
$request = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($RequestBase64)) | ConvertFrom-Json
function Hash([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' }
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Under([string]$Root, [string]$Relative) {
$full = [IO.Path]::GetFullPath((Join-Path $Root $Relative))
$prefix = $Root.TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar
if (-not $full.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Path escapes release root: $Relative" }
return $full
}
function Resolve-WebRoot([string]$Path) {
if (-not [IO.Path]::IsPathRooted($Path)) { throw 'Website root must be an absolute path' }
$resolved = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\','/')
if ($resolved.Length -le 3 -or -not (Test-Path -LiteralPath $resolved -PathType Container)) { throw 'Invalid website root' }
if (-not (Test-Path -LiteralPath (Join-Path $resolved 'cdn/version.json')) -and
-not (Get-ChildItem -LiteralPath $resolved -Filter '*.webgl.data.unityweb.bin.txt' -File | Select-Object -First 1)) {
throw 'Target is not an existing WebGL CDN website (no version or startup data)'
}
return $resolved
}
function Validate-Files($Files) {
$seen = @{}
foreach ($item in @($Files)) {
$name = [string]$item.path
if ($name -ne 'cdn/version.json' -and $name -notmatch '^yoo/(Art|DefaultPackage)/[A-Za-z0-9_.-]+$' -and
$name -cnotmatch '^[a-f0-9]{16}\.webgl\.data\.unityweb\.bin\.txt$') { throw "Unsupported release path: $name" }
if ($name.Split('/') -contains '..' -or $seen.ContainsKey($name)) { throw "Unsafe or duplicate path: $name" }
if ($item.sha256 -notmatch '^[a-f0-9]{64}$' -or [long]$item.size -lt 0) { throw "Invalid file metadata: $name" }
$seen[$name] = $item
}
if (-not $seen.ContainsKey('cdn/version.json')) { throw 'Release has no global version.json' }
return $seen
}
function Atomic-Copy([string]$Source, [string]$Target) {
New-Item -ItemType Directory -Path (Split-Path -Parent $Target) -Force | Out-Null
$temporary = "$Target.upload-$([Guid]::NewGuid().ToString('N')).tmp"
try {
[IO.File]::Copy($Source, $temporary, $false)
if (Test-Path -LiteralPath $Target) { [IO.File]::Replace($temporary, $Target, [System.Management.Automation.Language.NullString]::Value) }
else { [IO.File]::Move($temporary, $Target) }
} finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } }
}
try {
$root = Resolve-WebRoot $request.root
$current = Join-Path $root 'cdn/version.json'
$base = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'AIGameCdnPublish'
if ($StateRoot) { $base = [IO.Path]::GetFullPath($StateRoot) }
if ($Action -eq 'Check') {
@{ connected=$true; user=[Environment]::UserName; root=$root; currentVersionSha256=(Hash $current) } | ConvertTo-Json -Compress
exit 0
}
if ($request.releaseId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid release ID' }
$folder = Join-Path $base $request.releaseId
$archivePath = Join-Path $folder 'release.zip'
if ($Action -eq 'Plan') {
$files = Validate-Files $request.files
$changed = @($request.files | Where-Object {
$target = Under $root $_.path
-not (Test-Path -LiteralPath $target -PathType Leaf) -or
(Get-Item -LiteralPath $target).Length -ne [long]$_.size -or (Hash $target) -ne $_.sha256
} | ForEach-Object { $_.path })
@{ changed=$changed; currentVersionSha256=(Hash $current); root=$root } | ConvertTo-Json -Compress
exit 0
}
New-Item -ItemType Directory -Path $base -Force | Out-Null
if ($Action -eq 'Prepare') {
$null = Validate-Files $request.files
if (Test-Path -LiteralPath $folder) { throw 'Release staging directory already exists' }
New-Item -ItemType Directory -Path $folder | Out-Null
@{ archive=$archivePath } | ConvertTo-Json -Compress
exit 0
}
# The lock is outside the public website and covers both publishing and rollback.
$sha = [Security.Cryptography.SHA256]::Create()
try { $rootId = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-','') }
finally { $sha.Dispose() }
$lockPath = Join-Path $base "$rootId.lock"
$lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
try {
if ($Action -eq 'Rollback') {
$receipt = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $folder 'receipt.json') | ConvertFrom-Json
if ($receipt.root -ne $root -or (Hash $current) -ne $receipt.versionSha256) { throw 'A different version is live; rollback refused' }
$previous = Join-Path $folder 'previous.version.json'
if (-not (Test-Path -LiteralPath $previous)) { throw 'This release has no previous version to restore' }
$version = Get-Content -Raw -Encoding UTF8 -LiteralPath $previous | ConvertFrom-Json
foreach ($package in @($version.packages)) {
if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected previous package' }
$versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion }
if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid previous manifest version' }
$index = Under $root "yoo/$package/${package}_$versionId.bytes"
if (-not (Test-Path -LiteralPath $index) -or (Get-FileHash -LiteralPath $index -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) {
throw "Previous manifest missing or corrupt: $package"
}
}
Atomic-Copy $previous $current
@{ rolledBack=$true; releaseId=$request.releaseId; version=$version } | ConvertTo-Json -Depth 20 -Compress
exit 0
}
$files = Validate-Files $request.files
if ((Hash $current) -ne $request.expectedVersionSha256) { throw 'Live version changed since planning; make a fresh plan' }
if (Test-Path -LiteralPath (Join-Path $folder 'receipt.json')) { throw 'Release was already committed; inspect its receipt' }
$unpacked = Join-Path $folder 'files'
if (Test-Path -LiteralPath $unpacked) { throw 'Release already extracted; start a fresh release' }
New-Item -ItemType Directory -Path $unpacked | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($archivePath)
$seen = @{}
try {
foreach ($entry in $zip.Entries) {
$name = $entry.FullName
if (-not $files.ContainsKey($name) -or $seen.ContainsKey($name) -or $entry.Length -ne [long]$files[$name].size) { throw "Unexpected ZIP entry: $name" }
$destination = Under $unpacked $name
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null
$inputStream = $entry.Open()
$outputStream = [IO.File]::Open($destination, [IO.FileMode]::CreateNew)
try { $inputStream.CopyTo($outputStream) } finally { $inputStream.Dispose(); $outputStream.Dispose() }
if ((Hash $destination) -ne $files[$name].sha256) { throw "Uploaded SHA256 mismatch: $name" }
$seen[$name] = $true
}
} finally { $zip.Dispose() }
# Validate the entire release, including unchanged server files, before any website writes.
foreach ($item in @($request.files)) {
$source = if ($seen.ContainsKey($item.path)) { Under $unpacked $item.path } else { Under $root $item.path }
if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or (Get-Item -LiteralPath $source).Length -ne [long]$item.size -or (Hash $source) -ne $item.sha256) {
throw "Missing or corrupt release file: $($item.path)"
}
}
$versionSource = if ($seen.ContainsKey('cdn/version.json')) { Under $unpacked 'cdn/version.json' } else { $current }
$version = Get-Content -Raw -Encoding UTF8 -LiteralPath $versionSource | ConvertFrom-Json
if ($version.cdnRoot -ne $request.version.cdnRoot -or $version.packVersion -ne $request.version.packVersion -or
$version.resVersion -ne $request.version.resVersion -or (@($version.packages) -join ',') -ne (@($request.version.packages) -join ',')) {
throw 'Global version payload does not match the request'
}
foreach ($package in @($version.packages)) {
if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected package name' }
$versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion }
if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid package version' }
$name = "yoo/$package/${package}_$versionId.bytes"
if (-not $files.ContainsKey($name)) { throw "Manifest absent from release: $name" }
$source = if ($seen.ContainsKey($name)) { Under $unpacked $name } else { Under $root $name }
if ((Get-FileHash -LiteralPath $source -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) { throw "Manifest MD5 mismatch: $package" }
}
if (Test-Path -LiteralPath $current) { [IO.File]::Copy($current, (Join-Path $folder 'previous.version.json'), $false) }
foreach ($name in @($seen.Keys | Where-Object { $_ -ne 'cdn/version.json' } | Sort-Object)) {
Atomic-Copy (Under $unpacked $name) (Under $root $name)
}
# Commit marker is always last. All older content-addressed resources remain available.
if ($seen.ContainsKey('cdn/version.json')) { Atomic-Copy (Under $unpacked 'cdn/version.json') $current }
$receipt = @{ published=$true; releaseId=$request.releaseId; root=$root; version=$version;
changedFiles=$seen.Count; versionSha256=(Hash $current); publishedUtc=[DateTime]::UtcNow.ToString('o') }
$receiptJson = $receipt | ConvertTo-Json -Depth 20 -Compress
[IO.File]::WriteAllText((Join-Path $folder 'receipt.json'), $receiptJson, (New-Object Text.UTF8Encoding($false)))
Write-Output $receiptJson
} finally { $lock.Dispose() }
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}
+22
View File
@@ -0,0 +1,22 @@
// Shared visual direction for the SDK cover and the local Unity loading prefab.
export default {
totalLaunchTime: 7000,
animationDuration: 100,
designWidth: 1080,
designHeight: 1920,
scaleMode: 'NO_BORDER',
textConfig: {
firstStartText: '首次进入需要下载资源,请稍候',
downloadingText: ['正在下载资源…'],
compilingText: '正在准备冒险…',
initText: '正在布置营地…',
completeText: '准备就绪,即将出发',
textDuration: 1500,
style: { bottom: 225, height: 50, width: 920, lineHeight: 50, color: '#FFF8E8', fontSize: 32 },
},
barConfig: {
style: { width: 780, height: 30, padding: 3, bottom: 160, backgroundColor: '#7ED321' },
},
iconConfig: { visible: false, style: { width: 64, height: 23, bottom: 20 } },
materialConfig: { backgroundImage: 'images/startup-cover.png', backgroundVideo: '', iconImage: 'images/unity_logo.png' },
};
+8
View File
@@ -0,0 +1,8 @@
{
"host": "159.75.128.109",
"user": "Administrator",
"port": 22,
"identity_file": "Tools/.cdn-deploy/id_ed25519",
"remote_root": "C:\\XWorld\\CDN\\Friends\\webgl",
"public_base": "https://www.xworld.ren/game/Friends/webgl"
}
+9
View File
@@ -0,0 +1,9 @@
import bpy
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Idle.fbx")
print("Actions in Idle.fbx:", [a.name for a in bpy.data.actions])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Walking.fbx")
print("Actions in Walking.fbx:", [a.name for a in bpy.data.actions])
+73
View File
@@ -0,0 +1,73 @@
import time
import json
from pathlib import Path
import requests
BASE = "http://127.0.0.1:8008"
JOB_ID = "25f0f3ce2d124e748bc3e8415e7289c7"
OUTPUT_DIR = Path("Tools/output/explorer_3d")
session = requests.Session()
def wait_job(timeout=600, interval=2):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
r = session.get(f"{BASE}/api/jobs/{JOB_ID}", timeout=30)
r.raise_for_status()
job = r.json()
status = job.get("status")
stage = job.get("stage", "")
progress = job.get("progress", 0)
print(f"[{status}] stage={stage} progress={progress}%", flush=True)
if status in ("completed", "failed"):
if status != "completed" or job.get("error"):
raise RuntimeError(f"Animation failed: {job.get('error') or job}")
return job
time.sleep(interval)
raise TimeoutError("Animation timeout")
def download(url, out_path):
r = session.get(f"{BASE}{url}", timeout=300)
r.raise_for_status()
out = Path(out_path)
if r.content[:4] == b"PK\x03\x04":
out = out.with_suffix(".zip")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(r.content)
print(f"Downloaded: {out} ({len(r.content)} bytes)")
return out
# 1. Walk Animation
print("\n=== Generating Walk Animation ===")
r = session.post(f"{BASE}/api/jobs/{JOB_ID}/animate", data={
"motion_prompt": "A person walks forward at a steady pace.",
"seed": 42,
"cfg_scale": 5,
"in_place": "true"
})
print("Walk trigger:", r.status_code, r.text[:200])
walk_job = wait_job()
walk_dir = OUTPUT_DIR / "walk"
walk_dir.mkdir(parents=True, exist_ok=True)
for k, u in walk_job.get("downloads", {}).items():
if "anim" in k:
download(u, walk_dir / (k + (".fbx" if "fbx" in k else ".glb")))
# 2. Idle Animation
print("\n=== Generating Idle Animation ===")
r = session.post(f"{BASE}/api/jobs/{JOB_ID}/animate", data={
"motion_prompt": "A person stands calmly in an idle breathing pose.",
"seed": 100,
"cfg_scale": 5,
"in_place": "true"
})
print("Idle trigger:", r.status_code, r.text[:200])
idle_job = wait_job()
idle_dir = OUTPUT_DIR / "idle"
idle_dir.mkdir(parents=True, exist_ok=True)
for k, u in idle_job.get("downloads", {}).items():
if "anim" in k:
download(u, idle_dir / (k + (".fbx" if "fbx" in k else ".glb")))
print("\nBoth animations generated and downloaded successfully!")
+51
View File
@@ -0,0 +1,51 @@
import bpy
import mathutils
bpy.ops.wm.read_factory_settings(use_empty=True)
fbx_in = r"Tools/output/explorer_3d/mixamo_extracted/model.fbx"
fbx_out = r"AIClient/Assets/Bundles/Characters/Human/Explorer/Explorer.fbx"
bpy.ops.import_scene.fbx(filepath=fbx_in)
# Find armature and mesh
armature = None
mesh_objs = []
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
armature = obj
elif obj.type == 'MESH':
mesh_objs.append(obj)
print("Imported armature:", armature.name if armature else "None")
print("Imported meshes:", [m.name for m in mesh_objs])
# Calculate bounds of meshes
min_y = float('inf')
for obj in mesh_objs:
for v in obj.data.vertices:
world_co = obj.matrix_world @ v.co
if world_co.z < min_y: # In Blender Z is up
min_y = world_co.z
print("Mesh lowest Z in Blender:", min_y)
# Export FBX with Y-up, -Z forward in blender convention (which converts to +Z in Unity)
bpy.ops.export_scene.fbx(
filepath=fbx_out,
use_selection=False,
global_scale=1.0,
apply_unit_scale=True,
apply_scale_options='FBX_SCALE_NONE',
bake_space_transform=False,
object_types={'ARMATURE', 'MESH'},
use_mesh_modifiers=True,
mesh_smooth_type='FACE',
add_leaf_bones=False,
primary_bone_axis='Y',
secondary_bone_axis='X',
axis_forward='-Z',
axis_up='Y'
)
print("Exported normalized Explorer.fbx")
+98
View File
@@ -0,0 +1,98 @@
from PIL import Image
from pathlib import Path
import subprocess
ext_dir = Path("Tools/output/explorer_3d/mixamo_extracted")
target_dir = Path("AIClient/Assets/Bundles/Characters/Human/Explorer")
target_dir.mkdir(parents=True, exist_ok=True)
# 1. Synthesize _base.png and _mix.png
print("Synthesizing PBR textures...")
diffuse = Image.open(ext_dir / "diffuse.png").convert("RGBA")
diffuse.save(target_dir / "Explorer_base.png")
metallic = Image.open(ext_dir / "metallic.png").convert("L")
roughness = Image.open(ext_dir / "roughness.png").convert("L")
ao = Image.open(ext_dir / "ao.png").convert("L")
mix = Image.merge("RGBA", (
metallic,
roughness,
ao,
Image.new("L", diffuse.size, 255)
))
mix.save(target_dir / "Explorer_mix.png")
print("Saved Explorer_base.png and Explorer_mix.png")
# 2. Blender script to inspect and ensure bottom centered, Y-up, +Z front
blender_script = """
import bpy
import mathutils
bpy.ops.wm.read_factory_settings(use_empty=True)
fbx_in = r"Tools/output/explorer_3d/mixamo_extracted/model.fbx"
fbx_out = r"AIClient/Assets/Bundles/Characters/Human/Explorer/Explorer.fbx"
bpy.ops.import_scene.fbx(filepath=fbx_in)
# Find armature and mesh
armature = None
mesh_objs = []
for obj in bpy.context.scene.objects:
if obj.type == 'ARMATURE':
armature = obj
elif obj.type == 'MESH':
mesh_objs.append(obj)
print("Imported armature:", armature.name if armature else "None")
print("Imported meshes:", [m.name for m in mesh_objs])
# Calculate bounds of meshes
min_y = float('inf')
for obj in mesh_objs:
for v in obj.data.vertices:
world_co = obj.matrix_world @ v.co
if world_co.z < min_y: # In Blender Z is up
min_y = world_co.z
print("Mesh lowest Z in Blender:", min_y)
# Export FBX with Y-up, -Z forward in blender convention (which converts to +Z in Unity)
bpy.ops.export_scene.fbx(
filepath=fbx_out,
use_selection=False,
global_scale=1.0,
apply_unit_scale=True,
apply_scale_options='FBX_SCALE_NONE',
bake_space_transform=False,
object_types={'ARMATURE', 'MESH'},
use_mesh_modifiers=True,
mesh_smooth_type='FACE',
add_leaf_bones=False,
primary_bone_axis='Y',
secondary_bone_axis='X',
axis_forward='-Z',
axis_up='Y'
)
print("Exported normalized Explorer.fbx")
"""
blender_script_path = Path("Tools/normalize_explorer_fbx.py")
blender_script_path.write_text(blender_script, encoding="utf-8")
blender_exe = r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe"
print("Running Blender FBX normalization...")
res = subprocess.run([blender_exe, "--background", "--python", str(blender_script_path)], capture_output=True, text=True)
print(res.stdout[-600:])
if res.returncode != 0:
print(res.stderr)
raise RuntimeError("Blender export failed")
# 3. Copy motion files
import shutil
mixamo_source = Path(r"D:\UD\AI\XModel\LLM3DGraphics\mixamo\Male")
shutil.copy(mixamo_source / "Idle.fbx", target_dir / "Explorer@Idle.fbx")
shutil.copy(mixamo_source / "Walking.fbx", target_dir / "Explorer@Walk.fbx")
print("Copied Explorer@Idle.fbx and Explorer@Walk.fbx to target directory.")
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Publish the staged WebGL Art and hot-update packages over Windows OpenSSH.
No password handling, remote deletion, or shell-built SCP commands. The remote
transaction verifies every file before exposing cdn/version.json.
"""
from __future__ import annotations
import argparse
import base64
import gzip
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import urllib.request
import uuid
import zipfile
import zlib
REPO = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = REPO / "Tools/cdn_publish.local.json"
OUTPUT = REPO / "Tools/output/cdn_publish"
REMOTE_SCRIPT = REPO / "Tools/Publish-CdnRelease.ps1"
HASH32 = re.compile(r"[a-f0-9]{32}\Z")
RESOURCE_PATH = re.compile(r"yoo/(Art|DefaultPackage)/[A-Za-z0-9_.-]+\Z")
STARTUP_PATH = re.compile(r"[a-f0-9]{16}\.webgl\.data\.unityweb\.bin\.txt\Z")
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8-sig"))
def write_json(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def digest(path: Path, algorithm="sha256") -> str:
result = hashlib.new(algorithm)
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
result.update(block)
return result.hexdigest()
def validate_path(path: str) -> None:
if path != "cdn/version.json" and not RESOURCE_PATH.fullmatch(path) and not STARTUP_PATH.fullmatch(path):
raise ValueError(f"Unsupported release path: {path}")
if any(part in (".", "..") for part in path.split("/")):
raise ValueError(f"Unsafe release path: {path}")
def make_plan(stage: Path, destination: Path, startup_export: Path | None = None) -> dict:
version_file = stage / "version.prod.json"
version = read_json(version_file)
if version.get("cdnRoot") != "https://www.xworld.ren/game/Friends/webgl/yoo/DefaultPackage":
raise ValueError("Staged production version has an unexpected CDN root")
packages = version.get("packages")
if not isinstance(packages, list) or "DefaultPackage" not in packages or len(packages) != len(set(packages)):
raise ValueError("Invalid package list")
app = version.get("appVersionMax", "")
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", app):
raise ValueError("Invalid appVersionMax")
files = []
def add(source: Path, relative: str):
validate_path(relative)
if not source.is_file():
raise ValueError(f"Missing staged file: {source}")
files.append({"path": relative, "source": str(source.resolve()),
"size": source.stat().st_size, "sha256": digest(source)})
for package in packages:
field = {"Art": "resVersion", "DefaultPackage": "packVersion"}.get(package)
if field is None or not HASH32.fullmatch(version.get(field, "")):
raise ValueError(f"Invalid package/version: {package}")
package_version = version[field]
directory = stage / app / package
index = directory / f"{package}_{package_version}.bytes"
if not index.is_file() or digest(index, "md5") != package_version:
raise ValueError(f"Manifest MD5 mismatch or missing index: {index}")
marker = directory / f"{package}.version"
if marker.read_text(encoding="utf-8-sig").strip() != package_version:
raise ValueError(f"Package version marker mismatch: {marker}")
checksum = directory / f"{package}_{package_version}.hash"
expected_crc = zlib.crc32(index.read_bytes()).to_bytes(4, "little").hex()
if checksum.read_text(encoding="utf-8-sig").strip().lower() != expected_crc:
raise ValueError(f"Package hash marker mismatch: {checksum}")
selected = [marker, index, checksum]
selected.extend(sorted(p for p in directory.iterdir() if p.suffix in (".bundle", ".rawfile")))
if len(selected) <= 3:
raise ValueError(f"No payload files in {directory}")
for source in selected:
add(source, f"yoo/{package}/{source.name}")
if startup_export is not None:
game = (startup_export / "minigame/game.js").read_text(encoding="utf-8-sig")
hashes = re.findall(r"\bDATA_FILE_MD5\s*:\s*['\"]([a-f0-9]{16})['\"]", game)
sizes = re.findall(r"\bDATA_FILE_SIZE\s*:\s*['\"]([0-9]+)['\"]", game)
roots = re.findall(r"\bDATA_CDN\s*:\s*['\"]([^'\"]+)['\"]", game)
if len(hashes) != 1 or len(sizes) != 1 or roots != ["https://www.xworld.ren/game/Friends/webgl/"]:
raise ValueError("Cannot identify the exported startup data/CDN")
name = hashes[0] + ".webgl.data.unityweb.bin.txt"
source = startup_export / "webgl" / name
# WX UnityUtil.BuildFileMd5 uses the middle 16 characters of MD5.
if not source.is_file() or source.stat().st_size != int(sizes[0]) or digest(source, "md5")[8:24] != hashes[0]:
raise ValueError("Exported startup data size or MD5 mismatch")
add(source, name)
add(version_file, "cdn/version.json")
plan = {"schema": 1, "version": version, "files": files,
"totalBytes": sum(f["size"] for f in files)}
write_json(destination, plan)
return plan
def load_config(path: Path) -> dict:
config = read_json(path)
if not re.fullmatch(r"[A-Za-z0-9_.-]+", config.get("host", "")) or config["host"].startswith("-"):
raise ValueError("Invalid SSH host")
if not re.fullmatch(r"[A-Za-z0-9_.-]+", config.get("user", "")):
raise ValueError("Invalid SSH user")
if not isinstance(config.get("port"), int) or not 1 <= config["port"] <= 65535:
raise ValueError("Invalid SSH port")
key = Path(config["identity_file"]).expanduser()
config["identity_file"] = str(key if key.is_absolute() else REPO / key)
if not config.get("remote_root") or not config.get("public_base", "").startswith("https://"):
raise ValueError("Missing remote_root or HTTPS public_base")
return config
def ssh_options(config: dict, scp=False) -> list[str]:
return ["-P" if scp else "-p", str(config["port"]),
"-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=yes",
"-o", "IdentitiesOnly=yes", "-i", config["identity_file"]]
def remote(config: dict, action: str, request: dict) -> dict:
payload = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode()
script = REMOTE_SCRIPT.read_text(encoding="utf-8-sig")
code = f"& {{\n{script}\n}} -Action {action} -RequestBase64 '{payload}'"
# Feed the (potentially large) request over stdin; keep the Windows CMD command under its limit.
# Windows OpenSSH does not reliably deliver stdin EOF for larger redirected
# payloads. An explicit final line lets the server finish without waiting for it.
bootstrap = ("$lines=[Collections.Generic.List[string]]::new(); "
"while($null -ne ($line=[Console]::In.ReadLine())){if($line -eq '.'){break};$lines.Add($line)}; "
"$data=[Convert]::FromBase64String(($lines -join '')); "
"$stream=[IO.MemoryStream]::new($data); "
"$zip=[IO.Compression.GZipStream]::new($stream,[IO.Compression.CompressionMode]::Decompress); "
"$reader=[IO.StreamReader]::new($zip,[Text.Encoding]::UTF8); "
"& ([ScriptBlock]::Create($reader.ReadToEnd()))")
bootstrap64 = base64.b64encode(bootstrap.encode("utf-16le")).decode()
wire = base64.encodebytes(gzip.compress(code.encode("utf-8"))).decode("ascii") + ".\n"
try:
result = subprocess.run(["ssh", *ssh_options(config), f"{config['user']}@{config['host']}",
"powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive",
"-EncodedCommand", bootstrap64], input=wire, text=True,
encoding="utf-8", errors="replace", capture_output=True,
timeout=900 if action in ("Publish", "Rollback") else 120)
except subprocess.TimeoutExpired as error:
raise RuntimeError(f"SSH {action} timed out; inspect the server state before retrying a publication") from error
if result.returncode:
raise RuntimeError((result.stderr or result.stdout).strip())
try:
return json.loads(result.stdout.strip())
except json.JSONDecodeError as error:
raise RuntimeError(f"Unexpected SSH response: {result.stdout[:500]}") from error
def request_for(config: dict, plan: dict, release: str) -> dict:
return {"root": config["remote_root"], "releaseId": release,
"version": plan["version"],
"files": [{k: f[k] for k in ("path", "size", "sha256")} for f in plan["files"]]}
def make_archive(plan: dict, changed: list[str], path: Path) -> None:
wanted = set(changed)
by_path = {f["path"]: f for f in plan["files"]}
if not wanted.issubset(by_path):
raise ValueError("Server requested a path outside the release")
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
for name in sorted(wanted):
entry = by_path[name]
source = Path(entry["source"])
if source.stat().st_size != entry["size"] or digest(source) != entry["sha256"]:
raise ValueError(f"Staged file changed since planning: {name}")
archive.write(source, name)
def verify_public(config: dict, version: dict, release: str, plan: dict | None = None) -> None:
root = config["public_base"].rstrip("/")
def get(relative):
req = urllib.request.Request(f"{root}/{relative}?publish={release}", headers={"Cache-Control": "no-cache"})
with urllib.request.urlopen(req, timeout=30) as response:
return response.read()
actual = json.loads(get("cdn/version.json").decode("utf-8-sig"))
if actual != version:
raise RuntimeError("Public version.json does not match the published release")
for package in version["packages"]:
expected = version["resVersion" if package == "Art" else "packVersion"]
data = get(f"yoo/{package}/{package}_{expected}.bytes")
if hashlib.md5(data).hexdigest() != expected:
raise RuntimeError(f"Public {package} manifest MD5 mismatch")
for entry in (plan or {}).get("files", []):
if STARTUP_PATH.fullmatch(entry["path"]):
data = get(entry["path"])
if len(data) != entry["size"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
raise RuntimeError("Public startup data SHA256 mismatch")
def init_key(config_path: Path) -> None:
if not config_path.exists():
shutil.copyfile(REPO / "Tools/cdn_publish.example.json", config_path)
config = load_config(config_path)
key = Path(config["identity_file"])
key.parent.mkdir(parents=True, exist_ok=True)
if not key.exists():
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "aigame-cdn-deploy", "-f", str(key)], check=True)
if os.name == "nt":
path64 = base64.b64encode(str(key).encode()).decode()
code = "$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + path64 + "')); " + \
"$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; " + \
"$acl=New-Object Security.AccessControl.FileSecurity; $acl.SetOwner($sid); " + \
"$acl.SetAccessRuleProtection($true,$false); " + \
"$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid,'FullControl','Allow'))); " + \
"$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier('S-1-5-18')),'FullControl','Allow'))); " + \
"Set-Acl -LiteralPath $p -AclObject $acl -ErrorAction Stop"
subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand",
base64.b64encode(code.encode("utf-16le")).decode()], check=True)
if not key.with_suffix(".pub").is_file():
raise ValueError("Existing private key has no matching .pub file")
public_key = key.with_suffix(".pub").read_text(encoding="ascii").strip()
if not re.fullmatch(r"ssh-ed25519 [A-Za-z0-9+/=]+(?: .*)?", public_key):
raise ValueError("Unexpected public-key format")
setup = REPO / "Tools/.cdn-deploy/Install-PublicKey-OnServer.ps1"
setup.write_text("""# Run this file in an elevated PowerShell ON THE SERVER.
$ErrorActionPreference = 'Stop'
$path = Join-Path $env:ProgramData 'ssh/administrators_authorized_keys'
New-Item -ItemType Directory -Path (Split-Path -Parent $path) -Force | Out-Null
$key = 'restrict """ + public_key + """'
$existing = if (Test-Path -LiteralPath $path) { [IO.File]::ReadAllText($path) } else { '' }
if (-not $existing.Contains($key)) {
if ($existing -and -not $existing.EndsWith("`n")) { $existing += "`r`n" }
[IO.File]::WriteAllText($path, $existing + $key + "`r`n", (New-Object Text.UTF8Encoding($false)))
}
& icacls.exe $path /inheritance:r /grant:r '*S-1-5-32-544:F' '*S-1-5-18:F'
if ($LASTEXITCODE -ne 0) { throw 'Failed to set authorized_keys permissions' }
Write-Output 'AIGame CDN public key installed.'
""", encoding="utf-8-sig")
print(f"Dedicated key ready: {key}\nServer setup script (PUBLIC KEY ONLY): {setup}")
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("init-key", "plan", "check", "upload", "rollback"))
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
parser.add_argument("--stage", type=Path, default=REPO / "Tools/cdn_root/wechat")
parser.add_argument("--compare", action="store_true", help="Compare staged files with the server without publishing")
parser.add_argument("--startup-export", type=Path, help="Also publish startup data selected by EXPORT/minigame/game.js")
parser.add_argument("--release", help="Release ID to roll back")
args = parser.parse_args(argv)
if args.command == "init-key":
init_key(args.config)
return 0
config = load_config(args.config)
if args.command == "check":
print(json.dumps(remote(config, "Check", {"root": config["remote_root"]}), ensure_ascii=False, indent=2))
return 0
if args.command == "rollback":
if not args.release or not re.fullmatch(r"[a-f0-9]{32}", args.release):
raise ValueError("rollback requires --release with a 32-character release ID")
result = remote(config, "Rollback", {"root": config["remote_root"], "releaseId": args.release})
write_json(OUTPUT / f"rollback-{args.release}.json", result)
verify_public(config, result["version"], uuid.uuid4().hex)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
OUTPUT.mkdir(parents=True, exist_ok=True)
plan = make_plan(args.stage, OUTPUT / "plan.json", args.startup_export)
print(f"Staged: {len(plan['files'])} files, {plan['totalBytes'] / 1048576:.2f} MiB; pack={plan['version']['packVersion']}; art={plan['version'].get('resVersion', '')}")
if args.command == "plan" and not args.compare:
print(f"File list: {OUTPUT / 'plan.json'}")
return 0
release = uuid.uuid4().hex
request = request_for(config, plan, release)
comparison = remote(config, "Plan", request)
changed = comparison["changed"]
write_json(OUTPUT / "comparison.json", comparison)
total = sum(f["size"] for f in plan["files"] if f["path"] in changed)
print(f"Remote difference: {len(changed)} files, {total / 1048576:.2f} MiB")
for name in changed:
print(f" {name}")
if args.command == "plan":
return 0
request["expectedVersionSha256"] = comparison["currentVersionSha256"]
if not changed:
verify_public(config, plan["version"], release, plan)
print("Already published; public version and package manifests verified.")
return 0
archive = OUTPUT / f"{release}.zip"
make_archive(plan, changed, archive)
prepared = remote(config, "Prepare", request)
remote_zip = prepared["archive"].replace("\\", "/")
if not re.fullmatch(r"[A-Za-z]:/[A-Za-z0-9_./ -]+", remote_zip):
raise ValueError("Unexpected remote staging path")
subprocess.run(["scp", *ssh_options(config, scp=True), str(archive),
f"{config['user']}@{config['host']}:{remote_zip}"], check=True, timeout=1800)
result = remote(config, "Publish", request)
# Persist the server commit before checking public HTTP. A failed HTTP check must never replay a commit.
receipt = OUTPUT / f"release-{release}.json"
write_json(receipt, result)
print(f"Published release={release}; receipt={receipt}")
try:
verify_public(config, plan["version"], release, plan)
except Exception as error:
raise RuntimeError(f"SERVER ALREADY PUBLISHED. Public HTTP verification failed: {error}. Receipt: {receipt}") from error
print("Verified public version.json, package manifest MD5s and any selected startup data SHA256.")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
print(f"ERROR: {error}", file=sys.stderr)
raise SystemExit(1)
+275
View File
@@ -0,0 +1,275 @@
"""Author the task journal, seven-day rewards and mailbox via JSON -> Prefab."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / 'Doc/UIPrefabCreater'
INK = '#29465FFF'
MUTED = '#647B8BFF'
class Page:
def __init__(self, key, title, subtitle, icon, height):
self.name = 'UI_Prototype' + key
self.data = dict(schemaVersion=1, prefabName=self.name,
prefabPath=f'Assets/Bundles/UI/Prefab/{self.name}.prefab',
description='Normal 层营地冒险手册:安全区、可滚动内容、奖励优先;由 JSON 生成,沿用服务端奖励规则。',
canvas={'canvasScaler': {'referenceResolution': [1080, 1920], 'matchWidthOrHeight': 0}},
assets={'sprites': {}}, nodes=[], bindings=[], events=[])
self.node(self.name, None, self.stretch(), [dict(type='Canvas', sortingOrder=100),
dict(type='CanvasScaler', referenceResolution=[1080, 1920], matchWidthOrHeight=0), dict(type='GraphicRaycaster')])
self.data['assets']['sprites']['journal_paper_v1'] = 'Assets/Bundles/UI/Bg/journal_paper_v1.png'
self.node('Background', self.name, self.stretch(), [
dict(type='Image', sprite='journal_paper_v1', imageType='Simple', color='#FFFFFFFF', raycastTarget=True),
dict(type='MonoBehaviour', typeName='AOT.UI.BootArtworkCover')])
self.node('SafeArea', self.name, self.stretch(), [dict(type='MonoBehaviour', typeName='AOT.UI.UiSafeArea')])
self.node('Header', 'SafeArea', dict(anchorMin=[0, 1], anchorMax=[1, 1], pivot=[.5, 1], anchoredPosition=[0, 0], sizeDelta=[0, 164]))
self.button('Back', 'Header', 36, 26, 104, 104, '', icon='btn_back')
self.text('Title', 'Header', 166, 22, 690, 68, title, 50)
self.text('Subtitle', 'Header', 170, 94, 690, 42, subtitle, 26, MUTED)
self.icon('HeaderEmblem', 'Header', 910, 24, 110, 110, icon)
self.node('Scroll_Page', 'SafeArea', self.stretch([0, 22], [0, -170]),
[dict(type='ScrollRect', horizontal=False, vertical=True, viewport='Viewport_Page', content='Content_Page')])
self.node('Viewport_Page', 'Scroll_Page', self.stretch(),
[dict(type='Image', color='#FFFFFF00', raycastTarget=True), dict(type='RectMask2D')])
self.node('Content_Page', 'Viewport_Page', dict(anchorMin=[0, 1], anchorMax=[1, 1], pivot=[.5, 1], anchoredPosition=[0, 0], sizeDelta=[0, height]))
@staticmethod
def stretch(lo=None, hi=None):
return dict(anchorMin=[0, 0], anchorMax=[1, 1], offsetMin=lo or [0, 0], offsetMax=hi or [0, 0])
@staticmethod
def rect(x, y, w, h):
return dict(anchorMin=[0, 1], anchorMax=[0, 1], pivot=[0, 1], anchoredPosition=[x, -y], sizeDelta=[w, h])
def node(self, name, parent, rect, components=None, active=True):
self.data['nodes'].append(dict(name=name, parent=parent, active=active, rect=rect, components=components or []))
return name
def sprite(self, name):
folder = 'Journal' if name.startswith('journal_') else 'Home' if name.startswith('home_') else 'Prototype' if name.startswith('proto_') else 'Common'
self.data['assets']['sprites'][name] = f'Assets/Bundles/UI/Texture/{folder}/{name}.png'
return name
def image(self, name, parent, x, y, w, h, sprite, color='#FFFFFFFF', simple=False, raycast=False):
return self.node(name, parent, self.rect(x, y, w, h), [dict(type='Image', sprite=self.sprite(sprite),
imageType='Simple' if simple else 'Sliced', color=color, preserveAspect=simple, raycastTarget=raycast)])
def icon(self, name, parent, x, y, w, h, sprite):
return self.image(name, parent, x, y, w, h, sprite, simple=True)
def card(self, name, y, h, sprite='journal_card', color='#FFFFFFFF', x=40, w=1000, parent='Content_Page'):
return self.image(name, parent, x, y, w, h, sprite, color)
def text(self, name, parent, x, y, w, h, text, size=32, color=INK, align='Left'):
node = 'Txt_' + name
self.node(node, parent, self.rect(x, y, w, h), [dict(type='TextMeshProUGUI', text=text,
fontSize=size, color=color, alignment={'MidlineLeft':'Left','MidlineRight':'Right','TopLeft':'Left'}.get(align,align), raycastTarget=False)])
if parent in ['TaskSummary','MailWallet']:
self.data['nodes'][-1]['components'].append(dict(type='Shadow',effectColor='#10203360',effectDistance=[0,-1]))
self.data['bindings'].append(node)
return node
def button(self, name, parent, x, y, w, h, text, primary=False, icon=None, active=True):
sprite = icon or ('journal_primary_normal' if primary else 'journal_secondary_normal')
node = self.image('Btn_' + name, parent, x, y, w, h, sprite, simple=icon is not None, raycast=True)
obj = self.data['nodes'][-1]
obj['active'] = active
obj['components'].append(dict(type='Button', transition='ColorTint' if icon else 'SpriteSwap',
spriteHighlighted=self.sprite(sprite), spritePressed=self.sprite(sprite if icon else sprite.replace('normal', 'pressed')),
spriteDisabled=self.sprite('journal_disabled'), onClick='Prototype.' + name))
if not icon:
label = self.text(name, node, 12, 8, w-24, h-16, text, 32, '#5D431FFF' if primary else INK, 'Center')
self.data['bindings'].append(node)
self.data['events'].append(dict(name='Prototype.' + name, node=node))
return node
def progress(self, name, parent, x, y, w, h=22):
self.image(name + 'Track', parent, x, y, w, h, 'journal_progress_bg')
self.image(name + 'Fill', parent, x, y, w, h, 'journal_progress_fill')
def save(self):
(OUT / (self.name + '.json')).write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def tasks():
p = Page('Tasks', '冒险手册', '每天一小步,营地更热闹', 'home_ico_tasks', 1718)
p.card('TaskSummary', 12, 250, 'journal_hero')
p.text('TodayCaption', 'TaskSummary', 36, 28, 580, 44, '今日旅程', 30, '#F4CE88FF')
p.text('TaskSummary', 'TaskSummary', 36, 82, 660, 70, '0 / 3 份奖励已领取', 46, '#FFFFFFFF')
p.text('TaskReset', 'TaskSummary', 38, 170, 660, 42, '每日 00:00 更新', 26, '#C2D5DFFF')
p.icon('TodayReward', 'TaskSummary', 778, 32, 176, 176, 'home_ico_chest')
for i in range(3):
p.image('DailyMilestone' + str(i), 'TaskSummary', 38+i*216, 222, 198, 12, 'journal_progress_fill', '#ACC5D6FF')
p.text('DailyHeading', 'Content_Page', 52, 290, 500, 54, '每日委托', 36)
p.text('DailyHint', 'Content_Page', 600, 296, 422, 42, '完成即可领取奖励', 26, MUTED, 'MidlineRight')
icons = ['home_ico_pinball', 'home_ico_battle', 'home_ico_pets']
titles = ['完成3次弹珠发射', '完成1场冒险', '与伙伴互动1次']
for i in range(3):
card = 'TaskCard' + str(i+1)
p.card(card, 360+i*236, 216)
p.icon('DailyIcon'+str(i), card, 24, 40, 112, 112, icons[i])
p.text('DailyName'+str(i), card, 158, 24, 560, 50, titles[i], 36)
p.text('DailyInfo'+str(i), card, 160, 80, 540, 40, '完成一次结算即可计入', 26, MUTED)
p.progress('DailyProgress'+str(i), card, 160, 145, 350, 20)
p.text('DailyCount'+str(i), card, 532, 128, 170, 50, '0 / 3', 30, MUTED, 'MidlineRight')
p.text('DailyReward'+str(i), card, 734, 26, 236, 54, '弹珠 ×5', 34, '#99641FFF', 'Center')
p.button('ClaimDaily'+str(i), card, 738, 102, 224, 92, '领取奖励', True, active=False)
p.button('GoDaily'+str(i), card, 738, 102, 224, 92, '去完成')
p.text('DailyDone'+str(i), card, 738, 118, 224, 60, '已领取', 32, '#698479FF', 'Center')
p.data['nodes'][-1]['active'] = False
p.text('GrowthHeading', 'Content_Page', 52, 1090, 800, 54, '成长足迹', 36)
p.card('TaskCard0', 1162, 200, color='#E5EDF2FF')
p.icon('GoalIcon', 'TaskCard0', 24, 40, 112, 112, 'home_ico_home')
p.text('GoalName', 'TaskCard0', 158, 22, 550, 50, '营地初守', 36)
p.text('GoalInfo', 'TaskCard0', 160, 82, 540, 74, '首次通关 1-1\n成长奖励永久保留', 26, MUTED)
p.text('GoalReward', 'TaskCard0', 734, 22, 236, 54, '弹珠 ×5', 34, '#99641FFF', 'Center')
p.button('ClaimGoal', 'TaskCard0', 738, 98, 224, 88, '领取奖励', True, active=False)
p.button('GoGoal', 'TaskCard0', 738, 98, 224, 88, '去冒险')
p.text('GoalDone', 'TaskCard0', 738, 114, 224, 60, '已领取', 32, '#698479FF', 'Center')
p.data['nodes'][-1]['active'] = False
p.card('SigninEntry', 1396, 156, 'journal_gold_card')
p.icon('SigninEntryIcon', 'SigninEntry', 24, 20, 116, 116, 'home_ico_calendar')
p.text('SigninEntryTitle', 'SigninEntry', 164, 28, 520, 52, '七日同行礼', 36)
p.text('SigninEntryHint', 'SigninEntry', 166, 90, 520, 40, '累计登录,每一天都有收获', 26, MUTED)
p.button('ToSignin', 'SigninEntry', 738, 34, 224, 88, '查看奖励')
p.text('TaskRules', 'Content_Page', 64, 1590, 952, 84, '每日奖励请在重置前领取\n成长奖励仅可领取一次', 25, MUTED, 'Center')
p.save()
def signin():
p = Page('Signin', '七日同行礼', '每一次回到营地,都值得期待', 'home_ico_calendar', 1700)
p.card('SigninSummary', 12, 258, 'journal_gold_card')
p.text('SigninCaption', 'SigninSummary', 36, 28, 650, 44, '把小小的收获,攒成大大的惊喜', 30)
p.text('SigninSummary', 'SigninSummary', 36, 82, 690, 70, '已相伴 1 天', 48)
p.text('SigninHint', 'SigninSummary', 38, 176, 650, 42, '漏登不重置 · 已解锁奖励可补领', 26, MUTED)
p.icon('SigninHeroGift', 'SigninSummary', 786, 42, 176, 176, 'home_ico_gift')
p.text('SigninSection', 'Content_Page', 52, 302, 800, 54, '你的七日礼物', 36)
for i in range(7):
special = i == 6
card = 'SigninCard' + str(i)
x, y, w, h = (40, 1138, 1000, 300) if special else (40+(i%3)*340, 382+(i//3)*366, 320, 342)
p.card(card, y, h, 'journal_gold_card' if special else 'journal_card', x=x, w=w)
p.text('SigninDay'+str(i), card, 28, 18, w-56, 48, '第 7 天 · 同行大礼' if special else f'第 {i+1} 天', 34, INK, 'MidlineLeft' if special else 'Center')
p.icon('SigninIcon'+str(i), card, 44 if special else 104, 90 if special else 72, 170 if special else 112, 170 if special else 112, 'home_ico_chest' if special else 'home_ico_pinball')
p.text('SigninReward'+str(i), card, 260 if special else 18, 96 if special else 194, 420 if special else 284, 50, '弹珠 ×'+str([10,5,5,10,5,10,20][i]), 40 if special else 32, '#99641FFF', 'MidlineLeft' if special else 'Center')
if special: p.text('FinalGiftHint', card, 260, 174, 420, 48, '累计登录七天即可解锁', 26, MUTED)
p.button('Signin'+str(i), card, 718 if special else 28, 174 if special else 252, 246 if special else 264, 88 if special else 78, '领取', True)
# Locked/claimed days are labels, never disabled fake actions.
p.text('SigninState'+str(i), card, 718 if special else 28, 174 if special else 270, 246 if special else 264, 88 if special else 60, '等待解锁', 28, MUTED, 'Center')
p.button('ToTasks', 'Content_Page', 260, 1484, 560, 96, '去完成今日委托')
p.text('SigninRules', 'Content_Page', 64, 1604, 952, 60, '每日首次登录计入天数 · 每份奖励仅可领取一次', 25, MUTED, 'Center')
p.save()
def mail():
p = Page('Mail', '营地信箱', '来自小镇的问候与补给', 'home_ico_mail', 1840)
p.card('MailWallet', 12, 172, 'journal_hero')
p.icon('MailHero', 'MailWallet', 24, 24, 120, 120, 'home_ico_mail')
p.text('MailWallet', 'MailWallet', 170, 26, 780, 62, '营地来信', 42, '#FFFFFFFF')
p.text('MailSummaryHint', 'MailWallet', 172, 100, 780, 42, '点击来信,查看消息与随信礼物', 26, '#C2D5DFFF')
p.text('InboxHeading', 'Content_Page', 52, 210, 500, 54, '收到的来信', 36)
p.node('MailStack', 'Content_Page', p.rect(40, 284, 1000, 0), [
dict(type='VerticalLayoutGroup', spacing=24, childControlWidth=True, childControlHeight=True,
childForceExpandWidth=True, childForceExpandHeight=False),
dict(type='ContentSizeFitter', verticalFit='PreferredSize')])
for i in range(5):
b=p.button('Mail'+str(i), 'Content_Page', 40, 284+i*126, 1000, 114, '')
n=next(n for n in p.data['nodes'] if n['name']==b)
n['components'][0]['sprite']=p.sprite('journal_card')
n['components'][1].update(spriteHighlighted=p.sprite('journal_card'),spritePressed=p.sprite('journal_secondary_pressed'),spriteDisabled=p.sprite('journal_card'))
# The generic label is intentionally replaced by title and status.
p.data['nodes']=[n for n in p.data['nodes'] if n['name']!='Txt_Mail'+str(i)]
p.data['bindings'].remove('Txt_Mail'+str(i))
p.icon('MailIcon'+str(i), b, 20, 17, 80, 80, 'home_ico_mail')
p.text('MailTitle'+str(i), b, 128, 12, 800, 48, '营地来信', 34)
p.text('MailStatus'+str(i), b, 130, 66, 800, 36, '未读 · 附件待领取', 25, MUTED)
p.image('MailUnread'+str(i), b, 950, 28, 16, 16, 'journal_progress_fill')
p.text('MailEmpty', 'Content_Page', 100, 440, 880, 180, '信箱暂时空空的\n下一份问候,正在路上', 36, MUTED, 'Center')
p.data['nodes'][-1]['active']=False
p.node('MailPager', 'MailStack', p.rect(0,0,1000,76), [dict(type='LayoutElement', preferredHeight=76)])
p.button('MailPrev', 'Content_Page', 48, 930, 184, 76, '上一页')
p.text('MailPage', 'Content_Page', 260, 938, 560, 60, '1 / 1 页', 28, MUTED, 'Center')
p.button('MailNext', 'Content_Page', 848, 930, 184, 76, '下一页')
p.card('MailDetail', 1040, 502)
p.text('MailDetailTitle', 'MailDetail', 32, 26, 936, 60, '请选择一封邮件', 38)
p.text('MailDetailStatus', 'MailDetail', 34, 96, 932, 42, '查看来自营地的消息', 26, MUTED)
p.node('MailBodyScroll', 'MailDetail', p.rect(34, 156, 932, 164), [dict(type='ScrollRect',horizontal=False,vertical=True,viewport='MailBodyViewport',content='Txt_MailBody')])
p.node('MailBodyViewport', 'MailBodyScroll', p.stretch(), [dict(type='Image',color='#FFFFFF00',raycastTarget=True),dict(type='RectMask2D')])
p.text('MailBody','MailBodyViewport',0,0,920,164,'营地来信和奖励补给会显示在这里。',30,INK,'TopLeft')
p.data['nodes'][-1]['components'].append(dict(type='ContentSizeFitter',verticalFit='PreferredSize'))
p.text('MailAttachment','MailDetail',34,338,932,58,'',30,'#99641FFF')
p.button('ClaimMail','MailDetail',590,414,374,70,'领取附件',True)
p.button('ClaimAllMail','Content_Page',240,1580,600,104,'领取全部附件',True)
p.text('MailRules','Content_Page',64,1720,952,80,'附件领取后直接到账\n一键最多领取 20 封,请在到期前领取',25,MUTED,'Center')
stack_heights = {'Btn_Mail'+str(i):114 for i in range(5)}
stack_heights.update(Txt_MailEmpty=240,MailDetail=502,Btn_ClaimAllMail=104,Txt_MailRules=80)
for n in p.data['nodes']:
if n['name'] in stack_heights:
n['parent']='MailStack'
n['components'].append(dict(type='LayoutElement',preferredHeight=stack_heights[n['name']]))
if n['name'] in ['Btn_MailPrev','Txt_MailPage','Btn_MailNext']:
n['parent']='MailPager'
n['rect']['anchoredPosition'][0]-=40
n['rect']['anchoredPosition'][1]+=930
p.save()
def challenges():
p = Page('Challenges', '属性试炼', '元素相生相克 · 携同调伙伴挑战强敌', 'home_ico_battle', 1830)
# 1. Hero Card
p.card('ChallengeSummary', 12, 210, 'journal_hero')
p.text('HeroCaption', 'ChallengeSummary', 36, 26, 620, 44, '元素试炼 · 专属徽章与伙伴碎片', 30, '#F4CE88FF')
p.text('Summary', 'ChallengeSummary', 36, 78, 680, 64, '通过 1-1 后开放。只上阵对应元素,首次通过获得徽章和碎片。', 32, '#FFFFFFFF')
p.text('HeroHint', 'ChallengeSummary', 38, 150, 680, 40, '试炼不消耗弹珠 · 首胜获10碎片与专属徽章', 26, '#C2D5DFFF')
p.icon('ChallengeHero', 'ChallengeSummary', 778, 24, 160, 160, 'home_ico_battle')
# 2. Section Heading
p.text('TrialHeading', 'Content_Page', 52, 244, 500, 54, '五行试炼', 36)
p.text('TrialSub', 'Content_Page', 560, 250, 460, 42, '点击切换试炼项目', 26, MUTED, 'MidlineRight')
# 3. 5 Challenge Buttons
pet_icons = ['proto_pet_1', 'proto_pet_2', 'proto_pet_0', 'proto_pet_3', 'proto_pet_4']
default_titles = ['火 · 灼草之试', '草 · 碎岩之试', '岩 · 避雷之试', '电 · 破浪之试', '水 · 灭火之试']
for i in range(5):
b = p.button('Challenge' + str(i), 'Content_Page', 40, 308 + i * 140, 1000, 124, '')
n = next(n for n in p.data['nodes'] if n['name'] == b)
n['components'][0]['sprite'] = p.sprite('journal_card')
n['components'][1].update(
spriteHighlighted=p.sprite('journal_card'),
spritePressed=p.sprite('journal_secondary_pressed'),
spriteDisabled=p.sprite('journal_disabled'))
p.data['nodes'] = [n for n in p.data['nodes'] if n['name'] != 'Txt_Challenge' + str(i)]
p.data['bindings'].remove('Txt_Challenge' + str(i))
p.icon('ChallengeIcon' + str(i), b, 18, 14, 96, 96, pet_icons[i])
p.text('Challenge' + str(i), b, 134, 16, 640, 48, default_titles[i], 34, INK, 'Left')
p.text('ChallengeState' + str(i), b, 136, 68, 640, 40, '可挑战', 26, MUTED, 'Left')
p.text('ChallengeTag' + str(i), b, 780, 40, 180, 44, '选择 >', 26, '#8FA6B5FF', 'Center')
# 4. Detail Panel
p.text('DetailSectionHeading', 'Content_Page', 52, 1028, 500, 50, '试炼详情与阵容', 36)
p.text('DetailSectionHint', 'Content_Page', 560, 1034, 460, 42, '同调元素自动上阵', 26, MUTED, 'MidlineRight')
p.card('DetailPanel', 1088, 450, 'journal_gold_card')
p.icon('DetailEmblem', 'DetailPanel', 28, 22, 64, 64, 'home_ico_trophy')
p.text('DetailTitle', 'DetailPanel', 106, 28, 400, 44, '试炼情报', 34, INK)
p.text('DetailTag', 'DetailPanel', 720, 30, 240, 36, '不消耗弹珠', 26, '#99641FFF', 'MidlineRight')
p.text('Detail', 'DetailPanel', 34, 90, 932, 220, '选择试炼查看详细情报…', 28, INK, 'TopLeft')
p.button('Start', 'DetailPanel', 32, 324, 936, 96, '开始试炼', primary=True)
# 5. Quick Actions
p.text('QuickHeading', 'Content_Page', 52, 1558, 500, 48, '冒险整备', 36)
p.text('QuickHint', 'Content_Page', 560, 1564, 460, 40, '缺少对应元素伙伴?', 26, MUTED, 'MidlineRight')
p.button('Capture', 'Content_Page', 40, 1618, 485, 92, '结识伙伴')
p.button('Fish', 'Content_Page', 555, 1618, 485, 92, '小镇钓鱼')
# 6. Rules
p.text('Rules', 'Content_Page', 64, 1730, 952, 76, '属性试炼不消耗弹珠 · 首胜即发专属徽章与10枚伙伴碎片\n再次挑战仅在刷新个人最短用时纪录时计入排行榜', 25, MUTED, 'Center')
p.save()
if __name__ == '__main__':
tasks()
signin()
mail()
challenges()
print('Authored Tasks / Signin / Mail / Challenges JSON. Build and verify with Unity Pipeline.')
+18
View File
@@ -0,0 +1,18 @@
import subprocess
from pathlib import Path
blender_script = """
import bpy
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Idle.fbx")
print("Actions in Idle.fbx:", [a.name for a in bpy.data.actions])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=r"D:/UD/AI/XModel/LLM3DGraphics/mixamo/Male/Walking.fbx")
print("Actions in Walking.fbx:", [a.name for a in bpy.data.actions])
"""
Path("Tools/check_actions.py").write_text(blender_script, encoding="utf-8")
res = subprocess.run([r"C:\Program Files\Blender Foundation\Blender 5.2\blender.exe", "--background", "--python", "Tools/check_actions.py"], capture_output=True, text=True)
print(res.stdout[-400:])
+93
View File
@@ -0,0 +1,93 @@
import os
import sys
import time
import json
from pathlib import Path
import mimetypes
import requests
BASE = "http://127.0.0.1:8008"
OUTPUT_DIR = Path("Tools/output/explorer_3d")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
REF_IMAGE = OUTPUT_DIR / "explorer_ref.png"
session = requests.Session()
def wait_job(url_path, timeout=3600, interval=3):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
r = session.get(f"{BASE}{url_path}", timeout=30)
r.raise_for_status()
job = r.json()
status = job.get("status")
stage = job.get("stage", "")
progress = job.get("progress", 0)
print(f"[{status}] stage={stage} progress={progress}%", flush=True)
if status in ("completed", "failed"):
if status != "completed" or job.get("error"):
raise RuntimeError(f"Job failed: {job.get('error') or job}")
return job
time.sleep(interval)
raise TimeoutError(f"Job timeout: {url_path}")
def download(url, out_path):
r = session.get(f"{BASE}{url}", timeout=300)
r.raise_for_status()
out = Path(out_path)
if r.content[:4] == b"PK\x03\x04":
out = out.with_suffix(".zip")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(r.content)
print(f"Downloaded: {out} ({len(r.content)} bytes)")
return out
# 1. Mesh Generation
print("=== Step 1: DGX Hunyuan3D Mesh Generation ===")
mime = mimetypes.guess_type(REF_IMAGE.name)[0] or "image/png"
with REF_IMAGE.open("rb") as f:
resp = session.post(f"{BASE}/api/jobs", timeout=120, data={
"engine": "dgx",
"mode": "image",
"character_type": "humanoid",
"prompt": "chibi 3D game character boy adventurer in A-pose",
"resolution": 256,
"source_faces": 50000,
"target_faces": 5000,
"texture_size": 1024,
"output_format": "fbx"
}, files={"image": (REF_IMAGE.name, f, mime)})
if resp.status_code not in (200, 201, 202):
raise RuntimeError(f"Create job failed: {resp.status_code} {resp.text}")
job = resp.json()
job_id = job["id"]
print(f"Created Mesh Job ID: {job_id}")
mesh_job = wait_job(f"/api/jobs/{job_id}", timeout=1800)
print(f"Mesh completed. Ready for motion: {mesh_job.get('ready_for_motion')}")
(OUTPUT_DIR / "mesh_job.json").write_text(json.dumps(mesh_job, ensure_ascii=False, indent=2), encoding="utf-8")
# 2. MIA Humanoid Rigging
print("\n=== Step 2: Make-It-Animatable (MIA) Humanoid Rigging ===")
rig_resp = session.post(f"{BASE}/api/jobs/{job_id}/rig", timeout=120, data={
"character_type": "humanoid",
"skeleton_template": "auto"
})
if rig_resp.status_code not in (200, 201, 202):
raise RuntimeError(f"Trigger rig failed: {rig_resp.status_code} {rig_resp.text}")
print("Rig triggered:", rig_resp.json())
rig_job = wait_job(f"/api/jobs/{job_id}", timeout=1800)
print("Rigging completed!")
(OUTPUT_DIR / "rig_job.json").write_text(json.dumps(rig_job, ensure_ascii=False, indent=2), encoding="utf-8")
# Download character assets
print("\n=== Downloading Character Assets ===")
for key, url in rig_job.get("downloads", {}).items():
suffix = ".fbx" if "fbx" in key else ".glb" if "glb" in key else ".bin"
download(url, OUTPUT_DIR / f"{key}{suffix}")
print("Mesh & Rig completed successfully!")
+63
View File
@@ -0,0 +1,63 @@
import os
import sys
import time
import json
from pathlib import Path
import requests
BASE = "http://127.0.0.1:8008"
OUTPUT_DIR = Path("Tools/output/explorer_3d")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
session = requests.Session()
def wait_job(url_path, timeout=3600, interval=3):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
r = session.get(f"{BASE}{url_path}", timeout=30)
r.raise_for_status()
job = r.json()
status = job.get("status")
stage = job.get("stage", "")
progress = job.get("progress", 0)
print(f"[{status}] stage={stage} progress={progress}%", flush=True)
if status in ("completed", "failed"):
if status != "completed" or job.get("error"):
raise RuntimeError(f"Job failed: {job.get('error') or job}")
return job
time.sleep(interval)
raise TimeoutError(f"Job timeout: {url_path}")
# Step 0: Check status and generate reference image
print("Step 0: Generating Character Concept Image...")
prompt = (
"chibi 3D game character boy adventurer, young cute explorer, 3.5 heads tall cartoon proportions, "
"wearing brown explorer hat with goggles, beige short-sleeve adventurer jacket, cargo shorts, hiking boots, "
"small utility pouch on leather belt, standard A-pose with arms angled down 40 degrees, "
"legs straight and slightly apart, facing front directly, clean pure white background, full body, isolated, high quality 3D model turnaround"
)
img_batch = session.post(f"{BASE}/api/local-images/jobs", data={
"model": "turbo",
"profile": "concept",
"prompt": prompt,
"width": 1024,
"height": 1024,
"count": 1,
"seed": 20261104
}, timeout=60).json()
img_job_id = img_batch["id"]
print("Image Batch ID:", img_job_id)
img_res = wait_job(f"/api/local-images/jobs/{img_job_id}", timeout=300)
img_url = img_res["images"][0]["url"]
ref_path = OUTPUT_DIR / "explorer_ref.png"
img_bytes = session.get(f"{BASE}{img_url}").content
ref_path.write_bytes(img_bytes)
print(f"Downloaded reference image: {ref_path} ({len(img_bytes)} bytes)")
# Prepare 3D
session.post(f"{BASE}/api/local-images/prepare-3d", timeout=120)
print("3D environment prepared.")
+198
View File
@@ -0,0 +1,198 @@
import base64
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
import uuid
import zipfile
import zlib
REPO = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location("publish_cdn", REPO / "Tools/publish_cdn.py")
publish = importlib.util.module_from_spec(spec)
spec.loader.exec_module(publish)
class PublishTests(unittest.TestCase):
def setUp(self):
(REPO / "Temp").mkdir(exist_ok=True)
self.temp = tempfile.TemporaryDirectory(prefix="cdn-publish-tests-", dir=REPO / "Temp")
self.directory = Path(self.temp.name)
self.stage = self.directory / "stage"
self.root = self.directory / "CDN website with spaces"
self.local = self.directory / "localappdata"
self.local.mkdir()
self.env = dict(os.environ, LOCALAPPDATA=str(self.local))
self.old = self.create_stage(b"old index", b"old bundle")
self.root.mkdir()
for item in self.old["files"]:
target = self.root / item["path"]
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(item["source"], target)
self.old_version = (self.root / "cdn/version.json").read_bytes()
self.plan = self.create_stage(b"new index", b"new bundle")
self.request = publish.request_for({"remote_root": str(self.root)}, self.plan, uuid.uuid4().hex)
self.request["expectedVersionSha256"] = hashlib.sha256(self.old_version).hexdigest()
def tearDown(self):
self.temp.cleanup()
def create_stage(self, index, bundle):
directory = self.stage / "1.0.0/DefaultPackage"
directory.mkdir(parents=True, exist_ok=True)
for file in directory.iterdir():
file.unlink()
md5 = hashlib.md5(index).hexdigest()
(directory / f"DefaultPackage_{md5}.bytes").write_bytes(index)
(directory / f"DefaultPackage_{md5}.hash").write_text(zlib.crc32(index).to_bytes(4, "little").hex())
(directory / "DefaultPackage.version").write_text(md5)
(directory / f"payload_{hashlib.md5(bundle).hexdigest()}.bundle").write_bytes(bundle)
version = {"appVersionMin": "1.0.0", "appVersionMax": "1.0.0", "packVersion": md5,
"resVersion": "", "cdnRoot": "https://www.xworld.ren/game/Friends/webgl/yoo/DefaultPackage",
"packages": ["DefaultPackage"], "notice": "", "forceUpdate": False}
publish.write_json(self.stage / "version.prod.json", version)
return publish.make_plan(self.stage, self.directory / "plan.json")
def invoke(self, action, request=None, success=True):
if not shutil.which("powershell.exe"):
self.skipTest("Windows PowerShell required for remote transaction tests")
payload = base64.b64encode(json.dumps(request or self.request).encode()).decode()
result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-File", str(publish.REMOTE_SCRIPT),
"-Action", action, "-RequestBase64", payload, "-StateRoot", str(self.local / "AIGameCdnPublish")], env=self.env,
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30)
if success:
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
return json.loads(result.stdout)
self.assertNotEqual(result.returncode, 0)
return result.stderr
def prepare(self):
comparison = self.invoke("Plan")
prepared = self.invoke("Prepare")
publish.make_archive(self.plan, comparison["changed"], Path(prepared["archive"]))
return Path(prepared["archive"])
def test_local_manifest_crc_and_md5(self):
directory = self.stage / "1.0.0/DefaultPackage"
next(directory.glob("*.bytes")).write_bytes(b"corrupt")
with self.assertRaisesRegex(ValueError, "MD5"):
publish.make_plan(self.stage, self.directory / "bad-plan.json")
def test_local_crc_failure(self):
next((self.stage / "1.0.0/DefaultPackage").glob("*.hash")).write_text("00000000")
with self.assertRaisesRegex(ValueError, "hash marker"):
publish.make_plan(self.stage, self.directory / "bad-plan.json")
def test_archive_rejects_changed_local_payload(self):
entry = next(f for f in self.plan["files"] if f["path"].endswith(".bundle"))
Path(entry["source"]).write_bytes(b"changed after planning")
with self.assertRaisesRegex(ValueError, "changed since planning"):
publish.make_archive(self.plan, [entry["path"]], self.directory / "bad.zip")
def test_reject_traversal_and_unplanned_file(self):
for path in ("../version.json", "yoo/Art/..", "yoo/Art/../../evil", "cdn/secret.txt"):
with self.assertRaises(ValueError):
publish.validate_path(path)
with self.assertRaises(ValueError):
publish.make_archive(self.plan, ["unknown"], self.directory / "bad.zip")
def test_plan_is_read_only(self):
result = self.invoke("Plan")
self.assertIn("cdn/version.json", result["changed"])
self.assertFalse((self.local / "AIGameCdnPublish").exists())
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def startup_export(self):
export = self.directory / "export"
(export / "minigame").mkdir(parents=True)
(export / "webgl").mkdir()
data = b"new Unity shell startup data"
tag = hashlib.md5(data).hexdigest()[8:24]
name = tag + ".webgl.data.unityweb.bin.txt"
(export / "webgl" / name).write_bytes(data)
(export / "minigame/game.js").write_text(
f"const config={{DATA_FILE_MD5:'{tag}',DATA_FILE_SIZE:'{len(data)}',"
"DATA_CDN:'https://www.xworld.ren/game/Friends/webgl/'};", encoding="utf-8")
return export, name, data
def test_startup_export_transaction(self):
export, name, data = self.startup_export()
self.plan = publish.make_plan(self.stage, self.directory / "startup-plan.json", export)
self.request = publish.request_for({"remote_root": str(self.root)}, self.plan, uuid.uuid4().hex)
self.request["expectedVersionSha256"] = hashlib.sha256(self.old_version).hexdigest()
self.prepare()
self.assertTrue(self.invoke("Publish")["published"])
self.assertEqual((self.root / name).read_bytes(), data)
self.assertTrue(self.invoke("Rollback")["rolledBack"])
self.assertEqual((self.root / name).read_bytes(), data)
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def test_startup_export_rejects_corruption(self):
export, name, data = self.startup_export()
(export / "webgl" / name).write_bytes(b"x" * len(data))
with self.assertRaisesRegex(ValueError, "size or MD5"):
publish.make_plan(self.stage, self.directory / "bad-startup.json", export)
def test_startup_export_rejects_unknown_and_unsafe_files(self):
for name in ("webgl.data", "game.js", "../1234567890abcdef.webgl.data.unityweb.bin.txt"):
with self.assertRaises(ValueError):
publish.validate_path(name)
request = dict(self.request)
request["files"] = [*self.request["files"], {"path": name,"size": 1,"sha256": "0" * 64}]
self.invoke("Plan", request=request, success=False)
def test_publish_noop_and_rollback(self):
self.prepare()
receipt = self.invoke("Publish")
self.assertTrue(receipt["published"])
self.assertEqual(json.loads((self.root / "cdn/version.json").read_bytes()), self.plan["version"])
self.assertEqual(self.invoke("Plan")["changed"], [])
old_index = self.old["version"]["packVersion"]
self.assertTrue((self.root / f"yoo/DefaultPackage/DefaultPackage_{old_index}.bytes").exists())
self.assertTrue(self.invoke("Rollback")["rolledBack"])
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def test_corrupt_upload_keeps_live_version(self):
archive = self.prepare()
with zipfile.ZipFile(archive, "w") as stream:
item = self.plan["files"][0]
stream.writestr(item["path"], b"x" * item["size"])
self.invoke("Publish", success=False)
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def test_incomplete_upload_keeps_live_version(self):
archive = self.prepare()
with zipfile.ZipFile(archive, "w"):
pass
self.invoke("Publish", success=False)
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def test_stale_version_keeps_live_version(self):
self.prepare()
self.request["expectedVersionSha256"] = "0" * 64
self.invoke("Publish", success=False)
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def test_zip_traversal_keeps_live_version(self):
archive = self.prepare()
with zipfile.ZipFile(archive, "w") as stream:
stream.writestr("../evil", b"bad")
self.invoke("Publish", success=False)
self.assertFalse((archive.parent / "evil").exists())
self.assertEqual((self.root / "cdn/version.json").read_bytes(), self.old_version)
def test_rollback_refuses_later_release(self):
self.prepare()
self.invoke("Publish")
(self.root / "cdn/version.json").write_bytes(b"later release")
self.invoke("Rollback", success=False)
self.assertEqual((self.root / "cdn/version.json").read_bytes(), b"later release")
if __name__ == "__main__":
unittest.main()