diff --git a/crates/workshop-rs/src/catalog/data/catalog.json b/crates/workshop-rs/src/catalog/data/catalog.json index 94c7eb8..678d35c 100644 --- a/crates/workshop-rs/src/catalog/data/catalog.json +++ b/crates/workshop-rs/src/catalog/data/catalog.json @@ -189,13 +189,19 @@ "zh-CN": "开始强制重生室" }, "id": "startForcingSpawn", + "paramCoercions": [ + null, + { + "falseAsNumber": true + } + ], "paramDomains": [ "Team", null ], "paramTypes": [ "Team", - "Number|Boolean" + "Number" ], "params": [ "Team", @@ -227,6 +233,14 @@ "zh-CN": "开始修改英雄语音" }, "id": "startModifyingVoicelinePitch", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, null, @@ -249,6 +263,14 @@ "zh-CN": "设置技能充能" }, "id": "setAbilityCharge", + "paramCoercions": [ + null, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, "Button", @@ -271,6 +293,12 @@ "zh-CN": "开始为机器人强制设置名称" }, "id": "startForcingName", + "paramCoercions": [ + null, + { + "emptyArrayAsString": true + } + ], "paramDomains": [ null, null @@ -290,6 +318,14 @@ "zh-CN": "开始调整障碍大小" }, "id": "startScalingBarriers", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, null, @@ -360,6 +396,13 @@ "zh-CN": "绑定玩家" }, "id": "attachTo", + "paramCoercions": [ + null, + null, + { + "nullVectorAsNull": true + } + ], "paramDomains": [ null, null, @@ -534,6 +577,15 @@ "zh-CN": "在索引处修改玩家变量" }, "id": "modifyPlayerVariableAtIndex", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null, + null + ], "paramDomains": [ null, null, @@ -584,6 +636,14 @@ "zh-CN": "设置技能资源" }, "id": "setAbilityResource", + "paramCoercions": [ + null, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, "Button", @@ -666,6 +726,15 @@ "zh-CN": "在索引处修改全局变量" }, "id": "modifyGlobalVariableAtIndex", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null, + null + ], "paramDomains": [ null, null, @@ -853,6 +922,14 @@ "zh-CN": "设置技能冷却" }, "id": "setAbilityCooldown", + "paramCoercions": [ + null, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, "Button", @@ -861,7 +938,7 @@ "paramTypes": [ "Player|Array", "Button", - "Number|Boolean" + "Number" ], "params": [ "Object", @@ -1005,6 +1082,14 @@ "zh-CN": "开始调整玩家大小" }, "id": "startScalingSize", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, null, @@ -1132,6 +1217,13 @@ "zh-CN": "开始强制设置玩家位置" }, "id": "startForcingPosition", + "paramCoercions": [ + null, + { + "zeroAsNull": true + }, + null + ], "paramDomains": [ null, null, @@ -1298,6 +1390,13 @@ "zh-CN": "设置目标点描述" }, "id": "setObjectiveDescription", + "paramCoercions": [ + null, + { + "zeroAsNull": true + }, + null + ], "paramDomains": [ null, null, @@ -1361,6 +1460,13 @@ "zh-CN": "设置弹道引力" }, "id": "setProjectileGravity", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -1399,6 +1505,17 @@ "zh-CN": "设置弹药" }, "id": "setAmmo", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null, @@ -1421,6 +1538,13 @@ "zh-CN": "移除机器人" }, "id": "destroyDummy", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ "Team", null @@ -1455,6 +1579,13 @@ "zh-CN": "设置弹道速度" }, "id": "setProjectileSpeed", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -1493,6 +1624,17 @@ "zh-CN": "设置最大弹药" }, "id": "setMaxAmmo", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null, @@ -1515,10 +1657,24 @@ "zh-CN": "生成机器人" }, "id": "createDummyBot", + "paramCoercions": [ + null, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "nullVectorAsNull": true + }, + { + "nullVectorAsNull": true + } + ], "paramTypes": [ "Hero", "Team", - "Number|Boolean", + "Number", "Vector", "Vector" ], @@ -1558,6 +1714,14 @@ "zh-CN": "在索引处设置玩家变量" }, "id": "setPlayerVariableAtIndex", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramTypes": [ "Player Variable", "Number|Boolean", @@ -1594,6 +1758,13 @@ "zh-CN": "设置最大复生时间" }, "id": "setRespawnTime", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -1613,11 +1784,17 @@ "zh-CN": "设置比赛时间" }, "id": "setMatchTime", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null ], "paramTypes": [ - "Number|Boolean" + "Number" ], "params": [ "Number" @@ -1699,6 +1876,33 @@ "zh-CN": "开始限制阈值 " }, "id": "startForcingThrottle", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null, @@ -1824,6 +2028,13 @@ "zh-CN": "根据条件跳过" }, "id": "skipIf", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Boolean", "Number|Boolean" @@ -1839,6 +2050,14 @@ "zh-CN": "在索引处设置全局变量" }, "id": "setGlobalVariableAtIndex", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramTypes": [ "Global Variable", "Number|Boolean", @@ -2072,6 +2291,13 @@ "zh-CN": "等待" }, "id": "wait", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDefaults": [ null, "Wait.IGNORE_CONDITION" @@ -2126,6 +2352,28 @@ "zh-CN": "创建HUD文本" }, "id": "createHudText", + "paramCoercions": [ + null, + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + }, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null, + null, + null, + null, + null + ], "paramDefaults": [ null, null, @@ -2258,6 +2506,13 @@ "zh-CN": "设置引力" }, "id": "setGravity", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Player|Array", "Number|Boolean" @@ -2303,6 +2558,13 @@ "zh-CN": "设置移动速度" }, "id": "setMoveSpeed", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Player|Array", "Number" @@ -2318,6 +2580,13 @@ "zh-CN": "设置终极技能充能" }, "id": "setUltCharge", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Player|Array", "Number|Boolean" @@ -2333,6 +2602,12 @@ "zh-CN": "传送" }, "id": "teleport", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramTypes": [ "Player|Array", "Vector" @@ -2348,6 +2623,18 @@ "zh-CN": "追踪全局变量频率" }, "id": "chaseAtRate", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, null, @@ -2373,6 +2660,18 @@ "zh-CN": "持续追踪全局变量" }, "id": "chaseOverTime", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, null, @@ -2477,6 +2776,15 @@ "zh-CN": "设置状态" }, "id": "setStatusEffect", + "paramCoercions": [ + null, + null, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null, @@ -2502,6 +2810,12 @@ "zh-CN": "大字体信息" }, "id": "bigMessage", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramDefaults": [ "allPlayers", null @@ -2521,6 +2835,12 @@ "zh-CN": "小字体信息" }, "id": "smallMessage", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramDefaults": [ "allPlayers", null @@ -2544,7 +2864,7 @@ }, "id": "waitUntil", "paramTypes": [ - "Any", + "Boolean|Number", "Number" ], "params": [ @@ -2558,6 +2878,12 @@ "zh-CN": "跳过" }, "id": "skip", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean" ], @@ -2615,6 +2941,17 @@ "zh-CN": "创建效果" }, "id": "createEffect", + "paramCoercions": [ + null, + null, + null, + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, "Effect", @@ -2646,6 +2983,23 @@ "zh-CN": "创建地图文本" }, "id": "createInWorldText", + "paramCoercions": [ + null, + { + "zeroAsNull": true + }, + { + "nullVectorAsNull": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null, + null, + null, + null + ], "paramDefaults": [ null, null, @@ -3229,6 +3583,13 @@ "zh-CN": "修改玩家分数" }, "id": "addToScore", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -3382,6 +3743,13 @@ "zh-CN": "设置玩家分数" }, "id": "setScore", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -3401,6 +3769,13 @@ "zh-CN": "设置武器" }, "id": "setWeapon", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -3573,6 +3948,13 @@ "zh-CN": "修改队伍分数" }, "id": "addToTeamScore", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ "Team", null @@ -3742,6 +4124,13 @@ "zh-CN": "设置队伍分数" }, "id": "setTeamScore", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramDomains": [ "Team", null @@ -4027,7 +4416,7 @@ ] } ], - "digest": "ba22304296aafc2aa715ea9c47aa8ccf4065576f268af4eb3d9a99d03124f7d6", + "digest": "b4b4e162b755b77cc8afcc8862cb58cba21efdcd812e67675a5e98f988aaa850", "enums": [ { "domain": "Impulse", @@ -9743,6 +10132,7 @@ "reviewed": true, "source": "en-US spellings transcribed from the compatibility corpus workshop snapshots and the M5 support matrix; squareRoot spelling from the Workshop emission surface for .opy sqrt(); receiver-call action/value spellings (setMoveSpeed, isAlive, getPosition, getHealth, teleport, setMaxHealth, setHealth, setAimSpeed, setGravity, setDamageDealt, setDamageReceived, setUltCharge) from the pinned en-US emission surface for .opy eventPlayer.(...) forms, evidenced by the synthetic/receiver-calls corpus fixture (#104); ChaseTimeReeval and ChaseRateReeval member spellings (None, Destination and Duration, Destination and Rate) reference-validated against the pinned enum blocks and emission (#105); chaseOverTime (Chase Global Variable Over Time), isGameInProgress (Is Game In Progress), getPlayersInRadius (Players Within Radius), worldVector (World Vector Of), getThrottle (Throttle Of), setInvisibility (Set Invisible) with the Invis domain, setStatusEffect (Set Status) with the Status domain, the Transform domain, and the LosCheck domain transcribed from the en-US emission surface for the OPY semantic manifest probes (#109); chaseAtRate (Chase Global Variable At Rate), chasePlayerVariableAtRate (Chase Player Variable At Rate), and chasePlayerVariableOverTime (Chase Player Variable Over Time) transcribed from the en-US emission surface for the chase keyword-argument probes (#110); OSTW exercised builtin params/spellings and enum domain/member data transcribed from pinned reference probe emissions and the protect-ban entry-point reachable closure (#118); rule-event identities and EventTeam/EventPlayer filter aliases are transcribed from the user-provided workshop-data export and its Workshop event annotations, cross-checked against documented raw Workshop event forms; migrated to workshop-rs from the Wright-authored catalog on 2026-08-16 as the canonical Workshop catalog; the chase-family expected enum domains migrate the Wright-authored semantic manifest probe data (#109/#110) into the canonical catalog so the standalone core resolves the shared bare None member without any Wright tooling dependency; zh-CN aliases are generated from the user-provided workshop-data JSON; confirmed identity/GUID mappings cover global stop-chasing, force hero/throttle, Set Player Allowed Heroes, the four bare comparison symbols, and three enum aliases; legacy/provider syntax sugar is intentionally outside the declared catalog surface; the exact-match corpus and its exclusions are pinned separately in tools/corpus/zh-cn-corpus.json", "sourceNotes": [ + "Contextual literal substitutions are audited from the reviewed OverPy replacement metadata at https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/types.d.ts and its position-sensitive Workshop emission path at https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/compiler/astToWorkshop.ts; Wait Until's numeric-condition exception is retained from https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/compiler/functions/waitUntil.ts and no global coercion hierarchy is inferred", "Action and Value parameter names/order/types/defaults and Value returns reconciled from Workshop.codes structured article Properties and cross-checked with pinned OverPy static metadata; OSTW remains a static-only reference. Variadic array arguments and WIR-normalized indexed-variable actions are represented explicitly; boolean literal aliases are retained only where the reviewed Workshop/OverPy contract permits them.", "abort zh-CN retains both 中止 from the reviewed workshop-data export and 中断 observed in the reproducible Bastion c010e1a2d468ec7140f474e334067e5ab8d02d89 build:release/build:main:zh output; the first spelling is emitted deterministically and both are accepted for parsing", "disableBuiltinGameModeCompletion zh-CN retains 禁用内置游戏模式完成 from the reviewed workshop-data export and 关闭游戏预设完成条件 observed in the same reproducible Bastion build; the first spelling is emitted deterministically and both are accepted for parsing", @@ -9992,6 +10382,12 @@ "zh-CN": "目标位置" }, "id": "getObjectivePosition", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Any" ], @@ -10138,6 +10534,16 @@ "zh-CN": "随机整数" }, "id": "randomInteger", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean", "Number|Boolean" @@ -10236,6 +10642,17 @@ "zh-CN": "数组分割" }, "id": "slice", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Array", "Number|Boolean", @@ -10276,6 +10693,15 @@ "zh-CN": "字符串中字符" }, "id": "charAt", + "paramCoercions": [ + { + "emptyArrayAsString": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Any", "Any" @@ -10292,6 +10718,14 @@ "zh-CN": "字符串字符索引" }, "id": "strIndex", + "paramCoercions": [ + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + } + ], "paramTypes": [ "String", "String" @@ -10468,6 +10902,14 @@ "zh-CN": "加" }, "id": "add", + "paramCoercions": [ + { + "trueAsNumber": true + }, + { + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean|Vector", "Number|Boolean|Vector" @@ -10556,6 +10998,15 @@ "zh-CN": "减" }, "id": "subtract", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean|Vector", "Number|Boolean|Vector" @@ -10588,6 +11039,12 @@ "zh-CN": "除" }, "id": "divide", + "paramCoercions": [ + { + "trueAsNumber": true + }, + null + ], "paramTypes": [ "Number|Boolean|Vector", "Number|Boolean|Vector" @@ -10696,6 +11153,11 @@ "zh-CN": "数组" }, "id": "array", + "paramCoercions": [ + { + "zeroAsNull": true + } + ], "paramDefaults": [ "null" ], @@ -10714,6 +11176,20 @@ "zh-CN": "矢量" }, "id": "vector", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean", "Number|Boolean", @@ -10732,6 +11208,18 @@ "zh-CN": "自定义字符串" }, "id": "customString", + "paramCoercions": [ + null, + { + "zeroAsNull": true + }, + { + "zeroAsNull": true + }, + { + "zeroAsNull": true + } + ], "paramDefaults": [ null, "null", @@ -10758,6 +11246,18 @@ "zh-CN": "字符串" }, "id": "string", + "paramCoercions": [ + null, + { + "zeroAsNull": true + }, + { + "zeroAsNull": true + }, + { + "zeroAsNull": true + } + ], "paramDefaults": [ "Hello", "null", @@ -10784,6 +11284,13 @@ "zh-CN": "数组中的值" }, "id": "valueInArray", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Any", "Any" @@ -10830,6 +11337,17 @@ "zh-CN": "字符串替换" }, "id": "stringReplace", + "paramCoercions": [ + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + } + ], "paramTypes": [ "String|Array", "String|Array", @@ -10848,6 +11366,13 @@ "zh-CN": "截取字符串" }, "id": "stringSlice", + "paramCoercions": [ + { + "emptyArrayAsString": true + }, + null, + null + ], "paramTypes": [ "String", "Number", @@ -10866,6 +11391,14 @@ "zh-CN": "字符串分割" }, "id": "stringSplit", + "paramCoercions": [ + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + } + ], "paramTypes": [ "String|Array", "String|Array" @@ -10983,6 +11516,15 @@ "zh-CN": "If-Then-Else" }, "id": "ifThenElse", + "paramCoercions": [ + null, + { + "zeroAsNull": true + }, + { + "zeroAsNull": true + } + ], "paramTypes": [ "Boolean|Number", "Object|Array", @@ -11010,6 +11552,12 @@ "zh-CN": "添加至数组" }, "id": "appendToArray", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramTypes": [ "Object|Array", "Object|Array" @@ -11399,6 +11947,12 @@ "zh-CN": "数组包含" }, "id": "arrayContains", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramTypes": [ "Array", "Object|Array" @@ -11425,6 +11979,12 @@ "zh-CN": "数组值的索引" }, "id": "indexOfArrayValue", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramTypes": [ "Array", "Object|Array" @@ -11487,6 +12047,12 @@ "zh-CN": "从数组中移除" }, "id": "removeFromArray", + "paramCoercions": [ + null, + { + "zeroAsNull": true + } + ], "paramTypes": [ "Array", "Object|Array" @@ -11503,6 +12069,16 @@ "zh-CN": "较大" }, "id": "max", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean", "Number|Boolean" @@ -11519,6 +12095,16 @@ "zh-CN": "较小" }, "id": "min", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Number|Boolean", "Number|Boolean" @@ -12022,6 +12608,13 @@ "zh-CN": "弹药" }, "id": "getAmmo", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Player", "Number" @@ -12038,6 +12631,13 @@ "zh-CN": "最大弹药" }, "id": "getMaxAmmo", + "paramCoercions": [ + null, + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Player", "Number" @@ -12344,6 +12944,12 @@ "zh-CN": "目标是否完成" }, "id": "isObjectiveComplete", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + } + ], "paramTypes": [ "Number" ], @@ -13413,6 +14019,13 @@ "zh-CN": "此栏位的玩家" }, "id": "getPlayersInSlot", + "paramCoercions": [ + { + "falseAsNumber": true, + "trueAsNumber": true + }, + null + ], "paramDomains": [ null, "Team" @@ -13639,6 +14252,14 @@ "zh-CN": "字符串包含" }, "id": "strContains", + "paramCoercions": [ + { + "emptyArrayAsString": true + }, + { + "emptyArrayAsString": true + } + ], "paramDomains": [ null, null @@ -13659,6 +14280,11 @@ "zh-CN": "字符串长度" }, "id": "strLen", + "paramCoercions": [ + { + "emptyArrayAsString": true + } + ], "paramDomains": [ null ], @@ -13834,6 +14460,14 @@ "zh-CN": "余数" }, "id": "modulo", + "paramCoercions": [ + { + "trueAsNumber": true + }, + { + "trueAsNumber": true + } + ], "paramDomains": [ null, null @@ -13849,5 +14483,5 @@ "returnType": "Number" } ], - "version": "0.1.2" + "version": "0.1.3" } diff --git a/crates/workshop-rs/src/catalog/mod.rs b/crates/workshop-rs/src/catalog/mod.rs index f531640..cf9fc9a 100644 --- a/crates/workshop-rs/src/catalog/mod.rs +++ b/crates/workshop-rs/src/catalog/mod.rs @@ -95,6 +95,32 @@ impl Kind { } } +/// Literal substitutions accepted at one parameter position and normalized +/// into the parameter's declared semantic type before it enters WIR. +/// +/// These are deliberately per-parameter facts. They do not establish a +/// global relationship between Workshop booleans, numbers, arrays, strings, +/// vectors, or null. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParamCoercions { + /// Accept `False` as numeric zero. + #[serde(default)] + pub false_as_number: bool, + /// Accept `True` as numeric one. + #[serde(default)] + pub true_as_number: bool, + /// Accept numeric zero as `Null`. + #[serde(default)] + pub zero_as_null: bool, + /// Accept `Vector(0, 0, 0)` as `Null`. + #[serde(default)] + pub null_vector_as_null: bool, + /// Accept `Empty Array` as an empty string. + #[serde(default)] + pub empty_array_as_string: bool, +} + /// One catalog builtin. #[derive(Debug, Clone)] pub struct CatalogEntry { @@ -117,6 +143,8 @@ pub struct CatalogEntry { /// Evidence-backed semantic type per parameter position. `None` means /// the available sources do not prove a narrower type. pub param_types: Vec>, + /// Contextual literal substitutions per parameter position. + pub param_coercions: Vec>, /// Evidence-backed return type for Value entries. Actions must leave this /// unset; an absent value is intentionally evidence-insufficient. pub return_type: Option, @@ -204,6 +232,14 @@ impl CatalogEntry { .and_then(Option::as_deref) } + /// The contextual literal substitutions for an argument position. + pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> { + self.param_coercions + .get(index) + .or_else(|| self.variadic.then(|| self.param_coercions.last()).flatten()) + .and_then(Option::as_ref) + } + /// The evidence-backed return type of a Value, when available. pub fn return_type(&self) -> Option<&str> { self.return_type.as_deref() @@ -387,6 +423,8 @@ struct EntryFile { #[serde(default)] param_types: Vec>, #[serde(default)] + param_coercions: Vec>, + #[serde(default)] return_type: Option, #[serde(default)] variadic: bool, @@ -791,6 +829,7 @@ impl Catalog { param_domains: item.param_domains, param_defaults: item.param_defaults, param_types: item.param_types, + param_coercions: item.param_coercions, return_type: item.return_type, variadic: item.variadic, aliases, @@ -866,6 +905,13 @@ impl Catalog { entry.id ))); } + if entry.param_coercions.len() > entry.params.len() { + return Err(CatalogError::validation(format!( + "{} '{}' declares more param coercions than params", + entry.kind.as_str(), + entry.id + ))); + } if entry.kind != Kind::Value && entry.return_type.is_some() { return Err(CatalogError::validation(format!( "{} '{}' declares a return type but is not a value", diff --git a/crates/workshop-rs/src/parser.rs b/crates/workshop-rs/src/parser.rs index fbc3471..7793a25 100644 --- a/crates/workshop-rs/src/parser.rs +++ b/crates/workshop-rs/src/parser.rs @@ -17,7 +17,7 @@ use crate::wir::{ self, Action, Event, EventTarget, EventTeam, ModifyOp, PlayerEventKind, Value, ValueNode, }; -use crate::catalog::{Catalog, Kind, Locale}; +use crate::catalog::{Catalog, Kind, Locale, ParamCoercions}; use crate::error::{Result, WorkshopError}; use crate::lexer::{Token, TokenKind, tokenize}; @@ -121,6 +121,178 @@ impl Parser<'_> { }) } + fn contextual_coercions( + &self, + call_id: &str, + arg_index: usize, + ) -> Option { + [Kind::Action, Kind::Value].into_iter().find_map(|kind| { + self.catalog + .entry(kind, call_id) + .and_then(|entry| entry.param_coercions(arg_index)) + .copied() + }) + } + + fn is_zero_number(&self, value_id: wir::ValueId) -> bool { + matches!( + self.target.values.get(value_id), + Some(ValueNode { + value: Value::Number { value, .. }, + .. + }) if *value == 0.0 + ) + } + + fn is_empty_string(&self, value_id: wir::ValueId) -> bool { + matches!( + self.target.values.get(value_id), + Some(ValueNode { + value: Value::String(value), + .. + }) if value.is_empty() + ) + } + + fn normalize_contextual_argument( + &mut self, + call_id: &str, + arg_index: usize, + value_id: wir::ValueId, + ) -> wir::ValueId { + let Some(coercions) = self.contextual_coercions(call_id, arg_index) else { + return value_id; + }; + self.normalize_value_with_coercions(coercions, value_id) + } + + fn normalize_value_with_coercions( + &mut self, + coercions: ParamCoercions, + value_id: wir::ValueId, + ) -> wir::ValueId { + let Some(node) = self.target.values.get(value_id) else { + return value_id; + }; + let span = node.span; + let replacement = match &node.value { + Value::Bool(false) if coercions.false_as_number => Some(Value::Number { + value: 0.0, + text: "0".to_string(), + }), + Value::Bool(true) if coercions.true_as_number => Some(Value::Number { + value: 1.0, + text: "1".to_string(), + }), + Value::Number { value, .. } if coercions.zero_as_null && *value == 0.0 => { + Some(Value::Null) + } + Value::Vector { x, y, z } + if coercions.null_vector_as_null + && self.is_zero_number(*x) + && self.is_zero_number(*y) + && self.is_zero_number(*z) => + { + Some(Value::Null) + } + Value::Call { name, args } + if coercions.null_vector_as_null + && name == "vector" + && args.len() == 3 + && args.iter().all(|value_id| self.is_zero_number(*value_id)) => + { + Some(Value::Null) + } + Value::Call { name, args } + if coercions.empty_array_as_string && name == "emptyArray" && args.is_empty() => + { + Some(Value::String(String::new())) + } + Value::Call { name, args } + if coercions.empty_array_as_string + && name == "customString" + && args.len() == 1 + && self.is_empty_string(args[0]) => + { + Some(Value::String(String::new())) + } + Value::Array(elements) if coercions.empty_array_as_string && elements.is_empty() => { + Some(Value::String(String::new())) + } + _ => None, + }; + let Some(value) = replacement else { + return value_id; + }; + self.target.values.push(ValueNode::new(value, span)) + } + + fn normalize_modify_value(&mut self, op: ModifyOp, value_id: wir::ValueId) -> wir::ValueId { + let coercions = match op { + ModifyOp::Add + | ModifyOp::Subtract + | ModifyOp::Modulo + | ModifyOp::Min + | ModifyOp::Max + | ModifyOp::RemoveFromArrayByIndex => ParamCoercions { + false_as_number: true, + true_as_number: true, + ..Default::default() + }, + ModifyOp::AppendToArray | ModifyOp::RemoveFromArray => ParamCoercions { + zero_as_null: true, + ..Default::default() + }, + ModifyOp::Multiply | ModifyOp::Divide | ModifyOp::RaiseToPower => return value_id, + }; + self.normalize_value_with_coercions(coercions, value_id) + } + + fn modify_op_from_value(&self, value_id: wir::ValueId) -> Option { + let Value::Call { name, args } = &self.target.values.get(value_id)?.value else { + return None; + }; + if !args.is_empty() { + return None; + } + match name.as_str() { + "add" => Some(ModifyOp::Add), + "subtract" => Some(ModifyOp::Subtract), + "multiply" => Some(ModifyOp::Multiply), + "divide" => Some(ModifyOp::Divide), + "modulo" => Some(ModifyOp::Modulo), + "min" => Some(ModifyOp::Min), + "max" => Some(ModifyOp::Max), + "raiseToPower" => Some(ModifyOp::RaiseToPower), + "appendToArray" => Some(ModifyOp::AppendToArray), + "removeFromArray" | "removeFromArrayByValue" => Some(ModifyOp::RemoveFromArray), + "removeFromArrayByIndex" => Some(ModifyOp::RemoveFromArrayByIndex), + _ => None, + } + } + + fn normalize_modify_call_value( + &mut self, + call_id: &str, + arg_index: usize, + args: &[wir::ValueId], + value_id: wir::ValueId, + ) -> wir::ValueId { + if matches!( + call_id, + "modifyGlobalVariableAtIndex" | "modifyPlayerVariableAtIndex" + ) && arg_index == 3 + { + if let Some(op) = args + .get(2) + .and_then(|value_id| self.modify_op_from_value(*value_id)) + { + return self.normalize_modify_value(op, value_id); + } + } + value_id + } + fn canonical_keyword(&self, spelling: &str) -> String { self.catalog .resolve(Kind::Structural, &self.locale, spelling) @@ -1400,6 +1572,11 @@ impl Parser<'_> { }; let variable = self.global_by_name(&name)?; let value = self.value()?; + let value = if let AssignmentOperator::Modify(op) = &operator { + self.normalize_modify_value(*op, value) + } else { + value + }; self.expect(TokenKind::Semi, "expected ';' after assignment")?; let span = Some(Span::new(self.file(), start, self.previous_span().1)); let target_span = Some(Span::new(self.file(), start, target_end)); @@ -1491,6 +1668,11 @@ impl Parser<'_> { return self.member_assignment_action(saved, start); }; let value = self.value()?; + let value = if let AssignmentOperator::Modify(op) = &operator { + self.normalize_modify_value(*op, value) + } else { + value + }; self.expect(TokenKind::Semi, "expected ';' after assignment")?; let span = Some(Span::new(self.file(), start, self.previous_span().1)); let target_span = Some(Span::new(self.file(), target_start, target_end)); @@ -1550,34 +1732,41 @@ impl Parser<'_> { value: wir::ValueId, start: Position, ) -> wir::ActionId { - let (name, args) = match operator { - AssignmentOperator::Set => ( + let name = match &operator { + AssignmentOperator::Set => { if global { "setGlobalVariableAtIndex" } else { "setPlayerVariableAtIndex" - }, - vec![variable, index, value], - ), - AssignmentOperator::Modify(op) => ( + } + } + AssignmentOperator::Modify(_) => { if global { "modifyGlobalVariableAtIndex" } else { "modifyPlayerVariableAtIndex" - }, - vec![ - variable, - index, - self.target.values.push(ValueNode::new( - Value::Call { - name: op.catalog_id().to_string(), - args: Vec::new(), - }, - None, - )), - value, - ], - ), + } + } + }; + let index = self.normalize_contextual_argument(name, 1, index); + let (value, modify_op) = match operator { + AssignmentOperator::Set => (value, None), + AssignmentOperator::Modify(op) => (self.normalize_modify_value(op, value), Some(op)), + }; + let args = match modify_op { + None => vec![variable, index, value], + Some(op) => vec![ + variable, + index, + self.target.values.push(ValueNode::new( + Value::Call { + name: op.catalog_id().to_string(), + args: Vec::new(), + }, + None, + )), + value, + ], }; self.target.actions.push(Action::Call { name: name.to_string(), @@ -1856,6 +2045,7 @@ impl Parser<'_> { let op = self.modify_op()?; self.expect(TokenKind::Comma, "expected ',' after modify operator")?; let value = self.value()?; + let value = self.normalize_modify_value(op, value); self.expect(TokenKind::RParen, "expected ')'")?; self.expect(TokenKind::Semi, "expected ';'")?; Ok(self.target.actions.push(Action::ModifyGlobalVariable { @@ -1894,6 +2084,7 @@ impl Parser<'_> { let op = self.modify_op()?; self.expect(TokenKind::Comma, "expected ',' after modify operator")?; let value = self.value()?; + let value = self.normalize_modify_value(op, value); self.expect(TokenKind::RParen, "expected ')'")?; self.expect(TokenKind::Semi, "expected ';'")?; Ok(self.target.actions.push(Action::ModifyPlayerVariable { @@ -2119,6 +2310,7 @@ impl Parser<'_> { { self.pos += 1; let index = self.value()?; + let index = self.normalize_contextual_argument("valueInArray", 1, index); let end = self.peek().map(|token| token.end).unwrap_or(start); self.expect(TokenKind::RBracket, "expected ']' after array index")?; value = self.target.values.push(ValueNode::new( @@ -2175,6 +2367,9 @@ impl Parser<'_> { let when_true = self.value()?; self.expect(TokenKind::Colon, "expected ':' in conditional value")?; let when_false = self.value()?; + let when_true = self.normalize_contextual_argument("ifThenElse", 1, when_true); + let when_false = + self.normalize_contextual_argument("ifThenElse", 2, when_false); value = self.target.values.push(ValueNode::new( Value::Call { name: "ifThenElse".to_string(), @@ -2202,10 +2397,12 @@ impl Parser<'_> { "%" => "modulo", _ => op.as_str(), }; + let left = self.normalize_contextual_argument(name, 0, value); + let right = self.normalize_contextual_argument(name, 1, right); value = self.target.values.push(ValueNode::new( Value::Call { name: name.to_string(), - args: vec![value, right], + args: vec![left, right], }, Some(Span::new(self.file(), start, end)), )); @@ -2942,6 +3139,16 @@ impl Parser<'_> { let mut arg_index = 0usize; let mut raw_modify_operator = false; loop { + let canonical_arg_index = if matches!( + call_id, + "setPlayerVariableAtIndex" | "modifyPlayerVariableAtIndex" + ) && arg_index >= 2 + && args.len() == arg_index - 1 + { + arg_index - 1 + } else { + arg_index + }; // Indexed variable actions carry a project-local variable name, // not a Workshop value expression. Resolve it through the symbol // table so names such as `Brigitte` remain variable identities. @@ -2974,10 +3181,15 @@ impl Parser<'_> { } else { self.pos = saved; let saved_domain = self.expected_domain; - self.expected_domain = self.context.expected_domain(call_id, arg_index); + self.expected_domain = + self.context.expected_domain(call_id, canonical_arg_index); let arg = self.value(); self.expected_domain = saved_domain; - args.push(arg?); + args.push(self.normalize_contextual_argument( + call_id, + canonical_arg_index, + arg?, + )); } } else if matches!( call_id, @@ -3012,17 +3224,23 @@ impl Parser<'_> { // per the enclosing call's canonical signature (#111); nested // calls override the expectation for their own arguments. let saved = self.expected_domain; - self.expected_domain = - self.context - .expected_domain(call_id, arg_index) - .or_else(|| { - (matches!(call_id, "array" | "randomValueInArray")) - .then_some(saved) - .flatten() - }); + self.expected_domain = self + .context + .expected_domain(call_id, canonical_arg_index) + .or_else(|| { + (matches!(call_id, "array" | "randomValueInArray")) + .then_some(saved) + .flatten() + }); let arg = self.value(); self.expected_domain = saved; - args.push(arg?); + let arg = self.normalize_contextual_argument(call_id, canonical_arg_index, arg?); + args.push(self.normalize_modify_call_value( + call_id, + canonical_arg_index, + &args, + arg, + )); } arg_index += 1; match self.peek() { diff --git a/crates/workshop-rs/src/validate.rs b/crates/workshop-rs/src/validate.rs index b41fe6f..d7fa0ee 100644 --- a/crates/workshop-rs/src/validate.rs +++ b/crates/workshop-rs/src/validate.rs @@ -396,7 +396,9 @@ fn validate_call_signature( continue; } if let Some(expected) = entry.param_type(index) { - if !value_matches_type(program, catalog, *arg_id, expected) { + if !value_matches_type(program, catalog, *arg_id, expected) + && !contextual_value_matches(entry, index, program, *arg_id, expected) + { let actual = value_type_name(program, catalog, *arg_id); errors.push(WorkshopError::Unsupported { message: format!( @@ -455,6 +457,64 @@ fn validate_call_signature( } } +fn contextual_value_matches( + entry: &crate::catalog::CatalogEntry, + index: usize, + program: &wir::Program, + value_id: wir::ValueId, + expected: &str, +) -> bool { + let Some(coercions) = entry.param_coercions(index) else { + return false; + }; + let Some(node) = program.values.get(value_id) else { + return false; + }; + let expected_number = expected + .split('|') + .any(|alternative| matches!(alternative, "Number" | "Any" | "Unknown")); + let expected_string = expected + .split('|') + .any(|alternative| matches!(alternative, "String" | "Text")); + match &node.value { + wir::Value::Bool(false) => coercions.false_as_number && expected_number, + wir::Value::Bool(true) => coercions.true_as_number && expected_number, + wir::Value::Number { value, .. } => coercions.zero_as_null && *value == 0.0, + wir::Value::Vector { x, y, z } => { + coercions.null_vector_as_null + && is_zero_number(program, *x) + && is_zero_number(program, *y) + && is_zero_number(program, *z) + } + wir::Value::Call { name, args } => { + (coercions.null_vector_as_null + && name == "vector" + && args.len() == 3 + && args + .iter() + .all(|value_id| is_zero_number(program, *value_id))) + || (coercions.empty_array_as_string + && expected_string + && name == "emptyArray" + && args.is_empty()) + } + wir::Value::Array(elements) => { + coercions.empty_array_as_string && expected_string && elements.is_empty() + } + _ => false, + } +} + +fn is_zero_number(program: &wir::Program, value_id: wir::ValueId) -> bool { + matches!( + program.values.get(value_id), + Some(wir::ValueNode { + value: wir::Value::Number { value, .. }, + .. + }) if *value == 0.0 + ) +} + fn value_matches_type( program: &wir::Program, catalog: &Catalog, diff --git a/crates/workshop-rs/tests/contextual_semantics.rs b/crates/workshop-rs/tests/contextual_semantics.rs new file mode 100644 index 0000000..44c6f4e --- /dev/null +++ b/crates/workshop-rs/tests/contextual_semantics.rs @@ -0,0 +1,401 @@ +//! Executable witnesses for contextual Workshop literal semantics. + +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::parser; +use workshop_rs::roundtrip; +use workshop_rs::validate; +use workshop_rs::wir::{self, Action, Value}; + +fn catalog() -> Catalog { + Catalog::builtin().expect("built-in catalog") +} + +fn program_with(conditions: &str, actions: &str) -> wir::Program { + let source = format!( + r#"variables +{{ + global: + 0: probe +}} +rule ("contextual semantics") +{{ + event {{ Ongoing - Global; }} + conditions {{ + {conditions} + }} + actions {{ + {actions} + }} +}}"# + ); + parser::parse(&source, &catalog(), &Locale::new("en-US")).expect("contextual source parses") +} + +fn program(actions: &str) -> wir::Program { + program_with("", actions) +} + +fn first_action_args(program: &wir::Program) -> &[wir::ValueId] { + let rule = program.rules.iter().next().expect("rule"); + match program.actions.get(rule.actions[0]).expect("action") { + Action::Call { args, .. } => args, + other => panic!("expected a catalog action call, got {other:?}"), + } +} + +fn validate_program(program: &wir::Program) { + validate::validate_canonical_ids(program, &catalog()).expect("canonical validation"); +} + +fn first_value_call<'a>(program: &'a wir::Program, name: &str) -> &'a [wir::ValueId] { + program + .values + .iter() + .find_map(|node| match &node.value { + Value::Call { + name: value_name, + args, + } if value_name == name => Some(args.as_slice()), + _ => None, + }) + .unwrap_or_else(|| panic!("missing value call {name}")) +} + +#[test] +fn numeric_boolean_aliases_share_canonical_wir_only_when_declared() { + for (boolean, number, expected) in [("True", "1", 1.0), ("False", "0", 0.0)] { + let boolean_program = program(&format!("Wait({boolean}, Ignore Condition);")); + let number_program = program(&format!("Wait({number}, Ignore Condition);")); + validate_program(&boolean_program); + validate_program(&number_program); + assert!(roundtrip::equivalent(&boolean_program, &number_program)); + assert!(matches!( + &boolean_program + .values + .get(first_action_args(&boolean_program)[0]) + .expect("wait duration") + .value, + Value::Number { value, .. } if *value == expected + )); + } +} + +#[test] +fn one_sided_contextual_aliases_reject_the_other_boolean() { + let accepted = program("Start Forcing Spawn Room(Team 1, False);"); + validate_program(&accepted); + assert!(matches!( + &accepted + .values + .get(first_action_args(&accepted)[1]) + .expect("spawn room") + .value, + Value::Number { value, .. } if *value == 0.0 + )); + + let rejected = program("Start Forcing Spawn Room(Team 1, True);"); + let error = validate::validate_canonical_ids(&rejected, &catalog()) + .expect_err("True is not a documented alias for the spawn-room parameter"); + assert!(format!("{error:?}").contains("semantic type")); +} + +#[test] +fn wait_until_keeps_numeric_exception_without_global_truthiness() { + let numeric = program("Wait Until(1, 2);"); + validate_program(&numeric); + assert!(matches!( + &numeric + .values + .get(first_action_args(&numeric)[0]) + .expect("continue condition") + .value, + Value::Number { value, .. } if *value == 1.0 + )); + + let vector = program("Wait Until(Vector(1, 2, 3), 2);"); + let error = validate::validate_canonical_ids(&vector, &catalog()) + .expect_err("Wait Until must not accept arbitrary values as conditions"); + assert!(format!("{error:?}").contains("semantic type")); +} + +#[test] +fn null_and_empty_string_contexts_normalize_at_their_positions() { + let teleport = program("Teleport(Event Player, 0);"); + validate_program(&teleport); + assert!(matches!( + &teleport + .values + .get(first_action_args(&teleport)[1]) + .expect("teleport position") + .value, + Value::Null + )); + + let position = program("Start Forcing Player Position(Event Player, 0, False);"); + validate_program(&position); + assert!(matches!( + &position + .values + .get(first_action_args(&position)[1]) + .expect("forced position") + .value, + Value::Null + )); + + let dummy = + program("Create Dummy Bot(Hero(Bastion), Team 2, 0, Vector(0, 0, 0), Vector(0, 0, 0));"); + validate_program(&dummy); + let dummy_args = first_action_args(&dummy); + assert!(matches!( + &dummy + .values + .get(dummy_args[3]) + .expect("dummy position") + .value, + Value::Null + )); + assert!(matches!( + &dummy + .values + .get(dummy_args[4]) + .expect("dummy direction") + .value, + Value::Null + )); + + let name = program("Start Forcing Dummy Bot Name(Event Player, Empty Array);"); + validate_program(&name); + assert!(matches!( + &name.values + .get(first_action_args(&name)[1]) + .expect("forced name") + .value, + Value::String(value) if value.is_empty() + )); + + let string_source = r#"variables +{ + global: + 0: probe +} +rule ("empty string") +{ + event { Ongoing - Global; } + actions { Set Global Variable(probe, String Replace(Empty Array, Empty Array, Empty Array)); } +}"#; + let reparsed = roundtrip::round_trip_with_context( + string_source, + &catalog(), + &Locale::new("en-US"), + &catalog(), + ); + assert!( + reparsed.equivalent, + "empty string contextual alias must round-trip" + ); +} + +#[test] +fn nested_numeric_boolean_aliases_normalize_inside_vector_components() { + let program = program("Set Global Variable(probe, Vector(1, True, False));"); + validate_program(&program); + let args = first_value_call(&program, "vector"); + assert_eq!(args.len(), 3); + for (value_id, expected) in [(args[0], 1.0), (args[1], 1.0), (args[2], 0.0)] { + assert!(matches!( + &program.values.get(value_id).expect("vector component").value, + Value::Number { value, .. } if *value == expected + )); + } +} + +#[test] +fn comparisons_preserve_polymorphic_operand_types() { + let program = program_with("1 == True;", "Wait(1, Ignore Condition);"); + validate_program(&program); + let condition = program + .values + .get(program.rules.iter().next().expect("rule").conditions[0]) + .expect("comparison"); + let Value::Call { name, args } = &condition.value else { + panic!("expected comparison call, got {:?}", condition.value); + }; + assert_eq!(name, "=="); + assert!(matches!( + &program.values.get(args[0]).expect("left operand").value, + Value::Number { value, .. } if *value == 1.0 + )); + assert!(matches!( + &program.values.get(args[1]).expect("right operand").value, + Value::Bool(true) + )); +} + +#[test] +fn arithmetic_operators_apply_contextual_aliases_but_comparisons_do_not() { + let program = program("Set Global Variable(probe, True + 1);"); + validate_program(&program); + let args = first_value_call(&program, "add"); + assert!(matches!( + &program.values.get(args[0]).expect("left operand").value, + Value::Number { value, .. } if *value == 1.0 + )); +} + +#[test] +fn indexed_player_variable_sugar_uses_canonical_parameter_positions() { + let source = r#"variables +{ + global: + 0: probe + player: + 0: indexed +} + +rule ("indexed contextual semantics") +{ + event { Ongoing - Global; } + actions { Set Player Variable At Index(Event Player, indexed, True, Null); } +}"#; + let parsed = + parser::parse(source, &catalog(), &Locale::new("en-US")).expect("indexed source parses"); + validate::validate_canonical_ids(&parsed, &catalog()).expect("canonical validation"); + let action = parsed + .actions + .get(parsed.rules.iter().next().expect("rule").actions[0]) + .expect("indexed action"); + let Action::Call { args, .. } = action else { + panic!("expected indexed action call, got {action:?}"); + }; + assert!(matches!( + &parsed.values.get(args[1]).expect("index").value, + Value::Number { value, .. } if *value == 1.0 + )); +} + +#[test] +fn conditional_operator_applies_branch_contextual_aliases() { + let parsed = program("Set Global Variable(probe, True ? 0 : 1);"); + validate_program(&parsed); + let args = first_value_call(&parsed, "ifThenElse"); + assert!(matches!( + &parsed.values.get(args[1]).expect("true branch").value, + Value::Null + )); +} + +#[test] +fn audited_catalog_parameters_cover_boolean_numeric_aliases() { + for (source, expected) in [ + ("Set Gravity(Event Player, True);", 1.0), + ("Set Ultimate Charge(Event Player, False);", 0.0), + ("Set Team Score(Team 1, True);", 1.0), + ] { + let parsed = program(source); + validate_program(&parsed); + assert!(matches!( + &parsed + .values + .get(first_action_args(&parsed)[1]) + .expect("numeric parameter") + .value, + Value::Number { value, .. } if *value == expected + )); + } + + let parsed = program("Set Global Variable(probe, Is Objective Complete(True));"); + validate_program(&parsed); + let args = first_value_call(&parsed, "isObjectiveComplete"); + assert!(matches!( + &parsed.values.get(args[0]).expect("objective index").value, + Value::Number { value, .. } if *value == 1.0 + )); +} + +#[test] +fn array_index_sugar_applies_value_in_array_context() { + let parsed = program("Set Global Variable(probe, Array(1)[True]);"); + validate_program(&parsed); + let args = first_value_call(&parsed, "valueInArray"); + assert!(matches!( + &parsed.values.get(args[1]).expect("array index").value, + Value::Number { value, .. } if *value == 1.0 + )); +} + +#[test] +fn modify_contexts_apply_operation_specific_replacements() { + let source = r#"variables +{ + global: + 0: g + player: + 0: p +} +rule ("modify contextual semantics") +{ + event { Ongoing - Global; } + actions { + Modify Global Variable(g, Add, False); + Modify Global Variable(g, Append To Array, 0); + Modify Player Variable(Event Player, p, Subtract, True); + Modify Global Variable At Index(g, 0, Append To Array, 0); + Global.g[False] += 1; + } +}"#; + let parsed = + parser::parse(source, &catalog(), &Locale::new("en-US")).expect("modify source parses"); + validate_program(&parsed); + + let direct_values: Vec<_> = parsed + .actions + .iter() + .filter_map(|action| match action { + Action::ModifyGlobalVariable { value, .. } + | Action::ModifyPlayerVariable { value, .. } => Some(value), + _ => None, + }) + .collect(); + assert!(matches!( + &parsed.values.get(*direct_values[0]).expect("add value").value, + Value::Number { value, .. } if *value == 0.0 + )); + assert!(matches!( + &parsed + .values + .get(*direct_values[1]) + .expect("append value") + .value, + Value::Null + )); + assert!(matches!( + &parsed.values.get(*direct_values[2]).expect("subtract value").value, + Value::Number { value, .. } if *value == 1.0 + )); + + let indexed: Vec<_> = parsed + .actions + .iter() + .filter_map(|action| match action { + Action::Call { name, args, .. } if name == "modifyGlobalVariableAtIndex" => Some(args), + _ => None, + }) + .collect(); + assert_eq!(indexed.len(), 2); + assert!(matches!( + &parsed + .values + .get(indexed[0][3]) + .expect("indexed modify value") + .value, + Value::Null + )); + assert!(matches!( + &parsed + .values + .get(indexed[1][1]) + .expect("indexed assignment index") + .value, + Value::Number { value, .. } if *value == 0.0 + )); +} diff --git a/crates/workshop-rs/tests/identity.rs b/crates/workshop-rs/tests/identity.rs index 48c4306..d7f9941 100644 --- a/crates/workshop-rs/tests/identity.rs +++ b/crates/workshop-rs/tests/identity.rs @@ -5,14 +5,14 @@ use workshop_rs::catalog::{Catalog, Locale}; -/// The pinned digest of the committed catalog dataset (version 0.1.2). +/// The pinned digest of the committed catalog dataset (version 0.1.3). /// /// Any dataset change (entries, aliases, locale tables, provenance, target) /// changes the content digest, and this test fails until the pipeline /// (`workshop-catalog-gen build`) recomputes it and the pin is updated /// deliberately together with the data. const PINNED_CATALOG_DIGEST: &str = - "ba22304296aafc2aa715ea9c47aa8ccf4065576f268af4eb3d9a99d03124f7d6"; + "b4b4e162b755b77cc8afcc8862cb58cba21efdcd812e67675a5e98f988aaa850"; #[test] fn committed_catalog_digest_is_pinned() { @@ -37,7 +37,7 @@ fn identity_reports_all_four_machine_readable_identities() { let catalog = Catalog::builtin().expect("built-in catalog"); let identity = catalog.identity(); assert_eq!(identity.implementation_version, env!("CARGO_PKG_VERSION")); - assert_eq!(identity.catalog_version, "0.1.2"); + assert_eq!(identity.catalog_version, "0.1.3"); assert_eq!( identity.catalog_digest.as_deref(), Some(PINNED_CATALOG_DIGEST) diff --git a/docs/language-support/actions.md b/docs/language-support/actions.md index 60b6763..c8f9a3a 100644 --- a/docs/language-support/actions.md +++ b/docs/language-support/actions.md @@ -25,7 +25,7 @@ This document inventories the canonical Workshop actions supported by `workshop- | `Communicate` | ✅ Supported | Parameters: (Object: Player|Array, Comms: Comms). | | `Continue` | ✅ Supported | No parameters. | | `Create Beam Effect` | ✅ Supported | Parameters: (VisibleTo: Player|Array, Type: Beam, StartPosition: Vector, EndPosition: Vector, Color: Color, Reevaluation: EffectReeval). | -| `Create Dummy Bot` | ✅ Supported | Parameters: (Hero: Hero, Team: Team, Slot: Number|Boolean, Position: Vector, Direction: Vector). | +| `Create Dummy Bot` | ✅ Supported | Parameters: (Hero: Hero, Team: Team, Slot: Number, Position: Vector, Direction: Vector). | | `Create Effect` | ✅ Supported | Parameters: (VisibleTo: Player|Array, Type: Effect, Color: Color, Position: Vector|Player, Radius: Number, Reevaluation: EffectReeval). | | `Create HUD Text` | ✅ Supported | Parameters: (VisibleTo: Player|Array, Header: Object|String, Subheader: Object|String, Text: Object|String, Location: HudPosition, SortOrder: Number|Boolean, HeaderColor: Color, SubheaderColor: Color, TextColor: Color, Reevaluation: HudReeval, Spectators: SpecVisibility). | | `Create Homing Projectile` | ✅ Supported | Parameters: (Projectile: Projectile, Object: Player|Array, Position: Vector, Direction: Vector, Relativity: Relativity, ModifyHealth: ModifyHealth, Team: Team, Number: Number, Number: Number, Number: Number, DynamicEffect: DynamicEffect, DynamicEffect: DynamicEffect, Number: Number, Number: Number, Number: Number, Number: Number, Player: Player, Number: Number). | @@ -127,7 +127,7 @@ This document inventories the canonical Workshop actions supported by `workshop- | `Set Ability 1 Enabled` | ✅ Supported | Parameters: (Object: Player|Array, Boolean: Boolean). | | `Set Ability 2 Enabled` | ✅ Supported | Parameters: (Object: Player|Array, Boolean: Boolean). | | `Set Ability Charge` | ✅ Supported | Parameters: (Object: Player|Array, Button: Button, Number: Number). | -| `Set Ability Cooldown` | ✅ Supported | Parameters: (Object: Player|Array, Button: Button, Number: Number|Boolean). | +| `Set Ability Cooldown` | ✅ Supported | Parameters: (Object: Player|Array, Button: Button, Number: Number). | | `Set Ability Resource` | ✅ Supported | Parameters: (Object: Player|Array, Button: Button, Number: Number). | | `Set Aim Speed` | ✅ Supported | Parameters: (player: Player|Array, turnSpeedPercent: Number). | | `Set Allowed Heroes` | ✅ Supported | Parameters: (Player: Player|Array, Heroes: Hero|Array). | @@ -146,7 +146,7 @@ This document inventories the canonical Workshop actions supported by `workshop- | `Set Jump Vertical Speed` | ✅ Supported | Parameters: (Object: Player|Array, Number: Number). | | `Set Knockback Dealt` | ✅ Supported | Parameters: (Object: Player|Array, Number: Number). | | `Set Knockback Received` | ✅ Supported | Parameters: (Object: Player|Array, Number: Number). | -| `Set Match Time` | ✅ Supported | Parameters: (Number: Number|Boolean). | +| `Set Match Time` | ✅ Supported | Parameters: (Number: Number). | | `Set Max Ammo` | ✅ Supported | Parameters: (Object: Player|Array, Number: Number, Number: Number). | | `Set Max Health` | ✅ Supported | Parameters: (player: Player|Array, healthPercent: Number). | | `Set Melee Enabled` | ✅ Supported | Parameters: (Object: Player|Array, Boolean: Boolean). | @@ -180,7 +180,7 @@ This document inventories the canonical Workshop actions supported by `workshop- | `Start Forcing Player Outlines` | ✅ Supported | Parameters: (ViewedPlayers: Player|Array, ViewingPlayers: Player|Array, Visible: Boolean, Color: Color, Visibility: OutlineVisibility). | | `Start Forcing Player Position` | ✅ Supported | Parameters: (Player: Player, Position: Vector, Boolean: Boolean). | | `Start Forcing Player To Be Hero` | ✅ Supported | Parameters: (Object: Player|Array, Hero: Hero). | -| `Start Forcing Spawn Room` | ✅ Supported | Parameters: (Team: Team, Number: Number|Boolean). | +| `Start Forcing Spawn Room` | ✅ Supported | Parameters: (Team: Team, Number: Number). | | `Start Forcing Throttle` | ✅ Supported | Parameters: (Object: Player|Array, Number: Number|Boolean, Number: Number|Boolean, Number: Number|Boolean, Number: Number|Boolean, Number: Number|Boolean, Number: Number|Boolean). | | `Start Game Mode` | ✅ Supported | No parameters. | | `Start Heal Over Time` | ✅ Supported | Parameters: (Object: Player|Array, Player: Player, Number: Number, Number: Number). | @@ -224,4 +224,4 @@ This document inventories the canonical Workshop actions supported by `workshop- | `Teleport` | ✅ Supported | Parameters: (Player: Player|Array, Position: Vector). | | `Unpause Match Time` | ✅ Supported | No parameters. | | `Wait` | ✅ Supported | Parameters: (Duration: Any, WaitBehavior: Wait). | -| `Wait Until` | ✅ Supported | Parameters: (Condition: Any, Timeout: Number). | +| `Wait Until` | ✅ Supported | Parameters: (Condition: Boolean|Number, Timeout: Number). | diff --git a/docs/language-support/control-flow.md b/docs/language-support/control-flow.md index 611237c..6b60965 100644 --- a/docs/language-support/control-flow.md +++ b/docs/language-support/control-flow.md @@ -39,3 +39,28 @@ | `Abort If Condition Is True` | ✅ Supported | Terminates rule execution if all rule conditions are currently true. | | `Abort If Condition Is False` | ✅ Supported | Terminates rule execution if any rule condition is currently false. | | `Return` | 🚧 Coming soon | The audited contract is recorded, but the native parser/WIR surface does not yet represent a return action. | + +## Contextual Literal Semantics + +Literal substitutions are catalog facts attached to an exact action or value +parameter. They are normalized into canonical WIR only at that position; the +catalog does not define a global Boolean/Number, Null/Vector, or String/Array +coercion hierarchy. + +The reviewed contract includes these examples: + +- `False` and `True` may stand for numeric `0` and `1` only where the parameter + declares those substitutions. Some parameters intentionally declare only one + direction, such as `Start Forcing Spawn Room` accepting `False` for room `0`. +- Numeric `0` may stand for `Null` in selected object/position inputs, and + `Vector(0, 0, 0)` may stand for `Null` in selected position inputs. +- `Empty Array` may stand for an empty string in selected string inputs. +- `Compare` keeps its polymorphic operands. `Wait Until` accepts a numeric + condition as a documented exception, but does not normalize it to Boolean; + non-zero numbers are not treated as true by that action. Callers should use + an explicit comparison when Boolean behavior is intended. + +These distinctions are cross-checked against +[OverPy's replacement metadata](https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/types.d.ts), +its [Workshop emission replacements](https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/compiler/astToWorkshop.ts), +and its [Wait Until diagnostic](https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/compiler/functions/waitUntil.ts). diff --git a/docs/provenance.md b/docs/provenance.md index 5e4e9e5..277c6dd 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -64,7 +64,7 @@ license, reviewed) is embedded in the dataset itself and surfaced by | Chase family spellings (`Chase Global Variable Over Time`, `Chase Player Variable At Rate`, …) and their expected enum domains (`ChaseTimeReeval`, `ChaseRateReeval`) | Wright-authored OPY semantic manifest probe data (#109/#110), migrated into the canonical catalog so the standalone core resolves ambiguous bare members without any Wright tooling dependency (classes 1/5; canonical signature data is catalog-owned per ADR-0001 Decision 1). | | Rule event identities and filters (`global`, `eachPlayer`, player events, `subroutine`, `EventTeam`, and `EventPlayer`) | User-provided Workshop export at commit `d854bf01fc7bbf3b2169f67408c07a8da8989ad6` (`other.events`, `other.eventTeams`, `other.eventPlayers`, and computed `other.eventSlots`), cross-checked against documented raw Workshop event blocks; the canonical WIR keeps existing parameterless `eachPlayer` input and requires the evidenced team/player filters for other filtered events. The `Player` filter's accepted union (`EventPlayer` slot/all or a canonical `Hero`) is represented explicitly by `EventTarget` (classes 1/2). | | OSTW-exercised params/spellings and enum domains (CreateEffect, Workshop Setting, Hero/Map/Button/Icon/Operation/Rounding/InworldTextRev, …) | Pinned OSTW v3.4.0 reference probe emissions (P4/P5/P6/P6b) and the protect-ban entry-point reachable closure (class 5). | -| Parameter metadata (`paramDomains`, `paramDefaults`) | Pinned-reference probe evidence (classes 1/5), never copied from upstream game data. | +| Parameter metadata (`paramDomains`, `paramDefaults`, `paramCoercions`) | Pinned-reference probe evidence (classes 1/5), never copied from upstream game data. Contextual literal substitutions are recorded per parameter position and cross-checked against [OverPy's replacement metadata](https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/types.d.ts), [Workshop emission path](https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/compiler/astToWorkshop.ts), and [Wait Until handling](https://github.com/Zezombye/overpy/blob/2002431649cbdd7ddc3aa70bd184b598ec2820e5/src/compiler/functions/waitUntil.ts); they do not establish global type coercions. | | Action/Value parameter and return signatures | Workshop.codes structured article properties (Returns, Parameters, Type, and Default), cross-checked against the pinned OverPy metadata and static OSTW data; entries without convergent evidence remain explicitly evidence-insufficient. Representative article links are embedded in the catalog provenance. | | Action/Value signature cross-check | Representative Workshop.codes article links remain recorded in the catalog provenance; fetched snapshots and acceptance results are CI evidence, not generator or runtime inputs. | | Settings emission table (`src/settings/table.rs` and generated data files) | Hand-written fixture surface plus the reviewed `workshop-data` export at commit `d854bf01fc7bbf3b2169f67408c07a8da8989ad6`; generated entries, names, locale mappings, and source paths are committed together in the declared multi-locale projection, while pinned OverPy 9.7.10 output remains the behavioral check (classes 1/5). |