sctmgtool 0.3.1__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 Łukasz Gieryk
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,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: sctmgtool
3
+ Version: 0.3.1
4
+ Summary: A combat analysis tool for StarCraft: Tabletop Miniatures Game
5
+ Author-email: Łukasz Gieryk <108897150+lgieryk@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/lgieryk/sctmgtool
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ License-File: src/sctmgtool/units/NOTICE.txt
12
+ Requires-Dist: matplotlib
13
+ Requires-Dist: numpy
14
+ Requires-Dist: platformdirs
15
+ Requires-Dist: customtkinter
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Requires-Dist: pytest-cov; extra == "dev"
19
+ Requires-Dist: ruff; extra == "dev"
20
+ Requires-Dist: pyinstaller; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # SC:TMG Tool
24
+
25
+ A combat analysis tool for StarCraft: Tabletop Miniatures Game. It calculates
26
+ the expected outcome of clashes between units to help players with list-building
27
+ decisions.
28
+
29
+ **Disclaimer:** This is an unofficial fan-made project. It is not affiliated
30
+ with, sponsored by, or endorsed by the owners of the StarCraft or StarCraft:
31
+ Tabletop Miniatures Game trademarks.
32
+
33
+ Any references to unit names, factions, stats, or other game data are used
34
+ solely for identification and analytical purposes under fair use — see
35
+ [NOTICE.txt](src/sctmgtool/units/NOTICE.txt) for details.
36
+
37
+ ![Screenshot](https://raw.githubusercontent.com/lgieryk/repo/sctmgtool/main/assets/screenshot.png)
38
+
39
+ ## A word from the author
40
+ Is that UpgradeX, which gives TAG-Y, worth Z minerals? I wanted to know, so I
41
+ created SC:TMG Tool - an unofficial, poorly written, and _slow_ application
42
+ written in my spare time.
43
+
44
+ The main idea was to visualize how an upgrade affects the outcome of a clash
45
+ between two units. So if an upgrade does not directly translate to a lost (or
46
+ saved) hit point, it's not included.
47
+
48
+ While the rules of SC:TMG are not that complex, they do not translate easily
49
+ into a computer program. I applied multiple shortcuts to achieve the
50
+ functionality I needed most. And - obviously - I might have gotten a more
51
+ complex rule wrong. Typos (and a wrong stat here and there) are also to be
52
+ expected.
53
+
54
+ The tool is slow. It happens so as the result of every clash is simulated 10000
55
+ times. The results are saved, though, so returning to an already simulated clash
56
+ is slightly faster.
57
+
58
+ ## Installation
59
+ ### From PyPI
60
+ python3 -m venv env
61
+ source env/bin/activate
62
+ pip install --upgrade pip
63
+ pip install sctmgtool
64
+ python3 -m sctmgtool
65
+
66
+ ### Binary package for Windows
67
+ Download a precompiled binary for Windows from
68
+ https://github.com/lgieryk/sctmgtool/releases
69
+
70
+ ### Installation from sources
71
+ git clone https://github.com/lgieryk/sctmgtool.git
72
+ cd sctmgtool
73
+ python3 -m venv env
74
+ source env/bin/activate
75
+ pip install -e .
76
+ python3 -m sctmgtool
77
+
78
+ You may need to install some additional system packages:
79
+
80
+ apt-get update && apt-get install python3-tk
81
+
82
+ ## License
83
+ - Source code: [MIT License](LICENSE)
84
+ - Data files in `src/sctmgtool/units/`: distributed under separate terms, see
85
+ [NOTICE.txt](src/sctmgtool/units/NOTICE.txt).
@@ -0,0 +1,63 @@
1
+ # SC:TMG Tool
2
+
3
+ A combat analysis tool for StarCraft: Tabletop Miniatures Game. It calculates
4
+ the expected outcome of clashes between units to help players with list-building
5
+ decisions.
6
+
7
+ **Disclaimer:** This is an unofficial fan-made project. It is not affiliated
8
+ with, sponsored by, or endorsed by the owners of the StarCraft or StarCraft:
9
+ Tabletop Miniatures Game trademarks.
10
+
11
+ Any references to unit names, factions, stats, or other game data are used
12
+ solely for identification and analytical purposes under fair use — see
13
+ [NOTICE.txt](src/sctmgtool/units/NOTICE.txt) for details.
14
+
15
+ ![Screenshot](https://raw.githubusercontent.com/lgieryk/repo/sctmgtool/main/assets/screenshot.png)
16
+
17
+ ## A word from the author
18
+ Is that UpgradeX, which gives TAG-Y, worth Z minerals? I wanted to know, so I
19
+ created SC:TMG Tool - an unofficial, poorly written, and _slow_ application
20
+ written in my spare time.
21
+
22
+ The main idea was to visualize how an upgrade affects the outcome of a clash
23
+ between two units. So if an upgrade does not directly translate to a lost (or
24
+ saved) hit point, it's not included.
25
+
26
+ While the rules of SC:TMG are not that complex, they do not translate easily
27
+ into a computer program. I applied multiple shortcuts to achieve the
28
+ functionality I needed most. And - obviously - I might have gotten a more
29
+ complex rule wrong. Typos (and a wrong stat here and there) are also to be
30
+ expected.
31
+
32
+ The tool is slow. It happens so as the result of every clash is simulated 10000
33
+ times. The results are saved, though, so returning to an already simulated clash
34
+ is slightly faster.
35
+
36
+ ## Installation
37
+ ### From PyPI
38
+ python3 -m venv env
39
+ source env/bin/activate
40
+ pip install --upgrade pip
41
+ pip install sctmgtool
42
+ python3 -m sctmgtool
43
+
44
+ ### Binary package for Windows
45
+ Download a precompiled binary for Windows from
46
+ https://github.com/lgieryk/sctmgtool/releases
47
+
48
+ ### Installation from sources
49
+ git clone https://github.com/lgieryk/sctmgtool.git
50
+ cd sctmgtool
51
+ python3 -m venv env
52
+ source env/bin/activate
53
+ pip install -e .
54
+ python3 -m sctmgtool
55
+
56
+ You may need to install some additional system packages:
57
+
58
+ apt-get update && apt-get install python3-tk
59
+
60
+ ## License
61
+ - Source code: [MIT License](LICENSE)
62
+ - Data files in `src/sctmgtool/units/`: distributed under separate terms, see
63
+ [NOTICE.txt](src/sctmgtool/units/NOTICE.txt).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools.dynamic]
6
+ version = {attr = "sctmgtool.__version__"}
7
+
8
+ [project]
9
+ name = "sctmgtool"
10
+ description = "A combat analysis tool for StarCraft: Tabletop Miniatures Game"
11
+ authors = [
12
+ { name="Łukasz Gieryk", email="108897150+lgieryk@users.noreply.github.com" },
13
+ ]
14
+ license = "MIT"
15
+ license-files = ["LICENSE", "src/sctmgtool/units/NOTICE.txt"]
16
+ dynamic = ["version"]
17
+ requires-python = ">=3.12"
18
+ readme = "README.md"
19
+ dependencies = [
20
+ "matplotlib",
21
+ "numpy",
22
+ "platformdirs",
23
+ "customtkinter",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest",
29
+ "pytest-cov",
30
+ "ruff",
31
+ "pyinstaller",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/lgieryk/sctmgtool"
36
+
37
+ [tool.ruff]
38
+ line-length = 160
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Łukasz Gieryk
3
+
4
+ __version__ = "0.3.1"
@@ -0,0 +1,15 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Łukasz Gieryk
3
+
4
+ import logging
5
+ from multiprocessing import freeze_support
6
+ from sctmgtool.tools import process_unit_list
7
+ from sctmgtool.tk import tkinter_main
8
+ from sctmgtool.units import ALL_UNITS
9
+
10
+
11
+ if __name__ == "__main__":
12
+ freeze_support()
13
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
14
+ process_unit_list(ALL_UNITS)
15
+ tkinter_main(ALL_UNITS)
@@ -0,0 +1,257 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Łukasz Gieryk
3
+
4
+ from enum import Flag, Enum, auto
5
+ from typing import NamedTuple, Callable
6
+ from dataclasses import dataclass
7
+ import enum
8
+
9
+ # pylint: disable=invalid-name
10
+
11
+
12
+ class Faction(Flag):
13
+ Zerg = auto()
14
+ Terran = auto()
15
+ Protoss = auto()
16
+
17
+
18
+ # fmt: off
19
+ class Tag(Flag):
20
+ _None = 0b0000000000000000000000000000000000000000000000
21
+ Biological = 0b0000000000000000000000000000000000000000000001
22
+ Mechanical = 0b0000000000000000000000000000000000000000000010
23
+ Flying = 0b0000000000000000000000000000000000000000000100
24
+ Ground = 0b0000000000000000000000000000000000000000001000
25
+ Light = 0b0000000000000000000000000000000000000000010000
26
+ Armoured = 0b0000000000000000000000000000000000000000100000
27
+ Psionic = 0b0000000000000000000000000000000000000001000000
28
+ Sidearm = 0b0000000000000000000000000000000000000010000000
29
+ Specialist = 0b0000000000000000000000000000000000000100000000
30
+ _AntiEvade = 0b0000000000000000000000000000000000001000000000
31
+ AntiEvade1 = 0b0000000000000000000000000000000000011000000000
32
+ AntiEvade2 = 0b0000000000000000000000000000000000101000000000
33
+ AntiEvade3 = 0b0000000000000000000000000000000001001000000000
34
+ _Precision = 0b0000000000000000000000000000000010000000000000
35
+ Precision1 = 0b0000000000000000000000000000000110000000000000
36
+ Precision2 = 0b0000000000000000000000000000001010000000000000
37
+ Precision3 = 0b0000000000000000000000000000010010000000000000
38
+ Pinpoint = 0b0000000000000000000000000000100000000000000000
39
+ _LockedIn = 0b0000000000000000000000000001000000000000000000
40
+ LockedIn6 = 0b0000000000000000000001000001000000000000000000
41
+ _PierceArmoured = 0b0000000000000000000010000000000000000000000000
42
+ PierceArmoured2 = 0b0000000000000000001010000000000000000000000000
43
+ PierceArmoured3 = 0b0000000000000000010010000000000000000000000000
44
+ _PierceLight = 0b0000000000000000100000000000000000000000000000
45
+ PierceLight2 = 0b0000000000000010100000000000000000000000000000
46
+ _Tough = 0b0000000000001000000000000000000000000000000000
47
+ Tough1 = 0b0000000000011000000000000000000000000000000000
48
+ Tough2 = 0b0000000000101000000000000000000000000000000000
49
+ Unique = 0b0000000001000000000000000000000000000000000000
50
+ IndirectFire = 0b0000000010000000000000000000000000000000000000
51
+ Bulky = 0b0000000100000000000000000000000000000000000000
52
+ BurstFire = 0b0000001000000000000000000000000000000000000000
53
+ _CriticalHit = 0b0000010000000000000000000000000000000000000000
54
+ CriticalHit2 = 0b0001010000000000000000000000000000000000000000
55
+ Instant = 0b0010000000000000000000000000000000000000000000
56
+ # fmt: on
57
+
58
+ def __str__(self):
59
+ masked_value = self.value & ~Tag(Tag._AntiEvade | Tag._Precision | Tag._PierceArmoured | Tag._Tough).value
60
+ first = True
61
+ ret = ""
62
+ for name, member in Tag.__members__.items():
63
+ if not name.startswith("_") and member.value & masked_value:
64
+ if not first:
65
+ ret += "|"
66
+ first = False
67
+ ret += name
68
+ return ret
69
+
70
+ @staticmethod
71
+ def _lsb(val):
72
+ return (val & -val).bit_length() - 1
73
+
74
+ def sibling_bits(self):
75
+ siblings = 0
76
+ for _name, member in Tag.__members__.items():
77
+ if member.value & self.value:
78
+ siblings |= member.value
79
+ return siblings
80
+
81
+ def _extract(self, tag):
82
+ if tag not in self:
83
+ return 0
84
+ masked = ~((tag.value << 1) - 1) & self.value
85
+ return Tag._lsb(masked) - Tag._lsb(tag.value)
86
+
87
+ def anti_evade(self):
88
+ return self._extract(Tag._AntiEvade)
89
+
90
+ def precision(self):
91
+ return self._extract(Tag._Precision)
92
+
93
+ def pierce_armoured(self):
94
+ return self._extract(Tag._PierceArmoured)
95
+
96
+ def pierce_light(self):
97
+ return self._extract(Tag._PierceLight)
98
+
99
+ def tough(self):
100
+ return self._extract(Tag._Tough)
101
+
102
+ def critical_hit(self):
103
+ return self._extract(Tag._CriticalHit)
104
+
105
+
106
+ class SurgeDie(Enum):
107
+ @enum.nonmember
108
+ class _Inner(NamedTuple):
109
+ d3:int
110
+ d6:int
111
+ add:int
112
+
113
+ D3 = _Inner(1, 0, 0)
114
+ D6 = _Inner(0, 1, 0)
115
+ D3P1 = _Inner(1, 0, 1)
116
+
117
+ def __str__(self):
118
+ return self.name
119
+
120
+
121
+ @dataclass
122
+ class Weapon():
123
+ name: str
124
+ range: (int | str)
125
+ target: Tag
126
+ rate_of_attack: int
127
+ hit: int
128
+ surge: Tag
129
+ surge_die: SurgeDie
130
+ damage: int
131
+ exchange_for:str = ""
132
+ # pylint: disable-next=protected-access
133
+ tags:Tag = Tag._None
134
+
135
+ def add_tag(self, tag:Tag):
136
+ if tag & self.tags: # replace the existing value-tag with a new value, e.g., Pierce1 -> Pierce2
137
+ new = Tag(self.tags.value & ~tag.sibling_bits())
138
+ self.tags = new
139
+ self.tags |= tag
140
+
141
+ def add_surge_tag(self, tag:Tag):
142
+ if self.surge is None:
143
+ self.surge = tag
144
+ else:
145
+ self.surge |= tag
146
+
147
+ def set_surge_die(self, die:SurgeDie):
148
+ self.surge_die = die
149
+
150
+ def buff_roa(self, val:int):
151
+ self.rate_of_attack += val
152
+
153
+ def buff_hit(self, val:int):
154
+ self.hit = max(2, self.hit - val)
155
+
156
+ def set_damage(self, val:int):
157
+ self.damage = val
158
+
159
+ def type_letter(self):
160
+ return self.range if self.range in ("E", "C") else "R"
161
+
162
+ def __str__(self):
163
+ ef = f" FOR:{self.exchange_for}" if self.exchange_for != "" else ""
164
+ surge = f" {str(self.surge or ""):<16} {str(self.surge_die or ""):<4}"
165
+
166
+ if Tag.Flying in self.target and Tag.Ground in self.target:
167
+ tgt = "GF"
168
+ else:
169
+ tgt = "G " if Tag.Ground in self.target else "F "
170
+
171
+ return f"{self.name:<30} {self.range:>2} {tgt} {self.rate_of_attack}D6 {self.hit}+ {surge} DMG:{self.damage} {str(self.tags):<20}{ef}"
172
+
173
+
174
+ class Range(NamedTuple):
175
+ start: int
176
+ stop: int
177
+
178
+
179
+ class Squad(NamedTuple):
180
+ models: Range
181
+ supply: int
182
+ points: int
183
+
184
+
185
+ @dataclass
186
+ class Speed():
187
+ unit: int
188
+ model: int
189
+
190
+ def __str__(self):
191
+ return f"{self.unit}/{self.model}"
192
+
193
+
194
+ @dataclass
195
+ class Cost():
196
+ small: int|None = None
197
+ large: int|None = None
198
+ points: int|None = None
199
+
200
+ def __str__(self):
201
+ points = f"/{self.points}P" if self.points else ""
202
+ return f"{self.small or '-'}/{self.large or '-'}{points}"
203
+
204
+
205
+ class Hook(Enum):
206
+ ModifyOwner = auto()
207
+ RollPoolsInitiated = auto()
208
+
209
+
210
+ class Upgrade():
211
+ class Type(Flag):
212
+ Other = auto()
213
+ Defensive = auto()
214
+ Offensive = auto()
215
+
216
+ def __init__(self, name:str, /, apply:Callable|dict=None, *, message="", cost=None, upgrade_type=None):
217
+ if apply is Upgrade.activate_weapon:
218
+ def wrapper(unit):
219
+ return Upgrade.activate_weapon(unit, name)
220
+ apply = wrapper
221
+ message = "Upgrade weapon"
222
+
223
+ if callable(apply):
224
+ apply = {Hook.ModifyOwner: apply}
225
+ else:
226
+ assert isinstance(apply, dict)
227
+
228
+ self.name = name
229
+ self.apply = apply
230
+ self.upgrade_type = upgrade_type
231
+ self.message = message
232
+ self.cost = cost
233
+
234
+ def __str__(self):
235
+ return f"{self.name} {self.cost or "-"} {self.message}"
236
+
237
+ @staticmethod
238
+ def activate_weapon(unit, weapon_name:str):
239
+ unit.activate_weapon(weapon_name)
240
+
241
+
242
+ @dataclass
243
+ class Unit():
244
+ faction: Faction
245
+ name: str
246
+ shield: int
247
+ speed: Speed
248
+ evade: int
249
+ armour: int
250
+ hit_points: int
251
+ weapons: tuple[Weapon]
252
+ squad: tuple[Squad]
253
+ tags: Tag
254
+ upgrades: tuple[Upgrade]
255
+
256
+ def __str__(self):
257
+ return f"{self.name} SHLD:{self.shield} EVA:{self.evade}+ ARM:{self.armour}+ HP:{self.hit_points} {self.tags}"
@@ -0,0 +1,80 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Łukasz Gieryk
3
+
4
+ import pickle
5
+ from multiprocessing import Process, Manager, Lock, Event, Value
6
+ from pathlib import Path
7
+ import hashlib
8
+ import logging
9
+ import sctmgtool
10
+
11
+
12
+ class Cache:
13
+ MAJOR_REVISION = hashlib.sha256(sctmgtool.__version__.encode("utf-8")).hexdigest()
14
+ FILENAME = "cache.pkl"
15
+
16
+ def __init__(self, /, minor_revision: int, save_path: Path):
17
+ manager = Manager()
18
+ self.revision = (self.MAJOR_REVISION, minor_revision)
19
+ self.save_file_path = save_path / Cache.FILENAME
20
+
21
+ try:
22
+ logging.info("Cache path: %s", self.save_file_path)
23
+ with open(self.save_file_path, "rb") as file:
24
+ rev, data = pickle.load(file)
25
+ if rev == self.revision:
26
+ self.data = manager.dict(data)
27
+ else:
28
+ logging.warning("Cache version error; starting a new one from scratch")
29
+ self.data = manager.dict()
30
+ except (FileNotFoundError, AttributeError):
31
+ logging.warning("Cache access error; starting a new one from scratch")
32
+ self.data = manager.dict()
33
+
34
+ self.lock = Lock()
35
+ self.stop_event = Event()
36
+ self.workers = []
37
+ self.new_inserts = Value("i", 0)
38
+
39
+ def start_worker_func(self, worker_func: callable, arguments=None):
40
+ if arguments is None:
41
+ worker = Process(target=worker_func)
42
+ else:
43
+ worker = Process(target=worker_func, args=arguments)
44
+ worker.start()
45
+ self.workers.append(worker)
46
+
47
+ def stop_and_save(self):
48
+ self.stop_event.set()
49
+
50
+ for worker in self.workers:
51
+ worker.join()
52
+
53
+ if self.new_inserts.value == 0:
54
+ return
55
+
56
+ with self.lock:
57
+ with open(self.save_file_path, "wb") as file:
58
+ pickle.dump((self.revision, dict(self.data)), file)
59
+
60
+ def __getitem__(self, obj):
61
+ key = self.obj_to_key(obj)
62
+
63
+ if key in self.data:
64
+ return self.data[key]
65
+
66
+ value = self.compute_func(obj)
67
+
68
+ with self.lock:
69
+ self.data.setdefault(key, value)
70
+
71
+ with self.new_inserts.get_lock():
72
+ self.new_inserts.value += 1
73
+
74
+ return value
75
+
76
+ def obj_to_key(self, obj):
77
+ return obj
78
+
79
+ def compute_func(self, _obj):
80
+ raise NotImplementedError()
@@ -0,0 +1,17 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Łukasz Gieryk
3
+
4
+ from typing import NamedTuple, Any
5
+
6
+
7
+ class RollHookArgs(NamedTuple):
8
+ attacker: Any
9
+ weapon_batch: Any
10
+ defender: Any
11
+
12
+
13
+ class PoolHookArgs(NamedTuple):
14
+ attack_pool: Any
15
+ armour_pool: Any
16
+ damage_pool: Any
17
+ discard_pool: Any