from otree.api import * import csv, random, itertools, json # ★ 多了 json from pathlib import Path from collections import defaultdict from django.forms import HiddenInput # 在檔案最上方 import doc = """ Lottery app — 單一 app,對每位 participant 隨機指派 UI(single/grouped), 並在此基礎上再多切成 4 組 treatment: 1) 多玩法 SINGLE 組 (single_multi) 2) 單一玩法 SINGLE 組 (single_single) 3) 多玩法 GROUPED 組 (group_multi) 4) 單一玩法 GROUPED 組 (group_single) 每位 participant: - 先抽到其中一組 treatment - 依 treatment 決定 ui_mode(single 或 grouped) - 再產生 9 回合各自的玩法(拉霸 / 數字玩法 / 轉盤)。 """ class Constants(BaseConstants): name_in_url = 'lottery_app' players_per_group = None num_rounds = 1 showup_fee = 4 # 車馬費(基本報酬) # Part II: Holt-Laury 風險決策獎金設定 # Option A 的兩個報酬(較穩健) HL_A1 = 2.00 # 高報酬 HL_A2 = 1.60 # 低報酬 # Option B 的兩個報酬(較冒險,高低落差大) HL_B1 = 3.85 # 高報酬 HL_B2 = 0.10 # 低報酬 lottery_prize_1 = 1.5 # 原本 0 點 -> 0 USD lottery_prize_2 = 3 # 原本 500 點 -> 1.5 USD lottery_prize_3 = 9 # 原本 100 點 -> 3 USD lottery_prize_4 = 15 # 原本 300 點 -> 9 USD lottery_prize_0 = 0 # 原本 500 點 -> 15 USD SINGLE_SCHEDULE = [6, 6, 6, 12, 12, 12, 24, 24, 24] GROUPED_SPLIT = [(0, 1, 2), (3, 4, 5), (6, 7, 8)] CSV_FILENAME = '已排序調好格式.csv' # ---- 玩法名稱(可以之後改字)---- PLAY_TYPES = ['拉霸', '數字玩法', '轉盤'] # ---- 四種 treatment 設定 ---- # key: treatment 名稱 # ui_mode: single / grouped # play_mode: multi / single(多玩法 / 單一玩法) TREATMENTS = { 'single_multi': {'ui_mode': 'single', 'play_mode': 'multi'}, 'single_single': {'ui_mode': 'single', 'play_mode': 'single'}, 'group_multi': {'ui_mode': 'grouped', 'play_mode': 'multi'}, 'group_single': {'ui_mode': 'grouped', 'play_mode': 'single'}, } # ---------- CSV 載入與標準化 ---------- def _normalize_row(r: dict): """ 把一列 CSV 整理成標準欄位: - ticket_id - type - VARIATION - CLUSTER - P1, P2, P3, P4, P0(從 p1/p2/... 之類欄位抓) """ lk = {(k or '').strip().lower(): (r[k] if r.get(k) is not None else '') for k in r.keys()} def pick(*cands): for c in cands: v = lk.get(c, '') if v is None: v = '' v = str(v).strip() if v != '': return v return '' ticket_id = pick('ticket_id', 'ticket id', 'ticketid', 'id') typ = pick('type', 'TYPE'.lower()) variation = pick('variation', 'variati', 'variation_id', 'var') cluster = pick('cluster', 'cluster_id', 'group') # 這裡抓 p1/p2/p3/p4/p0(不分大小寫) p1 = pick('p1', 'P1', 'prob1', 'prob_1') p2 = pick('p2', 'P2', 'prob2', 'prob_2') p3 = pick('p3', 'P3', 'prob3', 'prob_3') p4 = pick('p4', 'P4', 'prob4', 'prob_4') p0 = pick('p0', 'P0', 'prob0', 'prob_0') return { 'ticket_id': ticket_id, 'type': typ, 'VARIATION': variation, 'CLUSTER': cluster, 'P1': p1, 'P2': p2, 'P3': p3, 'P4': p4, 'P0': p0, } def find_csv_path(app_file: str) -> Path: project_root = Path(app_file).resolve().parents[1] app_dir = Path(app_file).resolve().parent candidates = [ project_root / Constants.CSV_FILENAME, app_dir / Constants.CSV_FILENAME, project_root / 'data' / Constants.CSV_FILENAME, ] for p in candidates: if p.exists(): return p return candidates[0] def load_master_tickets(csv_path: Path): tickets = [] with open(csv_path, newline='', encoding='utf-8-sig') as f: reader = csv.DictReader(f) for r in reader: t = _normalize_row(r) if t['ticket_id']: if not t['VARIATION']: t['VARIATION'] = 'NA' if not t['CLUSTER']: t['CLUSTER'] = 'NA' tickets.append(t) return tickets # ---------- 抽樣資料結構與工具(原本邏輯,不動) ---------- def build_inventory(master): """ 依 (variation -> cluster_id -> list[tickets]) 建庫存,並把每個 cluster 內票券先行洗牌。 """ inv = defaultdict(lambda: defaultdict(list)) for t in master: v = str(t['VARIATION']).strip() c = str(t['CLUSTER']).strip() inv[v][c].append(t) # 同一 cluster 內部洗牌(避免固定順序) for v, clusters in inv.items(): for cid, lst in clusters.items(): random.shuffle(lst) return inv def common_cluster_size_or_error(inv): """檢查所有 variation/cluster 的大小是否一致;回傳共同大小,不一致且無法整除時直接報錯。""" sizes = set() for clusters in inv.values(): for lst in clusters.values(): sizes.add(len(lst)) if not sizes: raise ValueError("CSV 無任何 cluster 資料。") if len(sizes) == 1: return next(iter(sizes)) raise ValueError(f"偵測到不同的 CLUSTER 大小:{sorted(sizes)}。請整理成一致大小(通常為 6)。") def var_block_order(inv): """每個區塊使用 VARIATION = 1,2,3 各一次(順序隨機),且 1/2/3 三種在庫中都必須存在可用 cluster。""" needed = ['1', '2', '3'] for v in needed: if v not in inv or not inv[v]: raise ValueError("CSV 必須至少包含 VARIATION=1、2、3,且各自有可用 CLUSTER。") order = needed[:] # ['1','2','3'] random.shuffle(order) return order def combo_types_key(clusters, inv, variation): """把某個 variation 下若干 cluster 的 TYPE multiset 做排序後成 tuple 作為 key。""" types = [] for cid in clusters: for t in inv[variation][cid]: types.append(str(t.get('type', '')).strip()) types.sort() return tuple(types) def iter_combos(clist, k, limit=None): """ 依序產出 clist 的 k 組合。若有 limit,最多產出 limit 個組合(早停)。 為了隨機性,先把 clist 洗牌再 itertools.combinations。 """ clist = list(clist) random.shuffle(clist) count = 0 for combo in itertools.combinations(clist, k): yield combo count += 1 if limit is not None and count >= limit: break def find_type_consistent_combos_for_block( inv, used_clusters, var_order, need_clusters, max_try_first=3000, max_scan_follow=6000, ): """ 嘗試為一個三回合區塊找到: - 第 1 回合(var_order[0]):一個包含 need_clusters 個 cluster 的組合,定義 type_key - 第 2、3 回合(var_order[1], var_order[2]):各找一個組合,使 type_key 相同 條件: - (variation, cluster_id) 未在 used_clusters 中 - 三回合之間可用不同 cluster(甚至不同 cluster_id),只要 type_key 相同即可 失敗則 raise ValueError。 """ v0, v1, v2 = var_order all_clusters_v0 = [cid for cid in inv[v0].keys() if (v0, cid) not in used_clusters] all_clusters_v1 = [cid for cid in inv[v1].keys() if (v1, cid) not in used_clusters] all_clusters_v2 = [cid for cid in inv[v2].keys() if (v2, cid) not in used_clusters] if len(all_clusters_v0) < need_clusters or len(all_clusters_v1) < need_clusters or len(all_clusters_v2) < need_clusters: raise ValueError("可用 CLUSTER 不足,無法完成 3 回合一致 TYPE 的要求。") # 先隨機嘗試一些 v0 的組合 for combo0 in iter_combos(all_clusters_v0, need_clusters, limit=max_try_first): key = combo_types_key(combo0, inv, v0) # 在 v1 中找任一個符合 key 的組合 combo1_found = None scan_count = 0 for combo1 in itertools.combinations(all_clusters_v1, need_clusters): scan_count += 1 if scan_count > max_scan_follow: break if combo_types_key(combo1, inv, v1) == key: combo1_found = combo1 break if not combo1_found: continue # 在 v2 中找任一個符合 key 的組合 combo2_found = None scan_count = 0 for combo2 in itertools.combinations(all_clusters_v2, need_clusters): scan_count += 1 if scan_count > max_scan_follow: break if combo_types_key(combo2, inv, v2) == key: combo2_found = combo2 break if not combo2_found: continue # 成功 return { v0: list(combo0), v1: list(combo1_found), v2: list(combo2_found), } raise ValueError( "找不到能讓 3 回合 TYPE multiset 一致的 CLUSTER 組合,請檢查 CSV 是否各 VARIATION 具備對應的 TYPE 組。" ) def assign_9_rounds_for_participant(master): """ 依規則產生 9 個小回合([6,6,6, 12,12,12, 24,24,24])的抽樣結果。 - 每三回合為一區塊:VARIATION=1/2/3 各一次且順序隨機。 - 同一區塊 3 回合的 TYPE multiset 必須一致(但每回合可用不同 cluster,cluster 內部票券順序洗牌)。 - 跨全流程 cluster 不放回。 回傳:list[ list[ticket_dict] ] 長度 9 """ inv = build_inventory(master) csize = common_cluster_size_or_error(inv) schedule = Constants.SINGLE_SCHEDULE result_rounds = [] used_clusters = set() # 記錄已取用的 (variation, cluster_id) for a, b, c in Constants.GROUPED_SPLIT: # 本區塊三回合的張數都一樣(6 或 12 或 24) need_cards = schedule[a] assert need_cards == schedule[b] == schedule[c] if need_cards % csize != 0: raise ValueError(f"需抽 {need_cards} 張,但 CLUSTER 大小 {csize} 無法整除。請整理 CSV。") need_clusters = need_cards // csize # 這個區塊的 VARIATION 亂序(1,2,3 各一次) var_order = var_block_order(inv) # 尋找能讓三回合 TYPE multiset 一致的三組 cluster combos = find_type_consistent_combos_for_block(inv, used_clusters, var_order, need_clusters) # 依 var_order 順序,組裝出三個回合的票 for vidx, round_index in enumerate([a, b, c]): v = var_order[vidx] cur_clusters = combos[v] round_tickets = [] for cid in cur_clusters: # 這裡 inv[v][cid] 內部已洗牌 round_tickets.extend(inv[v][cid]) used_clusters.add((v, cid)) # round_tickets 長度等於 need_cards result_rounds.append(round_tickets) assert len(result_rounds) == 9 return result_rounds # ---------- 玩法抽取工具:依 treatment 產生 9 回合玩法 ---------- def assign_play_types_for_treatment(treatment: str): """ 給一個 treatment 名稱,回傳長度 9 的玩法 list。 play_types_per_round[i] 對應第 i+1 回合的玩法。 規則: - 多玩法 (multi): 每 3 回合是一個 cycle,三種玩法各一次(順序隨機)。 - 單一玩法 (single): 先抽一種玩法,9 回合都用這一種。 """ config = TREATMENTS.get(treatment) if config is None: raise ValueError(f"未知的 treatment: {treatment}") play_mode = config['play_mode'] if play_mode == 'multi': result = [] for _ in range(3): # 三個小 cycle block_types = PLAY_TYPES[:] random.shuffle(block_types) result.extend(block_types) return result elif play_mode == 'single': chosen = random.choice(PLAY_TYPES) return [chosen] * 9 else: raise ValueError(f"未知的 play_mode: {play_mode}") # ---------- oTree Models ---------- class Subsession(BaseSubsession): def creating_session(self): csv_path = find_csv_path(__file__) if not csv_path.exists(): raise FileNotFoundError( f"{csv_path} not found. 請把 {Constants.CSV_FILENAME} 放在專案根、lottery_app/ 或 data/ 之一。" ) master = load_master_tickets(csv_path) if not master: raise ValueError("CSV 為空或表頭不符,至少需 ticket_id 與 VARIATION/CLUSTER 欄。") # 警告重複 ticket_id seen, dup = set(), set() for t in master: tid = t['ticket_id'] if tid in seen: dup.add(tid) seen.add(tid) if dup: print(f"[WARN] duplicate ticket_id in CSV: {sorted(dup)}") # 對每個 participant: # - 抽 9 回合的彩券組合 # - 抽 treatment(同時決定 ui_mode 與玩法) for p in self.get_players(): rounds9 = assign_9_rounds_for_participant(master) p.participant.vars['assigned_rounds9'] = rounds9 pages = [[rounds9[i] for i in grp] for grp in Constants.GROUPED_SPLIT] p.participant.vars['assigned_pages3x3'] = pages # ★ 把「每回合顯示的 ticket_id」整理出來,等等存進 Player rounds9_ids = [[t['ticket_id'] for t in round_tickets] for round_tickets in rounds9] # ---- 新增:抽 treatment & 玩法 ---- treatment = random.choice(list(TREATMENTS.keys())) config = TREATMENTS[treatment] play_types = assign_play_types_for_treatment(treatment) # 存進 participant.vars(給頁面顯示用) p.participant.vars['treatment'] = treatment # e.g. 'single_multi' p.participant.vars['ui_mode'] = config['ui_mode'] # 'single' or 'grouped' p.participant.vars['play_types_per_round'] = play_types # 長度 9 的 list # ★ 存進 Player(實驗結束匯出資料會看到的欄位) p.treatment = treatment p.ui_mode_stored = config['ui_mode'] p.playtypes_json = json.dumps(play_types, ensure_ascii=False) p.shown_json = json.dumps(rounds9_ids, ensure_ascii=False) # choices_json 先留空,等受試者作答後在 Results 頁面填入 class Group(BaseGroup): pass class Player(BasePlayer): prolific_id = models.StringField(default=str(" ")) consent_electronic = models.BooleanField() # VAS-F 前測(18 題,各 0-100 整數) vasf_pre_1 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_2 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_3 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_4 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_5 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_6 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_7 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_8 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_9 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_10 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_11 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_12 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_13 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_14 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_15 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_16 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_17 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_pre_18 = models.IntegerField(min=0, max=100, blank=True, initial=-1) # VAS-F 後測(18 題,各 0-100 整數) vasf_post_1 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_2 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_3 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_4 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_5 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_6 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_7 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_8 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_9 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_10 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_11 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_12 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_13 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_14 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_15 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_16 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_17 = models.IntegerField(min=0, max=100, blank=True, initial=-1) vasf_post_18 = models.IntegerField(min=0, max=100, blank=True, initial=-1) #測驗錯誤次數 quiz_error_count = models.IntegerField(initial=0) # --- 各題錯誤次數統計 --- error_count_num = models.IntegerField(initial=0) # 數字抽獎題 error_count_wheel = models.IntegerField(initial=0) # 轉盤題 error_count_slot = models.IntegerField(initial=0) # 拉霸題 error_count_payrule1 = models.IntegerField(initial=0) # 付款規則 4-1 error_count_payrule2 = models.IntegerField(initial=0) # 付款規則 4-2 error_count_payrule3 = models.IntegerField(initial=0) # 付款規則 4-3 error_count_payrule4 = models.IntegerField(initial=0) # 付款規則 4-4 # --- 最後的 SURVEY --- # --- AI 相關 --- ai_used = models.StringField( label='Did you use AI anytime in this experiment?', choices=[['yes', 'Yes'], ['no', 'No']], widget=widgets.RadioSelect, blank=True # 設為 True,改由 HTML/JS 檢查 ) ai_assist_methods = models.LongStringField( label="Please tell us how you use AI and which AI engines you used (e.g., ChatGPT, Claude, Gemini...).", blank=True ) # --- 研究目的與策略 --- study_purpose_guess = models.LongStringField(label='What do you think was the purpose of this study?', blank=True) strategy_used = models.LongStringField(label='What were the strategies you employed during the experiment?', blank=True) # 新增:實驗說明清楚程度 (1-7) inst_clarity = models.IntegerField( min=1, max=7, label="Were the instructions in the study clear?" ) # 新增:不清楚的地方說明 inst_clarity_why = models.LongStringField( label="Please indicate the instructions that confuse you, if any.", blank=True ) # --- 背景與評分 --- stats_edu = models.StringField( label='Have you taken statistics courses at the college level or above?', choices=[['yes', 'Yes'], ['no', 'No']], widget=widgets.RadioSelect, blank=True ) # 1-7 分的題目 diff_overall = models.IntegerField(min=1, max=7, blank=True) diff_overall_why = models.LongStringField(label='為什麼?', blank=True) diff_wheel = models.IntegerField(min=1, max=7, blank=True) diff_wheel_why = models.LongStringField(label='為什麼?', blank=True) diff_slot = models.IntegerField(min=1, max=7, blank=True) diff_slot_why = models.LongStringField(label='為什麼?', blank=True) diff_number = models.IntegerField(min=1, max=7, blank=True) diff_number_why = models.LongStringField(label='為什麼?', blank=True) # 1-5 分的題目 option_increase_diff = models.IntegerField(min=1, max=5, blank=True) option_increase_diff_why = models.LongStringField(label='為什麼?', blank=True) change_playtype_easier = models.IntegerField(min=1, max=5, blank=True) change_playtype_easier_why = models.LongStringField(label='為什麼?', blank=True) page_switch_easier = models.IntegerField(min=1, max=5, blank=True) page_switch_easier_why = models.LongStringField(label='為什麼?', blank=True) # 其他欄位 choice_uncertainty = models.IntegerField(min=0,max=100, blank=True, initial=-1) willing_to_pay = models.StringField(label='(Unit: USD. Please enter numbers only, e.g., 5.5) ',blank=True) other_comments = models.LongStringField(label='Other comments', blank=True) # 用來儲存前測的所有反應數據 (JSON 格式) pvt_pre_results = models.LongStringField() # 用來儲存後測的所有反應數據 (JSON 格式) pvt_post_results = models.LongStringField() # ---- HL 風險測驗獎勵相關 ---- hl_is_selected = models.BooleanField(initial=False) # 是否抽中這 10% hl_chosen_row = models.IntegerField() # 抽中哪一題 (1-10) hl_chosen_option = models.StringField() # 受試者在那一題選 A 還是 B hl_won_prize = models.FloatField(initial=0.0) # 最終獲得的獎金 (e.g. 3.85) # ✅ 新增:鎖住 HL 抽獎只做一次(重整不變) hl_draw_done = models.BooleanField(initial=False) def calc_hl_payoff(self): # ===== 防止重整重抽 ===== if self.field_maybe_none('hl_draw_done'): return # 1. 決定是否抽中這 10% (1-100 抽到小於等於 10) if random.randint(1, 100) <= 10: self.hl_is_selected = True # 2. 隨機抽一題 (1-10) row_idx = random.randint(1, 10) self.hl_chosen_row = row_idx # 3. 取得受試者那一題的選擇 (hl_1 ~ hl_10) choice = getattr(self, f'hl_{row_idx}') self.hl_chosen_option = choice # 4. 根據題號與選擇進行抽獎 # 定義獎金對照表 (機率p, 獎金1, 獎金2) # A: p 獲得 2.00, (1-p) 獲得 1.60 # B: p 獲得 3.85, (1-p) 獲得 0.10 p = row_idx / 10.0 die_roll = random.random() # 產生 0~1 之間的浮點數 if choice == 'A': self.hl_won_prize = Constants.HL_A1 if die_roll <= p else Constants.HL_A2 else: self.hl_won_prize = Constants.HL_B1 if die_roll <= p else Constants.HL_B2 # 5. 將獎金加入總 payoff (注意匯率轉換,這裡直接加到 payoff) # 如果你的 payoff 單位是元,就直接加 self.payoff += cu(self.hl_won_prize) else: self.hl_is_selected = False # 1. 數字抽獎測驗 quiz_num_prob = models.IntegerField( label="In the 'Drawing a Number' lottery below, what is the probability (%) of receiving $9?", choices=[[10, "10%"], [15, "15%"], [50, "50%"], [5, "5%"], [20, "20%"]], widget=widgets.RadioSelect ) # 2. 轉盤測驗 quiz_wheel_prob = models.IntegerField( label="In the 'Spinner' lottery below, what is the probability (%) of receiving $3?", choices=[[10, "10%"], [60, "60%"], [5, "5%"], [25, "25%"]], widget=widgets.RadioSelect ) # 3. 拉霸機測驗 quiz_slot_prob = models.IntegerField( label="In the 'Slot Machine' lottery below, what is the probability (%) of receiving $1.5?", choices=[[40, "40%"], [20, "20%"], [10, "10%"], [30, "30%"]], widget=widgets.RadioSelect ) # ====== 新增:付款規則理解題(是/否) ====== payrule_q1 = models.BooleanField( choices=[[True, 'yes'], [False, 'no']], widget=widgets.RadioSelect, label='Your compensation in this part will be determined by the sum of prizes won in the lotteries selected in all nine tasks.' ) payrule_q2 = models.BooleanField( choices=[[True, 'yes'], [False, 'no']], widget=widgets.RadioSelect, label='The compensation is not yet determined after selecting a lottery. It will not be drawn and realized based on your selected lottery until the end of the experiment.' ) payrule_q3 = models.BooleanField( choices=[[True, 'yes'], [False, 'no']], widget=widgets.RadioSelect, label='Your compensation will be determined by one specific lottery selected from the nine tasks.' ) payrule_q4 = models.BooleanField( choices=[[True, 'yes'], [False, 'no']], widget=widgets.RadioSelect, label='You will definitely see all three formats of the lotteries in the choices.' ) # ========== ★ 這幾個是「用來存實驗資料」的新欄位 ========== # 實驗組別(四組之一) treatment = models.StringField() # 實際 UI 模式(single / grouped) ui_mode_stored = models.StringField() # 9 回合玩法(JSON 字串),例如 ["拉霸","數字玩法","轉盤",...] playtypes_json = models.LongStringField() # 9 回合顯示的彩券 ticket_id(JSON 字串,list of list) # 例如: [["t1","t2","t3"], ["t4","t5"], ... 共 9 個小 list] shown_json = models.LongStringField() # 9 回合中受試者的選擇(JSON 字串),例如 ["t2","t5",...] choices_json = models.LongStringField() # ====== Payoff 抽獎(正式獎金)用 ====== payoff_round = models.IntegerField(initial=0) payoff_ticket_id = models.StringField(initial='') payoff_number = models.IntegerField(initial=0) # 1~100 payoff_prize = models.FloatField(initial=0) # 0/1/2/3/4 payoff_draw_json = models.LongStringField(initial='') # 用來「鎖住只能玩一次」:JS 點完會把它設為 1,沒點不能 Next payoff_clicked = models.IntegerField(initial=0, blank=True) # 建議欄位長度設為 LongStringField,因為記錄猶豫過程產生的字串會比較長 click_timestamps1_json = models.LongStringField(initial='[]') click_timestamps2_json = models.LongStringField(initial='[]') click_timestamps3_json = models.LongStringField(initial='[]') # --- Single UI:9 個小回合 --- s1 = models.StringField(widget=widgets.RadioSelect, label="Round 1") s2 = models.StringField(widget=widgets.RadioSelect, label="Round 2") s3 = models.StringField(widget=widgets.RadioSelect, label="Round 3") s4 = models.StringField(widget=widgets.RadioSelect, label="Round 4") s5 = models.StringField(widget=widgets.RadioSelect, label="Round 5") s6 = models.StringField(widget=widgets.RadioSelect, label="Round 6") s7 = models.StringField(widget=widgets.RadioSelect, label="Round 7") s8 = models.StringField(widget=widgets.RadioSelect, label="Round 8") s9 = models.StringField(widget=widgets.RadioSelect, label="Round 9") # --- Grouped UI:3 頁 × 3 區塊 --- g1a = models.StringField(widget=widgets.RadioSelect, label="Page1 / Block A") g1b = models.StringField(widget=widgets.RadioSelect, label="Page1 / Block B") g1c = models.StringField(widget=widgets.RadioSelect, label="Page1 / Block C") g2a = models.StringField(widget=widgets.RadioSelect, label="Page2 / Block A") g2b = models.StringField(widget=widgets.RadioSelect, label="Page2 / Block B") g2c = models.StringField(widget=widgets.RadioSelect, label="Page2 / Block C") g3a = models.StringField(widget=widgets.RadioSelect, label="Page3 / Block A") g3b = models.StringField(widget=widgets.RadioSelect, label="Page3 / Block B") g3c = models.StringField(widget=widgets.RadioSelect, label="Page3 / Block C") hl_1 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_2 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_3 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_4 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_5 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_6 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_7 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_8 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_9 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_10 = models.StringField(choices=[['A', 'A'], ['B', 'B']]) hl_safe_choices = models.IntegerField(initial=0) # HL 測驗的最終結果(可選,用於計算風險係數) hl_safe_choices = models.IntegerField() # 計算受試者選了幾次 A # ---- label:每張彩券顯示 p50~p0 的機率說明(加上 %) ---- @staticmethod def _label(t): # 使用小寫 key 讀取,並直接對應 Constants 裡的美金金額 def with_percent(v): s = str(v or '').strip() if s == '': return '0%' return s.replace('%', '').strip() + '%' # 這裡的 p1~p5 對應 CSV 裡的新欄位名稱 p1 = with_percent(t.get('P1') or t.get('p1')) p2 = with_percent(t.get('P2') or t.get('p2')) p3 = with_percent(t.get('P3') or t.get('p3')) p4 = with_percent(t.get('P4') or t.get('p4')) p0 = with_percent(t.get('P0') or t.get('p0')) return ( f"Win {Constants.lottery_prize_1} USD prob: {p1}, " f"Win {Constants.lottery_prize_2} USD prob: {p2}, " f"Win {Constants.lottery_prize_3} USD prob: {p3}, " f"Win {Constants.lottery_prize_4} USD prob: {p4}, " f"Win {Constants.lottery_prize_0} USD prob: {p0}" ) # ---------- Single UI choices:每張 ticket 一個選項(value=ticket_id) ---------- def _single_choices(self, idx): rounds9 = self.participant.vars.get('assigned_rounds9', []) if not rounds9 or idx >= len(rounds9): return [] tickets = rounds9[idx] return [(t['ticket_id'], self._label(t)) for t in tickets] def s1_choices(self): return self._single_choices(0) def s2_choices(self): return self._single_choices(1) def s3_choices(self): return self._single_choices(2) def s4_choices(self): return self._single_choices(3) def s5_choices(self): return self._single_choices(4) def s6_choices(self): return self._single_choices(5) def s7_choices(self): return self._single_choices(6) def s8_choices(self): return self._single_choices(7) def s9_choices(self): return self._single_choices(8) # ---------- Grouped UI choices:每張 ticket 一個選項(value=ticket_id) ---------- def _group_choices(self, page_idx, block_idx): pages = self.participant.vars.get('assigned_pages3x3', []) if not pages or page_idx >= len(pages) or block_idx >= len(pages[page_idx]): return [] tickets = pages[page_idx][block_idx] return [(t['ticket_id'], self._label(t)) for t in tickets] def g1a_choices(self): return self._group_choices(0, 0) def g1b_choices(self): return self._group_choices(0, 1) def g1c_choices(self): return self._group_choices(0, 2) def g2a_choices(self): return self._group_choices(1, 0) def g2b_choices(self): return self._group_choices(1, 1) def g2c_choices(self): return self._group_choices(1, 2) def g3a_choices(self): return self._group_choices(2, 0) def g3b_choices(self): return self._group_choices(2, 1) def g3c_choices(self): return self._group_choices(2, 2) # ---------------- Payoff helpers ---------------- def get_choices_9(self): """回傳長度 9 的 choice ticket_id list(依 ui_mode mapping 到 round1~9)。""" ui = (self.ui_mode_stored or self.participant.vars.get('ui_mode', 'single')) if ui == 'single': return [self.s1, self.s2, self.s3, self.s4, self.s5, self.s6, self.s7, self.s8, self.s9] else: # grouped:g1a,b,c=round1~3;g2a,b,c=round4~6;g3a,b,c=round7~9 return [self.g1a, self.g1b, self.g1c, self.g2a, self.g2b, self.g2c, self.g3a, self.g3b, self.g3c] def save_choices_json(self): self.choices_json = json.dumps(self.get_choices_9(), ensure_ascii=False) @staticmethod def _to_float(x): try: if x is None: return 0.0 s = str(x).strip().replace('%', '') # ← 加這個 if s == '': return 0.0 return float(s) except Exception: return 0.0 def _get_ticket_dict(self, round_index0: int, ticket_id: str): """從 participant.vars['assigned_rounds9'][round] 找到該 ticket 的 dict。round_index0 是 0-based。""" rounds9 = self.participant.vars.get('assigned_rounds9', []) if not rounds9 or round_index0 < 0 or round_index0 >= len(rounds9): return None for t in rounds9[round_index0]: if str(t.get('ticket_id', '')).strip() == str(ticket_id).strip(): return t return None @staticmethod def _allocate_counts_to_100(entries, order): """ 讓「數字玩法」可以把機率分配成 1~100 的整數區間: - entries: [{key:50, prob:xx}, ...](prob 用「百分比」即可) - order: 固定獎項順序 回傳 list: [{key, count, prob}, ...](count 總和一定 = 100) """ ordered = [] for k in order: p = 0.0 for e in entries: if e['key'] == k: p = float(e['prob']) break ordered.append({'key': k, 'prob': p}) total = sum(o['prob'] for o in ordered) if total <= 0: out = [] for o in ordered: out.append({'key': o['key'], 'count': (100 if o['key'] == 0 else 0), 'prob': o['prob']}) return out raw = [(o['prob'] / total) * 100.0 for o in ordered] floors = [int(x // 1) for x in raw] fracs = [raw[i] - floors[i] for i in range(len(raw))] s = sum(floors) if s < 100: need = 100 - s idxs = sorted(range(len(fracs)), key=lambda i: fracs[i], reverse=True) for i in range(need): floors[idxs[i % len(idxs)]] += 1 elif s > 100: over = s - 100 idxs = sorted(range(len(fracs)), key=lambda i: fracs[i]) # 先扣小數小的 for i in range(over): j = idxs[i % len(idxs)] if floors[j] > 0: floors[j] -= 1 out = [] for i, o in enumerate(ordered): out.append({'key': o['key'], 'count': floors[i], 'prob': o['prob']}) return out # def ensure_payoff_draw(self): # # ... (前面的代碼保留) ... # t = self._get_ticket_dict(idx, self.payoff_ticket_id) or {} # # 從 CSV 讀取機率 (改用小寫 p1~p0) # probs = [ # self._to_float(t.get('p1', 0)), # self._to_float(t.get('p2', 0)), # self._to_float(t.get('p3', 0)), # self._to_float(t.get('p4', 0)), # self._to_float(t.get('p0', 0)), # ] # # 對應 Constants 裡的金額 # prizes = [ # Constants.lottery_prize_1, # Constants.lottery_prize_2, # Constants.lottery_prize_3, # Constants.lottery_prize_4, # Constants.lottery_prize_0, # ] # # 抽獎邏輯 # number = random.randint(1, 100) # final_prize = prizes[0] # cumulative = 0 # for p, prize in zip(probs, prizes): # cumulative += p # if number <= cumulative: # final_prize = prize # break # self.payoff_number = number # self.payoff_prize = final_prize # self.payoff = cu(final_prize) # 這裡會自動轉成美金報酬 def ensure_payoff_draw(self): # Only run once — if already drawn, skip if self.payoff_round and self.payoff_round > 0: return # Step 1: Randomly pick one of the 9 rounds (1-based) chosen_round = random.randint(1, 9) self.payoff_round = chosen_round idx = chosen_round - 1 # 0-based index for _get_ticket_dict # Step 2: Get the ticket the player chose for that round choices = self.get_choices_9() if not choices or idx >= len(choices): self.payoff_ticket_id = '' self.payoff_number = 0 self.payoff_prize = 0 return self.payoff_ticket_id = choices[idx] or '' # Step 3: Look up the ticket's probability data t = self._get_ticket_dict(idx, self.payoff_ticket_id) or {} # Read probabilities using uppercase keys (as stored by _normalize_row) probs = [ self._to_float(t.get('P0') or t.get('p0', 0)), self._to_float(t.get('P1') or t.get('p1', 0)), self._to_float(t.get('P2') or t.get('p2', 0)), self._to_float(t.get('P3') or t.get('p3', 0)), self._to_float(t.get('P4') or t.get('p4', 0)), ] # Corresponding prize amounts from Constants prizes = [ Constants.lottery_prize_0, Constants.lottery_prize_1, Constants.lottery_prize_2, Constants.lottery_prize_3, Constants.lottery_prize_4, ] # Step 4: Draw a number 1–100 and determine the prize number = random.randint(1, 100) final_prize = prizes[-1] # default to last prize if nothing matches cumulative = 0 for p, prize in zip(probs, prizes): cumulative += p if number <= cumulative: final_prize = prize break self.payoff_number = number self.payoff_prize = final_prize self.payoff = cu(final_prize)