from otree.api import * import csv, random, itertools, json # ★ 多了 json from pathlib import Path from collections import defaultdict from django.forms import HiddenInput # 在檔案最上方 import import json 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) # ========== oTree Pages ========== # 小工具:從 participant.vars 取出 9 回合玩法,並切出對應 block 的 3 個 def get_block_play_types(participant, block_index): """ block_index = 0,1,2 對應: 0 -> 回合 1-3 1 -> 回合 4-6 2 -> 回合 7-9 回傳:list 長度 3,例如 ['拉霸', '數字玩法', '轉盤'] """ play_types = participant.vars.get('play_types_per_round', []) # 防呆:長度不夠就補 '?' play_types = (play_types + ['?'] * 9)[:9] start = block_index * 3 end = start + 3 return play_types[start:end] def ai_is_yes(player: Player) -> bool: """ 從 participant.vars 讀取 AI 條件。 你只需要把 'ai_condition' 改成你實際存的 key。 可接受:'yes'/'no'、True/False、1/0、'是'/'否' """ v = player.participant.vars.get('ai_condition', None) if v is None: return False if isinstance(v, bool): return v if isinstance(v, (int, float)): return v == 1 if isinstance(v, str): return v.strip().lower() in ['yes', 'y', 'true', '1', '是', 'ai=是', 'ai'] return False class PostTask_AI_Check(Page): form_model = 'player' form_fields = ['ai_used'] class PostTask_AI_Detail(Page): form_model = 'player' form_fields = ['ai_assist_methods'] def is_displayed(self): return self.player.field_maybe_none('ai_used') == 'yes' class PostTask_Strategy(Page): form_model = 'player' form_fields = ['inst_clarity', 'inst_clarity_why','study_purpose_guess', 'strategy_used','stats_edu',] class PostTask_Ratings(Page): form_model = 'player' # 這裡必須包含所有在 HTML 裡出現的 input name form_fields = [ # 1-7 分題目的評分與原因 'diff_overall', 'diff_overall_why', 'diff_wheel', 'diff_wheel_why', 'diff_slot', 'diff_slot_why', 'diff_number', 'diff_number_why', # 1-5 分題目的評分與原因 (注意名稱必須與 models.py 一致) 'option_increase_diff', 'option_increase_diff_why', 'change_playtype_easier', 'change_playtype_easier_why', 'page_switch_easier', 'page_switch_easier_why', # 其他題目 'willing_to_pay', 'other_comments' ] def vars_for_template(self): return dict( rating_items=[ dict(field='diff_overall', label='Please evaluate the difficulty of the entire Lottery Choice task', scale=7), dict(field='diff_wheel', label='Please evaluate the difficulty of choosing lotteries of Spinner', scale=7), dict(field='diff_slot', label='Please evaluate the difficulty of choosing lotteries of Slot Machine', scale=7), dict(field='diff_number', label='Please evaluate the difficulty of choosing lotteries of Drawing a Number', scale=7), dict(field='option_increase_diff', label='Did increasing the number of options make the choice more difficult?', scale=5), dict(field='change_playtype_easier', label='Would switching the lottery format make the choices easier?', scale=5), dict(field='page_switch_easier', label='Would displaying each choice separately make the choices easier? ', scale=5), ] ) # Actual Pages # # class consent(Page): form_model = 'player' form_fields = ['consent_electronic'] class instructions(Page): pass class PVT_Experiment_Instruction(Page): pass class PVTPostTestInstructions(Page): pass class RiskPreferenceInstructions(Page): form_model = 'player' form_fields = [] # 說明頁,不蒐集答案 class Intro(Page): form_model = 'player' form_fields = ['quiz_num_prob', 'quiz_wheel_prob', 'quiz_slot_prob', 'payrule_q1', 'payrule_q2', 'payrule_q3', 'payrule_q4'] def error_message(self, values): solutions = { 'quiz_num_prob': 50, 'quiz_wheel_prob': 60, 'quiz_slot_prob': 40, 'payrule_q1': False, 'payrule_q2': True, 'payrule_q3': True, 'payrule_q4': False, } error_fields_map = { 'quiz_num_prob': 'error_count_num', 'quiz_wheel_prob': 'error_count_wheel', 'quiz_slot_prob': 'error_count_slot', 'payrule_q1': 'error_count_payrule1', 'payrule_q2': 'error_count_payrule2', 'payrule_q3': 'error_count_payrule3', 'payrule_q4': 'error_count_payrule4', } prob_fields = ['quiz_num_prob', 'quiz_wheel_prob', 'quiz_slot_prob'] payrule_fields = ['payrule_q1', 'payrule_q2', 'payrule_q3', 'payrule_q4'] errors = {} any_error = False payrule_has_error = False for field, solution in solutions.items(): if values[field] != solution: any_error = True counter_name = error_fields_map[field] setattr(self.player, counter_name, getattr(self.player, counter_name) + 1) if field in prob_fields: errors[field] = 'Your answer is incorrect.' elif field in payrule_fields: payrule_has_error = True if payrule_has_error: errors['payrule_q1'] = 'One or more of your answers in Question 4 are incorrect. Please review and try again.' if any_error: self.player.quiz_error_count += 1 return errors # ---- Single UI:3 頁,每頁 3 個 round ---- class SingleBlock1(Page): form_model = 'player' form_fields = ['s1', 's2', 's3', 'click_timestamps1_json'] def is_displayed(player: Player): return player.participant.vars.get('ui_mode') == 'single' def vars_for_template(self): block_types = get_block_play_types(self.player.participant, block_index=0) return dict( block_index=1, # 第一個 block(回合 1-3) block_types=block_types, block_types_str="、".join(block_types), treatment=self.player.participant.vars.get('treatment', 'unknown'), ) class SingleBlock2(Page): form_model = 'player' form_fields = ['s4', 's5', 's6', 'click_timestamps2_json'] def is_displayed(player: Player): return player.participant.vars.get('ui_mode') == 'single' def vars_for_template(self): block_types = get_block_play_types(self.player.participant, block_index=1) return dict( block_index=2, # 第二個 block(回合 4-6) block_types=block_types, block_types_str="、".join(block_types), treatment=self.player.participant.vars.get('treatment', 'unknown'), ) class SingleBlock3(Page): form_model = 'player' form_fields = ['s7', 's8', 's9', 'click_timestamps3_json'] def is_displayed(player: Player): return player.participant.vars.get('ui_mode') == 'single' def vars_for_template(self): block_types = get_block_play_types(self.player.participant, block_index=2) return dict( block_index=3, # 第三個 block(回合 7-9) block_types=block_types, block_types_str="、".join(block_types), treatment=self.player.participant.vars.get('treatment', 'unknown'), ) # ---- Grouped UI:維持原本三頁,但每頁都顯示對應 block 的玩法 ---- class G1(Page): form_model = 'player' form_fields = ['g1a', 'g1b', 'g1c', 'click_timestamps1_json'] def is_displayed(player: Player): return player.participant.vars.get('ui_mode') == 'grouped' def vars_for_template(self): block_types = get_block_play_types(self.player.participant, block_index=0) return dict( block_index=1, # 第一頁 = 回合 1-3 block_types=block_types, block_types_str="、".join(block_types), treatment=self.player.participant.vars.get('treatment', 'unknown'), ) class G2(Page): form_model = 'player' form_fields = ['g2a', 'g2b', 'g2c', 'click_timestamps2_json'] def is_displayed(player: Player): return player.participant.vars.get('ui_mode') == 'grouped' def vars_for_template(self): block_types = get_block_play_types(self.player.participant, block_index=1) return dict( block_index=2, # 第二頁 = 回合 4-6 block_types=block_types, block_types_str="、".join(block_types), treatment=self.player.participant.vars.get('treatment', 'unknown'), ) class G3(Page): form_model = 'player' form_fields = ['g3a', 'g3b', 'g3c', 'click_timestamps3_json'] def is_displayed(player: Player): return player.participant.vars.get('ui_mode') == 'grouped' def vars_for_template(self): block_types = get_block_play_types(self.player.participant, block_index=2) return dict( block_index=3, # 第三頁 = 回合 7-9 block_types=block_types, block_types_str="、".join(block_types), treatment=self.player.participant.vars.get('treatment', 'unknown'), ) def play_type_to_mode(play_type: str) -> str: pt = (play_type or "").strip() if "轉盤" in pt: return "wheel" if "拉霸" in pt: return "slot" return "number" class RiskElicitation(Page): form_model = 'player' form_fields = [f'hl_{i}' for i in range(1, 11)] def before_next_page(player: Player, timeout_happened=True): # 原本彩券部分的獎金已經在 ensure_payoff_draw 算過了 # 現在加上風險測驗的 10% 隨機獎勵 player.calc_hl_payoff() # 解決 KeyError:在此處指定隱藏欄位 def get_form_widgets(self): return {f'hl_{i}': HiddenInput() for i in range(1, 11)} # 解決 UndefinedVariable:把清單傳給 HTML def vars_for_template(self): hl_data = [ (1, "1/10 獲得 2.00元, 9/10 獲得 1.60元", "1/10 獲得 3.85元, 9/10 獲得 0.10元"), (2, "2/10 獲得 2.00元, 8/10 獲得 1.60元", "2/10 獲得 3.85元, 8/10 獲得 0.10元"), (3, "3/10 獲得 2.00元, 7/10 獲得 1.60元", "3/10 獲得 3.85元, 7/10 獲得 0.10元"), (4, "4/10 獲得 2.00元, 6/10 獲得 1.60元", "4/10 獲得 3.85元, 6/10 獲得 0.10元"), (5, "5/10 獲得 2.00元, 5/10 獲得 1.60元", "5/10 獲得 3.85元, 5/10 獲得 0.10元"), (6, "6/10 獲得 2.00元, 4/10 獲得 1.60元", "6/10 獲得 3.85元, 4/10 獲得 0.10元"), (7, "7/10 獲得 2.00元, 3/10 獲得 1.60元", "7/10 獲得 3.85元, 3/10 獲得 0.10元"), (8, "8/10 獲得 2.00元, 2/10 獲得 1.60元", "8/10 獲得 3.85元, 2/10 獲得 0.10元"), (9, "9/10 獲得 2.00元, 1/10 獲得 1.60元", "9/10 獲得 3.85元, 1/10 獲得 0.10元"), (10, "10/10 獲得 2.00元, 0/10 獲得 1.60元", "10/10 獲得 3.85元, 0/10 獲得 0.10元"), ] return dict(hary_laury_list=hl_data) class PayoffDraw(Page): form_model = 'player' form_fields = ['payoff_clicked'] def is_displayed(self): return True def vars_for_template(self): p = self.player # 1. 保存選擇並執行抽獎邏輯 p.save_choices_json() p.ensure_payoff_draw() # 2. 取得抽中那一回合的玩法資訊 idx0 = p.payoff_round - 1 play_types = (p.participant.vars.get('play_types_per_round', []) + ['?'] * 9)[:9] play_type = play_types[idx0] mode = play_type_to_mode(play_type) # 3. 取得該回合選中彩券的資料 t_final = p._get_ticket_dict(idx0, p.payoff_ticket_id) or {} def f(x): return Player._to_float(x) # 4. 根據 models.py 標準化的 P0-P4 欄位抓取資料 # 注意:Key 必須與 game_vis.js 的 ORDER [0, 1.5, 3, 9, 15] 一致 entries = [ {'key': 0, 'prob': f(t_final.get('P0', 0))}, {'key': 1.5, 'prob': f(t_final.get('P1', 0))}, {'key': 3, 'prob': f(t_final.get('P2', 0))}, {'key': 9, 'prob': f(t_final.get('P3', 0))}, {'key': 15, 'prob': f(t_final.get('P4', 0))}, ] cfg = { 'rootId': 'payoff-draw-root', 'mode': mode, 'entries': entries, 'finalNumber': p.payoff_number, 'finalPrize': p.payoff_prize, 'storageKey': f'lottery_payoff_played_{p.participant.code}', 'unlockNext': True, } return dict( drawn_round=p.payoff_round, play_type=play_type, payoff_draw_config_json=json.dumps(cfg, ensure_ascii=False), ) class FinalResults(Page): def vars_for_template(self): showup = float(Constants.showup_fee) p2_bonus = float(self.player.hl_won_prize) p3_bonus = float(self.player.payoff_prize) # ← 改這行,不用 self.player.payoff total = showup + p2_bonus + p3_bonus return { 'p3_bonus_clean': "{:.2f}".format(p3_bonus), 'total_amount_all': "{:.2f}".format(total), } class PVT_Pre(Page): template_name = 'lottery_app/PVT_Task.html' form_model = 'player' form_fields = ['pvt_pre_results'] def vars_for_template(self): # 使用 self.player 可以確保一定拿得到目前的受試者資料 return { 'is_post_test': False } class PVT_Post(Page): template_name = 'lottery_app/PVT_Task.html' form_model = 'player' form_fields = ['pvt_post_results'] def vars_for_template(self): return { 'is_post_test': True } # ---- VAS-F 18 題資料(題號、欄位名、左錨、右錨)---- VASF_ITEMS = [ dict(num=1, field_name='vasf_{phase}_1', keyword='Tired', anchor_left='not at all tired', anchor_right='extremely tired'), dict(num=2, field_name='vasf_{phase}_2', keyword='Sleepy', anchor_left='not at all sleepy', anchor_right='extremely sleepy'), dict(num=3, field_name='vasf_{phase}_3', keyword='Drowsy', anchor_left='not at all drowsy', anchor_right='extremely drowsy'), dict(num=4, field_name='vasf_{phase}_4', keyword='Fatigued', anchor_left='not at all fatigued', anchor_right='extremely fatigued'), dict(num=5, field_name='vasf_{phase}_5', keyword='Worn out', anchor_left='not at all worn out', anchor_right='extremely worn out'), dict(num=6, field_name='vasf_{phase}_6', keyword='Energetic', anchor_left='not at all energetic', anchor_right='extremely energetic'), dict(num=7, field_name='vasf_{phase}_7', keyword='Active', anchor_left='not at all active', anchor_right='extremely active'), dict(num=8, field_name='vasf_{phase}_8', keyword='Vigorous', anchor_left='not at all vigorous', anchor_right='extremely vigorous'), dict(num=9, field_name='vasf_{phase}_9', keyword='Efficient', anchor_left='not at all efficient', anchor_right='extremely efficient'), dict(num=10, field_name='vasf_{phase}_10', keyword='Lively', anchor_left='not at all lively', anchor_right='extremely lively'), dict(num=11, field_name='vasf_{phase}_11', keyword='Bushed', anchor_left='not at all bushed', anchor_right='totally bushed'), dict(num=12, field_name='vasf_{phase}_12', keyword='Exhausted', anchor_left='not at all exhausted', anchor_right='totally exhausted'), dict(num=13, field_name='vasf_{phase}_13', keyword='Keeping eyes open', anchor_left='keeping my eyes open is no effort at all', anchor_right='keeping my eyes open is a tremendous chore'), dict(num=14, field_name='vasf_{phase}_14', keyword='Moving body', anchor_left='moving my body is no effort at all', anchor_right='moving my body is a tremendous chore'), dict(num=15, field_name='vasf_{phase}_15', keyword='Concentrating', anchor_left='concentrating is no effort at all', anchor_right='concentrating is a tremendous chore'), dict(num=16, field_name='vasf_{phase}_16', keyword='Carrying on a conversation', anchor_left='carrying on a conversation is no effort at all', anchor_right='carrying on a conversation is a tremendous chore'), dict(num=17, field_name='vasf_{phase}_17', keyword='Desire to close eyes', anchor_left='I have absolutely no desire to close my eyes', anchor_right='I have a tremendous desire to close my eyes'), dict(num=18, field_name='vasf_{phase}_18', keyword='Desire to lie down', anchor_left='I have absolutely no desire to lie down', anchor_right='I have a tremendous desire to lie down'), ] def get_vasf_items(phase): """phase = 'pre' 或 'post',回傳帶有正確 field_name 的 item list""" result = [] for item in VASF_ITEMS: d = dict(item) d['field_name'] = item['field_name'].format(phase=phase) result.append(d) return result class PostTask_Uncertainty(Page): form_model = 'player' form_fields = ['choice_uncertainty'] class VASF_Pre(Page): template_name = 'lottery_app/VASF_Task.html' form_model = 'player' form_fields = [f'vasf_pre_{i}' for i in range(1, 19)] def vars_for_template(self): return dict( items=get_vasf_items('pre'), is_post_test=False, ) def error_message(self, values): # 確保每一題都有作答(不等於初始值 -1) #missing = [i for i in range(1, 19) if values.get(f'vasf_pre_{i}', -1) == -1] #if missing: #return 'Please respond to all items before continuing.' return None class VASF_Post(Page): template_name = 'lottery_app/VASF_Task.html' form_model = 'player' form_fields = [f'vasf_post_{i}' for i in range(1, 19)] def vars_for_template(self): return dict( items=get_vasf_items('post'), is_post_test=True, ) def error_message(self, values): missing = [i for i in range(1, 19) if values.get(f'vasf_post_{i}', -1) == -1] if missing: return 'Please respond to all items before continuing.' # ---- Results ---- class Redirect(Page): def is_displayed(self): return True @staticmethod def js_vars(player:Player): return dict(completionlink=player.subsession.session.config['completionlink']) page_sequence = [ consent, instructions, RiskPreferenceInstructions, RiskElicitation, VASF_Pre,# ← 前測疲勞(取代 PVT_Pre) Intro, SingleBlock1, SingleBlock2, SingleBlock3, G1, G2, G3, VASF_Post, # ← 後測疲勞(取代 PVT_Post) PostTask_Uncertainty, PostTask_AI_Check, PostTask_AI_Detail, PostTask_Strategy, PostTask_Ratings, PayoffDraw, FinalResults, Redirect, ]