from otree.api import * import random doc = """ Main task: - Lottery-style Investment game (1 practice + 5 paying rounds) with 5 different scenarios (per-round risky & safe returns). - Mathematical summation exercise (1 practice + 5 paying rounds) with simple addition. - Music treatment read from session.config['treatment']. """ def creating_session(self): # Only in round 1, pre-draw the paying investment round for each participant if self.round_number == 1: for p in self.get_players(): # paying investment rounds = 2–6 (the 5 real investment rounds) paying_round = random.randint(2, C.INV_TOTAL_ROUNDS) p.participant.vars['paying_inv_round'] = paying_round class C(BaseConstants): NAME_IN_URL = 'thesis_exp_main_task' PLAYERS_PER_GROUP = None # ----- Investment parameters ----- ENDOWMENT = 100 # coins per scenario # exchange rate from tokens to euros INV_EXCHANGE_RATE = 0.05 # 5 scenarios for the risky & safe assets # Percentages in decimal form (e.g. 0.24 = +24%) RISKY_UP_LIST = [+0.30, +0.24, +0.14, +0.19, +0.28, +0.17] RISKY_DOWN_LIST = [+0.06, +0.00, -0.10, -0.05, +0.04, -0.07] SAFE_RETURN_LIST = [+0.12, +0.06, +0.04, +0.01, +0.10, +0.05] INV_PRACTICE_ROUNDS = 1 # round 1 INV_REAL_ROUNDS = 5 # rounds 2–6 INV_TOTAL_ROUNDS = INV_PRACTICE_ROUNDS + INV_REAL_ROUNDS # 6 # ----- Math parameters ----- MATH_PRACTICE_ROUNDS = 1 MATH_REAL_ROUNDS = 5 MATH_TOTAL_ROUNDS = MATH_PRACTICE_ROUNDS + MATH_REAL_ROUNDS # 6 MATH_PAYMENT_SLOW = 0.20 # 20 cents per correct answer MATH_PAYMENT_FAST = 0.30 # 20 cents per correct answer + less than 10s MATH_FAST_THRESHOLD = 10 # seconds # math part starts after all investment rounds MATH_START_ROUND = INV_TOTAL_ROUNDS + 1 # 7 NUM_ROUNDS = INV_TOTAL_ROUNDS + MATH_TOTAL_ROUNDS # 12 class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): # ---------- Investment game ---------- # Number of coins invested in Asset A (risky) investment = models.IntegerField( min=0, max=C.ENDOWMENT, label="How many coins do you want to invest in Asset A (risky asset)?", ) # storing the time spent time_spent = models.FloatField(initial=0) # coin toss result coin_heads = models.BooleanField() # realized total coins from the scenario inv_tokens = models.IntegerField() # ---------- Mathematical summation exercise ---------- num1 = models.IntegerField(blank=True, null=True) num2 = models.IntegerField(blank=True, null=True) user_sum = models.IntegerField(label="Your answer:") correct = models.BooleanField() fast = models.IntegerField(initial=0) # ---------- Final payment (for Data/Monitor) ---------- final_payment_eur = models.FloatField(initial=0) donate = models.BooleanField( label="You have the option to donate 1€ of your earnings to UNICEF. Would you like to donate?", choices=[[True, "Yes"], [False, "No"]], widget=widgets.RadioSelect ) # ---------- Helper: which part of the task? ---------- def is_investment_round(self): return self.round_number <= C.INV_TOTAL_ROUNDS def is_investment_practice(self): return self.round_number == 1 def is_math_round(self): return self.round_number >= C.MATH_START_ROUND def is_math_practice(self): return self.round_number == C.MATH_START_ROUND # ---------- Helper: which investment scenario for this round? ---------- def get_investment_scenario_index(self): """ Map round numbers to scenario index 0–4. Round 1 (practice) -> scenario 0 Round 2 (real 1) -> scenario 1 Round 3 (real 2) -> scenario 2 Round 4 (real 3) -> scenario 3 Round 5 (real 4) -> scenario 4 Round 6 (real 5) -> scenario 5 """ # Scenario index = round_number - 1 idx = self.round_number - 1 # Safety bound based on length of scenario lists max_idx = len(C.RISKY_UP_LIST) - 1 if idx < 0: idx = 0 if idx > max_idx: idx = max_idx return idx def get_investment_params(self): """ Return (risky_up, risky_down, safe_return) for this round. """ idx = self.get_investment_scenario_index() up = C.RISKY_UP_LIST[idx] down = C.RISKY_DOWN_LIST[idx] safe = C.SAFE_RETURN_LIST[idx] return up, down, safe # ----- Investment payoff ----- def set_investment_payoff(self): """ Asset A: coin toss, scenario-specific +X% on heads, -Y% on tails. Asset B: scenario-specific +Z% for sure. """ heads = random.random() < 0.5 self.coin_heads = heads up, down, safe = self.get_investment_params() invest_A = self.investment invest_B = C.ENDOWMENT - invest_A if heads: ret_A = invest_A * (1 + up) else: ret_A = invest_A * (1 + down) ret_B = invest_B * (1 + safe) total = ret_A + ret_B self.inv_tokens = int(round(total)) if self.is_investment_practice(): self.payoff = cu(0) else: self.payoff = cu(self.inv_tokens) # ----- Math helpers ----- def ensure_math_problem(self): # Always generate new numbers when entering a math round if self.round_number >= C.MATH_START_ROUND: n1 = self.field_maybe_none('num1') n2 = self.field_maybe_none('num2') if n1 is None or n2 is None: self.num1 = random.randint(100, 999) self.num2 = random.randint(100, 999) def check_math_answer(self): self.ensure_math_problem() true_sum = self.num1 + self.num2 self.correct = (self.user_sum == true_sum) # Only paying on real math rounds, not practice if self.is_math_round() and not self.is_math_practice(): self.fast = int(self.time_spent is not None and self.time_spent < C.MATH_FAST_THRESHOLD) if self.correct: # Fast vs slow depends on time_spent if self.time_spent is not None and self.time_spent < C.MATH_FAST_THRESHOLD: self.payoff = cu(C.MATH_PAYMENT_FAST) else: self.payoff = cu(C.MATH_PAYMENT_SLOW) else: self.payoff = cu(0) else: if not self.is_investment_round(): self.payoff = cu(0) # ----- Rounding helper ----- def round_to_nearest_10_cents(self, value): # numeric rounding to 1 decimal place (0.1) return round(value, 1) # ----- Final payment calculation (investment + math) ----- def compute_final_payment(self): # 1) Investment part: make sure there is a paying round paying_round = self.participant.vars.get('paying_inv_round') # Fallback: if not set yet, draw it now and store it if paying_round is None: paying_round = random.randint(2, C.INV_TOTAL_ROUNDS) self.participant.vars['paying_inv_round'] = paying_round # Read tokens from that investment round paying_player = self.in_round(paying_round) inv_tokens = paying_player.inv_tokens or 0 # raw euros from investment inv_eur_raw = inv_tokens * C.INV_EXCHANGE_RATE # 2) Math part: sum earnings in REAL math rounds (not practice) correct_real = 0 fast_correct = 0 math_eur_raw = 0 for p in self.in_all_rounds(): if p.is_math_round() and not p.is_math_practice(): if p.correct: correct_real += 1 if p.time_spent is not None and p.time_spent < C.MATH_FAST_THRESHOLD: fast_correct += 1 math_eur_raw += C.MATH_PAYMENT_FAST else: math_eur_raw += C.MATH_PAYMENT_SLOW # --- round to nearest 10 cents numerically --- inv_eur_rounded = self.round_to_nearest_10_cents(inv_eur_raw) math_eur_rounded = self.round_to_nearest_10_cents(math_eur_raw) show_up_fee = 2.0 total_eur_rounded = self.round_to_nearest_10_cents( inv_eur_rounded + math_eur_rounded + show_up_fee ) # ------ store *final euros* in a player field so it appears in Data/Monitor ------ self.final_payment_eur = total_eur_rounded # For Payments tab to use exactly this number, the below two lines should be uncommented. # self.participant.payoff = cu(total_eur_rounded) # self.payoff = cu(total_eur_rounded) return dict( paying_round=paying_round, inv_tokens=inv_tokens, inv_eur=f"{inv_eur_rounded:.2f}", correct_real=correct_real, fast_correct=fast_correct, math_eur=f"{math_eur_rounded:.2f}", total_eur=f"{total_eur_rounded:.2f}", ) # ----- Music helper: should this page play music? ----- def show_music(self): treatment = self.session.config.get('treatment', 'no_music') return ( self.round_number > 1 and treatment in ['slow_music', 'personalized_music'] ) # ------ Treatment Music: which song should play? ------ def get_music_track(self): treatment = self.participant.vars.get('treatment', 'no_music') if treatment == 'slow_music': return 'audio/mozart.mp3' if treatment == 'personalized_music': lab_code = self.participant.vars.get('lab_code') if lab_code: return f'audio/{lab_code}.mp3' # e.g. C04 -> audio/C04.mp3 return '' # ================= PAGES ================= # # ---------- Investment game ---------- class InstructionsInv(Page): @staticmethod def is_displayed(player: Player): return player.round_number == 1 @staticmethod def vars_for_template(player: Player): # use scenario 0 percentages for the example up, down, safe = player.get_investment_params() return dict( endowment=C.ENDOWMENT, risky_up_pct=int(round(up * 100)), risky_down_pct=int(round(down * 100)), safe_return_pct=int(round(safe * 100)), play_music=player.show_music(), music_track=player.get_music_track(), ) class InvestmentDecision(Page): form_model = 'player' form_fields = ['investment', 'time_spent'] @staticmethod def is_displayed(player: Player): return player.is_investment_round() @staticmethod def vars_for_template(player: Player): up, down, safe = player.get_investment_params() return dict( endowment=C.ENDOWMENT, risky_up_pct=int(round(up * 100)), risky_down_pct=int(round(down * 100)), safe_return_pct=int(round(safe * 100)), play_music=player.show_music(), music_track=player.get_music_track(), ) @staticmethod def before_next_page(player: Player, timeout_happened): player.set_investment_payoff() class ResultsInvestment(Page): @staticmethod def is_displayed(player: Player): return player.is_investment_round() @staticmethod def vars_for_template(player: Player): invest_A = player.investment invest_B = C.ENDOWMENT - invest_A up, down, safe = player.get_investment_params() return dict( practice=player.is_investment_practice(), endowment=C.ENDOWMENT, invest_A=invest_A, invest_B=invest_B, risky_up_pct=int(round(up * 100)), risky_down_pct=int(round(down * 100)), safe_return_pct=int(round(safe * 100)), play_music=player.show_music(), music_track=player.get_music_track(), ) # ---------- Mathematical summation exercise ---------- class InstructionsMath(Page): @staticmethod def is_displayed(player: Player): return player.round_number == C.MATH_START_ROUND @staticmethod def vars_for_template(player: Player): return dict( pay_slow=C.MATH_PAYMENT_SLOW, pay_fast=C.MATH_PAYMENT_FAST, threshold=C.MATH_FAST_THRESHOLD, play_music=player.show_music(), music_track=player.get_music_track(), ) class MathQuestion(Page): form_model = 'player' form_fields = ['user_sum', 'time_spent'] @staticmethod def is_displayed(player: Player): return player.is_math_round() @staticmethod def vars_for_template(player: Player): # Always guarantee numbers exist player.ensure_math_problem() correct_so_far = sum( 1 for p in player.in_previous_rounds() if p.round_number >= C.MATH_START_ROUND and p.correct ) return dict( num1=player.num1, num2=player.num2, correct_so_far=correct_so_far, pay_slow=C.MATH_PAYMENT_SLOW, pay_fast=C.MATH_PAYMENT_FAST, threshold=C.MATH_FAST_THRESHOLD, play_music=player.show_music(), music_track=player.get_music_track(), ) @staticmethod def before_next_page(player: Player, timeout_happened): # Grade current question player.check_math_answer() # Prepare the next round numbers ONLY IF another math round follows next_round = player.round_number + 1 if next_round <= C.NUM_ROUNDS and next_round >= C.MATH_START_ROUND: next_p = player.in_round(next_round) next_p.num1 = None next_p.num2 = None class ResultsMath(Page): @staticmethod def is_displayed(player: Player): return player.is_math_round() @staticmethod def vars_for_template(player: Player): time_spent_str = f"{(player.time_spent or 0):.2f}" # count correct including current round correct_so_far = 0 for p in player.in_all_rounds(): if p.round_number >= C.MATH_START_ROUND and p.correct: correct_so_far += 1 return dict( correct=player.correct, correct_so_far=correct_so_far, pay_slow=C.MATH_PAYMENT_SLOW, pay_fast=C.MATH_PAYMENT_FAST, threshold=C.MATH_FAST_THRESHOLD, time_spent_str=time_spent_str, play_music=player.show_music(), music_track=player.get_music_track(), ) class FinalPayment(Page): form_model = 'player' form_fields = ['donate'] @staticmethod def is_displayed(player: Player): # Only once, in the very last round return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player: Player): details = player.compute_final_payment() slow_correct = details['correct_real'] - details['fast_correct'] return dict( paying_round=details['paying_round'], fast_correct=details['fast_correct'], slow_correct=slow_correct, inv_tokens=details['inv_tokens'], inv_eur=details['inv_eur'], correct_real=details['correct_real'], math_eur=details['math_eur'], total_eur=details['total_eur'], exchange_rate=C.INV_EXCHANGE_RATE, math_pay_slow=C.MATH_PAYMENT_SLOW, math_pay_fast=C.MATH_PAYMENT_FAST, threshold=C.MATH_FAST_THRESHOLD, music_track=player.get_music_track(), ) page_sequence = [ InstructionsInv, InvestmentDecision, ResultsInvestment, InstructionsMath, MathQuestion, ResultsMath, FinalPayment, ]