from otree.api import * import time doc = """ Part 2 puzzle task (matrices 41–160). One matrix per page. Global timer depends on scenario time budget. Scenario-dependent feedback: A: own performance only B: + rank (opponents hidden) C: + rank (opponents shown) Session1 override: - No time_choice / no WTP / no scenario_feedback - Single scenario with own-performance feedback only - Fixed duration: exactly 10 minutes for puzzles """ class C(BaseConstants): NAME_IN_URL = 'puzzle_task2' PLAYERS_PER_GROUP = None NUM_ROUNDS = 120 MIN_VIEW_SECONDS = 10 # Correct options for matrices 41–160 (1–12 = A–L) # Correct options for matrices 41–160 (1–12 = A–L) # Correct options for matrices 41–160 (1–12 = A–L) CORRECT_OPTIONS = [ 3, 11, 12, 4, 6, 2, 3, 3, 11, 2, 12, 3, 4, 1, 2, 7, 7, 9, 1, 8, 11, 6, 9, 11, 9, 6, 3, 9, 6, 2, 8, 1, 1, 6, 1, 9, 4, 3, 3, 6, 11, 2, 1, 5, 2, 5, 1, 9, 4, 9, 12, 9, 11, 9, 9, 3, 5, 4, 9, 8, 11, 4, 1, 8, 5, 9, 6, 7, 6, 7, 8, 1, 11, 7, 8, 7, 9, 9, 9, 9, 1, 7, 3, 7, 5, 2, 1, 9, 1, 8, 11, 3, 7, 2, 11, 8, 1, 11, 3, 1, 1, 1, 1, 11, 1, 9, 11, 5, 6, 5, 9, 3, 1, 8, 6, 1, 4, 5, 1, 8, ] # Session1 fixed time budgets (10 minutes) SESSION1_PUZZLE_SECONDS = 60*10 class Subsession(BaseSubsession): pass class Group(BaseGroup): pass class Player(BasePlayer): item_id = models.IntegerField() implemented_scenario_id = models.IntegerField() answer = models.IntegerField( choices=[ [1, "A"], [2, "B"], [3, "C"], [4, "D"], [5, "E"], [6, "F"], [7, "G"], [8, "H"], [9, "I"], [10, "J"], [11, "K"], [12, "L"] ], widget=widgets.RadioSelect, blank=True, ) viewed = models.BooleanField(initial=False) is_correct = models.BooleanField(initial=False) time_spent = models.FloatField(initial=0) submit_before5 = models.IntegerField(initial=0) correct_before5 = models.IntegerField(initial=0) feedback_time_spent = models.FloatField(initial=0) # ----------------------- # Helpers # ----------------------- def is_session1(player) -> bool: return bool(player.session.config.get('session1', False)) def is_pilot(player) -> bool: return bool(player.session.config.get('sessionPilot', False)) def get_selected_scenario(player) -> int: """ Session1: single scenario (own performance only). Otherwise: use participant.vars['implemented_scenario_id'] set upstream. """ if is_session1(player): return 1 sid = player.participant.vars.get('implemented_scenario_id') if sid is None: raise RuntimeError("implemented_scenario_id not set.") return int(sid) def get_piece_rate(player) -> Currency: """ Reads piece_rate from session.config. Interprets as euros per correct answer. """ try: return cu(player.session.config.get('piece_rate', 0)) except Exception: return cu(0) def get_implemented_rate_id(player): """ Pilot only: participant.vars['implemented_rate_id'] set upstream (scenario_feedback). """ rid = player.participant.vars.get('implemented_rate_id') if rid is None: raise RuntimeError("implemented_rate_id not set (pilot session).") return int(rid) def _interpret_budget_to_seconds(raw_value) -> int: """ Backward compatible: - if raw_value is minutes (<= 30), convert to seconds - if raw_value already looks like seconds (> 30), keep it """ v = int(raw_value) return v * 60 if v <= 30 else v def get_puzzle_budget_seconds(player, sid: int) -> int: """ Main: uses scenario_{sid}_puzzle_minutes Pilot: uses scenario_{sid}_puzzle_minutes_r{rid} """ p = player.participant if is_pilot(player): rid = get_implemented_rate_id(player) key = f"scenario_{sid}_puzzle_minutes_r{rid}" else: key = f"scenario_{sid}_puzzle_minutes" raw = p.vars.get(key) if raw is None: raise RuntimeError(f"Missing {key} in participant.vars.") return _interpret_budget_to_seconds(raw) def get_puzzle_budget_seconds_any(player) -> int: """ Session1: fixed 10 minutes. Otherwise: scenario-dependent budget (and rate-dependent in pilot). """ if is_session1(player): return C.SESSION1_PUZZLE_SECONDS sid = get_selected_scenario(player) return get_puzzle_budget_seconds(player, sid) def _minutes_str_from_seconds(seconds: int) -> str: mins = seconds / 60 return str(int(mins)) if mins.is_integer() else f"{mins:.2f}" def _stable_tiebreak(session_code: str, label: str) -> int: s = session_code + "|" + label return sum((i + 1) * ord(c) for i, c in enumerate(s)) def get_opponents_scores(player): """ Source of truth: session.config['opponent_true_values'] (length 9). These should correspond to the same task context as the feedback (here: puzzle totals). """ scores = player.session.config.get('opponent_true_values') if scores is None: raise RuntimeError("Missing session.config['opponent_true_values'].") if len(scores) != 9: raise RuntimeError("session.config['opponent_true_values'] must have length 9.") return [int(x) for x in scores] def build_ranking_table(player, your_perf, your_minutes, show_opponents): opponents_scores = get_opponents_scores(player) minutes_int = int(your_minutes) # Create the new variable your_time = f"{minutes_int} minutes" entries = [ dict( label="Vous", time=your_time, performance=your_perf, productivity=your_perf / your_minutes if your_minutes > 0 else 0, show=True, tiebreak=_stable_tiebreak(player.session.code, "Vous"), ) ] # Assumption: opponents_scores are 10-minute totals, so productivity is score/10. for i, s in enumerate(opponents_scores, start=1): entries.append(dict( label=f"P{i}", time="10 minutes", performance=s, productivity=s / 10, show=show_opponents, tiebreak=_stable_tiebreak(player.session.code, f"P{i}"), )) entries.sort(key=lambda e: (-e["performance"], e["tiebreak"])) rows, your_rank = [], None for r, e in enumerate(entries, start=1): if e["label"] == "Vous": your_rank = r rows.append(dict( rank=r, participant=e["label"], time=e["time"], performance=str(e["performance"]) if e["show"] else "", productivity=f"{e['productivity']:.2f}" if e["show"] else "", )) return your_rank, rows # ----------------------- # Pages # ----------------------- class Session1Part2Instructions(Page): @staticmethod def is_displayed(player): return is_session1(player) and player.round_number == 1 def vars_for_template(player): cpm = player.participant.vars.get('part1_correct_per_min') cpm_str = f"{cpm:.2f}" if cpm is not None else "" return dict( piece_rate=player.session.config.get('piece_rate'), num_correct=player.participant.vars.get('part1_num_correct'), correct_per_min=cpm_str, ) class Matrix(Page): form_model = 'player' form_fields = ['answer'] @staticmethod def get_timeout_seconds(player): total = get_puzzle_budget_seconds_any(player) p = player.participant now = time.time() start = p.vars.get('p2_start') if start is None: p.vars['p2_start'] = now start = now return max(0, total - (now - start)) @staticmethod def is_displayed(player): total = get_puzzle_budget_seconds_any(player) start = player.participant.vars.get('p2_start') return True if start is None else (time.time() - start) < total @staticmethod def vars_for_template(player): p = player.participant now = time.time() sid = get_selected_scenario(player) player.implemented_scenario_id = sid start = p.vars.get('p2_start') if start is None: p.vars['p2_start'] = now start = now player.item_id = 41 + player.round_number - 1 player.viewed = True p.vars['p2_matrix_start'] = now total = get_puzzle_budget_seconds_any(player) remaining = max(0, int(total - (now - start))) min_view = min(C.MIN_VIEW_SECONDS, remaining) p.vars['p2_earliest_submit'] = now + min_view return dict( item_id=player.item_id, q_path=f"rank_feedback/matrices/q/q{player.item_id}.jpg", a_path=f"rank_feedback/matrices/a/a{player.item_id}.jpg", total_time=total, elapsed_at_render=int(now - start), min_view_time=min_view, ) @staticmethod def error_message(player, values): t = player.participant.vars.get('p2_earliest_submit') if t and time.time() < t: return f"Veuillez attendre {int(t - time.time()) + 1} seconde(s)." @staticmethod def before_next_page(player, timeout_happened): start = player.participant.vars.get('p2_matrix_start') player.time_spent = time.time() - start if start else 0 correct = C.CORRECT_OPTIONS[player.round_number - 1] ans = player.field_maybe_none('answer') player.is_correct = ans == correct if ans is not None else False p = player.participant now = time.time() global_start = p.vars.get('p2_start') elapsed = (now - global_start) if global_start is not None else 0 player.submit_before5 = 1 if elapsed <= 5 * 60 else 0 player.correct_before5 = 1 if (player.submit_before5 == 1 and player.is_correct) else 0 class Feedback(Page): @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS @staticmethod def vars_for_template(player): part = player.participant if 'feedback_page_start' not in part.vars: part.vars['feedback_page_start'] = time.time() sid = get_selected_scenario(player) total_seconds = get_puzzle_budget_seconds_any(player) minutes = total_seconds / 60 minutes_str = _minutes_str_from_seconds(total_seconds) viewed = [r for r in player.in_all_rounds() if r.viewed] num_correct = sum(r.is_correct for r in viewed) per_min = num_correct / minutes if minutes > 0 else 0 show_rank = (not is_session1(player)) and (sid in (2, 3)) show_opponents = (not is_session1(player)) and (sid == 3) your_rank, table_rows = None, [] if show_rank: your_rank, table_rows = build_ranking_table( player, num_correct, minutes, show_opponents ) # Optional: show the payoff on the feedback page piece_rate = get_piece_rate(player) part2_puzzles_payoff = piece_rate * num_correct total_before5 = sum(r.correct_before5 for r in viewed) return dict( minutes_str=minutes_str, part2_num_correct=num_correct, part2_correct_per_min=f"{per_min:.2f}", show_rank=show_rank, your_rank=your_rank, table_rows=table_rows, piece_rate=piece_rate, part2_puzzles_payoff=part2_puzzles_payoff, total_before5=total_before5, ) @staticmethod def before_next_page(player, timeout_happened): part = player.participant start = part.vars.get('feedback_page_start') player.feedback_time_spent = (time.time() - start) if start else 0 viewed = [r for r in player.in_all_rounds() if r.viewed] num_correct = sum(r.is_correct for r in viewed) piece_rate = get_piece_rate(player) payoff = piece_rate * num_correct # Store for downstream apps part.vars['part2_num_correct'] = int(num_correct) part.vars['part2_puzzles_payoff'] = float(payoff) total_before5 = sum(r.correct_before5 for r in viewed) part.vars['part2_total_before5'] = int(total_before5) part.part2_total_before5 = int(total_before5) total_after5 = num_correct - total_before5 part.part2_total_after5 = int(total_after5) # Also store on participant model field (since you listed it in PARTICIPANT_FIELDS) if hasattr(part, 'part2_puzzles_payoff'): part.part2_puzzles_payoff = payoff class Transition(Page): @staticmethod def is_displayed(player): return player.round_number == C.NUM_ROUNDS page_sequence = [Session1Part2Instructions, Matrix, Feedback, Transition]