exp-runtime-engine 0.5.0__py3-none-any.whl

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.
exp_runtime/menu.py ADDED
@@ -0,0 +1,177 @@
1
+ """Main-menu contracts and original APK string/glyph resources.
2
+
3
+ No game artwork, glyphs, or episode metadata are embedded in the engine.
4
+ Native evidence and deliberate desktop adaptations: docs/MAIN_MENU.md.
5
+ """
6
+ from dataclasses import dataclass
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import tempfile
11
+
12
+ from .content import is_bundled
13
+ from .languages import TITLE_LANGUAGES, episode_title
14
+ from .games import GAMES, game_id
15
+ from .launcher import default_library, remember_library
16
+ from .runtime import SaveError, Session
17
+ from .menu_assets import MenuFont, MenuStrings
18
+ from .ui_assets import Rect
19
+
20
+
21
+ EPISODE_HEADER_HEIGHT = 24
22
+ EPISODE_ROW_HEIGHT = 42
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class EpisodeSection:
27
+ key: str
28
+ title: str
29
+ episodes: tuple[dict, ...]
30
+
31
+
32
+ def group_episodes(records, catalog, *, mega_label, novel_label, saved_label, saved=()):
33
+ """Native category headers with one consolidated section per category.
34
+
35
+ EXP IDs do not determine category names. Keep unidentified loose episodes
36
+ in numeric pack sections until the user supplies their original catalog.
37
+ """
38
+ from .episode_catalog import basename
39
+ groups = {}
40
+ for record in records:
41
+ label = catalog.category(record)
42
+ if label is None and is_bundled(record):
43
+ label = novel_label if basename(record['name']) == 'novel_bonus_content.exp' else mega_label
44
+ key = f'category:{label.casefold()}' if label is not None else f'pack:{record["pack_id"]}'
45
+ label = label if label is not None else f'Pack {record["pack_id"]}'
46
+ if key not in groups:
47
+ groups[key] = (label, [])
48
+ groups[key][1].append(record)
49
+ sections = [EpisodeSection(key, label, tuple(episodes)) for key, (label, episodes) in groups.items()]
50
+ sections.sort(key=lambda section: (
51
+ section.title.casefold() != mega_label.casefold(),
52
+ min((e['pack_id'], e['episode_id']) for e in section.episodes), section.key))
53
+ resumed = tuple(e for e in records if e['id'] in saved)
54
+ if resumed:
55
+ sections.insert(0, EpisodeSection('saved', saved_label, resumed))
56
+ return sections
57
+
58
+
59
+
60
+ class MenuState:
61
+ """Per-library preferences and selection; saves remain content-bound.
62
+
63
+ Automatic checkpoints have their own file, preserving the player's F5
64
+ slot. Resume uses the newer of those two saves and validates it on load.
65
+ """
66
+ def __init__(self, library):
67
+ self.library = library
68
+ self.path = library.directory / 'player.json'
69
+ self.music = self.sound = True
70
+ self.choice_hints = False
71
+ self.order = 'episode'
72
+ self.title_language = 'en'
73
+ self.selected = None
74
+ self.warning = ''
75
+ if self.path.exists():
76
+ try:
77
+ data = json.loads(self.path.read_text(encoding='utf-8'))
78
+ if (not isinstance(data, dict) or data.get('version') != 1
79
+ or type(data.get('music')) is not bool or type(data.get('sound')) is not bool
80
+ or type(data.get('choice_hints', False)) is not bool
81
+ or not isinstance(data.get('selected'), (str, type(None)))
82
+ or data.get('order', 'episode') not in ('episode', 'title')
83
+ or data.get('title_language', 'en') not in TITLE_LANGUAGES):
84
+ raise ValueError('Invalid player preferences')
85
+ self.music, self.sound, self.selected = data['music'], data['sound'], data['selected']
86
+ self.order = data.get('order', 'episode')
87
+ self.title_language = data.get('title_language', 'en')
88
+ self.choice_hints = data.get('choice_hints', False)
89
+ except (ValueError, OSError) as error:
90
+ self.warning = f'Could not read preferences: {error}'
91
+ ids = {e['id'] for e in library.episodes}
92
+ if self.selected not in ids:
93
+ saved = [(self.resume_path(e['id']), e['id']) for e in library.episodes]
94
+ saved = [(p.stat().st_mtime_ns, key) for p, key in saved if p]
95
+ preferred = GAMES[game_id(library)].DEFAULT_EPISODE
96
+ default = next((e for e in library.episodes if (e.get('pack_id'), e.get('episode_id')) == preferred),
97
+ library.episodes[0])
98
+ self.selected = max(saved)[1] if saved else default['id']
99
+
100
+ def title(self, record):
101
+ return episode_title(record, self.title_language)
102
+
103
+ def save_path(self, episode, *, automatic=False):
104
+ # Never use an arbitrary selector as part of a host path.
105
+ record = self.library.select(episode)
106
+ suffix = '.shs-auto.json' if automatic else '.shs-save.json'
107
+ return self.library.directory / 'saves' / (record['id'] + suffix)
108
+
109
+ def resume_path(self, episode):
110
+ paths = [self.save_path(episode, automatic=auto) for auto in (False, True)]
111
+ path = max((p for p in paths if p.is_file()), key=lambda p: p.stat().st_mtime_ns, default=None)
112
+ if path is not None:
113
+ try:
114
+ if path.stat().st_size > 8 * 1024 * 1024:
115
+ return path # Session.load reports the existing size limit.
116
+ saved = json.loads(path.read_text(encoding='utf-8'))
117
+ pending = saved.get('pending') if isinstance(saved, dict) else None
118
+ if isinstance(pending, dict) and pending.get('name') == 'episode_exit':
119
+ # A validated terminal checkpoint masks older progress.
120
+ # Keep the manual slot available for an explicit load.
121
+ session = Session.from_snapshot(self.library.open_episode(episode), saved)
122
+ if session.episode_exited:
123
+ return None
124
+ except (SaveError, OSError, ValueError, AttributeError):
125
+ # Invalid saves remain resumable so loading reports the error;
126
+ # a malformed completion marker must not discard progress.
127
+ pass
128
+ return path
129
+
130
+ def persist(self):
131
+ data = dict(version=1, selected=self.selected, music=self.music, sound=self.sound,
132
+ order=self.order, title_language=self.title_language, choice_hints=self.choice_hints)
133
+ temporary = None
134
+ try:
135
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=self.library.directory,
136
+ prefix='.player-', delete=False) as stream:
137
+ temporary = Path(stream.name)
138
+ json.dump(data, stream)
139
+ stream.write('\n')
140
+ stream.flush()
141
+ os.fsync(stream.fileno())
142
+ temporary.replace(self.path)
143
+ finally:
144
+ if temporary is not None:
145
+ temporary.unlink(missing_ok=True)
146
+
147
+ def session(self, episode, *, resume=True):
148
+ resources = self.library.open_episode(episode)
149
+ path = self.resume_path(episode) if resume else None
150
+ session = Session.load(resources, path) if path else Session(resources)
151
+ self.selected = resources.record['id']
152
+ self.persist()
153
+ return session
154
+
155
+ def checkpoint(self, session):
156
+ path = session.save(self.save_path(session.resources.record['id'], automatic=True))
157
+ self.selected = session.resources.record['id']
158
+ self.persist()
159
+ return path
160
+
161
+
162
+ def main_button_rects(bank):
163
+ """000df488 + 00147df8: native four-item vertical menu, 5px gaps.
164
+
165
+ The background is layout 74; live buttons are layout 75 (184x28),
166
+ independently positioned over it. The fourth uses the paid/ad-free width.
167
+ """
168
+ group = bank.rectangle(74, 0)
169
+ button = bank.rectangle(75, 0)
170
+ widths = (button.width, button.width, button.width, 155)
171
+ total_height = button.height * 4 + 15
172
+ top = 480 - (group.height // 2 + 5) - total_height / 2
173
+ travel = int(button.width * 2.6)
174
+ offsets = (-50, -5, 2, -35)
175
+ return tuple(Rect(round(-group.width / 2 - w + offsets[i] + travel - 20 * i - w / 2),
176
+ round(top + i * (button.height + 5)), w, button.height)
177
+ for i, w in enumerate(widths))
@@ -0,0 +1,36 @@
1
+ """Service 33's raw-text message panel and native acknowledgement gate."""
2
+ from dataclasses import dataclass
3
+
4
+ from .vm import VMError
5
+
6
+
7
+ @dataclass
8
+ class MessagePanel:
9
+ title: str
10
+ text: str
11
+ argument: int
12
+ elapsed_ms: int = 0
13
+
14
+ @property
15
+ def ready(self):
16
+ return self.elapsed_ms == 1000
17
+
18
+ @property
19
+ def countdown(self):
20
+ # FUN_000aca28 uses +900, rather than rounding up with +999.
21
+ return (1000 - self.elapsed_ms + 900) // 1000
22
+
23
+ def tick(self, elapsed_ms):
24
+ self.elapsed_ms = min(1000, self.elapsed_ms + elapsed_ms)
25
+
26
+ def validate(self):
27
+ if (type(self.argument) is not int or not -32768 <= self.argument <= 32767
28
+ or type(self.elapsed_ms) is not int or not 0 <= self.elapsed_ms <= 1000):
29
+ raise VMError('Invalid message-panel argument or clock')
30
+ for value in (self.title, self.text):
31
+ if not isinstance(value, str) or '\0' in value:
32
+ raise VMError('Invalid message-panel text')
33
+ try:
34
+ value.encode('latin-1')
35
+ except UnicodeEncodeError:
36
+ raise VMError('Message-panel text must be Latin-1') from None
@@ -0,0 +1,76 @@
1
+ """Shared timed word-game rules; see docs/MINIGAMES.md."""
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass
6
+ class WordGame:
7
+ good: list[str]
8
+ bad: list[str]
9
+ duration_ms: int
10
+ refresh_ms: int
11
+ remaining_ms: int
12
+ refresh_remaining_ms: int
13
+ options: list[str] = field(default_factory=list)
14
+ weights: list[int] = field(default_factory=list)
15
+ score: int = 0
16
+ round: int = 0
17
+ entry_ms: int = 400
18
+ animation_ms: int = 0
19
+ last_delta: int = 0
20
+
21
+ def deal(self, random):
22
+ """FUN_000af010: rejection sampling, then exactly twenty pair swaps."""
23
+ if len(self.good) < 2 or len(self.bad) < 3:
24
+ raise ValueError('Word game needs at least two good and three bad entries')
25
+ first, second = random.below(len(self.good)), random.below(len(self.bad))
26
+ third = random.below(len(self.bad))
27
+ while third == second:
28
+ third = random.below(len(self.bad))
29
+ good_fourth = random.next() & 1 == 0
30
+ pool, excluded = (self.good, (first,)) if good_fourth else (self.bad, (second, third))
31
+ fourth = random.below(len(pool))
32
+ while fourth in excluded:
33
+ fourth = random.below(len(pool))
34
+ self.options = [self.good[first], self.bad[second], self.bad[third], pool[fourth]]
35
+ self.weights = [1, -1, -1, 1 if good_fourth else -1]
36
+ for _ in range(20):
37
+ a, b = random.below(4), random.below(4)
38
+ self.options[a], self.options[b] = self.options[b], self.options[a]
39
+ self.weights[a], self.weights[b] = self.weights[b], self.weights[a]
40
+ self.refresh_remaining_ms = self.refresh_ms
41
+ self.round += 1
42
+
43
+ def pick(self, index, random):
44
+ if type(index) is not int or not 0 <= index < 4:
45
+ raise ValueError('Choose a word by its zero-based index (0–3)')
46
+ if self.entry_ms:
47
+ return False # Native transition input gate, FUN_000afee8.
48
+ self.last_delta = self.weights[index]
49
+ self.score = max(0, self.score + self.last_delta)
50
+ self.deal(random)
51
+ self.animation_ms = 200 # New labels slide in; native input stays live.
52
+ return True
53
+
54
+ def tick(self, elapsed_ms):
55
+ self.entry_ms = max(0, self.entry_ms - elapsed_ms)
56
+ self.animation_ms = max(0, self.animation_ms - elapsed_ms)
57
+ self.remaining_ms -= elapsed_ms
58
+ self.refresh_remaining_ms -= elapsed_ms
59
+ # FUN_000ae4f4 uses a strict negative comparison. The refresh counter
60
+ # is stored/subtracted, but has no expiry action in this Android build.
61
+ return self.duration_ms > 0 and self.remaining_ms < 0
62
+
63
+ def validate(self):
64
+ if (len(self.good) < 2 or len(self.bad) < 3 or self.duration_ms <= 0
65
+ or self.duration_ms > 32767 or not -32768 <= self.refresh_ms <= 32767
66
+ or not 0 <= self.remaining_ms <= self.duration_ms
67
+ or not self.refresh_ms - self.duration_ms <= self.refresh_remaining_ms <= self.refresh_ms
68
+ or len(self.options) != 4 or len(self.weights) != 4
69
+ or sum(w == 1 for w in self.weights) not in (1, 2)
70
+ or any(w not in (-1, 1) for w in self.weights)
71
+ or any(s not in (self.good if w == 1 else self.bad)
72
+ for s, w in zip(self.options, self.weights))
73
+ or self.round < 1 or not 0 <= self.score < self.round
74
+ or not 0 <= self.entry_ms <= 400 or not 0 <= self.animation_ms <= 200
75
+ or self.last_delta not in (-1, 0, 1)):
76
+ raise ValueError('Invalid saved word game')
@@ -0,0 +1,61 @@
1
+ """Native random streams and pipe-delimited option parsing.
2
+
3
+ Evidence and argument schemas: docs/MINIGAMES.md. Random permutations are
4
+ part of saved gameplay state, never recomputed by a renderer.
5
+ """
6
+ from dataclasses import dataclass, field
7
+ from time import process_time_ns
8
+
9
+
10
+ RAND48_INITIAL = 0x1234ABCD330E
11
+ RAND48_MASK = (1 << 48) - 1
12
+
13
+
14
+ @dataclass
15
+ class Random48:
16
+ """The libc lrand48 stream for word choices, separate from services 4/27."""
17
+ state: int = RAND48_INITIAL
18
+
19
+ def next(self):
20
+ self.state = (self.state * 0x5DEECE66D + 0xB) & RAND48_MASK
21
+ return self.state >> 17
22
+
23
+ def below(self, bound):
24
+ if bound <= 0:
25
+ raise ValueError('Random selection needs a positive bound')
26
+ return self.next() % bound
27
+
28
+
29
+ @dataclass
30
+ class NativeRandom:
31
+ """0004c248 / 00126fa8 / 00122554: CPU-clock seed and 32-bit LCG.
32
+
33
+ The returned word includes bits from the full multiplication, before
34
+ truncating the stored state. This is not the usual ANSI rand() result.
35
+ """
36
+ state: int = field(default_factory=lambda: (process_time_ns() // 1000) & 0xffffffff)
37
+
38
+ def next(self):
39
+ product = self.state * 0x41c64e6d + 0x3039
40
+ self.state = product & 0xffffffff
41
+ value = (product >> 16) & 0xffffffff
42
+ return abs(value if value < 0x80000000 else value - 0x100000000)
43
+
44
+ def below(self, bound):
45
+ if bound <= 0:
46
+ raise ValueError('Random selection needs a positive bound')
47
+ return self.next() % bound
48
+
49
+ def signed_below(self, bound):
50
+ value, negative = self.below(bound), self.next() & 1
51
+ return -value if negative else value
52
+
53
+
54
+ def pipe_list(text):
55
+ result = text.split('|')
56
+ # The native parser preserves a blank final field, including whitespace.
57
+ if not result[-1].strip():
58
+ result[-1] = ''
59
+ return result
60
+
61
+
exp_runtime/rar.py ADDED
@@ -0,0 +1,145 @@
1
+ """Bounded RAR4/RAR5 reading through libarchive, without external commands.
2
+
3
+ Apple platforms use the OS library. Desktop builds for Windows/Linux bundle
4
+ libarchive; source checkouts can use a locally installed copy. Only the RAR
5
+ decoders are enabled, and archive member paths are never used as output paths.
6
+ """
7
+ import ctypes as c
8
+ from functools import lru_cache
9
+ import os
10
+ from pathlib import Path
11
+ import sys
12
+
13
+
14
+ class RarError(ValueError):
15
+ pass
16
+
17
+
18
+ def _library_candidates():
19
+ if sys.platform in ('darwin', 'ios'):
20
+ yield '/usr/lib/libarchive.2.dylib'
21
+ return
22
+ name = 'archive.dll' if sys.platform == 'win32' else 'libarchive.so.13'
23
+ if bundle := getattr(sys, '_MEIPASS', None):
24
+ yield str(Path(bundle) / name)
25
+ else:
26
+ # The decoder is built at the workspace root, while this module lives
27
+ # in a component package. Locate the owning build helper instead of
28
+ # assuming a fixed source depth or using the caller's working directory.
29
+ # Installed wheels need no build tools and fall through to the system
30
+ # library. Frozen apps must not borrow a surrounding checkout's decoder.
31
+ for root in Path(__file__).resolve().parents:
32
+ if (root / 'tools/build_rar.py').is_file():
33
+ yield str(root / 'build/rar/install' / ('bin' if sys.platform == 'win32' else 'lib') / name)
34
+ break
35
+ from ctypes.util import find_library
36
+ if system := find_library('archive'):
37
+ yield system
38
+
39
+
40
+ @lru_cache(maxsize=1)
41
+ def library():
42
+ for path in _library_candidates():
43
+ try:
44
+ lib = c.CDLL(path)
45
+ signatures = {
46
+ 'archive_read_new': (c.c_void_p, []),
47
+ 'archive_read_support_format_rar': (c.c_int, [c.c_void_p]),
48
+ 'archive_read_support_format_rar5': (c.c_int, [c.c_void_p]),
49
+ 'archive_read_open_filename': (c.c_int, [c.c_void_p, c.c_char_p, c.c_size_t]),
50
+ 'archive_read_next_header': (c.c_int, [c.c_void_p, c.POINTER(c.c_void_p)]),
51
+ 'archive_read_data': (c.c_ssize_t, [c.c_void_p, c.c_void_p, c.c_size_t]),
52
+ 'archive_read_data_skip': (c.c_int, [c.c_void_p]),
53
+ 'archive_read_free': (c.c_int, [c.c_void_p]),
54
+ 'archive_error_string': (c.c_char_p, [c.c_void_p]),
55
+ 'archive_entry_pathname_utf8': (c.c_char_p, [c.c_void_p]),
56
+ 'archive_entry_size': (c.c_int64, [c.c_void_p]),
57
+ 'archive_entry_filetype': (c.c_uint, [c.c_void_p]),
58
+ 'archive_entry_symlink': (c.c_char_p, [c.c_void_p]),
59
+ 'archive_entry_hardlink': (c.c_char_p, [c.c_void_p]),
60
+ 'archive_entry_is_encrypted': (c.c_int, [c.c_void_p]),
61
+ }
62
+ if sys.platform == 'win32':
63
+ signatures['archive_read_open_filename_w'] = (c.c_int, [c.c_void_p, c.c_wchar_p, c.c_size_t])
64
+ for name, (result, args) in signatures.items():
65
+ function = getattr(lib, name)
66
+ function.restype, function.argtypes = result, args
67
+ return lib
68
+ except (OSError, AttributeError):
69
+ continue
70
+ raise RarError('RAR support needs libarchive with RAR5 support. Install libarchive or use an '
71
+ 'EXP Runtime desktop download. You can also import the extracted EXP files.')
72
+
73
+
74
+ class RarReader:
75
+ """Single-pass archive. Read or skip each member before advancing."""
76
+ def __init__(self, path):
77
+ self.lib, self.handle = library(), None
78
+ with Path(path).open('rb') as stream:
79
+ magic = stream.read(8)
80
+ if not (magic.startswith(b'Rar!\x1a\x07\x00') or magic == b'Rar!\x1a\x07\x01\x00'):
81
+ raise RarError('Invalid RAR signature')
82
+ self.handle = self.lib.archive_read_new()
83
+ if not self.handle:
84
+ raise RarError('Cannot allocate the RAR reader')
85
+ try:
86
+ self.check(self.lib.archive_read_support_format_rar(self.handle))
87
+ self.check(self.lib.archive_read_support_format_rar5(self.handle))
88
+ if sys.platform == 'win32':
89
+ self.check(self.lib.archive_read_open_filename_w(self.handle, str(Path(path)), 65536))
90
+ else:
91
+ self.check(self.lib.archive_read_open_filename(self.handle, os.fsencode(path), 65536))
92
+ except BaseException:
93
+ self.close()
94
+ raise
95
+
96
+ def check(self, status):
97
+ if status < 0:
98
+ raw = self.lib.archive_error_string(self.handle)
99
+ raise RarError(raw.decode('utf-8', 'replace') if raw else 'Invalid or unsupported RAR archive')
100
+ return status
101
+
102
+ def __iter__(self):
103
+ while True:
104
+ entry = c.c_void_p()
105
+ status = self.check(self.lib.archive_read_next_header(self.handle, c.byref(entry)))
106
+ if status == 1: # ARCHIVE_EOF
107
+ return
108
+ raw = self.lib.archive_entry_pathname_utf8(entry)
109
+ if raw is None:
110
+ raise RarError('RAR member has an invalid filename')
111
+ try:
112
+ name = raw.decode('utf-8')
113
+ except UnicodeDecodeError as error:
114
+ raise RarError('RAR member has an invalid filename') from error
115
+ yield dict(name=name, size=self.lib.archive_entry_size(entry),
116
+ mode=self.lib.archive_entry_filetype(entry),
117
+ link=bool(self.lib.archive_entry_symlink(entry) or self.lib.archive_entry_hardlink(entry)),
118
+ encrypted=bool(self.lib.archive_entry_is_encrypted(entry)))
119
+
120
+ def skip(self):
121
+ self.check(self.lib.archive_read_data_skip(self.handle))
122
+
123
+ def copy_to(self, destination, size):
124
+ buffer, total = c.create_string_buffer(65536), 0
125
+ while True:
126
+ count = self.check(self.lib.archive_read_data(self.handle, buffer, len(buffer)))
127
+ if count == 0:
128
+ break
129
+ total += count
130
+ if total > size:
131
+ raise RarError('RAR member exceeds its declared size')
132
+ destination.write(buffer.raw[:count])
133
+ if total != size:
134
+ raise RarError('RAR member is truncated')
135
+
136
+ def close(self):
137
+ if self.handle:
138
+ self.lib.archive_read_free(self.handle)
139
+ self.handle = None
140
+
141
+ def __enter__(self):
142
+ return self
143
+
144
+ def __exit__(self, *exc):
145
+ self.close()
@@ -0,0 +1,131 @@
1
+ """NPC relationship icon selection and native portrait-child actions.
2
+
3
+ Evidence: FUN_000ab048, FUN_000a90f0, FUN_0009ce2c, FUN_0009caf4.
4
+ Asset bytes remain in the player's APK. Drawing never writes relationship state.
5
+ """
6
+ from dataclasses import dataclass
7
+ import math
8
+
9
+ from .games import GAMES
10
+
11
+ FLASH_ASSETS = {asset: flash for game in GAMES.values() for asset, flash in game.RELATIONSHIP_FLASH.items()}
12
+ LOSS_ASSETS = {asset: loss for game in GAMES.values() for asset, loss in game.RELATIONSHIP_LOSS.items()}
13
+
14
+
15
+ def fraction(elapsed, duration):
16
+ return min(1.0, max(0.0, elapsed / duration))
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RelationshipChange:
21
+ character_id: int
22
+ asset_id: int
23
+ count: int
24
+ previous_asset: int
25
+ previous_count: int
26
+
27
+ @property
28
+ def changed(self):
29
+ return self.previous_count != -1 and (self.asset_id, self.count) != (self.previous_asset, self.previous_count)
30
+
31
+ @property
32
+ def loss(self):
33
+ return self.asset_id >= 0 and self.changed and self.asset_id == self.previous_asset and self.count < self.previous_count
34
+
35
+ @property
36
+ def gain_from(self):
37
+ if (self.asset_id < 0 or not self.changed or self.loss
38
+ or (self.character_id == 45 and self.asset_id in GAMES['shs'].RELATIONSHIP_ASSETS)):
39
+ return None
40
+ return self.previous_count if self.asset_id == self.previous_asset else 0
41
+
42
+ @property
43
+ def extra_delay_ms(self):
44
+ if self.loss:
45
+ return 600
46
+ first = self.gain_from
47
+ return 0 if first is None else 300 + 200 * (self.count - first - 1)
48
+
49
+ @property
50
+ def sound(self):
51
+ # CoD has no verified mapping to SHS's feedback sound IDs.
52
+ if self.asset_id in GAMES['shs'].RELATIONSHIP_ASSETS:
53
+ from .shs.relationships import sound
54
+ return sound(self)
55
+ return None
56
+
57
+ def validate(self):
58
+ if (not 0 < self.character_id <= 32767 or (self.asset_id != -1 and self.asset_id not in FLASH_ASSETS)
59
+ or not 1 <= self.count <= 4 or not -32768 <= self.previous_asset <= 32767
60
+ or not -1 <= self.previous_count <= 4):
61
+ raise ValueError('Invalid relationship indicator state')
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class IndicatorPose:
66
+ asset_id: int
67
+ x: float
68
+ y: float
69
+ rotation: float
70
+ flash_asset: int = -1
71
+ flash_scale: float = 0.0
72
+ flash_alpha: int = 0
73
+
74
+
75
+ @dataclass
76
+ class RelationshipAnimation:
77
+ change: RelationshipChange
78
+ delay_ms: int
79
+ elapsed_ms: int = 0
80
+
81
+ @property
82
+ def duration_ms(self):
83
+ first = self.change.gain_from
84
+ if first is not None:
85
+ return self.delay_ms + 200 * (self.change.count - first - 1) + max(800, self.change.extra_delay_ms)
86
+ return max(self.delay_ms, 2000 if self.change.loss else 0)
87
+
88
+ def tick(self, elapsed_ms):
89
+ self.elapsed_ms = min(self.duration_ms, self.elapsed_ms + elapsed_ms)
90
+
91
+ def settle(self):
92
+ self.elapsed_ms = self.duration_ms
93
+
94
+ def poses(self):
95
+ change = self.change
96
+ if change.asset_id < 0 or self.elapsed_ms < self.delay_ms:
97
+ return ()
98
+ count = change.previous_count if change.loss else change.count
99
+ first, result = change.gain_from, []
100
+ for index in range(count):
101
+ asset, angle = change.asset_id, 195 + 20 * index
102
+ drop, flash_asset, flash_scale, flash_alpha = 0.0, -1, 0.0, 0
103
+ if index >= change.count:
104
+ if self.elapsed_ms >= 2000:
105
+ continue
106
+ asset = LOSS_ASSETS[asset]
107
+ # Parent MoveTo(0,-500) starts after one second, independent
108
+ # of the incoming portrait's visibility delay.
109
+ drop = 500 * fraction(self.elapsed_ms - 1000, 1000)
110
+ elif first is not None and index >= first:
111
+ age = self.elapsed_ms - self.delay_ms - 200 * (index - first)
112
+ if age < 0:
113
+ continue
114
+ # RotateTo uses the shortest arc (00146a88), not a 195° spin.
115
+ angle = (angle - 360) * fraction(age, change.extra_delay_ms)
116
+ flash_asset = FLASH_ASSETS[asset]
117
+ burst = fraction(age - 300, 500)
118
+ flash_scale, flash_alpha = .5 + 1.5 * burst, int(200 * (1 - burst))
119
+ x, y = ((61, 16), (62, 17), (62, 18), (64, 19))[index]
120
+ radians = math.radians(angle)
121
+ # Cocos rotates clockwise in GL coordinates. Convert to top Y.
122
+ px = x * math.cos(radians) + y * math.sin(radians)
123
+ py = x * math.sin(radians) - y * math.cos(radians) + drop
124
+ result.append(IndicatorPose(asset, px, py, angle - 190 - 20 * index,
125
+ flash_asset, flash_scale, flash_alpha))
126
+ return tuple(result)
127
+
128
+ def validate(self):
129
+ self.change.validate()
130
+ if not 0 <= self.delay_ms <= 850 or not 0 <= self.elapsed_ms <= self.duration_ms:
131
+ raise ValueError('Invalid relationship animation clock')