| """Bark selection with phase cooldown (design R5/R11).""" | |
| from __future__ import annotations | |
| import random | |
| from typing import Dict, List, Optional, Set, Tuple | |
| class BarkSelector: | |
| def __init__(self, rng: Optional[random.Random] = None): | |
| self.rng = rng or random.Random() | |
| # (npc_id, phase) -> used bark ids this phase instance | |
| self._used: Dict[Tuple[str, str], Set[str]] = {} | |
| def reset_phase(self, phase: str) -> None: | |
| keys = [k for k in self._used if k[1] == phase] | |
| for k in keys: | |
| del self._used[k] | |
| def pick(self, npc_id: str, phase: str, barks: List[dict]) -> Optional[str]: | |
| if not barks: | |
| return None | |
| key = (npc_id, phase) | |
| used = self._used.setdefault(key, set()) | |
| candidates = [] | |
| for b in barks: | |
| bid = b.get("id") or b.get("text") | |
| bphase = b.get("phase") | |
| if bid in used: | |
| continue | |
| if bphase and bphase != phase: | |
| continue | |
| candidates.append(b) | |
| if not candidates: | |
| # allow phase-agnostic leftovers | |
| candidates = [b for b in barks if (b.get("id") or b.get("text")) not in used] | |
| if not candidates: | |
| return None | |
| choice = self.rng.choice(candidates) | |
| bid = choice.get("id") or choice.get("text") | |
| used.add(bid) | |
| return choice.get("text") | |
| # reseal-product R387 | |