fix(server): resolve KeyError avatar (error 1001_1 / 1001_3) with SafeDict & persistent auto-healing

This commit is contained in:
CAtlas0784 committed 2026-10-13 18:31:37 +07:00
1 parent f439733781
commit c7af4069c9
7 files changed
+355 -13

No files matched your search

+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.catlas.escapepenacony"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "v1.1.3"
versionCode = 7
versionName = "v1.1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Binary file not shown.
@@ -44,13 +44,128 @@ class PersistentRepository(private val context: Context) {
return File(serverBuildDir, "ppsr.pyc").exists()
}
companion object {
val DEFAULT_PERSISTENT_JSON_STRING = """
{
"send_hotfix": true,
"scene": {
"plane_id": 20313,
"x": 40748,
"y": 192819,
"z": 439218,
"calyx_x": 40748,
"calyx_y": 192810,
"calyx_z": 441000,
"map_layer": 2,
"calyx_group_id": 186,
"calyx_inst_id": 300001,
"calyx_entity_id": 1337,
"calyx_prop_id": 808
},
"avatar": {
"tb_gender": "Female",
"tb_path": "Elation",
"march_path": "Preservation",
"skin": {
"1001": 1100101,
"1310": 1131001,
"1415": 1141501,
"1407": 1140701,
"1501": 1150101
},
"lineup": [
1511,
1502,
1506,
1503
],
"global_passive": [
{
"name": "Castorice Global Passive",
"id": 140703,
"active": true
},
{
"name": "SW999 Global Passsive",
"id": 150602,
"active": true
},
{
"name": "Aha Global Passive",
"id": 151104,
"active": true
}
],
"buff": [
1000121
],
"leader": 0
},
"key": ""
}
""".trimIndent()
}
fun createDefaultPersistentJson(): JSONObject {
return JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
}
fun ensureValidPersistentJsonSync(): JSONObject {
try {
miscDir.mkdirs()
val defaultTemplate = createDefaultPersistentJson()
if (!persistentFile.exists()) {
persistentFile.writeText(defaultTemplate.toString(4), StandardCharsets.UTF_8)
return defaultTemplate
}
val content = persistentFile.readText(StandardCharsets.UTF_8)
val json = JSONObject(content)
var modified = false
if (!json.has("send_hotfix")) {
json.put("send_hotfix", true)
modified = true
}
if (!json.has("key")) {
json.put("key", "")
modified = true
}
if (!json.has("scene") || json.optJSONObject("scene") == null) {
json.put("scene", defaultTemplate.getJSONObject("scene"))
modified = true
}
val avatar = json.optJSONObject("avatar")
if (avatar == null) {
json.put("avatar", defaultTemplate.getJSONObject("avatar"))
modified = true
} else {
val defaultAvatar = defaultTemplate.getJSONObject("avatar")
val keys = listOf("tb_gender", "tb_path", "march_path", "skin", "lineup", "global_passive", "buff", "leader")
for (k in keys) {
if (!avatar.has(k) || avatar.isNull(k)) {
avatar.put(k, defaultAvatar.get(k))
modified = true
}
}
}
if (modified) {
persistentFile.writeText(json.toString(4), StandardCharsets.UTF_8)
}
return json
} catch (e: Exception) {
val fallback = createDefaultPersistentJson()
try {
persistentFile.writeText(fallback.toString(4), StandardCharsets.UTF_8)
} catch (_: Exception) {}
return fallback
}
}
suspend fun readPersistentJson(): Result<JSONObject> = withContext(Dispatchers.IO) {
try {
if (!persistentFile.exists()) {
return@withContext Result.failure(FileNotFoundException("persistent.json not found"))
}
val text = persistentFile.readText(StandardCharsets.UTF_8)
Result.success(JSONObject(text))
val json = ensureValidPersistentJsonSync()
Result.success(json)
} catch (e: Exception) {
Result.failure(e)
}
@@ -59,6 +174,25 @@ class PersistentRepository(private val context: Context) {
suspend fun writePersistentJson(json: JSONObject): Result<Unit> = withContext(Dispatchers.IO) {
try {
miscDir.mkdirs()
val defaultTemplate = createDefaultPersistentJson()
if (!json.has("send_hotfix")) json.put("send_hotfix", true)
if (!json.has("key")) json.put("key", "")
if (!json.has("scene") || json.optJSONObject("scene") == null) {
json.put("scene", defaultTemplate.getJSONObject("scene"))
}
val avatar = json.optJSONObject("avatar")
if (avatar == null) {
json.put("avatar", defaultTemplate.getJSONObject("avatar"))
} else {
val defaultAvatar = defaultTemplate.getJSONObject("avatar")
val keys = listOf("tb_gender", "tb_path", "march_path", "skin", "lineup", "global_passive", "buff", "leader")
for (k in keys) {
if (!avatar.has(k) || avatar.isNull(k)) {
avatar.put(k, defaultAvatar.get(k))
}
}
}
val backupFile = File(miscDir, "persistent.json.bak")
if (persistentFile.exists()) {
persistentFile.copyTo(backupFile, overwrite = true)
@@ -85,7 +219,7 @@ class PersistentRepository(private val context: Context) {
}
suspend fun setSendHotfix(enabled: Boolean): Result<Unit> {
val json = readPersistentJson().getOrElse { JSONObject() }
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
json.put("send_hotfix", enabled)
return writePersistentJson(json)
}
@@ -95,7 +229,7 @@ class PersistentRepository(private val context: Context) {
}
suspend fun setServerKey(key: String): Result<Unit> {
val json = readPersistentJson().getOrElse { JSONObject() }
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
json.put("key", key)
return writePersistentJson(json)
}
@@ -105,13 +239,13 @@ class PersistentRepository(private val context: Context) {
val avatar = json?.optJSONObject("avatar")
return TrailblazerConfig(
gender = avatar?.optString("tb_gender", "Female") ?: "Female",
tbPath = avatar?.optString("tb_path", "Destruction") ?: "Destruction",
tbPath = avatar?.optString("tb_path", "Elation") ?: "Elation",
marchPath = avatar?.optString("march_path", "Preservation") ?: "Preservation"
)
}
suspend fun setTrailblazerConfig(config: TrailblazerConfig): Result<Unit> {
val json = readPersistentJson().getOrElse { JSONObject() }
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
val avatar = json.optJSONObject("avatar") ?: JSONObject().also { json.put("avatar", it) }
avatar.put("tb_gender", config.gender)
avatar.put("tb_path", config.tbPath)
@@ -120,7 +254,7 @@ class PersistentRepository(private val context: Context) {
}
suspend fun applySceneTeleport(payload: SceneTeleportPayload): Result<Unit> {
val json = readPersistentJson().getOrElse { JSONObject() }
val json = readPersistentJson().getOrElse { createDefaultPersistentJson() }
val scene = json.optJSONObject("scene") ?: JSONObject().also { json.put("scene", it) }
scene.put("plane_id", payload.planeId)
@@ -11,6 +11,7 @@ import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import org.json.JSONObject
data class UpdateInfo(
val currentVersion: String,
@@ -187,7 +188,28 @@ class UpdateRepository(private val context: Context) {
val stagedMiscDir = File(effectiveStagedDir, "misc").apply { mkdirs() }
if (miscBackupFile.exists()) {
miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true)
try {
val defaultTemplate = JSONObject(PersistentRepository.DEFAULT_PERSISTENT_JSON_STRING)
val backedUpText = miscBackupFile.readText()
val backedUpJson = JSONObject(backedUpText)
if (backedUpJson.has("send_hotfix")) defaultTemplate.put("send_hotfix", backedUpJson.getBoolean("send_hotfix"))
if (backedUpJson.has("key")) defaultTemplate.put("key", backedUpJson.getString("key"))
if (backedUpJson.has("scene") && backedUpJson.optJSONObject("scene") != null) {
defaultTemplate.put("scene", backedUpJson.getJSONObject("scene"))
}
val backedUpAvatar = backedUpJson.optJSONObject("avatar")
if (backedUpAvatar != null) {
val avatar = defaultTemplate.getJSONObject("avatar")
val keys = backedUpAvatar.keys()
while (keys.hasNext()) {
val k = keys.next()
avatar.put(k, backedUpAvatar.get(k))
}
}
File(stagedMiscDir, "persistent.json").writeText(defaultTemplate.toString(4))
} catch (e: Exception) {
miscBackupFile.copyTo(File(stagedMiscDir, "persistent.json"), overwrite = true)
}
}
if (configBackupFile.exists()) {
@@ -161,6 +161,13 @@ class ServerService : Service() {
}
}
try {
val persistentRepo = com.catlas.escapepenacony.data.PersistentRepository(this)
persistentRepo.ensureValidPersistentJsonSync()
} catch (e: Exception) {
appendLog("[Warning] Persistent validation notice: ${e.message}")
}
appendLog("[System] Launching ppSR Python 3.14 Server...")
// Start readiness check in background
@@ -129,6 +129,7 @@ fun MainScreen() {
}
if (isServerInstalled) {
persistentRepo.ensureValidPersistentJsonSync()
sendHotfix = persistentRepo.getSendHotfix()
serverKey = persistentRepo.getServerKey()
trailblazerConfig = persistentRepo.getTrailblazerConfig()
+178
View File
@@ -136,6 +136,145 @@ try:
except Exception:
print("[Shim] marshmallow not available")
# --- 3.1 Persistent Template & SafeDict Protection ---
DEFAULT_PERSISTENT = {
"send_hotfix": True,
"scene": {
"plane_id": 20313,
"x": 40748,
"y": 192819,
"z": 439218,
"calyx_x": 40748,
"calyx_y": 192810,
"calyx_z": 441000,
"map_layer": 2,
"calyx_group_id": 186,
"calyx_inst_id": 300001,
"calyx_entity_id": 1337,
"calyx_prop_id": 808
},
"avatar": {
"tb_gender": "Female",
"tb_path": "Elation",
"march_path": "Preservation",
"skin": {
"1001": 1100101,
"1310": 1131001,
"1415": 1141501,
"1407": 1140701,
"1501": 1150101
},
"lineup": [
1511,
1502,
1506,
1503
],
"global_passive": [
{
"name": "Castorice Global Passive",
"id": 140703,
"active": True
},
{
"name": "SW999 Global Passsive",
"id": 150602,
"active": True
},
{
"name": "Aha Global Passive",
"id": 151104,
"active": True
}
],
"buff": [
1000121
],
"leader": 0
},
"key": ""
}
class SafeDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for k, v in list(self.items()):
if isinstance(v, dict) and not isinstance(v, SafeDict):
self[k] = SafeDict(v)
def __getitem__(self, key):
import copy
if key not in self:
if key == 'avatar':
self['avatar'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']))
return self['avatar']
elif key == 'scene':
self['scene'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['scene']))
return self['scene']
elif key == 'skin':
self['skin'] = SafeDict(copy.deepcopy(DEFAULT_PERSISTENT['avatar']['skin']))
return self['skin']
elif key == 'lineup':
self['lineup'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['lineup'])
return self['lineup']
elif key == 'buff':
self['buff'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['buff'])
return self['buff']
elif key == 'global_passive':
self['global_passive'] = copy.deepcopy(DEFAULT_PERSISTENT['avatar']['global_passive'])
return self['global_passive']
elif key == 'tb_gender':
return 'Female'
elif key == 'tb_path':
return 'Elation'
elif key == 'march_path':
return 'Preservation'
elif key == 'leader':
return 0
elif key == 'send_hotfix':
return True
elif key == 'key':
return ''
val = super().__getitem__(key)
if isinstance(val, dict) and not isinstance(val, SafeDict):
val = SafeDict(val)
self[key] = val
return val
def _sanitize_persistent_file(persistent_file):
try:
import json, os, copy
os.makedirs(os.path.dirname(os.path.abspath(persistent_file)), exist_ok=True)
data = {}
if os.path.exists(persistent_file):
try:
with open(persistent_file, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
data = {}
modified = False
if not isinstance(data, dict):
data = {}
modified = True
for k, v in DEFAULT_PERSISTENT.items():
if k not in data:
data[k] = copy.deepcopy(v)
modified = True
elif isinstance(v, dict) and isinstance(data[k], dict):
for sub_k, sub_v in v.items():
if sub_k not in data[k]:
data[k][sub_k] = copy.deepcopy(sub_v)
modified = True
if modified or not os.path.exists(persistent_file):
with open(persistent_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
print("[Patch] persistent.json validated and auto-healed with complete avatar/scene defaults")
except Exception as e:
print(f"[Patch Warning] sanitize persistent: {e}")
# --- 4. Shutdown Logic ---
def kill_server_process():
global _active_server_instance
@@ -264,6 +403,45 @@ def run_server(ppsr_path, activity_context=None):
except Exception as e:
print(f"[Patch Warning] Werkzeug patch: {e}")
# --- Persistent & Avatar Auto-healing Patch ---
_persistent_file = os.path.join(misc_dir, "persistent.json")
_sanitize_persistent_file(_persistent_file)
try:
import game.services.manager as _mgr_mod
_orig_load_persistent = _mgr_mod.Manager.load_persistent
@classmethod
def _patched_load_persistent(cls):
_orig_load_persistent()
if hasattr(cls, 'persistent') and isinstance(cls.persistent, dict):
cls.persistent = SafeDict(cls.persistent)
_mgr_mod.Manager.load_persistent = _patched_load_persistent
if hasattr(_mgr_mod.Manager, 'persistent') and isinstance(_mgr_mod.Manager.persistent, dict):
_mgr_mod.Manager.persistent = SafeDict(_mgr_mod.Manager.persistent)
print("[Patch] Manager.load_persistent patched with SafeDict avatar protection")
except Exception as e:
print(f"[Patch Warning] Manager persistent patch: {e}")
try:
import game.router as _router_mod
_orig_router_handle = _router_mod.handle
def _safe_router_handle(data):
try:
return _orig_router_handle(data)
except Exception as e:
import traceback
print(f"[Router Warning] Handled exception in router.handle: {e}")
traceback.print_exc()
return b''
_router_mod.handle = _safe_router_handle
print("[Patch] game.router.handle wrapped with crash-guard")
except Exception as e:
print(f"[Patch Warning] Router crash-guard: {e}")
print(f"Loading bytecode target: {ppsr_path}...")
with open(ppsr_path, "rb") as f: