| """CustomerAgent state machine (R5) — full MVP behaviors. |
| |
| States: SPAWNING → BROWSE ⇄ LINGER → CONSIDER → QUEUE → PAY → EXIT |
| Talk is interruptible from BROWSE/LINGER/CONSIDER and restores return_state. |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import List, Optional, Sequence |
|
|
| class AgentState(str, Enum): |
| SPAWNING = "SPAWNING" |
| BROWSE = "BROWSE" |
| LINGER = "LINGER" |
| CONSIDER = "CONSIDER" |
| QUEUE = "QUEUE" |
| PAY = "PAY" |
| TALK = "TALK" |
| EXIT = "EXIT" |
|
|
| |
| LEGAL_TRANSITIONS = { |
| AgentState.SPAWNING: {AgentState.BROWSE, AgentState.EXIT}, |
| AgentState.BROWSE: {AgentState.LINGER, AgentState.CONSIDER, AgentState.TALK, AgentState.EXIT}, |
| AgentState.LINGER: {AgentState.BROWSE, AgentState.CONSIDER, AgentState.TALK, AgentState.EXIT}, |
| AgentState.CONSIDER: {AgentState.QUEUE, AgentState.BROWSE, AgentState.TALK, AgentState.EXIT}, |
| AgentState.QUEUE: {AgentState.PAY, AgentState.EXIT}, |
| AgentState.PAY: {AgentState.EXIT}, |
| AgentState.TALK: {AgentState.BROWSE, AgentState.LINGER, AgentState.CONSIDER, AgentState.EXIT}, |
| AgentState.EXIT: set(), |
| } |
|
|
| @dataclass |
| class CustomerAgent: |
| agent_id: str |
| npc_id: str |
| kind: str |
| preferred_atmosphere: dict |
| desired_tags: List[str] |
| stage: str = "STRANGER" |
| state: AgentState = AgentState.SPAWNING |
| patience: float = 60.0 |
| consider_left: float = 0.0 |
| target_slot: Optional[str] = None |
| target_product: Optional[str] = None |
| consider_match: float = 0.0 |
| return_state: Optional[AgentState] = None |
| tick_counter: int = 0 |
| browse_ticks: int = 0 |
| linger_ticks: int = 0 |
| path: List[str] = field(default_factory=list) |
| path_index: int = 0 |
| last_bark: Optional[str] = None |
| entered_attract: float = 0.0 |
| purchase_done: bool = False |
| queue_wait: float = 0.0 |
| history: List[str] = field(default_factory=list) |
|
|
| def start(self, patience: float) -> None: |
| self.patience = float(patience) |
| self._enter(AgentState.BROWSE) |
| self.path = ["door", "browse_1", "browse_2", "counter"] |
| self.path_index = 0 |
| self.browse_ticks = 0 |
| self.linger_ticks = 0 |
| self.purchase_done = False |
| self.queue_wait = 0.0 |
|
|
| def _enter(self, new_state: AgentState) -> None: |
| self.state = new_state |
| self.history.append(new_state.value) |
|
|
| def can_transition(self, new_state: AgentState) -> bool: |
| return new_state in LEGAL_TRANSITIONS.get(self.state, set()) |
|
|
| def current_marker(self) -> str: |
| if not self.path: |
| return "door" |
| return self.path[min(self.path_index, len(self.path) - 1)] |
|
|
| def advance_path(self) -> None: |
| if self.path_index < len(self.path) - 1: |
| self.path_index += 1 |
|
|
| def begin_talk(self) -> None: |
| if self.state in (AgentState.EXIT, AgentState.PAY, AgentState.QUEUE): |
| return |
| self.return_state = self.state |
| self._enter(AgentState.TALK) |
|
|
| def end_talk(self) -> None: |
| if self.state != AgentState.TALK: |
| return |
| restore = self.return_state or AgentState.BROWSE |
| self.return_state = None |
| self._enter(restore) |
|
|
| def drain_patience(self, delta: float) -> bool: |
| if self.state in (AgentState.TALK, AgentState.PAY, AgentState.QUEUE, AgentState.CONSIDER): |
| |
| if self.state != AgentState.CONSIDER: |
| return False |
| self.patience -= delta * 0.25 |
| else: |
| self.patience -= delta |
| if self.patience <= 0: |
| self._enter(AgentState.EXIT) |
| return True |
| return False |
|
|
| def begin_consider(self, product_id: str, slot: str, match: float, consider_sec: float = 4.0) -> bool: |
| """BROWSE/LINGER → CONSIDER when a product is interesting enough.""" |
| if self.state not in (AgentState.BROWSE, AgentState.LINGER): |
| return False |
| if match < 0.35: |
| return False |
| self.target_product = product_id |
| self.target_slot = slot |
| self.consider_match = float(match) |
| self.consider_left = float(consider_sec) |
| self._enter(AgentState.CONSIDER) |
| return True |
|
|
| def join_queue(self) -> bool: |
| if self.state != AgentState.CONSIDER: |
| return False |
| self.queue_wait = 0.0 |
| self._enter(AgentState.QUEUE) |
| return True |
|
|
| def begin_pay(self) -> bool: |
| if self.state != AgentState.QUEUE: |
| return False |
| self._enter(AgentState.PAY) |
| return True |
|
|
| def complete_pay(self) -> bool: |
| if self.state != AgentState.PAY: |
| return False |
| self.purchase_done = True |
| self._enter(AgentState.EXIT) |
| return True |
|
|
| def abandon(self) -> None: |
| if self.state != AgentState.EXIT: |
| self._enter(AgentState.EXIT) |
|
|
| def tick_state( |
| self, |
| dt: float, |
| *, |
| display_products: Optional[Sequence[dict]] = None, |
| queue_ready: bool = True, |
| buy_threshold: float = 0.45, |
| ) -> AgentState: |
| """Advance the agent one sim step. Pure logic — no I/O. |
| |
| display_products items: {id, slot, match} match in [0,1]. |
| """ |
| self.tick_counter += 1 |
| if self.state == AgentState.EXIT: |
| return self.state |
| if self.state == AgentState.SPAWNING: |
| self.start(self.patience if self.patience > 0 else 60.0) |
| return self.state |
| if self.state == AgentState.TALK: |
| |
| return self.state |
|
|
| if self.drain_patience(dt): |
| return self.state |
|
|
| if self.state == AgentState.BROWSE: |
| self.browse_ticks += 1 |
| if self.browse_ticks % 2 == 0: |
| self.advance_path() |
| |
| if self.browse_ticks >= 3 and self.kind == "regular": |
| self._enter(AgentState.LINGER) |
| return self.state |
| |
| if display_products: |
| best = max(display_products, key=lambda x: float(x.get("match", 0.0))) |
| if float(best.get("match", 0.0)) >= buy_threshold: |
| self.begin_consider( |
| str(best.get("id") or best.get("product_id") or ""), |
| str(best.get("slot") or ""), |
| float(best.get("match", 0.0)), |
| ) |
| return self.state |
|
|
| if self.state == AgentState.LINGER: |
| self.linger_ticks += 1 |
| if display_products: |
| best = max(display_products, key=lambda x: float(x.get("match", 0.0))) |
| if float(best.get("match", 0.0)) >= buy_threshold * 0.9: |
| self.begin_consider( |
| str(best.get("id") or best.get("product_id") or ""), |
| str(best.get("slot") or ""), |
| float(best.get("match", 0.0)), |
| ) |
| return self.state |
| if self.linger_ticks >= 4: |
| self._enter(AgentState.BROWSE) |
| self.browse_ticks = 0 |
| return self.state |
|
|
| if self.state == AgentState.CONSIDER: |
| self.consider_left -= dt |
| if self.consider_left <= 0: |
| if self.consider_match >= buy_threshold: |
| self.join_queue() |
| else: |
| self._enter(AgentState.BROWSE) |
| self.target_product = None |
| return self.state |
|
|
| if self.state == AgentState.QUEUE: |
| self.queue_wait += dt |
| if queue_ready and self.queue_wait >= 0.5: |
| self.begin_pay() |
| return self.state |
|
|
| if self.state == AgentState.PAY: |
| |
| self.complete_pay() |
| return self.state |
|
|
| return self.state |
|
|
| def to_dict(self) -> dict: |
| return { |
| "agent_id": self.agent_id, |
| "npc_id": self.npc_id, |
| "kind": self.kind, |
| "state": self.state.value, |
| "patience": self.patience, |
| "stage": self.stage, |
| "target_product": self.target_product, |
| "entered_attract": self.entered_attract, |
| "purchase_done": self.purchase_done, |
| "history": list(self.history), |
| } |
|
|
| @classmethod |
| def from_dict(cls, data: dict) -> "CustomerAgent": |
| agent = cls( |
| agent_id=str(data["agent_id"]), |
| npc_id=str(data["npc_id"]), |
| kind=str(data.get("kind") or "passerby"), |
| preferred_atmosphere=dict(data.get("preferred_atmosphere") or {}), |
| desired_tags=list(data.get("desired_tags") or []), |
| stage=str(data.get("stage") or "STRANGER"), |
| patience=float(data.get("patience") or 60.0), |
| target_product=data.get("target_product"), |
| entered_attract=float(data.get("entered_attract") or 0.0), |
| purchase_done=bool(data.get("purchase_done") or False), |
| ) |
| st = data.get("state") |
| if st: |
| agent.state = AgentState(st) |
| agent.history = list(data.get("history") or []) |
| return agent |
|
|