from otree.api import * import random doc = """ general code for the binary threshold PUBLIC goods game """ class C(BaseConstants): NAME_IN_URL = 'btpgg' PLAYERS_PER_GROUP = 5 NUM_ROUNDS = 15 # Doing it in cents, because I want a cost of 7.5 and oTree does not like that THRESHOLD = 3 # default; can be overridden via session.config['THRESHOLD'] PUBLIC_GOOD = cu(150) # benefit if threshold met ACTION_ONE = cu(75) # cost if realized action = 1 ACTION_ZERO = cu(30) # cost if realized action = 0 ENDOWMENT = cu(80) # Ensures minimum, positive, payoff class Subsession(BaseSubsession): def creating_session(self): if self.round_number == 1 and 'paying_round' not in self.session.vars: self.session.vars['paying_round'] = random.randint(1, C.NUM_ROUNDS) class Group(BaseGroup): num_action_one = models.IntegerField() public_good_provided = models.BooleanField() class Player(BasePlayer): # End chatting stage ready_to_decide = models.BooleanField(initial=False) # Slider: probability of playing 1 mix_prob = models.FloatField(min=0, max=1) # Realized action after Bernoulli draw realized_action = models.IntegerField(choices=[0, 1]) # in-case group.id_in_subsession fails to save again group_number = models.IntegerField() # for the merge with chat data later chat_channel_label = models.StringField() # ---------- helpers ---------- def get_threshold(obj): # read from settings.py treatment; fallback to C.THRESHOLD return obj.session.config.get('THRESHOLD', C.THRESHOLD) def get_endowment(obj): # Accept plain numbers or cu(...) in settings.py; always wrap here. val = obj.session.config.get('ENDOWMENT', float(C.ENDOWMENT)) return cu(val) # ---------- FUNCTIONS ---------- def draw_actions_and_set_payoffs(group: Group): players = group.get_players() # realize actions for p in players: p.realized_action = 1 if random.random() < p.mix_prob else 0 group.num_action_one = sum(p.realized_action for p in players) q = get_threshold(group) group.public_good_provided = group.num_action_one >= q endowment = get_endowment(group) for p in players: benefit = C.PUBLIC_GOOD if group.public_good_provided else cu(0) cost = C.ACTION_ONE if p.realized_action == 1 else C.ACTION_ZERO # Round payoff includes endowment p.payoff = endowment + (benefit - cost) def creating_session(subsession): subsession.group_randomly() # In-case group.id_in_subsession does not save for g in subsession.get_groups(): for p in g.get_players(): p.group_number = g.id_in_subsession p.chat_channel_label = f"{subsession.round_number}--{g.id_in_subsession}" # ---------- PAGES ---------- class Round(Page): def vars_for_template(player: Player): return dict(cur_round=player.round_number, num_rounds=C.NUM_ROUNDS) class MyWaitPagePre(WaitPage): template_name = 'btpgg/MyWaitPagePre.html' class Chat(Page): form_model = 'player' @staticmethod def vars_for_template(player: Player): return dict( cur_round=player.round_number, num_rounds=C.NUM_ROUNDS, players_per_group=C.PLAYERS_PER_GROUP, threshold=get_threshold(player), public_good=C.PUBLIC_GOOD, cost_one=C.ACTION_ONE, cost_zero=C.ACTION_ZERO, endowment=get_endowment(player), chat_channel=f"{player.round_number}--{player.group_number}", ) # --- Live gate for the Chat page --- def live_method(player: Player, data): """ Frontend sends: { "type": "ping" } -> return current count to just this player { "type": "ready" } -> mark this player ready + broadcast new count; if all ready, broadcast "advance" """ group = player.group def ready_count(): return sum(bool(p.participant.vars.get('ready_to_decide')) for p in group.get_players()) msg_type = data.get("type") if msg_type == "ping": # reply only to the sender so they initialize their counter return {player.id_in_group: {"ready_count": ready_count(), "total": C.PLAYERS_PER_GROUP}} if msg_type == "ready": if not player.participant.vars.get('ready_to_decide'): player.participant.vars['ready_to_decide'] = True rc = ready_count() payload = {"ready_count": rc, "total": C.PLAYERS_PER_GROUP} if rc >= C.PLAYERS_PER_GROUP: payload["advance"] = True # broadcast to everyone return {0: payload} return {} class Decision(Page): form_model = 'player' form_fields = ['mix_prob'] @staticmethod def vars_for_template(player: Player): return dict( cur_round=player.round_number, num_rounds=C.NUM_ROUNDS, players_per_group=C.PLAYERS_PER_GROUP, threshold=get_threshold(player), public_good=C.PUBLIC_GOOD, cost_one=C.ACTION_ONE, cost_zero=C.ACTION_ZERO, endowment=get_endowment(player), chat_channel=f"{player.round_number}--{player.group_number}", # ADDED ) @staticmethod def is_displayed(player): # clear flag for future rounds player.participant.vars.pop('ready_to_decide', None) return True #class ResultsWaitPage(WaitPage): # after_all_players_arrive = draw_actions_and_set_payoffs class MyWaitPage(WaitPage): template_name = 'btpgg/MyWaitPage.html' after_all_players_arrive = draw_actions_and_set_payoffs class Results(Page): @staticmethod def vars_for_template(player: Player): g = player.group return dict( cur_round=player.round_number, num_rounds=C.NUM_ROUNDS, your_p=int(100*round(player.mix_prob, 2)), your_action="A" if player.realized_action == 1 else "B", your_action_bool=player.realized_action, num_ones=g.num_action_one, num_zeros=5-g.num_action_one, provided=g.public_good_provided, threshold=get_threshold(player), cost_one=C.ACTION_ONE, cost_zero=C.ACTION_ZERO, endowment=get_endowment(player), ) # Here for now, can be a separate app later with the questionnaire class Payments(Page): @staticmethod def is_displayed(player: Player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player: Player): # ensure paying round exists pr = player.session.vars.get('paying_round') if pr is None: pr = random.randint(1, C.NUM_ROUNDS) player.session.vars['paying_round'] = pr # base paying-round payoff paying = player.in_round(pr).payoff # ---- NEW: quiz bonus from round 1 ---- quiz_count = player.participant.vars['cq_first_correct_count'] quiz_bonus = player.participant.vars['cq_bonus'] total = paying + quiz_bonus return dict( paying_round=pr, paying_round_payoff=paying, quiz_correct_count=quiz_count, quiz_bonus_amount=quiz_bonus, total_payoff_display=total, ) @staticmethod def before_next_page(player: Player, timeout_happened): pr = player.session.vars['paying_round'] # match what we showed above: paying round + quiz bonus quiz_count = player.participant.vars['cq_first_correct_count'] quiz_bonus = player.participant.vars['cq_bonus'] player.participant.payoff = player.in_round(pr).payoff + quiz_bonus page_sequence = [Round, MyWaitPagePre, Chat, Decision, MyWaitPage, Results, Payments]