diff --git a/README.md b/README.md
index 39908ee..e0eadcf 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,8 @@
- フラットな候補リストから1つ選ぶノード
- `Prompt Random Choice Ex`
- フラット候補に加えて、`{}` による入れ子の候補展開に対応したノード
+ - 先にすべての葉候補へ展開し、最後に1回だけランダム選択します
+ - 展開済み候補リストは `options_text` が変わるまでキャッシュします
どちらもノードしての出力は同じです。
@@ -63,12 +65,21 @@
ここは実例を見てもらった方が話が早そうじゃ。
```text
-town|zoo{animals{birds|penguins}|aquarium,{()|fish|jellyfish}}
-town|zoo{animals,birds|aquarium,jellyfish}
-town|zoo,animals,birds
-town
+town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
-こんな感じで動くぞ。
+
+これは、内部的には次の候補へ展開され、キャッシュされるのじゃ。
+
+```text
+town
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+
+この5候補から1つが選ばれるのじゃ。
+入れ子になった各要素が、すべて等しい確率で出現するように工夫しておるのじゃ。
## 導入方法
@@ -165,16 +176,81 @@ day|day|day|sunset|night
- `change_every` が 2 以上なら、その回数ぶん同じ候補を維持する
- 実行時にタイトルへ `Choice: lake` や `Choice: (empty) (2/3)` のように表示する
+## Prompt Random Choice
+
+候補リストから1つ選びます。
+
+```text
+town|park|lake|coffee shop
+```
+
+`()` は明示的な空候補です。
+
+```text
+()|(full body:0.9)
+```
+
+## Prompt Random Choice Ex
+
+`Prompt Random Choice` と同じフラットな候補リストをそのまま使えます。
+
+```text
+town|park|lake|coffee shop
+```
+
+さらに、候補の中に `{}` を書くことで、選ばれた候補にだけ追加候補をぶら下げられます。
+
+```text
+town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
+```
+
+この入力は、内部的には次の候補へ展開されます。
+
+```text
+town
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+
+この5候補から1つが選ばれます。
+
+### 複数行の Ex 例
+
+```text
+zoo{
+ animals{
+ birds
+ penguins
+ }
+ aquarium{
+ fish
+ jellyfish
+ }
+}
+```
+
+出力例:
+
+```text
+zoo, animals, birds
+zoo, animals, penguins
+zoo, aquarium, fish
+zoo, aquarium, jellyfish
+```
+
### Ex のルール
- 選択候補の区切り文字は、実際の改行 または `|`
- 空候補は無視
- `()` は明示的な空候補
- `{}` の内部も、実際の改行 または `|` で候補分割
-- `{}` は最内側からランダムに展開
+- `{}` は最内側からすべての葉候補へ展開
- 展開結果は親要素へ `, ` で接続
-- `{}` がなくなるまで繰り返し展開
-- 展開回数には安全上限があります
+- 最終候補リストから最後に1回だけランダム選択
+- 展開済み候補リストは `options_text` が変わるまでキャッシュ
+- 展開候補数と展開回数には安全上限があります
- リテラルの `{` / `}` をプロンプト文字として使う用途は非対応
## 出力
@@ -187,6 +263,14 @@ day|day|day|sunset|night
ファイル名向けに安全化した出力です。
`selected_text` が空なら `empty` を返します。
+## 推奨構成
+
+`selected_text` を kjnodes の `Join String Multi` などへ接続し、区切り文字は結合ノード側で管理するのがおすすめです。
+
+複数の `Prompt Random Choice` / `Prompt Random Choice Ex` を並べることで、背景・時間帯・天気・構図などを別々にランダム化できます。
+
+ただし、Ex はすべての要素を1つにまとめるためのノードではなく、`zoo` の時だけ動物候補を追加するような、親子関係のある候補を扱うためのノードとして使うのがおすすめです。
+
## 標準選択記法 `{day|night|morning}` との違いについて
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index f46c847..2d532b7 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -7,8 +7,9 @@
- `Prompt Random Choice Ex`
- `Prompt Random Choice` と同じフラット候補リストに対応
- `{}` による入れ子の候補展開に対応
- - 最内側の `{}` からランダムに展開
+ - すべての葉候補へ展開してから、最後に1回だけランダム選択
- 展開結果を親要素へ `, ` で接続
+ - 展開済み候補リストを `options_text` 単位でキャッシュ
- `selected_text` / `selected_text_safe` を出力
## 例
@@ -16,8 +17,7 @@
```text
town|zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
```
-
-出力例:
+これは、内部的には次の候補へ展開されます。
```text
town
@@ -26,6 +26,7 @@ zoo, animals, penguins
zoo, aquarium, fish
zoo, aquarium, jellyfish
```
+この5候補から1つが選ばれます。
## 備考
diff --git a/nodes.py b/nodes.py
index 541e18c..a866f93 100644
--- a/nodes.py
+++ b/nodes.py
@@ -25,6 +25,7 @@ amusement park{ferris wheel|carousel|balloons}"""
INNER_BLOCK_PATTERN = re.compile(r"\{([^{}]*)\}", re.DOTALL)
MAX_EXPAND_STEPS = 64
+MAX_EXPANDED_OPTIONS = 4096
def _split_options(options_text: str):
@@ -33,10 +34,6 @@ def _split_options(options_text: str):
Empty candidates are ignored.
"()" is treated as an explicit empty candidate and becomes "".
-
- This function is shared by PromptRandomChoice and PromptRandomChoiceEx.
- For Ex, nested blocks such as zoo{animals|birds} stay together when
- splitting the top-level candidate list.
"""
text = str(options_text or "").replace("\r\n", "\n").replace("\r", "\n")
@@ -68,12 +65,9 @@ def _split_options(options_text: str):
for part in parts:
item = str(part).strip(" \t\r\n,")
- # Empty parts caused by repeated delimiters are ignored.
if not item:
continue
- # Explicit empty choice.
- # Example: ()|(full body:0.9)
if item == "()":
options.append("")
continue
@@ -83,72 +77,16 @@ def _split_options(options_text: str):
return options
-def _choose_from_options_text(options_text: str):
- options = _split_options(options_text)
- if not options:
- return ""
- return secrets.choice(options)
-
-
def _normalize_prompt_fragment(text: str):
- """
- Normalize comma-connected prompt fragments after nested expansion.
-
- This keeps the syntax forgiving:
- zoo{aquarium,{fish|jellyfish}}
- can safely become:
- zoo, aquarium, fish
- even if intermediate expansion produced duplicate commas.
- """
value = str(text or "")
-
- # Normalize line breaks/tabs to spaces.
value = re.sub(r"[\t\r\n]+", " ", value)
-
- # Normalize spaces around commas.
value = re.sub(r"\s*,\s*", ", ", value)
-
- # Collapse duplicate commas.
value = re.sub(r"(,\s*){2,}", ", ", value)
-
- # Collapse spaces.
value = re.sub(r"\s+", " ", value)
-
- # Trim awkward edges.
value = value.strip(" \t\r\n,")
return value
-def _expand_nested_choice(text: str):
- """
- Expand nested {...} blocks from the innermost blocks outward.
-
- Rule:
- - {...} chooses one candidate from inside.
- - Inside candidates are split by top-level "|" or actual newline.
- - The selected nested result is comma-connected to the parent text.
- - "()" is an explicit empty candidate.
- - Unmatched braces are left as-is after the expansion limit is reached.
-
- Example:
- zoo{animals{birds|penguins}|aquarium,{fish|jellyfish}}
- -> zoo, animals, birds
- -> zoo, aquarium, jellyfish
- """
- value = str(text or "").strip(" \t\r\n,")
-
- for _ in range(MAX_EXPAND_STEPS):
- match = INNER_BLOCK_PATTERN.search(value)
- if not match:
- break
-
- chosen = _choose_from_options_text(match.group(1))
- replacement = f", {chosen}" if chosen else ""
- value = value[:match.start()] + replacement + value[match.end():]
-
- return _normalize_prompt_fragment(value)
-
-
def _safe_text(value: str):
text = str(value or "")
@@ -166,6 +104,95 @@ def _safe_text(value: str):
return text or "empty"
+def _find_first_brace_block(text: str):
+ """Return (open_index, close_index) for the first balanced {...} block."""
+ open_index = text.find("{")
+ if open_index < 0:
+ return None
+
+ depth = 0
+ for index in range(open_index, len(text)):
+ char = text[index]
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return open_index, index
+
+ # Unmatched "{". Treat it as normal text rather than looping forever.
+ return None
+
+
+def _expand_one_expression(text: str, depth: int = 0):
+ """
+ Expand one candidate into all final leaf candidates.
+
+ This expands the first outermost {...} block, chooses every top-level
+ branch in that block, and then recursively expands only the selected
+ branch. This avoids duplicating unrelated branches.
+ """
+ if depth > MAX_EXPAND_STEPS:
+ raise ValueError(
+ "PromptRandomChoiceEx expansion exceeded the maximum expansion steps. "
+ "Please check nested braces."
+ )
+
+ value = str(text or "").strip(" \t\r\n,")
+ block = _find_first_brace_block(value)
+
+ if block is None:
+ return [_normalize_prompt_fragment(value)]
+
+ open_index, close_index = block
+ prefix = value[:open_index]
+ body = value[open_index + 1:close_index]
+ suffix = value[close_index + 1:]
+
+ branch_options = _split_options(body)
+ if not branch_options:
+ branch_options = [""]
+
+ expanded = []
+ for branch in branch_options:
+ if branch:
+ candidate = f"{prefix}, {branch}{suffix}"
+ else:
+ candidate = f"{prefix}{suffix}"
+
+ for item in _expand_one_expression(candidate, depth + 1):
+ normalized = _normalize_prompt_fragment(item)
+ expanded.append(normalized)
+
+ if len(expanded) > MAX_EXPANDED_OPTIONS:
+ raise ValueError(
+ "PromptRandomChoiceEx expanded too many options "
+ f"({len(expanded)} > {MAX_EXPANDED_OPTIONS})."
+ )
+
+ return expanded
+
+
+def _build_expanded_options(options_text: str):
+ top_options = _split_options(options_text)
+ expanded_options = []
+
+ for option in top_options:
+ for expanded in _expand_one_expression(option):
+ # Keep "" when it came from explicit (). It allows Ex to return an
+ # intentional empty selection just like PromptRandomChoice.
+ expanded_options.append(expanded)
+
+ if len(expanded_options) > MAX_EXPANDED_OPTIONS:
+ raise ValueError(
+ "PromptRandomChoiceEx expanded too many options "
+ f"({len(expanded_options)} > {MAX_EXPANDED_OPTIONS})."
+ )
+
+ return tuple(expanded_options)
+
+
+
class _RandomChoiceStateMixin:
def __init__(self):
self._current_choice = None
@@ -183,16 +210,14 @@ class _RandomChoiceStateMixin:
self._last_options_key = None
self._last_change_every = None
- def _pick_candidate(self, options):
- return secrets.choice(options)
-
def _select_new_choice(self, options):
- self._current_choice = self._pick_candidate(options)
+ self._current_choice = secrets.choice(options)
self._repeat_index = 1
def _run_choice(self, options, change_every):
change_every = max(1, int(change_every))
- options_key = tuple(options)
+ options = tuple(options)
+ options_key = options
if not options:
self._reset_state()
@@ -201,6 +226,7 @@ class _RandomChoiceStateMixin:
state_changed = (
self._last_options_key != options_key
or self._last_change_every != change_every
+ or self._current_choice not in options
)
if state_changed or self._current_choice is None:
@@ -273,6 +299,11 @@ class PromptRandomChoice(_RandomChoiceStateMixin):
class PromptRandomChoiceEx(_RandomChoiceStateMixin):
+ def __init__(self):
+ super().__init__()
+ self._expanded_options_cache_key = None
+ self._expanded_options_cache = tuple()
+
@classmethod
def INPUT_TYPES(cls):
return {
@@ -301,12 +332,20 @@ class PromptRandomChoiceEx(_RandomChoiceStateMixin):
FUNCTION = "pick"
CATEGORY = "utils/prompt"
- def _pick_candidate(self, options):
- raw_choice = secrets.choice(options)
- return _expand_nested_choice(raw_choice)
+ def _get_expanded_options_cached(self, options_text):
+ cache_key = str(options_text or "")
+
+ if self._expanded_options_cache_key == cache_key:
+ return self._expanded_options_cache
+
+ expanded_options = _build_expanded_options(cache_key)
+ self._expanded_options_cache_key = cache_key
+ self._expanded_options_cache = expanded_options
+
+ return expanded_options
def pick(self, options_text, change_every):
- options = _split_options(options_text)
+ options = self._get_expanded_options_cached(options_text)
selected_text, selected_text_safe, selected_text_title, repeat_index, change_every = self._run_choice(
options,
change_every,