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/audio.py +13 -0
- exp_runtime/background.py +58 -0
- exp_runtime/character_picker.py +92 -0
- exp_runtime/choice.py +116 -0
- exp_runtime/choice_hints.py +153 -0
- exp_runtime/content.py +742 -0
- exp_runtime/dialogue.py +151 -0
- exp_runtime/dialogue_animation.py +345 -0
- exp_runtime/dialogue_notice.py +51 -0
- exp_runtime/engine.py +609 -0
- exp_runtime/episode_catalog.py +171 -0
- exp_runtime/game_state.py +9 -0
- exp_runtime/games.py +35 -0
- exp_runtime/ios_fonts.py +228 -0
- exp_runtime/launcher.py +92 -0
- exp_runtime/loading.py +37 -0
- exp_runtime/menu.py +177 -0
- exp_runtime/message_panel.py +36 -0
- exp_runtime/minigames.py +76 -0
- exp_runtime/randomness.py +61 -0
- exp_runtime/rar.py +145 -0
- exp_runtime/relationships.py +131 -0
- exp_runtime/runtime.py +879 -0
- exp_runtime/scene_badge.py +65 -0
- exp_runtime/speaker_names.py +258 -0
- exp_runtime/text_input.py +55 -0
- exp_runtime/title_screen.py +104 -0
- exp_runtime/trace.py +76 -0
- exp_runtime/vm.py +391 -0
- exp_runtime_engine-0.5.0.dist-info/METADATA +49 -0
- exp_runtime_engine-0.5.0.dist-info/RECORD +34 -0
- exp_runtime_engine-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_engine-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_engine-0.5.0.dist-info/top_level.txt +1 -0
exp_runtime/audio.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Music playback routing, separate from exact script resource lookups."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class MusicCue:
|
|
7
|
+
asset_id: int
|
|
8
|
+
start_ms: int = 0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def music_cue(resource_id: int, *, game: str = 'shs') -> MusicCue:
|
|
12
|
+
from .games import GAMES
|
|
13
|
+
return GAMES[game].MUSIC_CUE(resource_id)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Native location alignment, independent of image decoding and drawing.
|
|
2
|
+
|
|
3
|
+
Android 0007b790/00136364 move node 1000 linearly over 250 ms. SHS and
|
|
4
|
+
CoD iOS use the same left/center/right targets. Store the fraction of the
|
|
5
|
+
image's horizontal overflow so saves and headless execution need no pixels.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PAN_MS = 250
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class BackgroundPan:
|
|
15
|
+
automatic: bool = True
|
|
16
|
+
alignment: int = 0 # Only consulted after service 97 supplies a value.
|
|
17
|
+
start: float = 0.5
|
|
18
|
+
target: float = 0.5
|
|
19
|
+
elapsed_ms: int = PAN_MS
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def position(self):
|
|
23
|
+
return self.start + (self.target - self.start) * (self.elapsed_ms / PAN_MS)
|
|
24
|
+
|
|
25
|
+
def center(self):
|
|
26
|
+
"""A replacement background node starts at (160,180)."""
|
|
27
|
+
self.start = self.target = 0.5
|
|
28
|
+
self.elapsed_ms = PAN_MS
|
|
29
|
+
|
|
30
|
+
def align(self, alignment):
|
|
31
|
+
if not self.automatic:
|
|
32
|
+
alignment = self.alignment
|
|
33
|
+
self.start = self.position
|
|
34
|
+
# Native switch defaults to the node's current position.
|
|
35
|
+
self.target = {1: 0.0, 2: 0.5, 3: 1.0}.get(alignment, self.start)
|
|
36
|
+
self.elapsed_ms = 0
|
|
37
|
+
|
|
38
|
+
def dialogue(self, presentation):
|
|
39
|
+
if presentation in (1, 2):
|
|
40
|
+
self.align(1 if presentation == 1 else 3)
|
|
41
|
+
|
|
42
|
+
def configure(self, automatic, alignment):
|
|
43
|
+
"""Service 97 also immediately requests its supplied alignment."""
|
|
44
|
+
self.automatic, self.alignment = automatic, alignment
|
|
45
|
+
self.align(alignment)
|
|
46
|
+
|
|
47
|
+
def tick(self, elapsed_ms):
|
|
48
|
+
self.elapsed_ms = min(PAN_MS, self.elapsed_ms + elapsed_ms)
|
|
49
|
+
|
|
50
|
+
def origin(self, width, height):
|
|
51
|
+
# Do not scale or clamp small images: native targets use their width.
|
|
52
|
+
return int((320 - width) * self.position), 180 - height // 2
|
|
53
|
+
|
|
54
|
+
def validate(self):
|
|
55
|
+
if (not -32768 <= self.alignment <= 32767
|
|
56
|
+
or not 0.0 <= self.start <= 1.0 or not 0.0 <= self.target <= 1.0
|
|
57
|
+
or not 0 <= self.elapsed_ms <= PAN_MS):
|
|
58
|
+
raise ValueError('Invalid background pan state')
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Service 78's five-position portrait selector (native UI type 5)."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# DAT_0025b830, converted from GL to top-left 320 x 480 coordinates.
|
|
7
|
+
POSITIONS = ((160, 275), (52, 241), (115, 170), (206, 170), (269, 241))
|
|
8
|
+
SWAP_MS = 50
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PortraitMotion:
|
|
13
|
+
start: tuple[float, float, float]
|
|
14
|
+
end: tuple[float, float, float]
|
|
15
|
+
elapsed_ms: int = SWAP_MS
|
|
16
|
+
opacity: int = 255
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def pose(self):
|
|
20
|
+
t = self.elapsed_ms / SWAP_MS
|
|
21
|
+
return tuple(a + (b - a) * t for a, b in zip(self.start, self.end))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CharacterPicker:
|
|
26
|
+
characters: list[int]
|
|
27
|
+
order: list[int]
|
|
28
|
+
portraits: list[PortraitMotion]
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def start(cls, characters):
|
|
32
|
+
if not 1 <= len(characters) <= 5:
|
|
33
|
+
raise ValueError('Character selection requires 1 to 5 character IDs')
|
|
34
|
+
portraits = []
|
|
35
|
+
for slot in range(len(characters)):
|
|
36
|
+
x, y = POSITIONS[slot]
|
|
37
|
+
pose = (float(x), float(y), 1.0 if slot == 0 else 0.6)
|
|
38
|
+
portraits.append(PortraitMotion(pose, pose, opacity=255 if slot == 0 else 91))
|
|
39
|
+
return cls(list(characters), list(range(len(characters))), portraits)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def selected(self):
|
|
43
|
+
return self.order[0]
|
|
44
|
+
|
|
45
|
+
def select(self, index):
|
|
46
|
+
"""Select an original array index, without completing the VM call."""
|
|
47
|
+
if type(index) is not int or not 0 <= index < len(self.characters):
|
|
48
|
+
raise ValueError('Select a portrait by its original zero-based index')
|
|
49
|
+
slot = self.order.index(index)
|
|
50
|
+
if slot == 0:
|
|
51
|
+
return False
|
|
52
|
+
previous = self.order[0]
|
|
53
|
+
self.order[0], self.order[slot] = index, previous
|
|
54
|
+
incoming, outgoing = self.portraits[index], self.portraits[previous]
|
|
55
|
+
# FUN_000d1d78 swaps indices even during motion. It starts new actions
|
|
56
|
+
# when at least one of the two widgets has no running action.
|
|
57
|
+
if incoming.elapsed_ms == SWAP_MS or outgoing.elapsed_ms == SWAP_MS:
|
|
58
|
+
first, second = incoming.pose, outgoing.pose
|
|
59
|
+
incoming.start, incoming.end = first, (second[0], second[1], 1.0)
|
|
60
|
+
outgoing.start, outgoing.end = second, (first[0], first[1], 0.6)
|
|
61
|
+
incoming.elapsed_ms = outgoing.elapsed_ms = 0
|
|
62
|
+
for position, original in enumerate(self.order):
|
|
63
|
+
self.portraits[original].opacity = 255 if position == 0 else 91
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def tick(self, elapsed_ms):
|
|
67
|
+
for portrait in self.portraits:
|
|
68
|
+
portrait.elapsed_ms = min(SWAP_MS, portrait.elapsed_ms + elapsed_ms)
|
|
69
|
+
|
|
70
|
+
def hit(self, point):
|
|
71
|
+
"""Native fixed circular hit regions, independent of animated widgets."""
|
|
72
|
+
x, y = point
|
|
73
|
+
for slot, original in enumerate(self.order):
|
|
74
|
+
cx, cy = POSITIONS[slot]
|
|
75
|
+
radius = 61 * (1 if slot == 0 else 0.6)
|
|
76
|
+
if (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2:
|
|
77
|
+
return original
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def validate(self):
|
|
81
|
+
count = len(self.characters)
|
|
82
|
+
if (not 1 <= count <= 5 or sorted(self.order) != list(range(count))
|
|
83
|
+
or len(self.portraits) != count
|
|
84
|
+
or any(not -32768 <= c <= 32767 for c in self.characters)):
|
|
85
|
+
raise ValueError('Invalid character selection state')
|
|
86
|
+
for portrait in self.portraits:
|
|
87
|
+
if not 0 <= portrait.elapsed_ms <= SWAP_MS or portrait.opacity not in (91, 255):
|
|
88
|
+
raise ValueError('Invalid portrait animation')
|
|
89
|
+
for x, y, scale in (portrait.start, portrait.end):
|
|
90
|
+
if (not all(math.isfinite(v) for v in (x, y, scale))
|
|
91
|
+
or not 52 <= x <= 269 or not 170 <= y <= 275 or not 0.6 <= scale <= 1):
|
|
92
|
+
raise ValueError('Invalid portrait position or scale')
|
exp_runtime/choice.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Choice-panel geometry in the original 320 by 480 coordinate system.
|
|
2
|
+
|
|
3
|
+
The supplied iPhone screenshots define the visual target. Fonts, skins and
|
|
4
|
+
insets come from the APK; the 44-pixel rows also occur in FUN_000d9038.
|
|
5
|
+
See docs/UI_FIDELITY.md for differences from the Android Cocos drawing path.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from .fonts import load_font, TextLayout, TextStyle, layout_text
|
|
12
|
+
from .ui_assets import LayoutBank, Rect, read_ui
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BODY_FONT = 'ArialRoundedMTBold16'
|
|
16
|
+
FOOTER_FONT = 'ArialMT11'
|
|
17
|
+
TITLE_FONTS = {1: 'PajamaHip26', 2: 'PajamaHip266', 3: 'PajamaHipG26'}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ChoiceRow:
|
|
22
|
+
index: int
|
|
23
|
+
rect: Rect
|
|
24
|
+
text: TextLayout
|
|
25
|
+
origin: tuple[float, float]
|
|
26
|
+
enabled: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ChoicePage:
|
|
31
|
+
box: Rect # Outer border, unlike DialoguePage's center-fill rectangle.
|
|
32
|
+
title_font: str
|
|
33
|
+
title: TextLayout
|
|
34
|
+
title_origin: tuple[float, float]
|
|
35
|
+
description: TextLayout
|
|
36
|
+
description_origin: tuple[float, float]
|
|
37
|
+
portrait: Rect | None
|
|
38
|
+
rows: tuple[ChoiceRow, ...]
|
|
39
|
+
theme: int
|
|
40
|
+
max_scroll: int
|
|
41
|
+
timer_panel: Rect | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChoiceLayout:
|
|
45
|
+
def __init__(self, resources):
|
|
46
|
+
self.resources = resources
|
|
47
|
+
self.bank = LayoutBank.parse(read_ui(resources, 14))
|
|
48
|
+
|
|
49
|
+
@lru_cache(maxsize=8)
|
|
50
|
+
def font(self, name):
|
|
51
|
+
return load_font(self.resources.library, name)
|
|
52
|
+
|
|
53
|
+
def page(self, details, *, theme=1, has_portrait=False):
|
|
54
|
+
minigame = details.get('minigame', False)
|
|
55
|
+
timed = not minigame and details.get('timeout_ms', 0) > 0
|
|
56
|
+
width = self.bank.layouts[65].width
|
|
57
|
+
header = self.bank.rectangle(7 if has_portrait else 8, 9)
|
|
58
|
+
title_font = TITLE_FONTS.get(theme, 'PajamaHipY26')
|
|
59
|
+
title_indent = 20 if details.get('portrait_mode', 1) == 1 else 5
|
|
60
|
+
title_style = TextStyle(26, 8, indents=(0, title_indent) if has_portrait else ())
|
|
61
|
+
title = layout_text(self.font(title_font), details['title'], header.width, title_style)
|
|
62
|
+
if not has_portrait:
|
|
63
|
+
# A character-free heading occupies the full top of the panel.
|
|
64
|
+
glyphs = tuple(replace(g, x=g.x + (header.width - line.width) / 2)
|
|
65
|
+
for line in title.lines for g in title.glyphs
|
|
66
|
+
if line.start <= g.index < line.end)
|
|
67
|
+
title = replace(title, glyphs=glyphs)
|
|
68
|
+
|
|
69
|
+
color = {1: (1, 76, 215), 2: (166, 45, 45), 3: (108, 108, 108)}.get(theme, (223, 163, 52))
|
|
70
|
+
body_font = self.font(BODY_FONT)
|
|
71
|
+
body_rect = self.bank.rectangle(65, 4)
|
|
72
|
+
# FUN_000d9038 measures progressively around the portrait; the actual
|
|
73
|
+
# text uses the smaller nominal height configured by FUN_000d6afc.
|
|
74
|
+
indents = ()
|
|
75
|
+
if has_portrait:
|
|
76
|
+
for count in (1, 2, 3):
|
|
77
|
+
indents = (105, 95, 85)[:count]
|
|
78
|
+
measured = layout_text(body_font, details['text'], body_rect.width,
|
|
79
|
+
TextStyle(16, 10, color, indents=indents))
|
|
80
|
+
if len(measured.lines) <= count:
|
|
81
|
+
break
|
|
82
|
+
description = layout_text(body_font, details['text'], body_rect.width,
|
|
83
|
+
TextStyle(10, 10, color, indents=indents))
|
|
84
|
+
description_y = max(26, title.height + 2)
|
|
85
|
+
header_height = math.ceil(max(112 if has_portrait else 38,
|
|
86
|
+
description_y + description.height + 6))
|
|
87
|
+
laid_out = []
|
|
88
|
+
for label, enabled in zip(details['options'], details['enabled']):
|
|
89
|
+
text = layout_text(body_font, label, width - 16,
|
|
90
|
+
TextStyle(12, 1, color if enabled else (150, 150, 150)))
|
|
91
|
+
_, top, _, bottom = text.ink_bounds
|
|
92
|
+
laid_out.append((text, max(37 if minigame else 44, math.ceil(bottom - top + 16)), enabled))
|
|
93
|
+
|
|
94
|
+
footer_height = self.bank.layouts[22].height if timed else (80 if minigame else 20)
|
|
95
|
+
height = header_height + sum(h for _, h, _ in laid_out) + footer_height
|
|
96
|
+
# The reference's panel is centered below its overlapping heading.
|
|
97
|
+
# Taller lists can scroll without changing the script's option order.
|
|
98
|
+
box = Rect((320 - width) // 2, max(32, (480 - height) // 2 + (0 if minigame else 11)), width, height)
|
|
99
|
+
rows, y = [], box.y + header_height
|
|
100
|
+
for index, (text, height, enabled) in enumerate(laid_out):
|
|
101
|
+
_, top, _, bottom = text.ink_bounds
|
|
102
|
+
rect = Rect(box.x + 3, y, width - 6, height)
|
|
103
|
+
origin = (box.x + 14, y + (height - (bottom - top)) / 2 - top + 2)
|
|
104
|
+
rows.append(ChoiceRow(index, rect, text, origin, enabled))
|
|
105
|
+
y += height
|
|
106
|
+
portrait = Rect(box.x - 27, box.y - 24, 128, 128) if has_portrait else None
|
|
107
|
+
timer_panel = None
|
|
108
|
+
max_scroll = max(0, box.y + box.height - 421)
|
|
109
|
+
if timed:
|
|
110
|
+
# Layout 22 supplies the timer's lower panel. Keep it visible when
|
|
111
|
+
# the desktop's expanded rows need scrolling above the footer.
|
|
112
|
+
timer_panel = Rect(box.x, min(y, 421 - footer_height), width, footer_height)
|
|
113
|
+
max_scroll = max(0, y - timer_panel.y)
|
|
114
|
+
return ChoicePage(box, title_font, title, (box.x + header.x, box.y - 9),
|
|
115
|
+
description, (box.x + 7, box.y + description_y), portrait,
|
|
116
|
+
tuple(rows), theme, max_scroll, timer_panel)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Optional hints from native word scores or isolated story-choice previews.
|
|
2
|
+
|
|
3
|
+
These are near-term outcome hints, not a solver for an episode's best ending.
|
|
4
|
+
Recognized relationship changes, score notices and per-game answer-feedback
|
|
5
|
+
sounds establish outcomes. Arbitrary counters and dialogue words do not.
|
|
6
|
+
"""
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .content import ContentError
|
|
11
|
+
from .games import GAMES
|
|
12
|
+
from .vm import VMError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Observed in the supplied SHS and CoD scripts. Unrecognized/localized notices
|
|
16
|
+
# stay unknown; ordinary dialogue containing these words is not evidence.
|
|
17
|
+
SCORE_NOTICES = frozenset(('Score Up!', 'Detective Score Up!'))
|
|
18
|
+
DECISIONS = frozenset(('choice', 'character_picker', 'text_input', 'word_game',
|
|
19
|
+
'word_grid', 'football', 'finished', 'episode_exit'))
|
|
20
|
+
PANELS = frozenset(('dialogue', 'presentation', 'message_panel'))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ChoiceHint:
|
|
25
|
+
kind: str # gain, loss, no_gain, mixed, unknown
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def preview_choices(session, *, max_steps=6000, max_panels=48, max_options=16,
|
|
29
|
+
max_decisions=4, max_branches=192):
|
|
30
|
+
"""Compare native outcomes on bounded, isolated runtime copies.
|
|
31
|
+
|
|
32
|
+
Immediate feedback takes precedence. For deferred feedback, compare the
|
|
33
|
+
same later answers across every initial option. An option dominates another
|
|
34
|
+
only if it is never worse and is better for at least one continuation.
|
|
35
|
+
Unmatched decisions, unknown services or exhausted budgets remain unknown.
|
|
36
|
+
"""
|
|
37
|
+
if session.pending and session.pending.name == 'word_game':
|
|
38
|
+
# Service 71 supplies explicit good/bad answers. Read the current deal's
|
|
39
|
+
# weights, including duplicate labels and its native random order.
|
|
40
|
+
game = session.engine.word_game
|
|
41
|
+
return tuple(ChoiceHint('gain' if weight > 0 else 'loss' if weight < 0 else 'no_gain')
|
|
42
|
+
for weight in game.weights) if game else ()
|
|
43
|
+
if not session.pending or session.pending.name != 'choice':
|
|
44
|
+
return ()
|
|
45
|
+
if min(max_steps, max_panels, max_options, max_decisions, max_branches) < 1:
|
|
46
|
+
raise ValueError('Preview budgets must be positive')
|
|
47
|
+
details = session.pending.details
|
|
48
|
+
feedback_sounds = GAMES[session.engine.game_key].CHOICE_FEEDBACK_SOUNDS
|
|
49
|
+
deadline = session.vm.steps_executed + max_steps
|
|
50
|
+
branches = 0
|
|
51
|
+
|
|
52
|
+
def follow(source, value, effects=None, panels=0):
|
|
53
|
+
nonlocal branches
|
|
54
|
+
branches += 1
|
|
55
|
+
if branches > max_branches:
|
|
56
|
+
return None
|
|
57
|
+
preview = deepcopy(source, {id(source.resources): source.resources,
|
|
58
|
+
id(source.vm.program): source.vm.program})
|
|
59
|
+
effects = dict(effects or {})
|
|
60
|
+
|
|
61
|
+
def observe(action):
|
|
62
|
+
if action.name == 'set_next_dialogue_notice' and action.details['text'] in SCORE_NOTICES:
|
|
63
|
+
effects['score_notice'] = effects.get('score_notice', 0) + 1
|
|
64
|
+
elif action.name == 'request_audio' and action.request.yield_id == 79:
|
|
65
|
+
feedback = feedback_sounds.get(action.details['asset_id'], 0)
|
|
66
|
+
if feedback:
|
|
67
|
+
# Explicit correct/wrong feedback can accompany script-local
|
|
68
|
+
# quiz scoring without a relationship or Score Up! notice.
|
|
69
|
+
# Keep opposite cues separate so mixed feedback cannot cancel.
|
|
70
|
+
key = 'correct_feedback' if feedback > 0 else 'incorrect_feedback'
|
|
71
|
+
effects[key] = effects.get(key, 0) + feedback
|
|
72
|
+
elif action.name in ('set_number', 'set_number_bit'):
|
|
73
|
+
owner, key = action.details['owner'], action.details['key']
|
|
74
|
+
if owner > 0 and key == 407:
|
|
75
|
+
address = preview.engine.number_key(owner, key)
|
|
76
|
+
effects[address] = preview.engine.numbers[address] - session.engine.numbers.get(address, 0)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
preview._complete_choice(value)
|
|
80
|
+
while preview.vm.steps_executed < deadline:
|
|
81
|
+
action = preview.advance(max_steps=deadline - preview.vm.steps_executed,
|
|
82
|
+
max_events=2000, observe=observe)
|
|
83
|
+
if action.name in DECISIONS:
|
|
84
|
+
return preview, effects, panels
|
|
85
|
+
if action.name not in PANELS or panels == max_panels:
|
|
86
|
+
break
|
|
87
|
+
panels += 1
|
|
88
|
+
preview._complete_panel()
|
|
89
|
+
preview.engine.title_screen = preview.engine.message_panel = None
|
|
90
|
+
except (ContentError, VMError, OSError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
nodes = [follow(session, value) if enabled and index < max_options else None
|
|
95
|
+
for index, (value, enabled) in enumerate(zip(details['values'], details['enabled']))]
|
|
96
|
+
hints = [ChoiceHint(_kind(node[1]) if node else 'unknown') for node in nodes]
|
|
97
|
+
if not any(node and any(node[1].values()) for node in nodes):
|
|
98
|
+
enabled = [i for i, value in enumerate(details['enabled']) if value]
|
|
99
|
+
|
|
100
|
+
def outcomes(group, depth):
|
|
101
|
+
if not group or any(node is None for node in group):
|
|
102
|
+
return None
|
|
103
|
+
if any(any(node[1].values()) for node in group):
|
|
104
|
+
return [[node[1] for node in group]]
|
|
105
|
+
actions = [node[0].pending for node in group]
|
|
106
|
+
if all(a.name in ('finished', 'episode_exit') for a in actions):
|
|
107
|
+
return [[{} for _ in group]]
|
|
108
|
+
if depth >= max_decisions or any(a.name != 'choice' for a in actions):
|
|
109
|
+
return None
|
|
110
|
+
# Match the actual script decision and result values, independently
|
|
111
|
+
# of native randomized display order. Do not guess how unrelated
|
|
112
|
+
# questions, disabled options or minigames correspond.
|
|
113
|
+
signatures = [(node[0].scene, a.request.pc,
|
|
114
|
+
tuple(sorted(v for v, on in zip(a.details['values'], a.details['enabled']) if on)))
|
|
115
|
+
for node, a in zip(group, actions)]
|
|
116
|
+
if any(s != signatures[0] for s in signatures) or not signatures[0][2]:
|
|
117
|
+
return None
|
|
118
|
+
values = tuple(dict.fromkeys(signatures[0][2]))
|
|
119
|
+
if len(values) > max_options:
|
|
120
|
+
return None
|
|
121
|
+
result = []
|
|
122
|
+
for value in values:
|
|
123
|
+
children = [follow(preview, value, effects, panels) for preview, effects, panels in group]
|
|
124
|
+
leaves = outcomes(children, depth + 1)
|
|
125
|
+
if leaves is None:
|
|
126
|
+
return None
|
|
127
|
+
result.extend(leaves)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
leaves = outcomes([nodes[i] for i in enabled], 1)
|
|
131
|
+
if leaves is not None:
|
|
132
|
+
dominates = set()
|
|
133
|
+
for a in range(len(enabled)):
|
|
134
|
+
for b in range(len(enabled)):
|
|
135
|
+
deltas = [row[a].get(key, 0) - row[b].get(key, 0)
|
|
136
|
+
for row in leaves for key in row[a].keys() | row[b].keys()]
|
|
137
|
+
if deltas and min(deltas) >= 0 and max(deltas) > 0:
|
|
138
|
+
dominates.add((a, b))
|
|
139
|
+
for index, original in enumerate(enabled):
|
|
140
|
+
if any(b == index for _, b in dominates):
|
|
141
|
+
hints[original] = ChoiceHint('no_gain')
|
|
142
|
+
elif any(a == index for a, _ in dominates):
|
|
143
|
+
hints[original] = ChoiceHint('gain')
|
|
144
|
+
# Absence of recognized feedback alone is not proof of a wrong answer.
|
|
145
|
+
# Mark non-scoring alternatives only when this decision has a known gain.
|
|
146
|
+
has_gain = any(hint.kind == 'gain' for hint in hints)
|
|
147
|
+
return tuple(ChoiceHint('unknown') if hint.kind == 'no_gain' and not has_gain else hint
|
|
148
|
+
for hint in hints)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _kind(effects):
|
|
152
|
+
gain, loss = any(v > 0 for v in effects.values()), any(v < 0 for v in effects.values())
|
|
153
|
+
return 'mixed' if gain and loss else 'gain' if gain else 'loss' if loss else 'no_gain'
|