from otree.api import * import random from common import * from common import compute_pie_share, CommonConstants as CC doc = ''' Part I Economy: Main game. 4 rounds, each containing 3 stages: 1. Intelligence Test (3 parts: Ravens, Analogies, Math — timed) 2. Visual feedback (pie charts: effort share vs payoff share) 3. Public goods game (contribute/withdraw tokens from common pool) ''' class C(CommonConstants): NAME_IN_URL = 'Part_I' PLAYERS_PER_GROUP = 3 # real groups of 3 matched in Practice WaitPage NUM_ROUNDS = 10 Instructions_general_path = "_templates/global/Instructions.html" Instructions_practice_1 = "_templates/global/Instructions_Practice_1.html" Part_II_Instructions_template = "_templates/global/Part_II_Instructions_template.html" RavensQuiz_template_path = "_templates/global/RavensQuiz.html" AnalogyQuiz_template_path = "_templates/global/AnalogyQuiz.html" MathQuiz_template_path = "_templates/global/MathQuiz.html" Interstitial_template_path = "_templates/global/Interstitial.html" PGG_endowment = 100 #TODO: Adjust if necessary Timer_text = "Time left to complete this part:" class Subsession(BaseSubsession): pass # creating_session intentionally left empty: group formation happens at # runtime in Grouping_WaitPage (round 1 only), after participant.group_id # has been set by Practice's Grouping_WaitPage. class Group(BaseGroup): pass class Player(BasePlayer): # ── Ravens Matrix scores & answers (one pair per round) ────────────────── Raven_score_1 = models.IntegerField(initial=0) Raven_answers_1 = models.LongStringField(initial='{}') Raven_score_2 = models.IntegerField(initial=0) Raven_answers_2 = models.LongStringField(initial='{}') Raven_score_3 = models.IntegerField(initial=0) Raven_answers_3 = models.LongStringField(initial='{}') Raven_score_4 = models.IntegerField(initial=0) Raven_answers_4 = models.LongStringField(initial='{}') Raven_score_5 = models.IntegerField(initial=0) Raven_answers_5 = models.LongStringField(initial='{}') Raven_score_6 = models.IntegerField(initial=0) Raven_answers_6 = models.LongStringField(initial='{}') Raven_score_7 = models.IntegerField(initial=0) Raven_answers_7 = models.LongStringField(initial='{}') Raven_score_8 = models.IntegerField(initial=0) Raven_answers_8 = models.LongStringField(initial='{}') Raven_score_9 = models.IntegerField(initial=0) Raven_answers_9 = models.LongStringField(initial='{}') Raven_score_10 = models.IntegerField(initial=0) Raven_answers_10 = models.LongStringField(initial='{}') # ── Analogy scores & answers ───────────────────────────────────────────── Analogy_score_1 = models.IntegerField(initial=0) Analogy_answers_1 = models.LongStringField(initial='{}') Analogy_score_2 = models.IntegerField(initial=0) Analogy_answers_2 = models.LongStringField(initial='{}') Analogy_score_3 = models.IntegerField(initial=0) Analogy_answers_3 = models.LongStringField(initial='{}') Analogy_score_4 = models.IntegerField(initial=0) Analogy_answers_4 = models.LongStringField(initial='{}') Analogy_score_5 = models.IntegerField(initial=0) Analogy_answers_5 = models.LongStringField(initial='{}') Analogy_score_6 = models.IntegerField(initial=0) Analogy_answers_6 = models.LongStringField(initial='{}') Analogy_score_7 = models.IntegerField(initial=0) Analogy_answers_7 = models.LongStringField(initial='{}') Analogy_score_8 = models.IntegerField(initial=0) Analogy_answers_8 = models.LongStringField(initial='{}') Analogy_score_9 = models.IntegerField(initial=0) Analogy_answers_9 = models.LongStringField(initial='{}') Analogy_score_10 = models.IntegerField(initial=0) Analogy_answers_10 = models.LongStringField(initial='{}') # ── Math scores & answers ──────────────────────────────────────────────── Math_score_1 = models.IntegerField(initial=0) Math_answers_1 = models.LongStringField(initial='{}') Math_score_2 = models.IntegerField(initial=0) Math_answers_2 = models.LongStringField(initial='{}') Math_score_3 = models.IntegerField(initial=0) Math_answers_3 = models.LongStringField(initial='{}') Math_score_4 = models.IntegerField(initial=0) Math_answers_4 = models.LongStringField(initial='{}') Math_score_5 = models.IntegerField(initial=0) Math_answers_5 = models.LongStringField(initial='{}') Math_score_6 = models.IntegerField(initial=0) Math_answers_6 = models.LongStringField(initial='{}') Math_score_7 = models.IntegerField(initial=0) Math_answers_7 = models.LongStringField(initial='{}') Math_score_8 = models.IntegerField(initial=0) Math_answers_8 = models.LongStringField(initial='{}') Math_score_9 = models.IntegerField(initial=0) Math_answers_9 = models.LongStringField(initial='{}') Math_score_10 = models.IntegerField(initial=0) Math_answers_10 = models.LongStringField(initial='{}') # ── Round total scores (sum of Raven + Analogy + Math) ─────────────────── Round_score_1 = models.IntegerField(initial=0) Round_score_2 = models.IntegerField(initial=0) Round_score_3 = models.IntegerField(initial=0) Round_score_4 = models.IntegerField(initial=0) Round_score_5 = models.IntegerField(initial=0) Round_score_6 = models.IntegerField(initial=0) Round_score_7 = models.IntegerField(initial=0) Round_score_8 = models.IntegerField(initial=0) Round_score_9 = models.IntegerField(initial=0) Round_score_10 = models.IntegerField(initial=0) # ── Pie payoffs (ECs earned from Economy_pie each round) ───────────── Pie_payoff_1 = models.FloatField(initial=0) Pie_payoff_2 = models.FloatField(initial=0) Pie_payoff_3 = models.FloatField(initial=0) Pie_payoff_4 = models.FloatField(initial=0) Pie_payoff_5 = models.FloatField(initial=0) Pie_payoff_6 = models.FloatField(initial=0) Pie_payoff_7 = models.FloatField(initial=0) Pie_payoff_8 = models.FloatField(initial=0) Pie_payoff_9 = models.FloatField(initial=0) Pie_payoff_10 = models.FloatField(initial=0) # ── Public goods game: contribution per round ──────────────────── PGG_contribution_1 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_2 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_3 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_4 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_5 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_6 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_7 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_8 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_9 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) PGG_contribution_10 = models.IntegerField(initial=0, min=-C.PGG_endowment, max=C.PGG_endowment) # ── Final results (populated on round 10 only) ──────────────── PGG_selected_round = models.IntegerField(initial=0) PGG_earnings = models.FloatField(initial=0) Total_bonus_ECs = models.FloatField(initial=0) # ── PGG belief elicitation (round 10 only) ──────────────────── pgg_belief_member2 = models.IntegerField(initial=0, min=-(C.PGG_investible * C.Economy_num_rounds), max= C.PGG_investible * C.Economy_num_rounds) pgg_belief_member3 = models.IntegerField(initial=0, min=-(C.PGG_investible * C.Economy_num_rounds), max= C.PGG_investible * C.Economy_num_rounds) pgg_belief_bonus = models.BooleanField(initial=False) # ── Treatment & multiplier (copied from participant for easy export) ─── treatment = models.StringField(initial='') multiplier = models.IntegerField(initial=0) 'Comprehension and attention checks' #whether the player got the comprehension questions rigt at the first try Comprehension_1 = models.BooleanField(initial=True) #In the first comprehension check, the questions the player has answered wrong are stored as a string below. Comprehension_wrong_answers = models.StringField(initial='') Comprehension_2 = models.BooleanField(initial=True) Comprehension_question_1 = models.BooleanField(choices=[ [False, 'My share of the 500 ECs pot is determined by my score alone, regardless of the scores of the other two.'], [True,'The higher my score is compared to the scores of the other two, the higher is my share of the pie.'], # Correct answer here [False, 'My share of the 500 ECs is determined by the sum of everones\' scores.'],], label = '[Competition stage] How does the competition over 500 ECs work?', widget=widgets.RadioSelect) Comprehension_question_2 = models.BooleanField(choices=[ [False, 'The total ECs earned by the group is maximized when all players contribute 0 ECs.'], [True,'The total ECs earned by the group is maximized when all players contribute 100 ECs.'], # Correct answer here [False, 'The total ECs earned by the group is maximized when all players contribute 50 ECs.'],], label = '[Cooperation stage] What maximizes the total ECs earned by the group in the cooperation stage?', widget=widgets.RadioSelect) Comprehension_question_3 = models.BooleanField(choices=[ [True,'The total ECs earned by me is maximized when I contribute 0 ECs and others contribute 100.'], # Correct answer here [False, 'The total ECs earned by me is maximized when I contribute 100 ECs and others contribute 0.'], [False, 'The total ECs earned by me is maximized when I contribute 50 ECs and others contribute 50.'],], label = '[Cooperation stage] What maximizes the total ECs earned by you in the cooperation stage?', widget=widgets.RadioSelect) # ── Treatment-specific comprehension question (one shown per treatment) ── # TODO: remove DEBUG from the wording Comprehension_question_4_PM = models.BooleanField( choices=[ [False, 'The highest performer gets a disproportionately large share of the earnings.'], [True, 'Everyone in my group is treated identically — earnings depend purely on relative performance.'], [False, 'Earnings are distributed equally among all group members.'], ], label='[DEBUG: PERFECT MERITOCRACY] What is true about how earnings are determined in your group?', widget=widgets.RadioSelect) Comprehension_question_4_EM = models.BooleanField( choices=[ [False, 'Multipliers are the same for everyone in the group.'], [False, 'Multipliers were assigned randomly at the start of the experiment.'], [True, 'Multipliers are assigned based on performance in the first two rounds.'], ], label='[DEBUG: EXCESSIVE MERITOCRACY] How were multipliers assigned in your group?', widget=widgets.RadioSelect) Comprehension_question_4_WS = models.BooleanField( choices=[ [False, 'My score equals my performance in the Intelligence Test.'], [True, 'My score equals my performance in the Intelligence Test plus the average performance of the three group members.'], [False, 'My score equals my performance in the Intelligence Test the average performance of the three group members.'], ], label='[DEBUG: WELFARE STATE] Your score determines your share of the pie. But how is your score determined?', widget=widgets.RadioSelect) Comprehension_question_4_Ar = models.BooleanField( choices=[ [False, 'Multipliers are the same for everyone in the group.'], [True, 'Multipliers were assigned randomly at the start of the experiment.'], [False, 'Multipliers are assigned based on performance in the first two rounds.'], ], label='[DEBUG: Aristocracy] How were multipliers assigned in your group?', widget=widgets.RadioSelect) # ── Helpers ──────────────────────────────────────────────────────────────────────────── def _round_field(base, round_number): return f'{base}_{round_number}' def _compute_round_sum(player, round_number): """Sum the 3 sub-scores and store in Round_score_N.""" raven = getattr(player, _round_field('Raven_score', round_number)) analogy = getattr(player, _round_field('Analogy_score', round_number)) math = getattr(player, _round_field('Math_score', round_number)) total = raven + analogy + math setattr(player, _round_field('Round_score', round_number), total) return total def _get_group_data(player, round_number): """Return (group_scores, group_multipliers) for all members of this player's group.""" group_members = player.group.get_players() scores = [getattr(p, _round_field('Round_score', round_number)) for p in group_members] multipliers = [p.participant.multiplier for p in group_members] return scores, multipliers def _compute_and_store_payoff(player, round_number): """Compute pie payoff for this player this round and store it.""" scores, multipliers = _get_group_data(player, round_number) player_score = getattr(player, _round_field('Round_score', round_number)) player_mult = player.participant.multiplier treatment = player.participant.Treatment payoff, _, _ = compute_pie_share( player_score, player_mult, scores, multipliers, treatment, welfare_check=C.Welfare_check, economy_pie=C.Economy_pie, ) setattr(player, _round_field('Pie_payoff', round_number), payoff) return payoff def _effort_shares(player, round_number): """Return (player_weighted_score, others_total_weighted_score) for effort pie chart.""" scores, multipliers = _get_group_data(player, round_number) player_score = getattr(player, _round_field('Round_score', round_number)) player_mult = player.participant.multiplier treatment = player.participant.Treatment _, player_w, total_w = compute_pie_share( player_score, player_mult, scores, multipliers, treatment, welfare_check=C.Welfare_check, economy_pie=C.Economy_pie, ) others_w = total_w - player_w return player_w, others_w def _accumulated_payoff_shares(player, up_to_round): """Return (player_accumulated, others_accumulated) across rounds 1..up_to_round.""" player_total = sum( getattr(player, _round_field('Pie_payoff', r)) for r in range(1, up_to_round + 1) ) group_members = player.group.get_players() others_total = sum( sum(getattr(p, _round_field('Pie_payoff', r)) for r in range(1, up_to_round + 1)) for p in group_members if p.id_in_group != player.id_in_group ) return player_total, others_total def _per_player_data(player, round_number): """Return (performances, earnings_this_round, accumulated, multipliers) as 3-element lists. Index 0 = current player ("You"), indices 1 & 2 = the other two members. - performances: raw correct-answer count for this round only - earnings_this_round: competition-stage Pie_payoff for this round only - accumulated: sum of competition-stage Pie_payoffs across rounds 1..round_number - multipliers: each player's score multiplier""" group_members = player.group.get_players() others = [p for p in group_members if p.id_in_group != player.id_in_group] ordered = [player] + others # You always first performances = [ int(getattr(p, _round_field('Round_score', round_number))) for p in ordered ] earnings_this_round = [ round(getattr(p, _round_field('Pie_payoff', round_number)), 1) for p in ordered ] accumulated = [ round(sum(getattr(p, _round_field('Pie_payoff', r)) for r in range(1, round_number + 1)), 1) for p in ordered ] multipliers_list = [p.participant.multiplier for p in ordered] return performances, earnings_this_round, accumulated, multipliers_list def _multiplier_reminder(treatment): """Return a short HTML reminder string shown below the multiplier table, tailored to the treatment condition. Returns '' for treatments that have no multiplier table (Perfect_Meritocracy, Welfare_State).""" if treatment == 'Perfect_Meritocracy': return '' # no multiplier table shown for this treatment elif treatment == 'Excessive_Meritocracy': return ('Remember that multipliers were assigned based on performance from the practice stage: ' 'the best performer received ×7 and the worst performer ×3.') elif treatment == 'Aristocracy': return ('Remember that these multipliers were assigned randomly ' 'at the start of the experiment.') elif treatment == 'Welfare_State': return '' # no multiplier table shown for this treatment return '' def _belief_bonus(player): """Return True if the player's PGG belief guesses are both within 100 ECs of the true cumulative PGG contributions of Group Members 2 and 3. Uses the same member ordering as _per_player_data (others in get_players() order).""" group_members = player.group.get_players() others = [p for p in group_members if p.id_in_group != player.id_in_group] m2, m3 = others[0], others[1] def true_total(p): return sum(getattr(p.in_round(r), f'PGG_contribution_{r}') for r in range(1, C.Economy_num_rounds + 1)) def within_100ec(guess, true): return abs(guess - true) <= 100 return (within_100ec(player.pgg_belief_member2, true_total(m2)) and within_100ec(player.pgg_belief_member3, true_total(m3))) _Q4_FIELD = { 'Perfect_Meritocracy': 'Comprehension_question_4_PM', 'Excessive_Meritocracy': 'Comprehension_question_4_EM', 'Welfare_State': 'Comprehension_question_4_WS', 'Aristocracy': 'Comprehension_question_4_Ar', } def _q4_field(player): return _Q4_FIELD.get(player.participant.Treatment) def _score_formula_vars(treatment): """Return score_formula_html and pictogram_score_text for the given treatment. Perfect Meritocracy and Welfare State omit multiplier language.""" if treatment == 'Perfect_Meritocracy': return { 'score_formula_html': 'Score = number of correct answers', 'pictogram_score_text': 'Score =  performance', } elif treatment == 'Welfare_State': return { 'score_formula_html': ( 'Score = number of correct answers +' ' average number of correct answers in your group' ), 'pictogram_score_text': 'Score = performance  +
 group\'s average perf.', } else: # Excessive_Meritocracy, Aristocracy return { 'score_formula_html': 'Score = number of correct answers × your multiplier', 'pictogram_score_text': 'Score = performance × multiplier', } def _instruction_vars(player): """Return the vars required by Part_II_Instructions_template.html. Shared by Part_II_Instructions, Comprehension_check_1/2/3 so the included template always has explanation, show_multiplier, multiplier, welfare_bonus_text.""" treatment = player.participant.Treatment multiplier = player.participant.multiplier explanation_map = { 'Perfect_Meritocracy': C.Explanation_Perfect_Meritocracy, 'Excessive_Meritocracy': C.Explanation_Excessive_Meritocracy, 'Welfare_State': C.Explanation_Welfare_State, 'Aristocracy': C.Explanation_Aristocracy, } return { 'explanation': explanation_map.get(treatment, ''), 'show_multiplier': treatment in ('Excessive_Meritocracy', 'Aristocracy'), 'multiplier': multiplier, 'welfare_bonus_text': C.Welfare_check_text if treatment == 'Welfare_State' else '', **_score_formula_vars(treatment), } # ── Base pages ───────────────────────────────────────────────────────────────────────── from common import MyBasePage # ── Group formation WaitPage (round 1 only) ──────────────────────────────────────── class Grouping_WaitPage(WaitPage): """ Runs once at the very start of Part_I_Economy (round 1 only). Reads participant.group_id (set by Practice's Grouping_WaitPage), calls set_group_matrix for round 1, then immediately propagates the same grouping to all subsequent rounds via group_like_round(1). This avoids relying on after_all_players_arrive firing in rounds 2-10 when is_displayed=False, which is unreliable in oTree 5. """ wait_for_all_groups = True @staticmethod def is_displayed(player: Player): return player.round_number == 1 @staticmethod def after_all_players_arrive(subsession: Subsession): players = subsession.get_players() group_map = {} for p in players: gid = getattr(p.participant, 'group_id', None) if gid is None: continue group_map.setdefault(gid, []).append(p) matrix = list(group_map.values()) subsession.set_group_matrix(matrix) # Propagate this grouping to all subsequent rounds immediately, # so rounds 2-10 never rely on group_like_round firing from a # skipped WaitPage. for r in range(2, C.NUM_ROUNDS + 1): subsession.in_round(r).group_like_round(1) # Write treatment & multiplier onto the player row for every round # so they appear directly in the oTree data export. for r in range(1, C.NUM_ROUNDS + 1): for p in subsession.in_round(r).get_players(): p.treatment = getattr(p.participant, 'Treatment', '') p.multiplier = getattr(p.participant, 'multiplier', 0) # ── Round WaitPage (sync scores before feedback) ───────────────────────────────── class Round_WaitPage(WaitPage): """ Wait for all group members to finish all 3 quiz parts before anyone advances to Round_Feedback. Ensures every Round_score_r and Pie_payoff_r is stored before the charts try to read teammates' values. """ body_text = "Waiting for other group members…" @staticmethod def after_all_players_arrive(group: Group): # All group members have submitted Round_Math, so every Round_score_r # is now saved. Compute payoffs here to avoid the race condition that # occurred when _compute_and_store_payoff ran in before_next_page # (where fast finishers saw stale scores of 0 from slow teammates). for p in group.get_players(): _compute_and_store_payoff(p, p.round_number) # ── Part II intro (round 1 only) ─────────────────────────────────────────────────── class Part_II_Instructions(MyBasePage): """Full instructions page shown once, before the 4 rounds begin.""" @staticmethod def is_displayed(player: Player): return player.round_number == 1 @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) treatment = player.participant.Treatment multiplier = player.participant.multiplier role = player.participant.role # Pick the right explanation text explanation_map = { 'Perfect_Meritocracy': C.Explanation_Perfect_Meritocracy, 'Excessive_Meritocracy': C.Explanation_Excessive_Meritocracy, 'Welfare_State': C.Explanation_Welfare_State, 'Aristocracy': C.Explanation_Aristocracy, } explanation = explanation_map.get(treatment, '') # Show multiplier only where it's individually relevant show_multiplier = treatment in ('Excessive_Meritocracy', 'Aristocracy') variables['Treatment'] = treatment variables['multiplier'] = multiplier variables['role'] = role variables['explanation'] = explanation variables['show_multiplier'] = show_multiplier variables['welfare_bonus_text'] = C.Welfare_check_text if treatment == 'Welfare_State' else '' variables.update(_score_formula_vars(treatment)) return variables # ── Stage 0: Round instructions ───────────────────────────────────────────────────── class Round_Instructions(MyBasePage): @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) variables['round_number'] = player.round_number variables['Treatment'] = player.participant.Treatment return variables # ── Stage 1a: Ravens Matrix ────────────────────────────────────────────────────────── class Round_RavensMatrix(MyBasePage): timeout_seconds = C.Economy_round_length timer_text = C.Timer_text @staticmethod def get_form_fields(player): r = player.round_number return [_round_field('Raven_score', r), _round_field('Raven_answers', r)] @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) r = player.round_number variables['hidden_fields'] = [_round_field('Raven_score', r), _round_field('Raven_answers', r)] variables['round_number'] = r variables['Treatment'] = player.participant.Treatment return variables @staticmethod def js_vars(player: Player): r = player.round_number return { 'score_field': _round_field('Raven_score', r), 'answers_field': _round_field('Raven_answers', r), 'participant_code': player.participant.code, 'puzzle_set': r + 2, # sets 1-2 used in Practice 'freeze_seconds': C.Submit_freeze_duration, } # ── Interstitial: Analogies ────────────────────────────────────────────────────────── class Interstitial_Analogy(MyBasePage): timeout_seconds = C.Interstitial_length @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) variables['completed_part_name'] = 'Matrix Reasoning' variables['next_part_name'] = 'Analogies' variables['interstitial_seconds'] = C.Interstitial_length return variables # ── Stage 1b: Analogies ───────────────────────────────────────────────────────────── class Round_Analogies(MyBasePage): timeout_seconds = C.Economy_round_length timer_text = C.Timer_text @staticmethod def get_form_fields(player): r = player.round_number return [_round_field('Analogy_score', r), _round_field('Analogy_answers', r)] @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) r = player.round_number variables['hidden_fields'] = [_round_field('Analogy_score', r), _round_field('Analogy_answers', r)] variables['round_number'] = r variables['Treatment'] = player.participant.Treatment return variables @staticmethod def js_vars(player: Player): r = player.round_number return { 'analogy_score_field': _round_field('Analogy_score', r), 'analogy_answers_field': _round_field('Analogy_answers', r), 'participant_code': player.participant.code, 'analogy_set': r + 2, # sets 1-2 used in Practice 'freeze_seconds': C.Submit_freeze_duration, } # ── Interstitial: Math ─────────────────────────────────────────────────────────────── class Interstitial_Math(MyBasePage): timeout_seconds = C.Interstitial_length @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) variables['completed_part_name'] = 'Analogies' variables['next_part_name'] = 'Mathematics' variables['interstitial_seconds'] = C.Interstitial_length return variables # ── Stage 1c: Math ─────────────────────────────────────────────────────────────────── class Round_Math(MyBasePage): timeout_seconds = C.Economy_round_length timer_text = C.Timer_text @staticmethod def get_form_fields(player): r = player.round_number return [_round_field('Math_score', r), _round_field('Math_answers', r)] @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) r = player.round_number variables['hidden_fields'] = [_round_field('Math_score', r), _round_field('Math_answers', r)] variables['round_number'] = r variables['Treatment'] = player.participant.Treatment return variables @staticmethod def js_vars(player: Player): r = player.round_number return { 'math_score_field': _round_field('Math_score', r), 'math_answers_field': _round_field('Math_answers', r), 'participant_code': player.participant.code, 'math_set': r + 2, # sets 1-2 used in Practice 'freeze_seconds': C.Submit_freeze_duration, } @staticmethod def before_next_page(player: Player, timeout_happened=False): """Compute round sum. Pie payoff is computed in Round_WaitPage once all group members have submitted (avoids race condition).""" r = player.round_number _compute_round_sum(player, r) # ── Stage 2: Visual Feedback ─────────────────────────────────────────────────────── class Round_Feedback(MyBasePage): """Bar charts: per-player performance (this round) and accumulated earnings.""" @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) r = player.round_number performances, earnings_this_round, accumulated, multipliers_list = _per_player_data(player, r) variables['round_number'] = r variables['Treatment'] = player.participant.Treatment variables['performances'] = performances # [you, m2, m3] variables['earnings_this_round'] = earnings_this_round # [you, m2, m3] variables['accumulated'] = accumulated # [you, m2, m3] variables['multipliers_list'] = multipliers_list # [you, m2, m3] variables['multiplier_reminder'] = _multiplier_reminder(player.participant.Treatment) return variables # ── Stage 3: Public Goods Game ───────────────────────────────────────────────────── class Round_PublicGoods(MyBasePage): """Slider to decide how many tokens to place in the common pool. Pie charts show: current-round effort share, accumulated payoff share.""" @staticmethod def get_form_fields(player): return [_round_field('PGG_contribution', player.round_number)] @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) r = player.round_number performances, earnings_this_round, accumulated, multipliers_list = _per_player_data(player, r) variables['round_number'] = r variables['Treatment'] = player.participant.Treatment variables['PGG_Commons'] = C.PGG_Commons variables['pgg_max'] = C.Pgg_upper_bound - C.PGG_Commons variables['tokens_earned'] = round(getattr(player, _round_field('Pie_payoff', r))) variables['contribution_field'] = _round_field('PGG_contribution', r) variables['hidden_fields'] = [_round_field('PGG_contribution', r)] variables['performances'] = performances # [you, m2, m3] variables['earnings_this_round'] = earnings_this_round # [you, m2, m3] variables['accumulated'] = accumulated # [you, m2, m3] variables['multipliers_list'] = multipliers_list # [you, m2, m3] variables['multiplier_reminder'] = _multiplier_reminder(player.participant.Treatment) return variables # ── Comprehension question pages (visible only first round) ──────────────────────────────────────────────────────────────────────────── class Comprehension_check_1(MyBasePage): @staticmethod def get_form_fields(player): base = ['Comprehension_question_1', 'Comprehension_question_2', 'Comprehension_question_3'] q4 = _q4_field(player) if q4: base.append(q4) return base @staticmethod def is_displayed(player: Player): return player.round_number == 1 @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) variables.update(_instruction_vars(player)) # needed to render Part_II modal return variables @staticmethod def before_next_page(player: Player, timeout_happened=False): q4 = _q4_field(player) q4_correct = getattr(player, q4) if q4 else True player_passed_comprehension = (player.Comprehension_question_1 and player.Comprehension_question_2 and player.Comprehension_question_3 and q4_correct) wrong_answers = '' if not player.Comprehension_question_1: player.Comprehension_question_1 = None # reset so it doesn't pre-fill check_2 wrong_answers += 'first question' if not player.Comprehension_question_2: if wrong_answers: wrong_answers += ', ' player.Comprehension_question_2 = None wrong_answers += 'second question' if not player.Comprehension_question_3: if wrong_answers: wrong_answers += ', ' player.Comprehension_question_3 = None wrong_answers += 'third question' if q4 and not q4_correct: if wrong_answers: wrong_answers += ', ' setattr(player, q4, None) wrong_answers += 'fourth question' player.Comprehension_wrong_answers = wrong_answers player.Comprehension_1 = player_passed_comprehension if player_passed_comprehension: player.participant.vars['Comprehension_passed'] = True class Comprehension_check_2(MyBasePage): @staticmethod def get_form_fields(player): base = ['Comprehension_question_1', 'Comprehension_question_2', 'Comprehension_question_3'] q4 = _q4_field(player) if q4: base.append(q4) return base @staticmethod def is_displayed(player: Player): return not player.Comprehension_1 and player.round_number == 1 @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) variables['Comprehension_wrong_answers'] = player.Comprehension_wrong_answers variables.update(_instruction_vars(player)) # needed to render Part_II modal return variables @staticmethod def before_next_page(player: Player, timeout_happened=False): q4 = _q4_field(player) q4_correct = getattr(player, q4) if q4 else True player_passed_comprehension = (player.Comprehension_question_1 and player.Comprehension_question_2 and player.Comprehension_question_3 and q4_correct) wrong_answers = '' if not player.Comprehension_question_1: wrong_answers += 'first question' if not player.Comprehension_question_2: if wrong_answers: wrong_answers += ', ' wrong_answers += 'second question' if not player.Comprehension_question_3: if wrong_answers: wrong_answers += ', ' wrong_answers += 'third question' if q4 and not q4_correct: if wrong_answers: wrong_answers += ', ' wrong_answers += 'fourth question' player.Comprehension_wrong_answers = wrong_answers player.Comprehension_2 = player_passed_comprehension if player_passed_comprehension: player.participant.vars['Comprehension_passed'] = True else: player.participant.vars['Comprehension_passed'] = False class Comprehension_check_3(MyBasePage): """Shown only when player has failed BOTH check_1 and check_2. Forces the player to re-enter all correct answers before proceeding.""" @staticmethod def get_form_fields(player): base = ['Comprehension_question_1', 'Comprehension_question_2', 'Comprehension_question_3'] q4 = _q4_field(player) if q4: base.append(q4) return base @staticmethod def is_displayed(player: Player): return (not player.Comprehension_1 and not player.Comprehension_2 and player.round_number == 1) @staticmethod def error_message(player, values): q4 = _q4_field(player) fields = ['Comprehension_question_1', 'Comprehension_question_2', 'Comprehension_question_3'] if q4: fields.append(q4) if not all(values.get(f) for f in fields): return ('Some answers are still incorrect. ' 'Please review the correct answers shown above and select the right options.') @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) variables['Comprehension_wrong_answers'] = player.Comprehension_wrong_answers variables.update(_instruction_vars(player)) # needed to render Part_II modal return variables # ── PGG Belief Elicitation (round 10 only) ─────────────────────────────────────────────────── class PGG_Beliefs(MyBasePage): """Ask participants to guess how much each group member invested across all PGG rounds. Displayed only in round 10, after the final PGG decision and before the wait page.""" form_model = 'player' form_fields = ['pgg_belief_member2', 'pgg_belief_member3'] @staticmethod def is_displayed(player: Player): return player.round_number == 10 @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) r = player.round_number # 10 performances, _, accumulated, multipliers_list = _per_player_data(player, r) own_pgg_total = sum( getattr(player.in_round(rr), f'PGG_contribution_{rr}') for rr in range(1, C.Economy_num_rounds + 1) ) slider_bound = C.PGG_investible * C.Economy_num_rounds # 1000 variables.update({ 'round_number': r, 'performances': performances, 'accumulated': accumulated, 'multipliers_list': multipliers_list, 'multiplier_reminder': _multiplier_reminder(player.participant.Treatment), 'own_pgg_total': own_pgg_total, 'own_pgg_total_abs': abs(own_pgg_total), 'slider_min': -slider_bound, 'slider_max': slider_bound, 'pgg_guess_bonus': C.PGG_Guess_ECs, }) return variables # ── Final WaitPage (round 10 only: sync PGG contributions, compute final earnings) ── class Final_WaitPage(WaitPage): """After the last PGG decision, wait for all group members, then compute final earnings from Practice + Competition + one randomly-selected PGG round.""" @staticmethod def is_displayed(player: Player): return player.round_number == 10 @staticmethod def after_all_players_arrive(group: Group): players = group.get_players() selected_round = random.randint(1, 10) # Gather each player's PGG contribution in the selected round contributions = [] for p in players: p_in_sel = p.in_round(selected_round) contributions.append(getattr(p_in_sel, f'PGG_contribution_{selected_round}')) total_pool = sum(contributions) pool_return = (total_pool * 1.5) / 3 for i, p in enumerate(players): p.PGG_selected_round = selected_round # PGG earnings tokens_kept = C.PGG_Commons - contributions[i] p.PGG_earnings = tokens_kept + pool_return # Competition ECs (accumulated across all 10 rounds) competition_ecs = sum( getattr(p.in_round(r), f'Pie_payoff_{r}') for r in range(1, 11) ) # Practice ECs (stored on participant by Practice app) practice_ecs = getattr(p.participant, 'Practice_ECs_total', 0) p.Total_bonus_ECs = practice_ecs + competition_ecs + p.PGG_earnings # PGG belief bonus (guessed both group members' totals within 10%) p.pgg_belief_bonus = _belief_bonus(p) if p.pgg_belief_bonus: p.Total_bonus_ECs += C.PGG_Guess_ECs # Store for cross-app use in Part_II_Social_Cohesion (tier ranking) p.participant.Part_I_total_ECs = p.Total_bonus_ECs # ── Final Results (round 10 only) ───────────────────────────────────────────────────── class Final_Results(MyBasePage): """Summary page shown after the last round: breakdown of all earnings.""" @staticmethod def is_displayed(player: Player): return player.round_number == 10 @staticmethod def vars_for_template(player: Player): variables = MyBasePage.vars_for_template(player) practice_ecs = getattr(player.participant, 'Practice_ECs_total', 0) competition_ecs = sum( getattr(player.in_round(r), f'Pie_payoff_{r}') for r in range(1, 11) ) pgg_earnings = player.PGG_earnings total_ecs = player.Total_bonus_ECs eur_amount = total_ecs / C.EC_exchange_rate # Group comparison: total ECs for [You, Member 2, Member 3] group_members = player.group.get_players() others = [p for p in group_members if p.id_in_group != player.id_in_group] ordered = [player] + others group_totals = [round(p.Total_bonus_ECs, 1) for p in ordered] # Multiplier table data (same as Round_Feedback) multipliers_list = [p.participant.multiplier for p in ordered] variables.update({ 'practice_ecs': round(practice_ecs, 1), 'competition_ecs': round(competition_ecs, 1), 'pgg_earnings': round(pgg_earnings, 1), 'pgg_selected_round': player.PGG_selected_round, 'total_ecs': round(total_ecs, 1), 'eur_amount': round(eur_amount, 2), 'group_totals': group_totals, 'multipliers_list': multipliers_list, 'multiplier_reminder': _multiplier_reminder(player.participant.Treatment), }) return variables # ── Page sequence ──────────────────────────────────────────────────────────────────────────── page_sequence = [ Grouping_WaitPage, # round 1 only: form oTree groups from participant.group_id Part_II_Instructions, # round 1 only: show treatment explanation + multiplier Comprehension_check_1, # round 1 only: first attempt (3 + 1 treatment-specific questions) Comprehension_check_2, # round 1 only: second attempt (if first failed) Comprehension_check_3, # round 1 only: forced re-entry if both attempts failed Round_Instructions, # Intelligence Test: 3 parts Round_RavensMatrix, Interstitial_Analogy, Round_Analogies, Interstitial_Math, Round_Math, # Sync and feedback Round_WaitPage, # sync: wait for all group members' scores before feedback Round_Feedback, Round_PublicGoods, PGG_Beliefs, # round 10 only: belief elicitation about group members' PGG totals # Final results (round 10 only) Final_WaitPage, Final_Results, ]