from otree.api import * from extdef import ( N_MEMBERS, N_BLOCKS, N_ROUNDS_PER_BLOCK, TOTAL_ROUNDS, CTR_POINTS, NOISE_VALS, POLICIES, VENMO_ACCOUNT, NOISE_RANGE, POINTS_PER_DOLLAR, PARTICIPATION_FEE, ) import numpy as np import json doc = 'Group Voting: majority vote determines outcome. show_others_ctr toggles whether others center points are visible.' class C(BaseConstants): NAME_IN_URL = 'group_noinfo' PLAYERS_PER_GROUP = N_MEMBERS # 9 NUM_ROUNDS = TOTAL_ROUNDS # 180 class Subsession(BaseSubsession): pass def creating_session(subsession): if subsession.round_number == 1: subsession.session.show_others_ctr = subsession.session.config.get('show_others_ctr', False) class Group(BaseGroup): policy_A = models.IntegerField() policy_B = models.IntegerField() winning_policy = models.IntegerField() winning_option = models.StringField() # 'A' or 'B' def set_outcome(group): votes_a = sum(1 for p in group.get_players() if p.vote == 'A') votes_b = sum(1 for p in group.get_players() if p.vote == 'B') if votes_a > votes_b: group.winning_option = 'A' group.winning_policy = group.policy_A elif votes_b > votes_a: group.winning_option = 'B' group.winning_policy = group.policy_B else: group.winning_option = str(np.random.choice(['A', 'B'])) group.winning_policy = group.policy_A if group.winning_option == 'A' else group.policy_B for p in group.get_players(): p.round_payoff = 100 - abs(p.ideal_point - group.winning_policy) p.payoff = cu(p.round_payoff) def set_all_outcomes(subsession): for group in subsession.get_groups(): set_outcome(group) class Player(BasePlayer): ctr_point = models.IntegerField() ideal_point = models.IntegerField() vote = models.StringField() # 'A' or 'B' round_payoff = models.IntegerField() # ── Helpers ──────────────────────────────────────────────────────── def _get_indices(player): r = player.round_number - 1 block = r // N_ROUNDS_PER_BLOCK round_in_block = r % N_ROUNDS_PER_BLOCK group_idx = (player.group.id_in_subsession - 1) % len(CTR_POINTS) player_pos = player.id_in_group - 1 return block, round_in_block, group_idx, player_pos def _round_values(player): """Compute all round-specific values directly from extdef — never reads DB fields.""" block, rib, gidx, ppos = _get_indices(player) ctr_point = CTR_POINTS[gidx][block][ppos] ideal_point = ctr_point + NOISE_VALS[gidx][block][rib][ppos] policy_A, policy_B = POLICIES[gidx][block][rib] return block, rib, ctr_point, ideal_point, policy_A, policy_B def _others_ctr_points(player): """Return list of center points for the other N_MEMBERS-1 players this block.""" block, _, gidx, ppos = _get_indices(player) return [CTR_POINTS[gidx][block][p] for p in range(N_MEMBERS) if p != ppos] # ── Pages ────────────────────────────────────────────────────────── class Instructions(Page): @staticmethod def is_displayed(player): return player.round_number == 1 @staticmethod def vars_for_template(player): return { 'n_blocks': N_BLOCKS, 'n_rounds': N_ROUNDS_PER_BLOCK, 'total_rounds': TOTAL_ROUNDS, 'n_members': N_MEMBERS, 'n_others': N_MEMBERS - 1, 'majority': N_MEMBERS // 2 + 1, 'points_per_dollar': POINTS_PER_DOLLAR, 'participation_fee': f'{PARTICIPATION_FEE:.2f}', 'show_others_ctr': player.session.show_others_ctr, } class BlockIntro(Page): @staticmethod def is_displayed(player): return (player.round_number - 1) % N_ROUNDS_PER_BLOCK == 0 @staticmethod def vars_for_template(player): block, _, ctr_point, _, _, _ = _round_values(player) show_others = player.session.show_others_ctr return { 'block_num': block + 1, 'n_blocks': N_BLOCKS, 'n_rounds': N_ROUNDS_PER_BLOCK, 'ctr_point': ctr_point, 'range_lo': ctr_point - NOISE_RANGE, 'range_hi': ctr_point + NOISE_RANGE, 'show_others_ctr': show_others, 'others_ctr_json': json.dumps(_others_ctr_points(player)) if show_others else '[]', } class Decision(Page): form_model = 'player' form_fields = ['vote'] @staticmethod def vars_for_template(player): block, rib, ctr_point, ideal_point, policy_A, policy_B = _round_values(player) show_others = player.session.show_others_ctr return { 'block_num': block + 1, 'round_in_block': rib + 1, 'ctr_point': ctr_point, 'range_lo': ctr_point - NOISE_RANGE, 'range_hi': ctr_point + NOISE_RANGE, 'ideal_point': ideal_point, 'policy_A': policy_A, 'policy_B': policy_B, 'show_others_ctr': show_others, 'others_ctr_json': json.dumps(_others_ctr_points(player)) if show_others else '[]', } @staticmethod def before_next_page(player, timeout_happened): block, rib, ctr_point, ideal_point, policy_A, policy_B = _round_values(player) player.ctr_point = ctr_point player.ideal_point = ideal_point player.group.policy_A = policy_A player.group.policy_B = policy_B class WaitForVotes(WaitPage): wait_for_all_groups = True after_all_players_arrive = 'set_all_outcomes' title_text = 'Waiting...' body_text = 'Waiting for all participants to submit their choice.' class Results(Page): @staticmethod def vars_for_template(player): block, rib, ctr_point, ideal_point, policy_A, policy_B = _round_values(player) return { 'block_num': block + 1, 'round_in_block': rib + 1, 'ctr_point': ctr_point, 'range_lo': ctr_point - NOISE_RANGE, 'range_hi': ctr_point + NOISE_RANGE, 'ideal_point': ideal_point, 'policy_A': policy_A, 'policy_B': policy_B, 'winning_option': player.group.winning_option, 'winning_policy': player.group.winning_policy, 'round_payoff': player.round_payoff, } class FinalResults(Page): @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): total_pts = sum( p.round_payoff for p in player.in_all_rounds() if p.round_payoff is not None ) earnings_usd = total_pts / POINTS_PER_DOLLAR total_usd = earnings_usd + PARTICIPATION_FEE venmo_url = ( f'https://venmo.com/{VENMO_ACCOUNT}' f'?txn=charge&amount={total_usd:.2f}¬e=Experiment+payment' ) return { 'total_pts': total_pts, 'earnings_usd': f'{earnings_usd:.2f}', 'participation_fee': f'{PARTICIPATION_FEE:.2f}', 'total_usd': f'{total_usd:.2f}', 'venmo_url': venmo_url, } page_sequence = [Instructions, BlockIntro, Decision, WaitForVotes, Results, FinalResults]