from otree.api import Bot, Submission import os import random import time from .pages import ( Consent, Intake, GeneralInstructions, ExampleDemo, ExperimentIntro, IndividualRound, SignalRound, ActionRound, ActionKnownRound, Survey, Demographics, LikertScale, PartnerRatings, Results, ThankYou, ) from .models import Constants def _bot_identity_for(participant_id_in_session: int, session_code: str = ''): """Return (node_id, label, role_title) for a bot participant. Uses org chart labels so matching logic is exercised with real nodes. Node choice is randomized (but deterministic per session) so bots don't always join as node 0, 1, 2, 3... which can hide matching issues. """ from .org_chart_utils import get_all_node_labels, get_node_id_by_label from .pages import _load_team_title_by_name labels = [l for l in (get_all_node_labels() or []) if get_node_id_by_label(l) is not None] if not labels: return 10, "Test User", "Test Role" # Deterministic shuffle per session so node assignment is: # - randomized for better matching tests # - stable/reproducible within a given bot run seed = (hash(session_code) if session_code else 0) & 0xFFFFFFFF rng = random.Random(seed) shuffled = list(labels) rng.shuffle(shuffled) idx = max(0, int(participant_id_in_session or 1) - 1) % len(shuffled) label = shuffled[idx] node_id = get_node_id_by_label(label) title_by_name = _load_team_title_by_name() or {} role_title = title_by_name.get(label) or "Test Role" return int(node_id), label, role_title class PlayerBot(Bot): # Default: run the full end-to-end consented flow. # (Keeping a separate no-consent regression path in the code is still useful, # but we don't include it in `cases` so `otree test org_learning 30` runs just # one scenario.) cases = ['consent_full'] def _rng(self): # Deterministic per participant for reproducible exports. return random.Random(hash(self.participant.code) % (2**32)) def _two_guesses(self): rng = self._rng() first = rng.randint(4, 16) updated = max(4, min(16, first + rng.choice([-1, 0, 1]))) return first, updated def play_round(self): if self.case == 'no_consent': # Kept for optional regression testing. if self.round_number == 1: yield Consent, dict(consent_agreed=False) yield Submission(ThankYou, check_html=False) return if self.case != 'consent_full': return # Round 1: consent + intake + instructions + practice + intro. if self.round_number == 1: if os.environ.get('BOT_STAGGER') in ['1', 'true', 'True']: time.sleep(0.05 * (self.participant.id_in_session - 1)) node_id, label, role_title = _bot_identity_for(self.participant.id_in_session, session_code=self.session.code) yield Consent, dict(consent_agreed=True) yield Intake, dict( org_chart_position=node_id, role_title=role_title, first_name=label, ) yield GeneralInstructions demo_guess, _ = self._two_guesses() yield ExampleDemo, dict(demo_guess=demo_guess) yield ExperimentIntro # Experiment rounds: one of (Individual, Signal, Action, ActionKnown) total_rounds = int(self.participant.vars.get('num_rounds') or 0) if total_rounds <= 0: return if self.round_number > total_rounds: return plan = self.participant.vars.get('round_plan') or [] entry = plan[self.round_number - 1] if self.round_number <= len(plan) else {} round_type = entry.get('round_type') pair_mode = entry.get('pair_mode') first, updated = self._two_guesses() if round_type == 'individual': yield IndividualRound, dict(individual_guess_1=first, individual_guess_2=updated) elif round_type == 'pair' and pair_mode == 'signal': yield SignalRound, dict(signal_guess_initial=first, signal_guess_updated=updated) elif round_type == 'pair' and pair_mode == 'action': yield ActionRound, dict(action_guess_initial=first, action_guess_updated=updated) elif round_type == 'pair' and pair_mode == 'action-known': rng = self._rng() choice = 'match' if (rng.randint(0, 1) == 0) else 'own' payload = dict(action_guess_initial=first, action_known_choice=choice) if choice == 'own': payload['action_guess_updated'] = updated yield ActionKnownRound, payload # Final round: post-experiment pages. if self.round_number == total_rounds: # Survey + PartnerRatings read raw POST data from self._form_data. # Bots can still submit arbitrary keys even though form_fields=[] on those Pages. survey_payload = { 'advice_name_1': 'Colleague A', 'advice_name_2': 'Colleague B', 'advice_name_3': 'Colleague C', 'friends_name_1': 'Colleague D', 'friends_name_2': 'Colleague E', 'friends_name_3': 'Colleague F', 'insight_name_1': 'Colleague G', 'insight_name_2': 'Colleague H', 'insight_name_3': 'Colleague I', } yield Submission(Survey, survey_payload, check_html=False) yield Demographics, dict( gender='Prefer not to say', gender_self_describe='', tenure_years='1-2', work_location='Remote', ) yield LikertScale, {f'likert_{i}': 4 for i in range(1, 16)} partner_codes = sorted( { e.get('partner_code') for e in plan if (e or {}).get('round_type') == 'pair' and (e or {}).get('partner_code') } ) if partner_codes: ratings_payload = {f'rating_{code}': '7' for code in partner_codes} yield Submission(PartnerRatings, ratings_payload, check_html=False) yield Results, dict(participant_comments='Bot: completed full flow.')