import random import time from otree.api import * doc = """ Buyer-Seller-Appraiser Bargaining Experiment — Allegiance Bias Treatment. Single-stage 90-second bargaining. Appraiser submits valuation before bargaining starts. Buyer has a button in the first 45s to invite the expert (costs 5 lab points). 22 total rounds: Round 1 = instructions/comprehension only, Round 2 = practice bargaining, Rounds 3-22 = 20 real rounds. Buyer-Appraiser pairings are fixed from round 2 onward. Seller is randomly rematched every round. """ # ── Constants ───────────────────────────────────────────────────────────────── class C(BaseConstants): """ Global experiment constants. These never change during a session. Editing any value here will affect the entire experiment. """ NAME_IN_URL = 'bargaining_game' # Appears in the browser URL PLAYERS_PER_GROUP = 3 # One Buyer, one Seller, one Appraiser per group NUM_ROUNDS = 22 # Round 1=instructions, Round 2=practice, Rounds 3-22=real ROLES = ['Buyer', 'Seller', 'Appraiser'] PRICE_MIN = 0 # Minimum slider position on bargaining page PRICE_MAX = 400 # Maximum slider position on bargaining page APPRAISER_MIN = 100 # Minimum valid appraisal value APPRAISER_MAX = 400 # Maximum valid appraisal value BARGAINING_SECONDS = 90 # Total bargaining phase duration in seconds INVITE_WINDOW_SECONDS = 45 # Buyer can only invite expert within first 45 seconds APPRAISAL_BOX_HALF = 5 # Half-width of appraiser's signal range (full range = 10) # ── Session Setup ────────────────────────────────────────────────────────────── def creating_session(subsession): """ Called automatically by oTree at the start of every round. Handles role assignment and group matching logic differently depending on the round: Round 1: - Randomly assigns all participants to Buyer, Seller, or Appraiser roles. - Stores role codes in participant.vars so they persist across all rounds. - Creates initial random groups (all three roles shuffled independently). Round 2: - Establishes permanent Buyer-Appraiser pairings by randomly shuffling appraisers and pairing them with buyers. These pairings are saved in participant.vars and reused for rounds 3-22. - Sellers are randomly assigned for this round only. Rounds 3-22: - Buyer-Appraiser pairings remain fixed (loaded from participant.vars). - Sellers are randomly reshuffled every round. After matching, each group's pairing labels and product values are initialized. """ players = subsession.get_players() round_number = subsession.round_number if round_number == 1: # Shuffle all participants and divide them equally into three roles all_participants = [p.participant for p in players] random.shuffle(all_participants) n = len(all_participants) per_role = n // 3 buyers_codes = [] sellers_codes = [] appraisers_codes = [] # Assign roles by thirds: first third = Buyers, second = Sellers, third = Appraisers for i, part in enumerate(all_participants): if i < per_role: part.vars['role_name'] = 'Buyer' buyers_codes.append(part.code) elif i < per_role * 2: part.vars['role_name'] = 'Seller' sellers_codes.append(part.code) else: part.vars['role_name'] = 'Appraiser' appraisers_codes.append(part.code) # Store role code lists in every participant's vars so any round can access them for part in all_participants: part.vars['buyers_codes'] = buyers_codes part.vars['sellers_codes'] = sellers_codes part.vars['appraisers_codes'] = appraisers_codes # Build a lookup dict: participant code -> player object for this round code_to_player = {p.participant.code: p for p in players} # Write role_name to each player's database field for p in players: p.role_name = p.participant.vars['role_name'] # Reconstruct role lists as player objects buyers = [code_to_player[c] for c in buyers_codes] sellers = [code_to_player[c] for c in sellers_codes] appraisers = [code_to_player[c] for c in appraisers_codes] # Shuffle each role independently for random round 1 grouping buyers_shuffled = buyers[:] appraisers_shuffled = appraisers[:] sellers_shuffled = sellers[:] random.shuffle(buyers_shuffled) random.shuffle(appraisers_shuffled) random.shuffle(sellers_shuffled) # Set group matrix: each group = [Buyer, Seller, Appraiser] matrix = [ [buyers_shuffled[i], sellers_shuffled[i], appraisers_shuffled[i]] for i in range(per_role) ] subsession.set_group_matrix(matrix) else: # Rounds 2-22: restore roles from participant.vars code_to_player = {p.participant.code: p for p in players} for p in players: p.role_name = p.participant.vars.get('role_name', '') # Load role code lists from the first participant's vars (same for everyone) part0 = players[0].participant buyers_codes = part0.vars['buyers_codes'] sellers_codes = part0.vars['sellers_codes'] appraisers_codes = part0.vars['appraisers_codes'] buyers = [code_to_player[c] for c in buyers_codes] sellers = [code_to_player[c] for c in sellers_codes] appraisers = [code_to_player[c] for c in appraisers_codes] if round_number == 2: # Create fixed Buyer-Appraiser pairings for the first time. # Shuffle appraisers and pair each one with a buyer by index. # These pairings are saved in participant.vars and reused for all future rounds. appraisers_shuffled = appraisers[:] random.shuffle(appraisers_shuffled) real_pairings = [(buyers[i].participant.code, appraisers_shuffled[i].participant.code) for i in range(len(buyers))] # Write pairings to every participant so all rounds can access them for part in part0.session.get_participants(): part.vars['real_pairings'] = real_pairings # Load fixed Buyer-Appraiser pairings (established in round 2) real_pairings = part0.vars['real_pairings'] # Reshuffle sellers randomly every round sellers_shuffled = sellers[:] random.shuffle(sellers_shuffled) # Build group matrix using fixed buyer-appraiser pairs and new random seller each round matrix = [ [code_to_player[real_pairings[i][0]], # Fixed Buyer sellers_shuffled[i], # Random Seller code_to_player[real_pairings[i][1]]] # Fixed Appraiser for i in range(len(buyers)) ] subsession.set_group_matrix(matrix) # After groups are set, initialise each group's product value and store pairing labels for group in subsession.get_groups(): _init_group(group) buyer = get_buyer(group) seller = get_seller(group) appraiser = get_appraiser(group) # Use participant label if available, otherwise fall back to participant code b_lbl = buyer.participant.label or buyer.participant.code s_lbl = seller.participant.label or seller.participant.code a_lbl = appraiser.participant.label or appraiser.participant.code # Write pairing labels to every player in the group for data export for p in group.get_players(): p.paired_buyer_label = b_lbl p.paired_appraiser_label = a_lbl p.paired_seller_label = s_lbl group.group_labels = f'Buyer:{b_lbl} Seller:{s_lbl} Appraiser:{a_lbl}' def _init_group(group): """ Initialises the product value and appraiser signal range for a group at the start of each round. Called once per group inside creating_session. Steps: 1. Randomly pick a box center between 105 and 395 (ensuring the full 10-point range stays within 100-400). 2. Set the appraiser's signal range as [box_center - 5, box_center + 5]. 3. Draw the actual product value uniformly from that range. 4. Assign the same product value to all three players in the group. The appraiser sees the range; the buyer and seller only know the product value is somewhere between 100 and 400. """ appraiser = get_appraiser(group) appraiser.appraisal_box_center = random.randint(105, 395) box_min = appraiser.appraisal_box_center - C.APPRAISAL_BOX_HALF box_max = appraiser.appraisal_box_center + C.APPRAISAL_BOX_HALF appraiser.sig_low = box_min appraiser.sig_high = box_max # Draw the true product value from the appraiser's range pv = random.randint(box_min, box_max) # All three players get the same product value stored on their player record for p in group.get_players(): p.product_value = pv def _compute_round_payoff(player): """ Computes and stores the final_payoff and cumulative_payoff for a player for the current round. Called from before_next_page on FinalResults and AppraiserRoundResult so it runs even if a participant times out or skips ahead. Payoff rules: Buyer: - Deal reached: product_value - final_price + 75 - No deal: 75 - Expert called: subtract 5 in either case Seller: - Deal reached: final_price - No deal: product_value - 100 Appraiser: - Expert was called by buyer: 5 - Expert not called: 0 Uses field_maybe_none() on group fields to safely handle None values (e.g. bargaining_price is None when no deal was reached). Returns: Tuple of (final_payoff, cumulative_payoff) for this player. """ group = player.group appraiser_called = group.field_maybe_none('wants_appraiser') == True deal_reached = group.bargaining_deal final_price = group.field_maybe_none('bargaining_price') pv = player.product_value or 0 if player.role_name == 'Buyer': payoff = (pv - final_price + 75) if deal_reached else 75 if appraiser_called: payoff -= 5 elif player.role_name == 'Seller': payoff = final_price if deal_reached else (pv - 100) else: # Appraiser payoff = 5 if appraiser_called else 0 player.final_payoff = int(round(payoff)) # Sum all round payoffs from round 2 onwards (excludes round 1 instructions round) cumulative_pts = sum( p.final_payoff or 0 for p in player.in_all_rounds() if p.round_number >= 2 and p.final_payoff is not None ) player.cumulative_payoff = cumulative_pts return player.final_payoff, cumulative_pts # ── Models ──────────────────────────────────────────────────────────────────── class Subsession(BaseSubsession): """ Standard oTree subsession. No custom fields needed — all session-level logic is handled in creating_session(). """ pass class Group(BaseGroup): """ Stores all group-level data for a single round. All three players in a group share these values. Fields use blank=True/null=True where the value may not exist (e.g. bargaining_price is None if no deal was reached). """ bargaining_deal = models.BooleanField(initial=False) # True if buyer and seller reached a deal bargaining_price = models.IntegerField(blank=True, null=True) # Price at which deal was struck; None if no deal ended_by_timer = models.BooleanField(initial=False) # True if the 90s timer ran out without a deal buyer_bar_pos = models.IntegerField(initial=0) # Buyer's final slider position (0-400) seller_bar_pos = models.IntegerField(initial=400) # Seller's final slider position (0-400) total_time = models.IntegerField(blank=True, null=True) # Total seconds elapsed in bargaining phase round_start_ms = models.FloatField(blank=True, null=True) # Unix timestamp in ms when bargaining started round_ended = models.BooleanField(initial=False) # True once the round has concluded (deal or timeout) wants_appraiser = models.BooleanField(blank=True, null=True) # True=invited, False=declined, None=not yet decided group_labels = models.StringField(blank=True) # e.g. 'Buyer:X01 Seller:X05 Appraiser:X09' class Player(BasePlayer): """ Stores all player-level data for a single round. Fields are repeated for every round (1-22) for each participant. Survey fields are only collected in round 22; all other rounds leave them blank. """ # ── Role ────────────────────────────────────────────────────────────────── role_name = models.StringField() # 'Buyer', 'Seller', or 'Appraiser' # ── Product & Payoff ────────────────────────────────────────────────────── product_value = models.IntegerField(blank=True, null=True) # True realized value of the product this round final_payoff = models.IntegerField(blank=True, null=True) # Lab points earned this round cumulative_payoff = models.IntegerField(initial=0) # Running total of lab points from round 2 onwards # ── Bargaining ──────────────────────────────────────────────────────────── price_history = models.LongStringField(initial='') # JSON array of submitted price events: [{price, time}, ...] time_in_round = models.IntegerField(initial=0) # Seconds spent in bargaining phase # ── Knowledge Check (round 1 only) ──────────────────────────────────────── comprehension_passed = models.BooleanField(initial=False) # True if participant eventually passed all 6 questions knowledge_check_bonus = models.IntegerField(initial=0) # Number of questions correct on FIRST attempt (0-6) first_attempt_done = models.BooleanField(initial=False) # True once participant has submitted at least once kq1_correct = models.BooleanField(initial=False) # True if question 1 was correct on first attempt kq2_correct = models.BooleanField(initial=False) # True if question 2 was correct on first attempt kq3_correct = models.BooleanField(initial=False) # True if question 3 was correct on first attempt kq4_correct = models.BooleanField(initial=False) # True if question 4 was correct on first attempt kq5_correct = models.BooleanField(initial=False) # True if question 5 was correct on first attempt kq6_correct = models.BooleanField(initial=False) # True if question 6 was correct on first attempt # ── Appraiser Signal (Appraiser row only) ───────────────────────────────── appraisal_box_center = models.IntegerField(blank=True, null=True) # Midpoint of the 10-point signal range sig_low = models.IntegerField(blank=True, null=True) # Lower bound of signal range (box_center - 5) sig_high = models.IntegerField(blank=True, null=True) # Upper bound of signal range (box_center + 5) appraisal_value = models.IntegerField(min=C.APPRAISER_MIN, max=C.APPRAISER_MAX, blank=True, null=True) # Submitted appraisal (100-400) # ── Expert Invitation (Buyer row only) ──────────────────────────────────── call_appraiser = models.BooleanField(blank=True, null=True) # True if buyer clicked invite; False if declined; None if window expired invite_time = models.FloatField(blank=True, null=True) # Elapsed seconds from bargaining start to invite click; None if not invited # ── Pairing Tracking ────────────────────────────────────────────────────── paired_buyer_label = models.StringField(blank=True) # Participant label of Buyer in this group this round paired_appraiser_label = models.StringField(blank=True) # Participant label of Appraiser in this group this round paired_seller_label = models.StringField(blank=True) # Participant label of Seller in this group this round # ── Survey Fields (round 22 only — blank all other rounds) ─────────────── q_age = models.IntegerField(blank=True, null=True, min=10, max=100) q_is_student = models.IntegerField(blank=True, null=True) # 1=Yes, 0=No q_field_of_study = models.StringField(blank=True) # Free text (students only) q_year_of_study = models.IntegerField(blank=True, null=True) # 1=1st year undergrad ... 9=Does not apply q_highest_education = models.IntegerField(blank=True, null=True) # 1=Primary ... 6=Does not apply (non-students) q_region = models.IntegerField(blank=True, null=True) # Kept in model but not collected (removed from survey form) q_region_other = models.StringField(blank=True) # Kept in model but not collected q_gender = models.IntegerField(blank=True, null=True) # 1=Female, 2=Male, 3=Self-identify, 4=Prefer not to answer q_gender_self_identify = models.StringField(blank=True) # Free text if q_gender=3 q_income_bracket = models.IntegerField(blank=True, null=True) # Kept in model but not collected (removed from survey form) q_expense_coverage = models.IntegerField(blank=True, null=True) # Kept in model but not collected (removed from survey form) q_risk_willingness = models.IntegerField(blank=True, null=True, min=0, max=10) # 0=not willing, 10=very willing q_appraisal_bias = models.IntegerField(blank=True, null=True) # 1=Always favored buyer ... 7=Always favored seller q_instruction_clarity = models.IntegerField(blank=True, null=True, min=1, max=7) # 1=very unclear, 7=very clear q_comments = models.LongStringField(blank=True) # Optional free-text comments # ── Helper Functions ────────────────────────────────────────────────────────── def get_buyer(group): """Returns the Buyer player object from a group.""" return next(p for p in group.get_players() if p.role_name == 'Buyer') def get_seller(group): """Returns the Seller player object from a group.""" return next(p for p in group.get_players() if p.role_name == 'Seller') def get_appraiser(group): """Returns the Appraiser player object from a group.""" return next(p for p in group.get_players() if p.role_name == 'Appraiser') def get_appraiser_valuation(group): """ Safely returns the appraiser's submitted appraisal value for a group. Uses field_maybe_none() to avoid crashes if the appraiser has not yet submitted their valuation (e.g. page refresh or timing edge case). Returns None if no valuation has been submitted yet. """ return get_appraiser(group).field_maybe_none('appraisal_value') # ── Pages ───────────────────────────────────────────────────────────────────── class AssignRolesWaitPage(WaitPage): """ The very first page of the experiment. All participants wait here until everyone has connected, then creating_session runs and assigns roles. Only shown in round 1. """ @staticmethod def is_displayed(player): return player.round_number == 1 class RoleAssignment(Page): """ Tells each participant which role they have been assigned (Buyer, Seller, Appraiser). Only shown in round 1. Passes the role name to the template for display. """ @staticmethod def is_displayed(player): return player.round_number == 1 @staticmethod def vars_for_template(player): return dict(role=player.role_name) class InstructionsPage1(Page): """ General instructions page shown to all participants before the comprehension check. Explains the experiment setup, product value ranges, payoff rules, and examples. Only shown in round 1. No role-specific content — role-specific details come in InstructionsPage2. """ @staticmethod def is_displayed(player): return player.round_number == 1 class HardStop(WaitPage): """ Experimenter-controlled gate placed between InstructionsPage1 and ComprehensionCheck. All participants wait here after reading the general instructions. The experimenter releases everyone at once by clicking 'Mark complete' in the admin session monitor. This allows the experimenter to answer questions before the knowledge check begins. wait_for_all_groups=True means the release affects all groups simultaneously. Only shown in round 1. """ wait_for_all_groups = True @staticmethod def is_displayed(player): return player.round_number == 1 class ComprehensionCheck(Page): """ Six-question knowledge check. Participants must answer all questions correctly to proceed. They can retry as many times as needed, but only the first attempt counts for the knowledge bonus ($0.30 per correct answer on first attempt). Uses a live_method to handle answer submission via JavaScript without a page reload: - Receives answers via liveSend({type: 'submit_answers', answers: {...}}) - Checks each answer against the correct key - On first submission only: records per-question correctness and total bonus count - Returns {status: 'passed'} if all correct, {status: 'failed', wrong: [...]} otherwise - The page only advances (form.submit()) when all answers are correct Correct answers: q1=B, q2=C, q3=A, q4=B, q5=A, q6=D Only shown in round 1. """ form_model = 'player' form_fields = [] @staticmethod def is_displayed(player): return player.round_number == 1 @staticmethod def live_method(player, data): if data.get('type') == 'submit_answers': answers = data.get('answers', {}) correct = {'q1': 'B', 'q2': 'C', 'q3': 'A', 'q4': 'B', 'q5': 'A', 'q6': 'D'} wrong = [] for q, correct_ans in correct.items(): if answers.get(q) != correct_ans: wrong.append(q) # Only record per-question results on the very first submission attempt if not player.first_attempt_done: player.first_attempt_done = True correct_count = sum(1 for q, a in correct.items() if answers.get(q) == a) player.knowledge_check_bonus = correct_count player.kq1_correct = (answers.get('q1') == correct['q1']) player.kq2_correct = (answers.get('q2') == correct['q2']) player.kq3_correct = (answers.get('q3') == correct['q3']) player.kq4_correct = (answers.get('q4') == correct['q4']) player.kq5_correct = (answers.get('q5') == correct['q5']) player.kq6_correct = (answers.get('q6') == correct['q6']) if not wrong: player.comprehension_passed = True return {player.id_in_group: {'status': 'passed'}} else: return {player.id_in_group: {'status': 'failed', 'wrong': wrong}} class InstructionsPage2(Page): """ Role-specific instructions page shown after the comprehension check. Content is conditional on the participant's role (Buyer, Seller, or Appraiser). Covers: bargaining mechanics, expert invitation rules, payment breakdown, and a reminder that the next round is practice. Only shown in round 1. """ @staticmethod def is_displayed(player): return player.round_number == 1 @staticmethod def vars_for_template(player): return dict(role=player.role_name) class RoundStartWaitPage(WaitPage): """ Synchronisation wait page at the start of every bargaining round (rounds 2-22). All participants wait here until everyone in their group is ready. Passes round display info to the template (e.g. 'Round 1 of 20'). Not shown in round 1 (instructions round). """ @staticmethod def is_displayed(player): return player.round_number > 1 @staticmethod def vars_for_template(player): return dict(round_number=player.round_number, num_rounds=C.NUM_ROUNDS, display_round=max(0, player.round_number - 2), display_total=C.NUM_ROUNDS - 2) class AppriserBargainingPage(Page): """ Page where the Appraiser submits their valuation before bargaining begins. Shown only to the Appraiser role, rounds 2-22. The appraiser sees their 10-point signal range (sig_low to sig_high) and must submit a valuation between 100 and 400. They are not restricted to their signal range — they may report any value in 100-400. The buyer and seller are simultaneously waiting on AppriserWaitPage while this page is active. vars_for_template computes the visual position of the signal range on the slider (box_min_pct and box_max_pct) for rendering purposes. """ form_model = 'player' form_fields = ['appraisal_value'] @staticmethod def is_displayed(player): return player.round_number > 1 and player.role_name == 'Appraiser' @staticmethod def vars_for_template(player): box_center = player.appraisal_box_center or 250 box_min = box_center - C.APPRAISAL_BOX_HALF box_max = box_center + C.APPRAISAL_BOX_HALF # Convert range bounds to percentage positions for the visual slider box_min_pct = ((box_min - C.APPRAISER_MIN) / (C.APPRAISER_MAX - C.APPRAISER_MIN)) * 100 box_max_pct = ((box_max - C.APPRAISER_MIN) / (C.APPRAISER_MAX - C.APPRAISER_MIN)) * 100 return dict(price_min=C.APPRAISER_MIN, price_max=C.APPRAISER_MAX, box_center=box_center, box_min=box_min, box_max=box_max, box_min_pct=box_min_pct, box_max_pct=box_max_pct, sig_low=box_min, sig_high=box_max, round_number=player.round_number, num_rounds=C.NUM_ROUNDS, display_round=max(0, player.round_number - 2), display_total=C.NUM_ROUNDS - 2) class AppriserWaitPage(WaitPage): """ Buyer and Seller wait here while the Appraiser submits their valuation. Shown only to Buyer and Seller, rounds 2-22. The Appraiser is simultaneously on AppriserBargainingPage. Once the Appraiser submits, everyone advances to BargainingStartWaitPage. """ title_text = "Please wait" body_text = "Waiting for the appraiser to submit their valuation." @staticmethod def is_displayed(player): return player.round_number > 1 and player.role_name in ('Buyer', 'Seller') class BargainingStartWaitPage(WaitPage): """ Final synchronisation point before bargaining begins. All three roles (Buyer, Seller, Appraiser) converge here after the Appraiser submits. Records the round start timestamp in milliseconds so the JavaScript timer on BargainingPage can calculate elapsed time accurately. Shown to all players in rounds 2-22. """ @staticmethod def is_displayed(player): return player.round_number > 1 @staticmethod def after_all_players_arrive(group): # Record Unix timestamp in ms at the exact moment all players are ready group.round_start_ms = int(time.time() * 1000) class BargainingPage(Page): """ The main 90-second bargaining page. Shown only to Buyer and Seller, rounds 2-22. The Appraiser is simultaneously on AppraiserWaitBargaining. Mechanics: - Buyer starts at price 0, Seller at 400. - Both submit prices via a text input box. Positions are broadcast to both participants via liveSend/liveRecv so each sees the other's position on a visual read-only track. - If the buyer's submitted price meets or exceeds the seller's, a deal is triggered. - During the first 45 seconds, the Buyer has an 'Invite Expert' button. Clicking it costs 5 lab points and reveals the appraiser's valuation to both. - After 45 seconds, the invite button disappears permanently. - After 90 seconds, the timer fires and the round ends without a deal. live_method handles all real-time messages from the browser: position_update: Player submitted a price via text input. Updates group positions and broadcasts new positions to both players. invite_appraiser: Buyer clicked the invite button. Sets wants_appraiser=True, records invite_time, and broadcasts the appraiser's valuation to both players. invite_declined: The 45-second invite window expired without the buyer clicking. Sets wants_appraiser=False. deal: Buyer's position crossed seller's. Records deal price and total time, broadcasts deal_confirmed to both players. timeout: 90-second timer expired. Records ended_by_timer=True and broadcasts timeout to both players. """ @staticmethod def is_displayed(player): return player.round_number > 1 and player.role_name in ('Buyer', 'Seller') @staticmethod def vars_for_template(player): group = player.group is_buyer = player.role_name == 'Buyer' # Check if expert was already invited (handles page refresh edge case) already_invited = group.field_maybe_none('wants_appraiser') == True return dict( role = player.role_name, is_buyer = is_buyer, price_min = C.PRICE_MIN, price_max = C.PRICE_MAX, max_seconds = C.BARGAINING_SECONDS, invite_window = C.INVITE_WINDOW_SECONDS, # Fall back to current time if round_start_ms is somehow missing round_start_ms = group.field_maybe_none('round_start_ms') or int(time.time() * 1000), round_number = player.round_number, num_rounds = C.NUM_ROUNDS, display_round = max(0, player.round_number - 2), display_total = C.NUM_ROUNDS - 2, appraiser_value = get_appraiser_valuation(group), already_invited = already_invited, ) @staticmethod def live_method(player, data): group = player.group msg_type = data.get('type') if msg_type == 'position_update': # Store the player's current submitted price and broadcast both positions to group val = data.get('value') if val is None: return if player.role_name == 'Buyer': group.buyer_bar_pos = int(val) else: group.seller_bar_pos = int(val) return {0: {'type': 'position_update', 'buyer_pos': group.buyer_bar_pos, 'seller_pos': group.seller_bar_pos}} elif msg_type == 'invite_appraiser': # Buyer clicked invite button — record the invitation and broadcast appraisal value # Guard against duplicate invites (e.g. double-click or race condition) if not group.field_maybe_none('wants_appraiser'): group.wants_appraiser = True buyer = get_buyer(group) buyer.call_appraiser = True # Record elapsed seconds at invite moment; None if time somehow missing buyer.invite_time = float(data['time']) if data.get('time') is not None else None appraiser_val = get_appraiser_valuation(group) return {0: {'type': 'appraiser_invited', 'appraiser_value': appraiser_val}} elif msg_type == 'invite_declined': # 45-second invite window expired without buyer clicking — record as declined # Only set if wants_appraiser is still None (not already invited) if group.field_maybe_none('wants_appraiser') is None: group.wants_appraiser = False buyer = get_buyer(group) buyer.call_appraiser = False elif msg_type == 'deal': # Buyer price >= seller price — record deal outcome # Guard against duplicate deal messages from both players if not group.round_ended: group.round_ended = True group.bargaining_deal = True group.bargaining_price = int(data['price']) if data.get('price') is not None else None group.total_time = data.get('total_time') player.time_in_round = data.get('time_in_round', 0) price = group.field_maybe_none('bargaining_price') return {0: {'type': 'deal_confirmed', 'price': price}} elif msg_type == 'timeout': # 90-second timer fired — record timeout outcome # Guard against duplicate timeout messages if not group.round_ended: group.round_ended = True group.ended_by_timer = True group.total_time = data.get('total_time') player.time_in_round = data.get('time_in_round', 0) return {0: {'type': 'timeout', 'reason': 'timer'}} class AppraiserWaitBargaining(WaitPage): """ Appraiser waits here while the Buyer and Seller complete the bargaining phase. Shown only to the Appraiser, rounds 2-22. The Buyer and Seller are simultaneously on BargainingPage. Once both Buyer and Seller advance from BargainingPage, the Appraiser is released to AppraiserRoundResult. """ title_text = "Please wait" body_text = "The Buyer and Seller are negotiating. Please wait." @staticmethod def is_displayed(player): return player.round_number > 1 and player.role_name == 'Appraiser' class FinalResults(Page): """ Shows the round outcome to Buyer and Seller. Shown rounds 2-22. Displays: product value, deal/no deal, final price, appraiser value (if invited), this round's payoff, and cumulative payoff so far. before_next_page: Calls _compute_round_payoff() to ensure payoff is saved even on timeout. On the final round (round 22), converts total lab points to CAD and sets player.payoff (oTree's built-in payment field) for the admin payments tab. Conversion rate: 100 lab points = $0.60 CAD (rate = 0.006). Also adds $3.00 show-up fee and knowledge bonus ($0.30 per correct answer). vars_for_template: Recalculates payoff for display purposes (same logic as _compute_round_payoff). Passes display_round (round_number - 2) so round 3 shows as 'Round 1 of 20'. Round 2 shows as 'Practice Round' via template conditional. """ @staticmethod def is_displayed(player): return player.round_number > 1 and player.role_name != 'Appraiser' @staticmethod def before_next_page(player, timeout_happened): _compute_round_payoff(player) if player.round_number == C.NUM_ROUNDS: all_rounds = player.in_all_rounds() total_pts = sum(p.final_payoff or 0 for p in all_rounds if p.round_number >= 2) cash = round(total_pts * 0.006, 2) knowledge_bonus_cash = round(player.in_round(1).knowledge_check_bonus * 0.30, 2) player.payoff = round(cash + 3.00 + knowledge_bonus_cash, 2) @staticmethod def vars_for_template(player): group = player.group deal_reached = group.bargaining_deal final_price = group.field_maybe_none('bargaining_price') pv = player.product_value or 0 appraiser_called = group.field_maybe_none('wants_appraiser') == True if player.role_name == 'Buyer': payoff = (pv - final_price + 75) if deal_reached else 75 if appraiser_called: payoff -= 5 else: payoff = final_price if deal_reached else (pv - 100) player.final_payoff = int(round(payoff)) cumulative_pts = sum( p.final_payoff or 0 for p in player.in_all_rounds() if p.round_number >= 2 and p.final_payoff is not None ) player.cumulative_payoff = cumulative_pts return dict( role = player.role_name, payoff = player.final_payoff, cumulative_payoff = cumulative_pts, product_value = pv, deal_reached = deal_reached, final_price = final_price, appraiser_called = appraiser_called, appraiser_value = get_appraiser_valuation(group), is_decision_maker = (player.role_name == 'Buyer'), # Used in template to show buyer-specific text round_number = player.round_number, num_rounds = C.NUM_ROUNDS, display_round = max(0, player.round_number - 2), display_total = C.NUM_ROUNDS - 2, is_last_round = (player.round_number == C.NUM_ROUNDS), ) class AppraiserRoundResult(Page): """ Shows the round outcome to the Appraiser. Shown rounds 2-22. Displays: whether the buyer invited them, this round's payoff (5 or 0), and cumulative payoff so far. before_next_page: Calls _compute_round_payoff() to ensure payoff is saved even on timeout. On the final round (round 22), converts total lab points to CAD and sets player.payoff for the admin payments tab. Conversion rate: 5 lab points = $1.25 CAD (rate = 0.25). Also adds $3.00 show-up fee and knowledge bonus. """ @staticmethod def is_displayed(player): return player.round_number > 1 and player.role_name == 'Appraiser' @staticmethod def before_next_page(player, timeout_happened): _compute_round_payoff(player) if player.round_number == C.NUM_ROUNDS: all_rounds = player.in_all_rounds() total_pts = sum(p.final_payoff or 0 for p in all_rounds if p.round_number >= 2) cash = round(total_pts * 0.25, 2) knowledge_bonus_cash = round(player.in_round(1).knowledge_check_bonus * 0.30, 2) player.payoff = round(cash + 3.00 + knowledge_bonus_cash, 2) @staticmethod def vars_for_template(player): group = player.group appraiser_called = group.field_maybe_none('wants_appraiser') == True payoff = 5 if appraiser_called else 0 player.final_payoff = payoff cumulative_pts = sum( p.final_payoff or 0 for p in player.in_all_rounds() if p.round_number >= 2 and p.final_payoff is not None ) player.cumulative_payoff = cumulative_pts return dict( payoff = player.final_payoff, cumulative_payoff = cumulative_pts, appraiser_called = appraiser_called, round_number = player.round_number, num_rounds = C.NUM_ROUNDS, display_round = max(0, player.round_number - 2), display_total = C.NUM_ROUNDS - 2, is_last_round = (player.round_number == C.NUM_ROUNDS), ) class SurveyPage(Page): """ Post-experiment survey. Shown only on the final round (round 22) to all roles. Collects demographic and experiment feedback data. form_fields: All active survey fields. Note that q_income_bracket, q_expense_coverage, q_region, and q_region_other are kept in the Player model but NOT in form_fields — they will always be blank in the data. vars_for_template: Passes current field values back to the template using field_maybe_none() to avoid crashes if fields are None. These val_* variables allow the template to pre-populate fields if the participant refreshes the page. error_message: Custom server-side validation. Returns a dict of field-level error messages if required fields are missing or conditionally required fields are not filled (e.g. year_of_study is required if is_student=1). """ form_model = 'player' form_fields = [ 'q_age', 'q_is_student', 'q_field_of_study', 'q_year_of_study', 'q_highest_education', 'q_gender', 'q_gender_self_identify', 'q_risk_willingness', 'q_appraisal_bias', 'q_instruction_clarity', 'q_comments', ] @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): return dict( role = player.role_name, val_age = player.field_maybe_none('q_age') or '', val_is_student = player.field_maybe_none('q_is_student'), val_field_of_study = player.field_maybe_none('q_field_of_study') or '', val_year_of_study = player.field_maybe_none('q_year_of_study'), val_highest_education = player.field_maybe_none('q_highest_education'), val_gender = player.field_maybe_none('q_gender'), val_gender_self = player.field_maybe_none('q_gender_self_identify') or '', val_risk = player.field_maybe_none('q_risk_willingness') or '', val_appraisal_bias = player.field_maybe_none('q_appraisal_bias'), val_clarity = player.field_maybe_none('q_instruction_clarity') or '', val_comments = player.field_maybe_none('q_comments') or '', ) @staticmethod def error_message(player, values): """ Server-side form validation. Returns a dict of {field_name: error_message}. oTree displays these errors next to the relevant fields. Conditional validation: year_of_study required if is_student=1, highest_education required if is_student=0, gender_self_identify required if gender=3. """ errors = {} if values.get('q_age') is None: errors['q_age'] = 'Please enter your age.' if values.get('q_is_student') is None: errors['q_is_student'] = 'Please answer this question.' if values.get('q_is_student') == 1 and not values.get('q_year_of_study'): errors['q_year_of_study'] = 'Please select your year of study.' if values.get('q_is_student') == 0 and not values.get('q_highest_education'): errors['q_highest_education'] = 'Please select your highest level of education.' if values.get('q_gender') is None: errors['q_gender'] = 'Please select an option.' if values.get('q_gender') == 3 and not (values.get('q_gender_self_identify') or '').strip(): errors['q_gender_self_identify'] = 'Please specify.' if values.get('q_risk_willingness') is None: errors['q_risk_willingness'] = 'Please enter a number from 0 to 10.' if values.get('q_appraisal_bias') is None: errors['q_appraisal_bias'] = 'Please select an option.' if values.get('q_instruction_clarity') is None: errors['q_instruction_clarity'] = 'Please enter a number from 1 to 7.' return errors class TotalPayoffPage(Page): """ Final payment summary page. Shown only on round 22 to all roles. Displays a breakdown of total lab points earned, conversion to CAD, knowledge bonus, show-up fee, and total payment. Conversion rates: Buyer/Seller: 100 lab points = $0.60 CAD (rate = 0.006) Appraiser: 5 lab points = $1.25 CAD (rate = 0.25) Counts lab points from round 2 onwards only (round 1 has no bargaining). """ @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): all_rounds = player.in_all_rounds() # Sum final_payoff for all bargaining rounds (round 2 onwards) total_pts = sum(p.final_payoff or 0 for p in all_rounds if p.round_number >= 2) role = player.role_name if role == 'Appraiser': cash = round(total_pts * 0.25, 2) # 5 pts = $1.25 CAD else: cash = round(total_pts * 0.006, 2) # 100 pts = $0.60 CAD show_up_fee = 3.00 knowledge_bonus_cash = round(player.in_round(1).knowledge_check_bonus * 0.30, 2) total_cash = round(cash + show_up_fee + knowledge_bonus_cash, 2) return dict( role = role, total_pts = total_pts, cash = cash, show_up_fee = show_up_fee, knowledge_bonus_cash = knowledge_bonus_cash, knowledge_correct = player.in_round(1).knowledge_check_bonus, # Number correct (0-6) total_cash = total_cash, ) # ── Page Sequence ───────────────────────────────────────────────────────────── # Pages are shown in this order every round. Each page's is_displayed() method # controls whether it actually appears for a given player and round. # Round 1 only: AssignRolesWaitPage through InstructionsPage2 # Rounds 2-22: RoundStartWaitPage through AppraiserRoundResult # Round 22 only: SurveyPage and TotalPayoffPage page_sequence = [ AssignRolesWaitPage, # Round 1: wait for all to connect, roles assigned InstructionsPage1, # Round 1: general experiment instructions (all roles) HardStop, # Round 1: experimenter gate — release via admin 'Mark complete' ComprehensionCheck, # Round 1: 6-question knowledge check with bonus InstructionsPage2, # Round 1: role-specific instructions RoundStartWaitPage, # Rounds 2-22: sync all players before each round AppriserBargainingPage, # Rounds 2-22: Appraiser submits valuation (Appraiser only) AppriserWaitPage, # Rounds 2-22: Buyer/Seller wait for Appraiser (Buyer/Seller only) BargainingStartWaitPage, # Rounds 2-22: final sync + records round_start_ms BargainingPage, # Rounds 2-22: 90-second live bargaining (Buyer/Seller only) AppraiserWaitBargaining, # Rounds 2-22: Appraiser waits during bargaining (Appraiser only) FinalResults, # Rounds 2-22: round outcome shown to Buyer and Seller AppraiserRoundResult, # Rounds 2-22: round outcome shown to Appraiser SurveyPage, # Round 22 only: post-experiment survey TotalPayoffPage, # Round 22 only: final payment breakdown ]