feat(state): 生成 10032 动作/状态层 Inventory.kt(运行时类型优先)

- 新增 extract_action_inventory / infer_action_types / analyze_action_trace / gen_action_kt 四个工具

- extractor 支持 return ( 换行形式,类数 619 → 864(唯一名 816,字段 2129)

- 生成器按「运行时 dispatch 打点 > 调用点字面量 > 协议字段表」定类型,认不出写 Any? 并留证据

- 修掉两处会生成非法 Kotlin 的问题:逗号落在注释内、运行时 JS 模型名被当成 Kotlin 类型

- 结果:306/2129 字段已定类型(runtime 162 / callsite 144),compileKotlinJs 通过
This commit is contained in:
TermiNexus committed 2026-10-21 20:19:53 +08:00
1 parent 20cc0f9858
commit e8f5cf6e24
8 files changed
+37510

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+525
View File
@@ -0,0 +1,525 @@
{
"generatedAt": "2026-10-21T12:13:34.495Z",
"stats": {
"Rows": 4304,
"Parsed": 4129,
"Skipped": 175,
"Actions": 135,
"Unresolved": 14
},
"Types": {
"SetLocations": {
"captchaLocations": "List<Any?>"
},
"SocialNetworkAvailability": {
"socialNetworkAvailability": "Map<String, Boolean>"
},
"SocialNetworkClientIds": {
"socialNetworkClientIds": "Map<String, Any>"
},
"SetInviteNeed": {
"inviteNeed": "Boolean"
},
"Init": {
"disableRegistrationMessage": "Any?",
"disableShopMessage": "Any?",
"homeScreenMessage": "Any?",
"announcementItems": "List<String>",
"battleId": "Long",
"remainingTime": "Int",
"battleName": "String",
"matchBattle": "Boolean",
"isParkourMode": "Boolean",
"isTutorial": "Boolean",
"valuableBattle": "Boolean",
"scoreLimit": "Int",
"fund": "Int",
"options": "List<String>",
"customBattleName": "Any?",
"battleFormat": "String",
"isMatchmakingMark": "Boolean",
"mapNameWithoutMode": "String"
},
"SetPasswordRestrictions": {
"minPasswordLength": "Int",
"maxPasswordLength": "Int",
"passwordNeedLower": "Boolean",
"passwordNeedUpper": "Boolean",
"passwordNeedDigit": "Boolean"
},
"GoTo": {
"screen": "List<String>",
"history": "Boolean"
},
"Configure": {
"modes": "List<String>",
"disabledModes": "List<String>",
"holidayEnabled": "Boolean",
"holidayTitle": "String",
"holidayIcon": "List<String>",
"eventBigTile": "List<String>",
"holidayDescription": "String",
"minRankForProBattle": "Int",
"maxUsersInGroup": "Int",
"groupsInEventMode": "Boolean",
"eventEndDate": "List<String>",
"borderColorForEventButton": "String",
"config": "String"
},
"InitProBattleRewardLimit": {
"proLimit": "String"
},
"LimitReached": {
"isLimitReached": "Boolean"
},
"Loaded": {
"users": "Map<String, String>"
},
"SetUserId": {
"userId": "Long"
},
"SetPremiumRemainingSeconds": {
"seconds": "Int"
},
"SetDisabledWithoutPremiumLocations": {
"locations": "List<Any?>"
},
"SetRankNames": {
"rankNames": "List<String>"
},
"UpdateHasClan": {
"hasClan": "Boolean"
},
"UpdateTimeToJoinRestriction": {
"restrictionTimeJoinClanInSec": "Int"
},
"AddIncomingClan": {
"clanIds": "List<Any?>"
},
"AddNewIncomingClan": {
"clanIds": "List<Any?>"
},
"AddUsersToNewAccepted": {
"clanId": "Int",
"userIds": "List<Any?>",
"usersId": "List<Any?>"
},
"AddNewIncoming": {
"newIncoming": "List<Any?>"
},
"UpdateClanLicense": {
"hasLicense": "Boolean"
},
"AddUsersToAccepted": {
"usersId": "Any?"
},
"SetLimits": {
"acceptedLimit": "Int",
"incomingLimit": "Int"
},
"AddUsersToIncoming": {
"usersId": "List<Any?>"
},
"AddUsersToNewIncoming": {
"usersId": "List<Any?>"
},
"UpdateOnlineStatus": {
"status": "Map<String, String>"
},
"UpdatePremiumStatus": {
"premiums": "Map<String, String>"
},
"UpdateRanks": {
"ranks": "Map<String, Int>"
},
"UpdateUids": {
"uids": "Any?"
},
"SetMountedItemsProperties": {
"properties": "List<String>"
},
"SetNewsItemAction": {
"news": "List<String>"
},
"SetUserPropertiesAction": {
"user": "String"
},
"UpdateEventPass": {
"eventPassEndTime": "Int"
},
"UpdateStars": {
"stars": "Map<String, Int>"
},
"SetCoins": {
"coins": "Int"
},
"SetRubies": {
"rubies": "Int"
},
"SetUnViewedElements": {
"unViewedElements": "List<String>"
},
"SetMissionToElement": {
"missionToElement": "Map<String, String>"
},
"SetRubiesForRename": {
"rubiesForRename": "Int"
},
"SetAttachedCreatorTag": {
"creatorTag": "Any?"
},
"SetUse2FA": {
"isUse2FA": "Boolean"
},
"SetTwitchEnabled": {
"twitchEnabled": "Boolean"
},
"SetAccount": {
"account": "Any?"
},
"SetDaysForNextLinking": {
"days": "Int"
},
"EnableSetBattleInviteNotifications": {
"isEnabled": "Boolean"
},
"SetProfileBlockStatus": {
"isProfileBlocked": "Boolean"
},
"SetChallenge": {
"challenge": "Challenge"
},
"SetBattlePassPackages": {
"battlePassPackages": "Map<String, Long>"
},
"InitUserReports": {
"isEnabled": "Boolean",
"helpdeskTicketsEnabled": "Boolean",
"availableReports": "Int",
"availableGameReports": "Int"
},
"SetActiveContract": {
"contractId": "Any?"
},
"SetContract": {
"contract": "List<Any?>"
},
"RemoveItemNotification": {
"section": "String",
"itemId": "Long",
"type": "String",
"parentId": "Long",
"excludeType": "Any?",
"skipPeriodCheck": "Boolean"
},
"AddItemNotifications": {
"data": "List<ItemNotificationData>"
},
"SetEventPassShopItemId": {
"shopItemId": "Any?"
},
"SetKeys": {
"keys": "Map<String, Int>"
},
"SetReferralEventData": {
"eventData": "String"
},
"SetClanEditPrice": {
"clanEditPrice": "String"
},
"SetMinRankForCreate": {
"rankIndex": "Int"
},
"NewIncomingUserNotification": {
"count": "Int"
},
"NewAcceptedUserNotification": {
"count": "Int"
},
"NewClanInviteNotification": {
"count": "Int"
},
"InitGarageResources": {
"params": "String"
},
"InitNotificationSound": {
"sound": "List<String>"
},
"SetRankIndexToMinGS": {
"rankIndexToMinGS": "Map<String, Int>"
},
"SetIcons": {
"icons": "List<String>"
},
"SetCoinPaymentProviderUrl": {
"url": "String"
},
"ToggleShopActivity": {
"enabled": "Boolean"
},
"SetQuestCategories": {
"categories": "List<String>"
},
"SetContainerData": {
"type": "String",
"name": "String",
"description": "String"
},
"SetCreatorTags": {
"creatorTags": "List<Any?>"
},
"SetPaint": {
"itemId": "Long",
"paint": "Coloring"
},
"SetDrone": {
"itemId": "Long",
"resource": "List<String>",
"emissionColors": "EmissionColors"
},
"SetQuestCategoriesStatistic": {
"statistic": "List<String>"
},
"SetPromoCode": {
"code": "String"
},
"AddMessages": {
"messages": "List<String>"
},
"UpdateTypingSpeedAntifloodParams": {
"symbolCost": "Int",
"enterCost": "Int"
},
"AddMap": {
"id": "Long",
"info": "String"
},
"RoundStarted": {
"battleId": "Long",
"timeLeftInSec": "Int"
},
"UpdateFund": {
"battleId": "Long",
"fund": "Int"
},
"AddBattle": {
"battleId": "Long",
"creatorId": "Long",
"params": "String",
"suspicionLevel": "String"
},
"AddUsers": {
"battleId": "Long",
"users": "List<String>",
"team": "String"
},
"UpdateTeamScore": {
"battleId": "Long",
"team": "String",
"score": "Int"
},
"RemoveUser": {
"battleId": "Long",
"userId": "Long"
},
"UpdateUserScore": {
"battleId": "Long",
"userId": "Long",
"score": "Int"
},
"UnsubscribeUpdateUserData": {
"usersId": "List<Any?>"
},
"RemoveBattle": {
"battleId": "Long"
},
"ChatLoaded": {
"commonBattleChatEnabled": "Boolean",
"teamBattleChatEnabled": "Boolean"
},
"SetBattleUpdatedQuests": {
"updatedQuestsData": "List<Any?>"
},
"SetSpectator": {
"spectatorMode": "Boolean"
},
"SetReArmor": {
"enabled": "Boolean"
},
"SetRankRange": {
"rankRange": "String"
},
"SetBattleMode": {
"mode": "String"
},
"EnterToBattle": {
"battleId": "Long"
},
"SetIdleKickPeriod": {
"idleKickPeriodMsec": "Int"
},
"UpdateUsersScoreKillsAndDeaths": {
"userScores": "List<String>"
},
"UpdateScore": {
"team": "String",
"score": "Int"
},
"InitSuspiciousState": {
"suspiciousUserIds": "List<Any?>",
"suspiciousByIpUserIds": "List<Any?>"
},
"AddRelatedBazooka": {
"bazookaId": "Long",
"supplyId": "Long"
},
"SetTankResistance": {
"user": "Long",
"tankResistance": "List<String>"
},
"SetTankInfo": {
"user": "Long",
"info": "String"
},
"SetGearScore": {
"user": "Long",
"score": "Int"
},
"OnUserJoinTheBattle": {
"userId": "Long"
},
"SetLocalWeaponResistanceProperty": {
"itemProperty": "String"
},
"PauseActivated": {
"timeToAutoLeavePeriod": "Int",
"timeToAutoPausePeriod": "Int"
},
"UpdateUserScoreKillsAndDeaths": {
"userStat": "String"
},
"OnKilled": {
"killer": "Long",
"destroyed": "Long",
"damageType": "String",
"isCritical": "Boolean"
},
"AddMessage": {
"message": "Any?"
},
"OnUserLeaveTheBattle": {
"userId": "Long"
},
"RemoveTankInfo": {
"user": "Long"
},
"OnSuicide": {
"destroyed": "Long"
},
"SetRestrictionMount": {
"delayMs": "Int"
},
"AddOverdriveDescription": {
"itemId": "Long",
"descriptionData": "OverdriveDescriptionData"
},
"AddRelatedResistanceModule": {
"itemId": "Long",
"resistanceModuleId": "Long"
},
"InitState": {
"type": "String",
"action": "String"
},
"SetGarageSkinsAction": {
"skins": "List<GarageItemObject>"
},
"GarageDevicesLoadedAction": {
"baseItemId": "Long",
"devices": "Any?",
"mountedDevice": "List<String>"
},
"UpdateGearScore": {
"gearScore": "Int",
"weaponGS": "Int",
"hullGS": "Int",
"droneGS": "Int",
"bazookaGS": "Int",
"resistanceGS": "Int"
},
"OnChangeEquipment": {
"destroyed": "Long"
},
"SetReArmorTemporaryDisabled": {
"disabled": "Boolean"
},
"SetFlagOrBallOwner": {
"ownerUserId": "Long"
},
"GarageUpdateDepotItem": {
"itemId": "Int",
"count": "Int"
},
"SelectBattle": {
"battleId": "Long"
},
"SelectBattleFormat": {
"format": "BattleFormat"
},
"UserEnterBattle": {
"battles": "Map<String, String>"
},
"SubscribeUpdateUserData": {
"userId": "Long"
},
"RoundFinish": {
"battleId": "Long"
},
"QuestsChangeCategory": {
"previousScreen": "Any?",
"previousQuests": "String",
"currentQuests": "String"
},
"QuestsLoaded": {
"quests": "List<String>"
},
"SetReferrals": {
"referralsCount": "Int",
"referralsData": "Map<String, Any?>"
},
"UpdateQuests": {
"quests": "List<String>"
},
"AddUser": {
"battleId": "Long",
"infoUser": "String",
"team": "String"
},
"SwapTeam": {
"battleId": "Long"
},
"GarageUpgradeItemConfirm": {
"itemId": "Long",
"level": "Int"
},
"UpdateCrystals": {
"crystal": "Int"
}
},
"Unresolved": [
"Init.disableRegistrationMessage = null",
"Init.disableShopMessage = null",
"Init.homeScreenMessage = null",
"Init.customBattleName = null",
"AddUsersToAccepted.usersId = List<Any?>|List<Long>",
"UpdateUids.uids = Map<String, String>|Map<String, Any>|Map<String, Int>|Map<String, Long>",
"SetAttachedCreatorTag.creatorTag = null",
"SetAccount.account = null",
"SetActiveContract.contractId = null",
"RemoveItemNotification.excludeType = null",
"SetEventPassShopItemId.shopItemId = null",
"AddMessage.message = BattleChatUserMessage|BattleChatTeamMessage",
"GarageDevicesLoadedAction.devices = List<GarageDeviceObject>|List<Any?>",
"QuestsChangeCategory.previousScreen = List<String>|QuestCategory"
]
}
+121
View File
@@ -0,0 +1,121 @@
// analyze_action_trace.js —— 把运行时动作打点(toString 原文)解析成「字段类型覆盖表」
//
// 打点来源:module_10849 的 ReduxToModelGateway.dispatch(`yt.uar`),每行是动作类的 toString:
// "SetInviteNeed(inviteNeed=false)"
// "Init(disableRegistrationMessage=null, disableShopMessage=null, …)"
// "SocialNetworkClientIds(socialNetworkClientIds={facebook=…, google=…})"
// "AddMap(mapName=LostTemple)" (无引号文本 = 字符串/枚举名,按 String 记)
//
// 输出:names/action_types_10032.json
// { "SetInviteNeed": { "inviteNeed": "Boolean" }, … }
// 供 gen_action_kt.js 覆盖生成时的类型。
//
// 用法: node analyze_action_trace.js
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Trace = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_trace.json'), 'utf8'));
const Rows = Trace.trace || [];
// --- 值解析 -------------------------------------------------------------
function SplitTop(Text, Sep) {
const Out = [];
let Depth = 0;
let Cur = '';
for (const C of Text) {
if ('([{'.includes(C)) Depth++;
else if (')]}'.includes(C)) Depth--;
if (C === Sep && Depth === 0) { Out.push(Cur); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Out.push(Cur);
return Out;
}
function ParseValue(V) {
const S = String(V).trim();
if (S === 'true' || S === 'false') return 'Boolean';
if (S === 'null') return 'null';
if (/^-?\d+$/.test(S)) {
const N = Number(S);
return Math.abs(N) > 2147483647 ? 'Long' : 'Int';
}
if (/^-?\d+\.\d+(E-?\d+)?$/.test(S) || /^-?\d+E-?\d+$/i.test(S)) return 'Double';
if (S.startsWith('[') && S.endsWith(']')) {
const Inner = S.slice(1, -1).trim();
if (!Inner) return 'List<Any?>';
const Elems = SplitTop(Inner, ',').map(ParseValue);
return 'List<' + UnifyAll(Elems).replace(/\?$/, '') + '>';
}
if (S.startsWith('{') && S.endsWith('}')) {
const Inner = S.slice(1, -1).trim();
if (!Inner) return 'Map<String, Any?>';
const Pairs = SplitTop(Inner, ',').map((P) => {
const Eq = P.indexOf('=');
return Eq < 0 ? 'Any?' : ParseValue(P.slice(Eq + 1));
});
return 'Map<String, ' + UnifyAll(Pairs).replace(/\?$/, '') + '>';
}
const Model = /^([A-Z][A-Za-z0-9_$]*)\(/.exec(S);
if (Model) return Model[1];
return 'String';
}
function UnifyAll(List) {
const Distinct = new global.Set(List.filter((T) => T && T !== 'null'));
if (!Distinct.size) return 'Any?';
if (Distinct.size === 1) return [...Distinct][0];
// Int + Long → Long;其余冲突 → Any?
if (Distinct.size === 2 && Distinct.has('Int') && Distinct.has('Long')) return 'Long';
return 'Any?';
}
// --- 解析每行 ------------------------------------------------------------
const Types = new Map(); // 动作名 → Map<字段, Set<类型>>
let Parsed = 0;
let Skipped = 0;
for (const R of Rows) {
const S = String(R.s || '');
const M = /^([A-Z][A-Za-z0-9_$]*)\((.*)\)$/s.exec(S);
if (!M) { Skipped++; continue; }
const [, Name, Body] = M;
if (!Body.trim()) { Parsed++; continue; }
const Fields = Types.get(Name) || new Map();
for (const Part of SplitTop(Body, ',')) {
const Eq = Part.indexOf('=');
if (Eq < 0) continue;
const Key = Part.slice(0, Eq).trim();
const Val = ParseValue(Part.slice(Eq + 1));
const Bucket = Fields.get(Key) || new global.Set();
Bucket.add(Val);
Fields.set(Key, Bucket);
}
Types.set(Name, Fields);
Parsed++;
}
// --- 汇总成最终类型 ------------------------------------------------------
const Out = {};
const Unresolved = [];
for (const [Name, Fields] of Types) {
const Row = {};
for (const [Key, Bucket] of Fields) {
const T = UnifyAll([...Bucket]);
Row[Key] = T;
if (T === 'Any?') Unresolved.push(Name + '.' + Key + ' = ' + [...Bucket].join('|'));
}
Out[Name] = Row;
}
fs.writeFileSync(path.join(PROJECT, 'names', 'action_types_10032.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
stats: { Rows: Rows.length, Parsed, Skipped, Actions: Object.keys(Out).length, Unresolved: Unresolved.length },
Types: Out,
Unresolved,
}, null, 1), 'utf8');
console.log('打点 ' + Rows.length + ' 行:可解析 ' + Parsed + ',跳过(无 toString)' + Skipped +
';动作 ' + Object.keys(Out).length + ',字段类型冲突 ' + Unresolved.length);
console.log(' 输出: 3DTank/names/action_types_10032.json');
for (const N of Object.keys(Out).slice(0, 6)) {
console.log(' ' + N + ': ' + JSON.stringify(Out[N]));
}
+76
View File
@@ -0,0 +1,76 @@
// extract_action_inventory.js —— 把「动作/状态类」结构化成 JSON(S3 状态层的第一步)
//
// 为什么要它:
// module_10032(1.5MB / 1661 导出)里没有 Redux 引擎,只有业务动作与状态数据类。
// 这些类的可恢复信息有三份:
// ① 构造参数(`function Cls(a, b, c) {`)
// ② toString 里的真名与字段顺序(`return "SetFragsCount(a=" + … `)
// ③ 构造体里的 `this.<属性> = <参数>` 绑定
// 本工具把三者对齐成一张表,供后续生成 Kotlin data class / sealed interface。
//
// 用法: node extract_action_inventory.js [模块号=10032] [--out <文件>]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
const Mod = Arg('module', '10032');
const Out = path.resolve(Arg('out', path.join(PROJECT, 'names', 'action_inventory_' + Mod + '.json')));
const Src = fs.readFileSync(path.join(PROJECT, 'src', 'module_' + Mod + '.js'), 'utf8');
// ① 构造参数
const Ctors = new Map(); // 局部类名 → [参数名…]
for (const M of Src.matchAll(/function ([A-Za-z0-9_$]+)\(([^)]*)\)\s*\{/g)) {
if (Ctors.has(M[1])) continue;
const Params = M[2].trim() ? M[2].split(',').map((S) => S.trim()) : [];
Ctors.set(M[1], Params);
}
// ② toString:`(helper(Cls).toString = function () { … return "<Name>(<字段>=" …`
const Rows = [];
for (const M of Src.matchAll(/\(\s*[A-Za-z_$][\w$]*\(([A-Za-z0-9_$]+)\)\.toString = function \(\) \{([\s\S]{0,4000}?)\n\s*\}\)/g)) {
const Cls = M[1];
const Body = M[2];
// 真名既可能紧跟 return(`return "Foo(`),也可能被包裹(`return (\n "Foo(`)
const NameM = /return\s*\(?\s*"([A-Z][A-Za-z0-9_]{1,60})\(/.exec(Body);
if (!NameM) continue;
const Name = NameM[1];
// 字段顺序:`字段名=` 出现顺序(排除内部的 "…=" 之外的表达式影响,只用带标识符前缀的片段)
const Fields = [];
const FieldRe = /([A-Za-z_][A-Za-z0-9_]{0,40})=/g;
let F;
while ((F = FieldRe.exec(Body))) {
if (F[1] === 'return') continue;
Fields.push(F[1]);
}
Rows.push({ Local: Cls, Name, Params: Ctors.get(Cls) || [], ToStringFields: Fields });
}
// ③ 构造体绑定:`this.<属性> = <参数>`
const Binds = new Map(); // 局部类名 → [{Prop, Param}]
for (const M of Src.matchAll(/function ([A-Za-z0-9_$]+)\(([^)]*)\)\s*\{([\s\S]{0,3000}?)\n\s{4}\}/g)) {
const Cls = M[1];
const Body = M[3];
const List = [];
for (const B of Body.matchAll(/this\.([A-Za-z0-9_$]+) = ([A-Za-z0-9_$]+)/g)) List.push({ Prop: B[1], Param: B[2] });
if (List.length) Binds.set(Cls, List);
}
for (const R of Rows) R.Binds = Binds.get(R.Local) || [];
const Names = new Map();
for (const R of Rows) if (!Names.has(R.Name)) Names.set(R.Name, R);
const Out_obj = {
module: Mod,
generatedAt: new Date().toISOString(),
stats: { Rows: Rows.length, UniqueNames: Names.size, WithParams: Rows.filter((R) => R.Params.length).length },
Rows,
};
fs.writeFileSync(Out, JSON.stringify(Out_obj, null, 1), 'utf8');
console.log('动作/状态类 ' + Rows.length + '(唯一名 ' + Names.size + ',有构造参数 ' +
Out_obj.stats.WithParams + ')');
console.log(' 输出: ' + Out);
for (const R of Rows.slice(0, 8)) {
console.log(' ' + R.Name + '(' + R.Params.join(', ') + ') 字段=' + R.ToStringFields.slice(0, 6).join(','));
}
+175
View File
@@ -0,0 +1,175 @@
// gen_action_kt.js —— 生成 S3 状态层的 Kotlin 数据类(动作 + 状态)
//
// 输入:
// names/action_inventory_10032.json 类名 / 构造参数 / toString 字段顺序
// names/action_calls_10032.json 每个类的调用点实参表达式
// names/codec_fields.json 协议字段的 Prop → 线上类型(类型反查的权威来源)
// 输出:
// TankiOffline/app/src/jsMain/kotlin/App/State/Inventory.kt
//
// 类型判定(认不出就写 Any? 并附调用点原文,不静默猜):
// !0 / !1 → Boolean
// 纯数字字面量 → Int(带小数点 → Double)
// "…" → String
// <x>.<prop> → 查 codec_fields 的 Prop 得到协议类型再映射 Kotlin
// new <Cls>(…) / <Cls>() → 该 Cls(同表内的类)或 Any?
// 其它 → Any?(注释里留原文)
//
// 用法: node gen_action_kt.js [--out <文件>]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
// TankIC/3DTank/tools → TankIC/TankiOffline/...
const OUT = path.resolve(Arg('out', path.join(PROJECT, '..', 'TankiOffline', 'app', 'src', 'jsMain', 'kotlin', 'App', 'State', 'Inventory.kt')));
const Inventory = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_inventory_10032.json'), 'utf8'));
const Calls = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_calls_10032.json'), 'utf8')).Calls || {};
const Codec = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'codec_fields.json'), 'utf8'));
// 运行时真值(客户端 dispatch 打点解析出来的字段类型)优先级最高
let RuntimeTypes = {};
try {
RuntimeTypes = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_types_10032.json'), 'utf8')).Types || {};
} catch (E) { RuntimeTypes = {}; }
// Prop → 协议类型(重复项若类型不一致则不采用)
const PropType = new Map();
const PropBad = new Set();
for (const C of Object.values(Codec.Codecs)) {
for (const F of C.Fields || []) {
if (!F.Prop) continue;
if (PropBad.has(F.Prop)) continue;
const Prev = PropType.get(F.Prop);
if (Prev && Prev !== F.Type) { PropType.delete(F.Prop); PropBad.add(F.Prop); continue; }
if (!Prev) PropType.set(F.Prop, F.Type);
}
}
// 协议类型 → Kotlin
function KotlinOf(ProtoType) {
let T = String(ProtoType || '').trim();
let Suffix = '';
if (T.startsWith('opt<')) { T = T.slice(4, -1).trim(); Suffix = '?'; }
const L = /^list<([\s\S]+)>$/.exec(T);
if (L) return 'List<' + KotlinOf(L[1]).replace(/\?$/, '') + '>' + Suffix;
const M = /^map<([\s\S]+)>$/.exec(T);
if (M) {
const P = M[1].split(',').map((X) => KotlinOf(X).replace(/\?$/, ''));
return 'Map<' + P[0] + ', ' + P[1] + '>' + Suffix;
}
if (T.startsWith('enum<')) return 'Int' + Suffix;
const Base = {
bool: 'Boolean', i8: 'Int', i16: 'Int', i32: 'Int', i64: 'Long',
f32: 'Float', f64: 'Double', string: 'String', bytes: 'ByteArray',
vector3: 'Vector3',
}[T];
return (Base || 'Any?') + (Base ? Suffix : '');
}
const Names = new Set(Inventory.Rows.map((R) => R.Name));
function InferType(Expr) {
const E = String(Expr || '').trim();
if (!E) return null;
if (E === '!0' || E === '!1') return 'Boolean';
if (/^-?\d+$/.test(E)) return 'Int';
if (/^-?\d+\.\d+$/.test(E)) return 'Double';
if (/^".*"$/.test(E)) return 'String';
const Prop = /^[A-Za-z_$][\w$]*\.([A-Za-z0-9_$]+)$/.exec(E);
if (Prop) {
const T = PropType.get(Prop[1]);
if (T) return KotlinOf(T);
return null;
}
const New = /^new\s+([A-Za-z0-9_$]+)\s*\(/.exec(E);
if (New && Names.has(New[1])) return New[1];
// 明显是布尔表达式:!x / x.equals(...) / x instanceof Y / 比较
if (/^!/.test(E) || /\.equals\(|instanceof|===|!==|[<>]=?/.test(E)) return 'Boolean';
return null;
}
// 只接受「当前工程里确实存在」的 Kotlin 类型,否则退回 Any?(避免生成引用不到的名字而编译失败)
// · 标量 / Vector3 / 它们的容器 → 接受
// · 运行时 toString 里的模型类名 → 拒绝(那是 JS 模型名,本工程没有对应 Kotlin 类)
// · 调用点 new <动作类>(...) → 接受(同文件内确有该 data class,且不能是自身)
const Primitive = new Set(['Boolean', 'Int', 'Long', 'Float', 'Double', 'String', 'ByteArray', 'Vector3']);
function CheckType(T, Source, Self) {
const S = String(T).trim().replace(/\?$/, '');
if (Primitive.has(S)) return { Ok: true, Type: T };
const L = /^List<([\s\S]+)>$/.exec(S);
if (L) {
const I = CheckType(L[1], Source, Self);
return I.Ok ? { Ok: true, Type: 'List<' + I.Type.replace(/\?$/, '') + '>' + (/\?$/.test(T) ? '?' : '') } : { Ok: false, Bad: T };
}
const M = /^Map<([\s\S]+)>$/.exec(S);
if (M) {
const P = M[1].split(',').map((X) => CheckType(X.trim(), Source, Self));
return P.every((X) => X.Ok)
? { Ok: true, Type: 'Map<' + P[0].Type.replace(/\?$/, '') + ', ' + P[1].Type.replace(/\?$/, '') + '>' + (/\?$/.test(T) ? '?' : '') }
: { Ok: false, Bad: T };
}
if (Source === 'callsite' && Names.has(S) && S !== Self) return { Ok: true, Type: T };
return { Ok: false, Bad: T };
}
const Emitted = [];
const Seen = new Set();
let TypedFields = 0;
let TotalFields = 0;
for (const R of Inventory.Rows) {
if (Seen.has(R.Name)) continue;
Seen.add(R.Name);
const Sites = Calls[R.Name] || [];
const Fields = R.ToStringFields.length ? R.ToStringFields : R.Params.map((P, I) => 'field' + (I + 1));
const Lines = [];
Fields.forEach((F, I) => {
TotalFields++;
// ① 运行时打点(真值)→ ② 协议字段表/字面量反推 → ③ Any?
const Rt = RuntimeTypes[R.Name] && RuntimeTypes[R.Name][F];
let Type = Rt || null;
let Source = Rt ? 'runtime' : '';
const Notes = [];
if (!Type) {
for (const S of Sites) {
const A = S.Args[I];
if (A === undefined) continue;
const T = InferType(A);
if (T) { if (!Type) { Type = T; Source = 'callsite'; } }
else if (Notes.length < 2) Notes.push(A.slice(0, 40));
}
}
let Note = '';
if (Type) {
const C = CheckType(Type, Source, R.Name);
if (C.Ok) Type = C.Type;
else { Note = '// 未收录类型: ' + C.Bad + '(' + Source + ')'; Type = null; }
}
if (Type) TypedFields++;
if (!Type && !Note) Note = Notes.length ? '// 调用点: ' + Notes.join(' | ') : '// 无调用点';
if (!Note) Note = '// ' + Source;
// 注释必须压成单行,且逗号要放在注释之前(否则逗号被 // 吞掉)
Note = Note.replace(/\s+/g, ' ').slice(0, 120);
Lines.push(' val ' + F + ': ' + (Type || 'Any?') + ', ' + Note);
});
Emitted.push('data class ' + R.Name + '(\n' + Lines.join('\n') + '\n)');
}
const Head = [
'// 本文件由 3DTank/tools/gen_action_kt.js 生成。',
'// 来源:module_10032 的动作/状态类(类名与字段顺序取自 toString 证据,类型由调用点 + 协议字段表反推)。',
'// 标注 Any? 的字段表示调用点认不出类型,行尾注释给出原始实参,便于人工补。',
'package App.State',
'',
'import Core.Vector3',
'',
'// 动作/状态类共 ' + Emitted.length + ' 个,字段 ' + TotalFields + ' 个(已定类型 ' + TypedFields + ' 个)',
'',
];
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, Head.join('\n') + Emitted.join('\n\n') + '\n', 'utf8');
console.log('生成 ' + Emitted.length + ' 个 data class,字段 ' + TotalFields + ',已定类型 ' + TypedFields +
'(' + ((TypedFields * 100 / Math.max(1, TotalFields)).toFixed(1)) + '%)');
console.log(' 输出: ' + OUT);
+112
View File
@@ -0,0 +1,112 @@
// infer_action_types.js —— 从调用点反推「动作/状态类」的字段类型(S3 状态层第二步)
//
// 思路:
// 1. module_10032 的导出表:导出键 → 局部类名;配合 action_inventory 得到 类名 ↔ 导出键;
// 2. 其它模块用 `<别名> = i(10032).$_$.<键>` 引入,再 `<别名>(...)` 构造(Kotlin data class 的 new 被编译成函数调用);
// 3. 采集每个构造点的实参表达式,按下面规则定类型(认不出就记 unknown,不猜):
// · 数字字面量 → Int/Double;true/false → Boolean;"…" → String;null → null
// · `<x>.f20_1/g20_1/h20_1`(向量分量) → Float
// · `new <Cls>()` / `<Cls>()` → 该 Cls 的局部名
// · `<x>.<prop>` → 记录 prop 名,供后续按 codec_fields 反查
//
// 用法: node infer_action_types.js [--limit 30]
'use strict';
const fs = require('fs');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const SRC = path.join(PROJECT, 'src');
const Argv = process.argv.slice(2);
const Arg = (N, D) => { const I = Argv.indexOf('--' + N); return I >= 0 && Argv[I + 1] ? Argv[I + 1] : D; };
const Limit = Number(Arg('limit', '30')) || 30;
const Inventory = JSON.parse(fs.readFileSync(path.join(PROJECT, 'names', 'action_inventory_10032.json'), 'utf8'));
function SplitArgs(Text) {
const Out = [];
let Depth = 0;
let Cur = '';
for (let I = 0; I < Text.length; I++) {
const C = Text[I];
if (C === '(' || C === '[' || C === '{') Depth++;
else if (C === ')' || C === ']' || C === '}') Depth--;
if (C === ',' && Depth === 0) { Out.push(Cur.trim()); Cur = ''; continue; }
Cur += C;
}
if (Cur.trim()) Out.push(Cur.trim());
return Out;
}
function FindDeps(Src) {
const Head = /\(\s*function\s*\(([^)]*)\)\s*\{/.exec(Src);
let Tail = null;
const Re = /\}\)\(([^;]*)\);/g;
let M;
while ((M = Re.exec(Src))) Tail = M;
if (!Head || !Tail) return {};
const P = SplitArgs(Head[1]);
const A = SplitArgs(Tail[1]);
const Out = {};
for (let I = 0; I < P.length && I < A.length; I++) {
const X = /\(\s*(\d+)\s*\)/.exec(A[I]);
if (X) Out[P[I]] = X[1];
}
return Out;
}
// 10032 导出表:键 → 局部类名
const Src10032 = fs.readFileSync(path.join(SRC, 'module_10032.js'), 'utf8');
const ExportToLocal = new Map();
for (const M of Src10032.matchAll(/t\.\$_\$\.([A-Za-z0-9_$]+) = ([A-Za-z0-9_$]+)/g)) ExportToLocal.set(M[1], M[2]);
const LocalToName = new Map();
for (const R of Inventory.Rows) if (!LocalToName.has(R.Local)) LocalToName.set(R.Local, R.Name);
const KeyToName = new Map();
for (const [K, L] of ExportToLocal) if (LocalToName.has(L)) KeyToName.set(K, LocalToName.get(L));
// 扫全包调用点
const Calls = new Map(); // 动作名 → [{Module, Args}]
for (const F of fs.readdirSync(SRC).filter((N) => /^module_\d+\.js$/.test(N))) {
if (F === 'module_10032.js') continue;
const Text = fs.readFileSync(path.join(SRC, F), 'utf8');
const Deps = FindDeps(Text);
const Alias = new Map(); // 局部别名 → 动作名
for (const M of Text.matchAll(/(?<![A-Za-z0-9_$.])([A-Za-z_$][\w$]*) = ([A-Za-z_$][\w$]*)\.\$_\$\.([A-Za-z0-9_$]+)[,;)]/g)) {
if (Deps[M[2]] !== '10032') continue;
const Name = KeyToName.get(M[3]);
if (Name && !Alias.has(M[1])) Alias.set(M[1], Name);
}
if (!Alias.size) continue;
for (const [Local, Name] of Alias) {
const Re = new RegExp('(?<![\\w$.])new ' + Local.replace(/[$]/g, '\\$') + '\\s*\\(', 'g');
let M;
while ((M = Re.exec(Text))) {
const Open = Text.indexOf('(', M.index);
let Depth = 0;
let End = -1;
for (let I = Open; I < Text.length; I++) {
if (Text[I] === '(') Depth++;
else if (Text[I] === ')') { Depth--; if (Depth === 0) { End = I; break; } }
}
if (End < 0) continue;
const Args = SplitArgs(Text.slice(Open + 1, End));
const List = Calls.get(Name) || [];
List.push({ Module: F.replace('.js', ''), Args });
Calls.set(Name, List);
}
}
}
const Named = Inventory.Rows.filter((R) => Calls.has(R.Name));
console.log('有调用点的动作/状态类 ' + Named.length + ' / ' + Inventory.Rows.length);
console.log('总调用点 ' + [...Calls.values()].reduce((A, B) => A + B.length, 0));
for (const R of Named.slice(0, Limit)) {
const List = Calls.get(R.Name);
const Sample = List.slice(0, 3).map((C) => C.Module + ': ' + C.Args.join(' , '));
console.log(' ' + R.Name + '(' + R.Params.join(',') + ') ×' + List.length);
for (const S of Sample) console.log(' ' + S.slice(0, 140));
}
fs.writeFileSync(path.join(PROJECT, 'names', 'action_calls_10032.json'), JSON.stringify({
generatedAt: new Date().toISOString(),
stats: { Classes: Inventory.Rows.length, WithCalls: Named.length },
Calls: Object.fromEntries([...Calls.entries()]),
}, null, 1), 'utf8');
console.log(' 输出: 3DTank/names/action_calls_10032.json');
File diff suppressed because it is too large. Load diff