gmdbuilder 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xtreme
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: gmdbuilder
3
+ Version: 0.1.0
4
+ Summary: An unopinionated General-Purpose Geometry Dash framework for safe and easy level editing and scripting
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: gmdkit>=0.4.0
10
+ Requires-Dist: questionary>=2.1.1
11
+ Provides-Extra: dev
12
+ Requires-Dist: basedpyright; extra == "dev"
13
+ Requires-Dist: ruff; extra == "dev"
14
+ Requires-Dist: pytest>=7.0; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ ![Python Badge](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=fff&style=for-the-badge)
18
+ ![Framework](https://img.shields.io/badge/Framework-6A1B9A?style=for-the-badge)
19
+ ![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)
20
+ ![Level Editor](https://img.shields.io/badge/Level%20Editor-424242?style=for-the-badge)
21
+
22
+ # gmdbuilder
23
+ A type-safe general-purpose Python framework for pragmatic Geometry Dash level editing and scripting.
24
+
25
+ gmdbuilder lets you:
26
+ - Read & write Geometry Dash levels
27
+ - Automatically scan and protect against bugs (property types/ranges, spawn limit, etc.)
28
+ - Work directly with triggers, groups, and objects - and choose your own abstractions
29
+ - Use pre-built systems and templates to accelerate development
30
+
31
+ **gmdbuilder** is developed in collaboration with HDanke, the creator of **gmdkit** (a dependency of this framework) and his unofficial **GD Editor Docs**.
32
+
33
+ *(No overengineered language was made in the making of this project)*
34
+
35
+ ## Why Python?
36
+
37
+ Python fits surprisingly well as a language for GD scripting:
38
+ - Exceptionally good at building/verifying dictionaries (which all GD objects are)
39
+ - Operator overloading for counters and other special logic
40
+ - Any programming paradigm that you want is well supported
41
+ - Reliable type system with good debugger/type-checker tooling
42
+ - Huge package ecosystem
43
+
44
+ ## Installation
45
+ Install the latest release from PyPI (i didnt set this up yet):
46
+
47
+ ```bash
48
+ pip install gmdkit
49
+ ```
50
+
51
+ Install the latest development version from GitHub:
52
+
53
+ ```bash
54
+ pip install git+https://github.com/UHDanke/gmdkit.git
55
+ ```
56
+
57
+ ## Getting Started
58
+
59
+ ```python
60
+ from gmdbuilder import level
61
+
62
+ # This group gets deleted at level-load and automatically added to new objects at level-export
63
+ level.tag_group = 9999 # Set to 9999 by default
64
+
65
+ # From .gmd file, supports full object editing/deleting
66
+ level.from_file("example.gmd")
67
+
68
+ # From WSLiveEditor, only supports adidng objects
69
+ level.from_live_editor()
70
+
71
+ obj_list = level.objects # mutations are validated
72
+
73
+ # Object properties are in the form { "a<key number>": value }
74
+ repr(obj_list[1])
75
+
76
+ # Object ID and Property enums (all values are Literal)
77
+ from gmdbuilder.mappings.obj_prop import ObjProp
78
+ from gmdbuilder.mappings.obj_id import ObjId
79
+
80
+ for obj in obj_list:
81
+ if obj[ObjProp.ID] == ObjID.Trigger.MOVE:
82
+ obj[ObjProp.GROUPS] = {}
83
+ elif obj[ObjProp.ID] == ObjID.Trigger.COUNT:
84
+ obj_list.remove(obj)
85
+
86
+ # Translates to { a1: 1 }
87
+ block = from_raw_object({1: 1})
88
+
89
+ obj_list.delete_where(block)
90
+ obj_list.delete_where(lambda obj: obj[ObjProp.ID] == 1)
91
+
92
+ # Translates to { a1: 1611, a2: 50, a3: 45 }
93
+ object = from_object_string("1,1611,2,50,3,45;", obj_type=CountType)
94
+ object[ObjProp.Trigger.Count.ACTIVATE_GROUP] = True
95
+
96
+ # Export object edits, deletions and additions
97
+ level.export_to_file(file_path="example_updated.gmd")
98
+
99
+ # Export added objects to WSLiveEditor
100
+ level.export_to_live_editor()
101
+ ```
@@ -0,0 +1,85 @@
1
+ ![Python Badge](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=fff&style=for-the-badge)
2
+ ![Framework](https://img.shields.io/badge/Framework-6A1B9A?style=for-the-badge)
3
+ ![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)
4
+ ![Level Editor](https://img.shields.io/badge/Level%20Editor-424242?style=for-the-badge)
5
+
6
+ # gmdbuilder
7
+ A type-safe general-purpose Python framework for pragmatic Geometry Dash level editing and scripting.
8
+
9
+ gmdbuilder lets you:
10
+ - Read & write Geometry Dash levels
11
+ - Automatically scan and protect against bugs (property types/ranges, spawn limit, etc.)
12
+ - Work directly with triggers, groups, and objects - and choose your own abstractions
13
+ - Use pre-built systems and templates to accelerate development
14
+
15
+ **gmdbuilder** is developed in collaboration with HDanke, the creator of **gmdkit** (a dependency of this framework) and his unofficial **GD Editor Docs**.
16
+
17
+ *(No overengineered language was made in the making of this project)*
18
+
19
+ ## Why Python?
20
+
21
+ Python fits surprisingly well as a language for GD scripting:
22
+ - Exceptionally good at building/verifying dictionaries (which all GD objects are)
23
+ - Operator overloading for counters and other special logic
24
+ - Any programming paradigm that you want is well supported
25
+ - Reliable type system with good debugger/type-checker tooling
26
+ - Huge package ecosystem
27
+
28
+ ## Installation
29
+ Install the latest release from PyPI (i didnt set this up yet):
30
+
31
+ ```bash
32
+ pip install gmdkit
33
+ ```
34
+
35
+ Install the latest development version from GitHub:
36
+
37
+ ```bash
38
+ pip install git+https://github.com/UHDanke/gmdkit.git
39
+ ```
40
+
41
+ ## Getting Started
42
+
43
+ ```python
44
+ from gmdbuilder import level
45
+
46
+ # This group gets deleted at level-load and automatically added to new objects at level-export
47
+ level.tag_group = 9999 # Set to 9999 by default
48
+
49
+ # From .gmd file, supports full object editing/deleting
50
+ level.from_file("example.gmd")
51
+
52
+ # From WSLiveEditor, only supports adidng objects
53
+ level.from_live_editor()
54
+
55
+ obj_list = level.objects # mutations are validated
56
+
57
+ # Object properties are in the form { "a<key number>": value }
58
+ repr(obj_list[1])
59
+
60
+ # Object ID and Property enums (all values are Literal)
61
+ from gmdbuilder.mappings.obj_prop import ObjProp
62
+ from gmdbuilder.mappings.obj_id import ObjId
63
+
64
+ for obj in obj_list:
65
+ if obj[ObjProp.ID] == ObjID.Trigger.MOVE:
66
+ obj[ObjProp.GROUPS] = {}
67
+ elif obj[ObjProp.ID] == ObjID.Trigger.COUNT:
68
+ obj_list.remove(obj)
69
+
70
+ # Translates to { a1: 1 }
71
+ block = from_raw_object({1: 1})
72
+
73
+ obj_list.delete_where(block)
74
+ obj_list.delete_where(lambda obj: obj[ObjProp.ID] == 1)
75
+
76
+ # Translates to { a1: 1611, a2: 50, a3: 45 }
77
+ object = from_object_string("1,1611,2,50,3,45;", obj_type=CountType)
78
+ object[ObjProp.Trigger.Count.ACTIVATE_GROUP] = True
79
+
80
+ # Export object edits, deletions and additions
81
+ level.export_to_file(file_path="example_updated.gmd")
82
+
83
+ # Export added objects to WSLiveEditor
84
+ level.export_to_live_editor()
85
+ ```
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gmdbuilder"
7
+ version = "0.1.0"
8
+ description = "An unopinionated General-Purpose Geometry Dash framework for safe and easy level editing and scripting"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "gmdkit>=0.4.0",
14
+ "questionary>=2.1.1"
15
+ ]
16
+
17
+ # Optional dependencies for development
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "basedpyright",
21
+ "ruff",
22
+ "pytest>=7.0",
23
+ ]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.basedpyright]
29
+ typeCheckingMode = "strict"
30
+ reportAny = "none"
31
+ reportExplicitAny = "none"
32
+ reportMissingTypeStubs = "none"
33
+
34
+ [tool.ruff.lint]
35
+ ignore = [
36
+ "E701", # Multiple statements on one line (colon)
37
+ "E702", # Multiple statements on one line (semicolon)
38
+ "E731", # Do not assign a lambda expression, use a def
39
+ "E741", # Ambiguous variable name (allows single letter vars like l, o, I)
40
+ # "E402", # Import not at top line
41
+ # "F401", # Imported but unused (useful for __init__.py files)
42
+ # "F403", # Star imports (from module import *)
43
+ # "B008", # Do not perform function call in argument defaults
44
+ # "B904", # Use raise from to specify exception cause
45
+ # "N802", # Function name should be lowercase
46
+ # "N803", # Argument name should be lowercase
47
+ # "N806", # Variable in function should be lowercase
48
+ ]
49
+
50
+ [tool.pytest.ini_options]
51
+ addopts = "-qv"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,158 @@
1
+ """Core utilities for working with ObjectType dicts."""
2
+
3
+ from functools import lru_cache
4
+ from typing import Any, Literal, TypeVar, overload
5
+ from gmdkit.models.object import Object as KitObject
6
+ from gmdkit.models.prop.list import IDList, RemapList
7
+
8
+
9
+ from gmdbuilder.mappings import obj_prop
10
+ from gmdbuilder.validation import validate
11
+ import gmdbuilder.object_types as td
12
+
13
+ ObjectType = td.ObjectType
14
+
15
+ T = TypeVar('T', bound=ObjectType)
16
+
17
+
18
+ class Object(dict[str, Any]):
19
+ """
20
+ Note: Not for users to call directly
21
+
22
+ The actual dict implementation hidden behind the ObjectType TypedDict
23
+
24
+ This is to intercept & validate mutations of objects and add new helpers.
25
+ """
26
+ __slots__ = ("_obj_id",)
27
+
28
+ def __init__(self, obj_id: int):
29
+ super().__init__()
30
+ self._obj_id = int(obj_id)
31
+ super().__setitem__("a1", self._obj_id)
32
+
33
+ def __setitem__(self, k: str, v: Any):
34
+ validate(self._obj_id, k, v)
35
+ if k == obj_prop.ID:
36
+ self._obj_id = int(v)
37
+ super().__setitem__(k, v)
38
+
39
+ def update(self, *args: Any, **kwargs: Any):
40
+ # Construct items dict from args and kwargs
41
+ items: dict[str, Any]
42
+ if args:
43
+ if len(args) != 1:
44
+ raise TypeError(f"update() takes at most 1 positional argument ({len(args)} given)")
45
+ __m = args[0]
46
+ items = dict(__m)
47
+ items.update(kwargs)
48
+ else:
49
+ items = dict(kwargs)
50
+
51
+ for k, v in items.items():
52
+ validate(self._obj_id, k, v)
53
+ if obj_prop.ID in items:
54
+ self._obj_id = int(items[obj_prop.ID])
55
+ super().update(items)
56
+
57
+
58
+
59
+ @lru_cache(maxsize=1024)
60
+ def _to_raw_key_cached(key: str) -> int | str:
61
+ if key.startswith('k'):
62
+ return key
63
+ if key.startswith('a'):
64
+ tail = key[1:]
65
+ if tail.isdigit():
66
+ return int(tail)
67
+ raise ValueError()
68
+
69
+ def to_kit_object(obj: ObjectType) -> KitObject:
70
+ """
71
+ Convert ObjectType to a new gmdkit int-keyed dict for gmdkit or debugging.
72
+
73
+ Example:
74
+ {'a1': 900, 'a2': 50, 'a57': {2}} → {1: 900, 2: 50, 57: IDList([2])}
75
+ """
76
+ raw: dict[int|str, Any] = {}
77
+ for k, v in obj.items():
78
+ match k:
79
+ case obj_prop.GROUPS:
80
+ raw[_to_raw_key_cached(k)] = IDList(v)
81
+ case obj_prop.PARENT_GROUPS:
82
+ raw[_to_raw_key_cached(k)] = IDList(v)
83
+ case obj_prop.Trigger.Spawn.REMAPS:
84
+ raw[_to_raw_key_cached(k)] = RemapList.from_dict(v) # type: ignore
85
+ case _:
86
+ try:
87
+ raw[_to_raw_key_cached(k)] = v
88
+ except ValueError as e:
89
+ raise ValueError(f"Object has bad/unsupported key {k!r}:\n{obj=}") from e
90
+ return KitObject(raw)
91
+
92
+
93
+ @lru_cache(maxsize=1024)
94
+ def _from_raw_key_cached(key: object) -> str:
95
+ if isinstance(key, int):
96
+ return f"a{key}"
97
+ if isinstance(key, str) and (key.startswith("a") or key.startswith("k")):
98
+ return key
99
+ raise ValueError()
100
+
101
+
102
+ def from_kit_object(obj: dict[int|str, Any]) -> ObjectType:
103
+ """
104
+ Convert gmdkit object dict to object typeddict.
105
+
106
+ Example:
107
+ {1: 900, 2: 50, 57: IDList([2])} → {a1: 900, a2: 50, a57: {2}}
108
+ """
109
+ new = {}
110
+ for k, v in obj.items():
111
+ match k:
112
+ case 57:
113
+ new[obj_prop.GROUPS] = set(v) if v else set()
114
+ case 274:
115
+ new[obj_prop.PARENT_GROUPS] = set(v) if v else set()
116
+ case 442:
117
+ new[obj_prop.Trigger.Spawn.REMAPS] = v.to_dict()
118
+ case 52:
119
+ new[obj_prop.Trigger.Pulse.TARGET_TYPE] = bool(v)
120
+ case _:
121
+ try:
122
+ new[_from_raw_key_cached(k)] = v
123
+ except ValueError as e:
124
+ raise ValueError(f"Object has bad/unsupported key {k!r}: \n{obj=}") from e
125
+ return new # type: ignore
126
+
127
+
128
+ @overload
129
+ def from_object_string(obj_string: str) -> ObjectType: ...
130
+ @overload
131
+ def from_object_string(obj_string: str, *, obj_type: type[T]) -> T: ...
132
+ def from_object_string(obj_string: str, *, obj_type: type[ObjectType] | None = None) -> ObjectType:
133
+ """
134
+ Convert GD level object string to ObjectType.
135
+
136
+ Example:
137
+ "1,1,2,50,3,45;" → {'a1': 1, 'a2': 50, 'a3': 45}
138
+ """
139
+ return from_kit_object(KitObject.from_string(obj_string)) # type: ignore
140
+
141
+
142
+ @overload
143
+ def new_object(object_id: Literal[3016]) -> td.AdvFollowType: ...
144
+ @overload
145
+ def new_object(object_id: Literal[1346]) -> td.RotateType: ...
146
+ @overload
147
+ def new_object(object_id: Literal[901]) -> td.MoveType: ...
148
+ @overload
149
+ def new_object(object_id: int) -> ObjectType: ...
150
+ def new_object(object_id: int) -> ObjectType:
151
+ """
152
+ Create a new Object with defaults from gmdkit.
153
+
154
+ Returns:
155
+ ObjectType dict with default properties (using 'a<num>' keys)
156
+ """
157
+ # Convert from gmdkit's {1: val, 2: val} to our {'a1': val, 'a2': val}
158
+ return from_kit_object(KitObject.default(object_id)) # type: ignore
@@ -0,0 +1,222 @@
1
+
2
+ from typing import Any, Callable, Literal, Union, get_args, get_origin, Required
3
+ from gmdbuilder.mappings import obj_id, obj_prop
4
+ from gmdbuilder import object_types as td
5
+
6
+ ObjectType = td.ObjectType
7
+ tid = obj_id.Trigger
8
+ cid = obj_id.Collectible
9
+ ID_TO_TYPEDDICT: dict[int, type[ObjectType]] = {
10
+ tid.ALPHA: td.AlphaType,
11
+ tid.ADV_FOLLOW: td.AdvFollowType,
12
+ tid.ADV_RANDOM: td.AdvRandomType,
13
+ tid.ANIMATE: td.AnimateType,
14
+ tid.ANIMATE_KEYFRAME: td.AnimateKeyframeType,
15
+ tid.ARROW: td.ArrowType,
16
+ tid.BG_EFFECT_ENABLE: td.TriggerType,
17
+ tid.BG_EFFECT_DISABLE: td.TriggerType,
18
+ tid.BPM: td.BpmType,
19
+ tid.CAMERA_EDGE: td.CameraEdgeType,
20
+ tid.CAMERA_GUIDE: td.CameraGuideType,
21
+ tid.CAMERA_MODE: td.CameraModeType,
22
+ tid.CHANGE_BG: td.ChangeBgType,
23
+ tid.CHANGE_GR: td.ChangeGrType,
24
+ tid.CHANGE_MG: td.ChangeMgType,
25
+ tid.CHECKPOINT: td.CheckpointType,
26
+ tid.COUNT: td.CountType,
27
+ tid.COLOR: td.ColorType,
28
+ tid.COLLISION: td.CollisionType,
29
+ tid.COLLISION_BLOCK: td.CollisionBlockType,
30
+ tid.EDIT_ADV_FOLLOW: td.EditAdvFollowType,
31
+ tid.EDIT_MG: td.MgEditType,
32
+ tid.EVENT: td.EventType,
33
+ tid.EDIT_SFX: td.SfxType,
34
+ tid.EDIT_SONG: td.SongType,
35
+ tid.END: td.EndType,
36
+ tid.FOLLOW: td.FollowType,
37
+ tid.FOLLOW_PLAYER_Y: td.FollowPlayerYType,
38
+ tid.FORCE_BLOCK: td.ForceBlockType,
39
+ tid.GAMEPLAY_OFFSET: td.GameplayOffsetType,
40
+ tid.GRADIENT: td.GradientType,
41
+ tid.GRAVITY: td.GravityType,
42
+ tid.INSTANT_COLLISION: td.InstantCollisionType,
43
+ tid.INSTANT_COUNT: td.InstantCountType,
44
+ tid.ITEM_COMPARE: td.ItemCompareType,
45
+ tid.ITEM_EDIT: td.ItemEditType,
46
+ tid.ITEM_PERSIST: td.ItemPersistType,
47
+ tid.KEYFRAME: td.KeyframeType,
48
+ tid.LINK_VISIBLE: td.LinkVisibleType,
49
+ tid.MG_SPEED: td.MgSpeedType,
50
+ tid.MOVE: td.MoveType,
51
+ tid.ON_DEATH: td.OnDeathType,
52
+ tid.OPTIONS: td.OptionsType,
53
+ tid.OFFSET_CAMERA: td.OffsetCameraType,
54
+ tid.PICKUP: td.PickupType,
55
+ tid.PLAYER_CONTROL: td.PlayerControlType,
56
+ tid.PULSE: td.PulseType,
57
+ tid.RANDOM: td.RandomType,
58
+ tid.RESET: td.ResetType,
59
+ tid.ROTATE: td.RotateType,
60
+ tid.ROTATE_CAMERA: td.RotateCameraType,
61
+ tid.SCALE: td.ScaleType,
62
+ tid.SEQUENCE: td.SequenceType,
63
+ tid.SFX: td.SfxType,
64
+ tid.STOP: td.StopType,
65
+ tid.SHAKE: td.ShakeType,
66
+ tid.SONG: td.SongType,
67
+ tid.SPAWN: td.SpawnType,
68
+ tid.SPAWN_PARTICLE: td.SpawnParticleType,
69
+ tid.STATE_BLOCK: td.StateBlockType,
70
+ tid.STATIC_CAMERA: td.StaticCameraType,
71
+ tid.TELEPORT: td.TeleportType,
72
+ tid.TIME: td.TimeType,
73
+ tid.TIMEWARP: td.TimewarpType,
74
+ tid.TIME_CONTROL: td.TimeControlType,
75
+ tid.TIME_EVENT: td.TimeEventType,
76
+ tid.TOGGLE: td.ToggleType,
77
+ tid.TOGGLE_BLOCK: td.ToggleBlockType,
78
+ tid.TOUCH: td.TouchType,
79
+ tid.UI: td.UiType,
80
+ tid.ZOOM_CAMERA: td.ZoomCameraType,
81
+ tid.PLAYER_HIDE: td.TriggerType,
82
+ tid.PLAYER_SHOW: td.TriggerType,
83
+ tid.Enter.MOVE: td.EffectType,
84
+ tid.EnterPreset.FADE_ONLY: td.TriggerType,
85
+ cid.KEY: td.CollectibleType,
86
+ cid.USER_COIN: td.AnimatedType,
87
+ cid.SMALL_COIN: td.CollectibleType,
88
+ obj_id.TEXT: td.TextType,
89
+ obj_id.ITEM_LABEL: td.ItemLabelType,
90
+ obj_id.PARTICLE_OBJECT: td.ParticleType,
91
+ obj_id.Orb.BLACK: td.TriggerType,
92
+ obj_id.Orb.BLUE: td.TriggerType,
93
+ obj_id.Orb.GREEN: td.TriggerType,
94
+ obj_id.Orb.RED: td.TriggerType,
95
+ obj_id.Orb.YELLOW: td.TriggerType,
96
+ obj_id.Orb.PINK: td.TriggerType,
97
+ obj_id.Orb.SPIDER: td.TriggerType,
98
+ obj_id.Orb.TELEPORT: td.TriggerType,
99
+ obj_id.Orb.TOGGLE: td.ToggleBlockType,
100
+ obj_id.Orb.DASH_GREEN: td.DashType,
101
+ obj_id.Orb.DASH_PINK: td.DashType,
102
+ obj_id.Portal.Teleport.ENTER: td.PortalType,
103
+ obj_id.Portal.Teleport.EXIT: td.ExitPortalType,
104
+ obj_id.Portal.Teleport.LINKED: td.PortalType,
105
+ 3002: td.AnimatedType,
106
+ 1020: td.SawType,
107
+ 1582: td.SawType,
108
+ 1709: td.SawType,
109
+ }
110
+ """Unfinished mapping of Object IDs to non-common Object TypedDicts"""
111
+
112
+
113
+ def _append_types(cls: object, obj_type: type[ObjectType]):
114
+ if isinstance(cls, list):
115
+ for c in cls:
116
+ if isinstance(c, int):
117
+ ID_TO_TYPEDDICT[c] = obj_type
118
+ else:
119
+ for obj in [v for v in vars(cls).values() if isinstance(v, int)]:
120
+ ID_TO_TYPEDDICT[obj] = obj_type
121
+
122
+ _append_types(obj_id.Pad, td.TriggerType)
123
+ _append_types(obj_id.Portal, td.GamemodePortalType)
124
+ _append_types(obj_id.Speed, td.TriggerType)
125
+ _append_types(obj_id.Modifier, td.TriggerType)
126
+ _append_types(obj_id.Trigger.Shader, td.ShaderType)
127
+ _append_types(obj_id.Trigger.Area, td.EffectType)
128
+ _append_types([i for i in range(920, 925)], td.AnimatedType)
129
+ _append_types([i for i in range(1849, 1859)], td.AnimatedType)
130
+ _append_types([1936, 1937, 1938, 1939], td.AnimatedType)
131
+ _append_types([i for i in range(2020, 2056)], td.AnimatedType)
132
+ _append_types([2864, 2865], td.AnimatedType)
133
+ _append_types([i for i in range(2867, 2895)], td.AnimatedType)
134
+ _append_types([3000, 3001, 3002], td.AnimatedType)
135
+ _append_types([85, 86, 87, 88, 89], td.SawType)
136
+ _append_types([97, 98], td.SawType)
137
+ _append_types([137, 138, 139], td.SawType)
138
+ _append_types([154, 155, 156], td.SawType)
139
+ _append_types([i for i in range(180, 189)], td.SawType)
140
+ _append_types([222, 223, 224], td.SawType)
141
+ _append_types([375, 376, 377, 378], td.SawType)
142
+ _append_types([i for i in range(394, 400)], td.SawType)
143
+
144
+ ID_TO_ALLOWED_KEYS = {
145
+ k: set(v.__required_keys__) | set(v.__optional_keys__)
146
+ for k, v in ID_TO_TYPEDDICT.items()
147
+ }
148
+
149
+ COMMON_ALLOWED_KEYS = td.ObjectType.__required_keys__ | td.ObjectType.__optional_keys__
150
+
151
+ UNHASHABLE_VALUE_KEYS = {
152
+ obj_prop.GROUPS,
153
+ obj_prop.PARENT_GROUPS,
154
+ obj_prop.Trigger.Spawn.REMAPS,
155
+ obj_prop.Trigger.AdvRandom.TARGETS,
156
+ obj_prop.Trigger.Event.EVENTS,
157
+ obj_prop.Trigger.Sequence.SEQUENCE
158
+ }
159
+
160
+
161
+ hashable_value_key_to_isinstance: dict[str, Callable[[Any], bool]] = {}
162
+
163
+
164
+ # ACTUAL FUNCTION WE CARE ABOUT
165
+ def value_is_correct_type(key: str, v: Any) -> bool:
166
+ """Check if value v is of the correct type for key."""
167
+ if key in UNHASHABLE_VALUE_KEYS:
168
+ raise ValueError(f"Key {key} has unhashable value, cannot be type checked.")
169
+ if key not in hashable_value_key_to_isinstance:
170
+ raise ValueError(f"No type information available for key {key!r} : {v!r}")
171
+ return hashable_value_key_to_isinstance[key](v)
172
+
173
+
174
+ def _type_to_isinstance(typ: Any) -> Callable[[Any], bool]:
175
+ """Convert a type annotation to a runtime check function."""
176
+ origin = get_origin(typ)
177
+
178
+ if origin is Required:
179
+ inner_type = get_args(typ)[0]
180
+ return _type_to_isinstance(inner_type)
181
+ if origin is Union:
182
+ checks = [_type_to_isinstance(t) for t in get_args(typ)]
183
+ return lambda v: any(check(v) for check in checks)
184
+ elif origin is Literal:
185
+ allowed = get_args(typ)
186
+ return lambda v: v in allowed
187
+ elif typ is Any:
188
+ return lambda v: True
189
+ elif isinstance(typ, type):
190
+ if typ is bool:
191
+ return lambda v: isinstance(v, bool)
192
+ elif typ is float:
193
+ return lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
194
+ elif typ is int:
195
+ return lambda v: isinstance(v, int) and not isinstance(v, bool)
196
+ else:
197
+ return lambda v: isinstance(v, typ)
198
+
199
+ raise ValueError(f"Unsupported type annotation: {typ!r}")
200
+
201
+
202
+ def _typeddict_to_isinstance(typeddict: type):
203
+ """Extract type annotations from a TypedDict and populate runtime checks."""
204
+ annotations: dict[str, Any] = {}
205
+
206
+ for base in reversed(typeddict.__mro__):
207
+ if hasattr(base, '__annotations__'):
208
+ annotations.update(base.__annotations__)
209
+
210
+ for key, typ in annotations.items():
211
+ if key in hashable_value_key_to_isinstance: continue
212
+ if key in UNHASHABLE_VALUE_KEYS: continue
213
+
214
+ try:
215
+ hashable_value_key_to_isinstance[key] = _type_to_isinstance(typ)
216
+ except ValueError as e:
217
+ print(f"Warning: Skipping key {key!r} with type {typ}: {e}")
218
+
219
+
220
+ _typeddict_to_isinstance(td.ObjectType)
221
+ for tyd in ID_TO_TYPEDDICT.values():
222
+ _typeddict_to_isinstance(tyd)