Repository navigation
Expand file tree
/
Copy pathrun.py
More file actions
executable file
·1111 lines (927 loc) · 43.3 KB
/
Copy pathrun.py
File metadata and controls
executable file
·1111 lines (927 loc) · 43.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
run.py
USER参加型のLLM駆動ワンナイト人狼ゲーム
ゲーム形式:
- 5人プレイ (LLM 4人 + USER 1人)
- 7役職から4つをランダムに選択
- 発言2週 → 質問フェーズ → 投票 → 決着
"""
# =========================
# ====== PARAMETERS =======
# =========================
# ========== LLMモデル設定 ==========
MODEL = "qwen2.5:72b"
OLLAMA_TIMEOUT = 3600
# ========== ゲームルール設定 ==========
RULES_FILE = "rule.md"
# 役職の英語名と日本語名の対応
ROLE_JP = {
"Werewolf": "人狼",
"Madman": "狂人",
"Seer": "占い",
"Hanged_Man": "吊人",
"Dictator": "独裁",
"Hunter": "狩人",
"Villager": "村人",
"Mayor": "村長", # USERの役職(村人と同じ)
}
ROLE_EN = {v: k for k, v in ROLE_JP.items()}
# 候補役職(7種)
CANDIDATE_ROLES = ["Werewolf", "Madman", "Seer", "Hanged_Man", "Dictator", "Hunter", "Villager"]
# 固定役職(コマンドラインで指定可能)
FIXED_ROLES = None # None の場合はランダム、リストの場合は固定
SELECTED_ROLES = None # ゲームで選ばれた4役職(実行時に設定)
# ========== プレイヤー設定 ==========
LLM_PLAYERS = ["A", "B", "C", "D"]
USER_PLAYER = "USER"
ALL_PLAYERS = LLM_PLAYERS + [USER_PLAYER]
# ========== ゲームフロー設定 ==========
DAY_SAY_ROUNDS = 2 # 発言の周回数
DEBUG = False
AUTO_MODE = False # 自動モード(全員LLM)
# ========== 難易度設定(USER向け役職開示) ==========
# 各役職が5人の中に存在するか/誰がその役職かをUSERに開示するか
# True: USERに開示、False: USERに非開示(LLMには常に非開示)
DISCLOSE_TO_USER = {
"Werewolf": False, # 人狼の情報を開示するか
"Madman": False, # 狂人の情報を開示するか
"Seer": False, # 占い師の情報を開示するか
"Hanged_Man": False, # 吊人の情報を開示するか
"Dictator": False, # 独裁者の情報を開示するか
"Hunter": False, # 狩人の情報を開示するか
"Villager": False, # 村人の情報を開示するか
"Mayor": False, # 村長(USER固定なので開示の必要なし)
}
# ========== ログ設定 ==========
# ログディレクトリ構造: log/<ログ種別>/<日付>/
LOG_BASE_DIR = "log"
LOG_TYPE_GAME = "game" # ゲームログ
LOG_TYPE_DEBUG = "debug" # デバッグログ
LOG_TYPE_SYSTEM = "system" # システムログ
CLEAR_LOG_ON_START = True
# ========== RAG(検索拡張生成)設定 ==========
RAG_LOG_TAIL = 30 # ログの末尾から何行を参照するか(少なめに設定してLLMのエコーを防ぐ)
RAG_INCLUDE_RULES = False # ルールファイルをRAGコンテキストに含めるか(プロンプトに統合済み)
RAG_INCLUDE_ROLE = True # 役職戦略をRAGコンテキストに含めるか
RAG_INCLUDE_SECRET = True # 秘密情報をRAGコンテキストに含めるか
RAG_STRIP_EXAMPLES = True # 戦略ファイルから具体的な発言例(シナリオ)を除去するか
# ========== コンテキスト制限 ==========
CTX_MAX_CHARS_PER_LINE = 2800 # 1行あたりの最大文字数
MAX_RAG_CHARS = 16000 # RAGコンテキスト全体の最大文字数
# ========== LLM出力制限 ==========
MAX_SAY_SENTENCES = 8 # 発言の最大文数(適切な文量に制限)
MAX_SAY_CHARS = 250 # 発言の最大文字数(150〜200文字を目標に、250で上限切り)
# ========== プロンプトテンプレート ==========
# 発言プロンプトのコア部分(最小限に簡素化)
SAY_PROMPT_TEMPLATE = (
"あなたはプレイヤー {player} です。【戦略】の指示に厳密に従って発言してください。\n\n"
"【必須】\n"
"- 一人称で話す(「私」を使う。「{player}さん」と自己言及しない)\n"
"- 役職CO、具体的な推理、投票先の明示\n"
"- 150〜200文字の簡潔な発言\n"
"- 日本語のみ\n\n"
"【絶対禁止】\n"
"- 曖昧表現:「様子を見る」「意見を聞きたい」「情報を集める」「慎重に」「まだ〜ない」「〜かもしれない」\n"
"- HTMLタグ、箇条書き\n\n"
"発言は <say>…</say> で囲む。"
)
# 投票プロンプトのコア部分
VOTE_PROMPT_TEMPLATE = (
"あなたはプレイヤー {player} です。"
"生存者: {alive}。投票は <vote>{options}</vote> で回答。"
)
# 質問回答プロンプトのコア部分
QUESTION_PROMPT_TEMPLATE = (
"あなたはプレイヤー {player}、役職は{role}です。\n"
"USERの質問: {question}\n\n"
"一人称で回答してください。回答は <answer>…</answer> で。"
)
# 狩人処刑プロンプトのコア部分
HUNTER_PROMPT_TEMPLATE = (
"あなたはプレイヤー {player}(狩人)です。"
"処刑なしのため狩人能力が発動。生存者 {alive} から1人を処刑。"
"処刑先は <execute>{options}</execute> で回答。"
)
# =========================
# ========= CODE =========
# =========================
import os
import re
import sys
import random
import subprocess
from datetime import datetime
from typing import Dict, List, Set, Optional, Tuple
# グローバル変数:現在のログファイルパス
CURRENT_LOG_PATH = None
def dbg(*a):
if DEBUG:
print("[DBG]", *a)
# ---------- I/O ----------
def read_text(path: str) -> str:
try:
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
pass
return ""
def get_log_path(log_type: str = LOG_TYPE_GAME) -> str:
"""ログファイルのパスを生成: log/<ログ種別>/<日付>/game_HHMMSS.txt"""
now = datetime.now()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S")
log_dir = os.path.join(LOG_BASE_DIR, log_type, date_str)
os.makedirs(log_dir, exist_ok=True)
log_filename = f"game_{time_str}.txt"
return os.path.join(log_dir, log_filename)
def write_log_header():
"""ログファイルを初期化"""
global CURRENT_LOG_PATH
CURRENT_LOG_PATH = get_log_path(LOG_TYPE_GAME)
with open(CURRENT_LOG_PATH, "w", encoding="utf-8") as f:
f.write("# ワンナイト人狼 ゲームログ\n")
f.write(f"# 開始時刻: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
def append_log(line: str):
"""ログファイルに1行追加"""
if CURRENT_LOG_PATH is None:
return
with open(CURRENT_LOG_PATH, "a", encoding="utf-8") as f:
f.write(line.rstrip("\n") + "\n")
def read_log_tail(n: int) -> str:
"""ログファイルの末尾n行を読み込む"""
if CURRENT_LOG_PATH is None or not os.path.exists(CURRENT_LOG_PATH):
return "(まだ発言なし)"
with open(CURRENT_LOG_PATH, "r", encoding="utf-8") as f:
lines = f.readlines()
tail = lines[-n:] if len(lines) > n else lines
clipped = []
for ln in tail:
body = ln.rstrip("\n")
if len(body) > CTX_MAX_CHARS_PER_LINE:
body = body[:CTX_MAX_CHARS_PER_LINE]
clipped.append(body + "\n")
text = "".join(clipped)
if len(text) > MAX_RAG_CHARS:
text = text[-MAX_RAG_CHARS:]
return text.strip() or "(まだ発言なし)"
def call_ollama(prompt: str) -> str:
try:
res = subprocess.run(
["ollama", "run", MODEL],
input=prompt,
check=True,
capture_output=True,
text=True,
timeout=OLLAMA_TIMEOUT,
)
return res.stdout.strip()
except subprocess.TimeoutExpired:
return ""
except subprocess.CalledProcessError as e:
return (e.stdout or "").strip()
# ---------- テキスト抽出 ----------
def sanitize_sentence(s: str) -> str:
# 【緊急対策】中国語の完全除去(非日本語文字を検出)
# 中国語特有の文字パターンを検出して除去
s = re.sub(r'[\u4e00-\u9fff]+[^。!?]*?[::][^。]*?。', '', s) # 中国語文を除去
s = re.sub(r'你|现在|但你|需要|可以|因为|如果|通过|这种|方式', '', s) # 中国語キーワード除去
# 【緊急対策】メタプロンプト漏洩の完全除去
# "あなたの役職:"で始まるメタ情報を完全削除
if 'あなたの役職' in s or 'E (あなた)' in s or '戦略:' in s:
# ``` で囲まれた部分だけを抽出
code_block_match = re.search(r'```\s*([^`]+?)\s*```', s, flags=re.S)
if code_block_match:
s = code_block_match.group(1)
else:
# ``` がない場合、最初の"私は"で始まる文から抽出
first_watashi = re.search(r'(私は[^。]+。(?:[^。]+。)*)', s)
if first_watashi:
s = first_watashi.group(1)
else:
# それでもダメなら全て削除
return ""
# HTMLタグを完全除去
s = re.sub(r'<[^>]+>', '', s)
# 英語混入を日本語に変換
s = s.replace("wolf", "人狼").replace("Wolf", "人狼")
s = s.replace("WOLF", "人狼").replace("Werewolf", "人狼")
s = s.replace("white", "白").replace("White", "白")
s = s.replace("black", "黒").replace("Black", "黒")
# Markdown記号を除去
s = re.sub(r'#{1,6}\s+', '', s)
s = re.sub(r'\*\*([^*]+)\*\*', r'\1', s)
s = re.sub(r'\*([^*]+)\*', r'\1', s)
s = re.sub(r'```[^```]*```', '', s, flags=re.S) # コードブロック除去
# RAG構造由来のメタ情報を完全除去
s = re.sub(r'---\s*', '', s) # --- セパレータ
s = re.sub(r'Round\s+\d+[^。]*?(?=私|あなた|市民|占い|人狼|吊人|村人|狩人|独裁|狂人|皆|全員|\Z)', '', s) # Round 1, Round 2など
# 【最重要】秘密情報とメタ情報の完全除去
secret_patterns = [
r'あなたの役職[::は].*?。', # 「あなたの役職: 吊人」など
r'役職[::は]\s*(人狼|占い|吊人|村人|狩人|独裁|狂人|村長).*?。',
r'自分が処刑されれば単独勝利.*?。',
r'人狼以外を処刑させる.*?。',
r'市民を欺き.*?。',
r'ワンナイト人狼.*?ゲームログ.*', # ゲームログのタイトル
r'===.*?===', # ログのセパレータ
r'\[夜\].*?(対象は非公開)', # 夜フェーズのログ
r'\[.*?\]', # その他のログマーカー
r'更新\s*',
]
for pattern in secret_patterns:
s = re.sub(pattern, '', s, flags=re.DOTALL)
# RAGセクション名・構造の完全除去(超包括的)
meta_patterns = [
r'(投票戦略|成功の鍵|状況\d+|基本戦略|最優先|次点|絶対に避ける|状況分析|策略|発言例|具体的な推理|投票呼びかけ|対応|注意|理由|目標|重要|ポイント|分析|状況整理|投票方針|信用性評価|判断|評価|議論への応答|他のプレイヤーへの質問|占い師としての発言|次の手順|言葉通りの提案|あなたの役割)[::]\s*\d*\.?\s*',
r'(現在の状況は以下の通りです|このように|以下の通り|次のように|残りの候補)[::]\s*',
r'村人としての議論\s*',
r'ワンナイト人狼\s+発言\s*',
r'(私|あなた)((USER|[A-D]))[のが]発言\s*[::]\s*', # 私(USER)の発言: パターン
r'((USER|[A-D])さん)?の\s*発言\s*[::]\s*', # Aさんの発言: パターン
r'(USER|[A-D])[::]\s+以上が', # USER: 以上が...
r'よろしくお願いします。\s*$', # 末尾の定型句
r'意見\s*[::]\s*',
r'結論\s*[::]\s*',
r'最後の判断\s*[::]\s*',
r'質問\s*[::]\s*',
r'私の反応\s*[::]\s*',
r'具体的には\s*',
r'発言戦略\s*[::]\s*',
r'不合理な疑い\s*[::]\s*',
r'優柔寡断さ\s*[::]\s*',
r'\*\*[^*]+\*\*\s*[::]\s*', # **太字テキスト**: パターン
r'^\d+\.\s*\*\*[^*]+\*\*', # 1. **太字テキスト** パターン
r'主な疑問点は以下の通りです[::]\s*',
r'[::]\s*村人(私)', # 役職情報の露出
]
for pattern in meta_patterns:
s = re.sub(pattern, '', s, flags=re.MULTILINE)
# 箇条書きパターンとメタ分析の除去
s = re.sub(r'^(USER|[A-D])([^)]+)の発言\s*', '', s, flags=re.MULTILINE) # 発言者メタデータ
s = re.sub(r'^\d+\.\s*', '', s, flags=re.MULTILINE) # 箇条書き番号
s = re.sub(r'^-\s*', '', s, flags=re.MULTILINE) # - で始まる箇条書き
s = re.sub(r'[❌✅]\s*', '', s) # チェックマーク記号
s = re.sub(r'(狩人|占い師|人狼|吊人|村人|独裁者|狂人)としての(対応|発言|戦略|判断|分析)\s*', '', s) # 役職メタ分析
s = re.sub(r'(現在の状況|発言内容)の分析\s*', '', s) # メタ分析テキスト
s = re.sub(r'以下(は|の通り)[::、]\s*', '', s) # リスト前置詞
# 曖昧表現の除去
s = re.sub(r'(皆さんの意見も聞きたい|追加の情報提供を期待|様子を見|情報を集める必要)[^。]*。', '', s)
# §END マーカーの除去
s = re.sub(r'§END[。\.]*', '', s)
s = s.replace("\n", " ").strip()
s = re.sub(r"\s+", " ", s).strip()
if s and s[-1] not in "。.!!??":
s += "。"
if len(s) > MAX_SAY_CHARS:
cut = s[:MAX_SAY_CHARS]
m = re.search(r"[。.!!??]", cut)
s = cut[:m.end()] if m else cut
return s
def extract_tagged(raw: str, tag: str) -> str:
if not raw:
return ""
m = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", raw, flags=re.S)
if not m:
return ""
body = m.group(1).strip()
if tag == "say":
# LLMがゲームログをエコーしている場合を検出して除去
# プレイヤー名(A:, B:, C:, D:, USER:)が含まれている場合、最初の出現前まで取得
player_marker_match = re.search(r'\b([A-D]|USER):\s', body)
if player_marker_match:
# 最初のプレイヤーマーカー以前のテキストのみを使用
body = body[:player_marker_match.start()].strip()
parts = re.split(r"(?<=[。.!!??])\s*", body)
parts = [p for p in parts if p][:max(1, MAX_SAY_SENTENCES)]
body = "".join(sanitize_sentence(p) for p in parts)
return body
def extract_fallback_say(raw: str) -> str:
if not raw:
return ""
text = raw.strip()
# LLMがゲームログをエコーしている場合を検出して除去
# プレイヤー名(A:, B:, C:, D:, USER:)が含まれている場合、最初の出現前まで取得
player_marker_match = re.search(r'\b([A-D]|USER):\s', text)
if player_marker_match:
# 最初のプレイヤーマーカー以前のテキストのみを使用
text = text[:player_marker_match.start()].strip()
parts = re.split(r"(?<=[。.!!??])\s*", text)
parts = [p for p in parts if p][:max(1, MAX_SAY_SENTENCES)]
return "".join(sanitize_sentence(p) for p in parts)
def fix_self_reference(text: str, speaker: str) -> str:
"""
発言者が自分の名前を客観視している箇所を一人称に置換
例: spk="A"の場合、「Aさん」→「私」
"""
if not text or not speaker:
return text
# 「Aさん」→「私」の置換
# 先に「Aさんと」「Aさんも」など助詞が付いているケースを処理
patterns = [
(rf'{speaker}さんと', '私と'),
(rf'{speaker}さんも', '私も'),
(rf'{speaker}さんが', '私が'),
(rf'{speaker}さんは', '私は'),
(rf'{speaker}さんに', '私に'),
(rf'{speaker}さんの', '私の'),
(rf'{speaker}さんを', '私を'),
(rf'{speaker}さんへ', '私へ'),
(rf'{speaker}さんで', '私で'),
(rf'{speaker}さんから', '私から'),
(rf'{speaker}さん', '私'), # 最後に単独の「Aさん」を処理
]
for pattern, replacement in patterns:
text = re.sub(pattern, replacement, text)
# 重複表現の修正(「私(私)」→「私」)
text = re.sub(r'私(私)', '私', text)
text = re.sub(r'私\(私\)', '私', text)
return text
# ---------- 役職配布 ----------
def pick_role_set(n_roles: int = 4, fixed_roles: Optional[List[str]] = None) -> List[str]:
"""候補から n_roles 枚を重複なしで選ぶ。fixed_rolesが指定されていればそれを使用"""
if fixed_roles:
return fixed_roles[:]
bag = CANDIDATE_ROLES[:]
random.shuffle(bag)
return bag[:n_roles]
def assign_roles(players: List[str], fixed_roles: Optional[List[str]] = None) -> Dict[str, str]:
"""
7つの役職から4つをランダムに選択し、USER以外の4人(A, B, C, D)に配布。
USERは村長(Mayor)として設定。
"""
global SELECTED_ROLES
selected = pick_role_set(n_roles=4, fixed_roles=fixed_roles)
SELECTED_ROLES = selected[:] # 選ばれた4役職を保存
# USER以外の4人のプレイヤー(LLM)に選ばれた4役職をランダムに配布
llm_players = [p for p in players if p != USER_PLAYER]
random.shuffle(selected)
roles = {}
for i, p in enumerate(llm_players):
roles[p] = selected[i]
# USERは村長(Mayor)として設定(村人と同じ役職)
roles[USER_PLAYER] = "Mayor"
return roles
# ---------- 夜のアクション(占い師の占い)----------
def seer_night_action(roles: Dict[str, str], secrets: Dict[str, List[str]]) -> List[str]:
"""占い師が1人をランダムに占う"""
logs: List[str] = []
seers = [p for p in ALL_PLAYERS if roles[p] == "Seer"]
if not seers:
return logs
seer = seers[0]
candidates = [p for p in ALL_PLAYERS if p != seer]
if not candidates:
return logs
target = random.choice(candidates)
real = roles[target]
# 見え方:人狼は黒、その他は白(村人相当)
# 村長も村人として見える
if real == "Werewolf":
seen = "人狼"
else:
seen = "村人"
secrets[seer].append(f"占い結果: {target} は {seen}。")
logs.append(f"[夜] 占い師が占いを実行しました(対象は非公開)")
return logs
# ---------- RAG コンテキスト ----------
def load_combo_strategy(roles: Dict[str, str], player: str) -> str:
"""combos_4配下の戦略ファイルを読み込む(必要に応じて発言例を除去)"""
# 役職の組み合わせからディレクトリ名を生成
# SELECTED_ROLESに保存されたゲーム開始時に選ばれた4役職を使用
if SELECTED_ROLES is None:
# フォールバック: rolesから推定(ただし5人目のVillagerを除外する必要がある)
unique_roles = set(roles.values())
mapped_roles = {ROLE_JP.get(r, r) for r in unique_roles}
else:
# SELECTED_ROLESを使って正確な4役職を取得
mapped_roles = {ROLE_JP.get(r, r) for r in SELECTED_ROLES}
role_names = sorted(mapped_roles)
combo_name = "_".join(role_names)
combo_dir = os.path.join("combos_4", combo_name)
if not os.path.isdir(combo_dir):
target_set = set(role_names)
for cand in os.listdir("combos_4"):
cand_path = os.path.join("combos_4", cand)
if not os.path.isdir(cand_path):
continue
if set(cand.split("_")) == target_set:
combo_dir = cand_path
break
else:
return ""
# プレイヤーの役職ファイルを読み込む
role_jp = ROLE_JP.get(roles[player], roles[player])
strategy_file = os.path.join(combo_dir, f"{role_jp}.md")
if not os.path.exists(strategy_file):
return ""
strategy_text = read_text(strategy_file)
# RAG_STRIP_EXAMPLESがTrueの場合、具体的な発言例(```で囲まれた部分)を除去
if RAG_STRIP_EXAMPLES:
# コードブロック(```...```)を除去
strategy_text = re.sub(r'```[^`]*```', '', strategy_text, flags=re.DOTALL)
return strategy_text
def build_rag_context(player: str, role: str, secret_lines: List[str], roles: Dict[str, str]) -> str:
ctx_sections = []
if RAG_INCLUDE_RULES:
rules = read_text(RULES_FILE)
if rules:
ctx_sections.append(f"【ルール】\n{rules}")
if RAG_INCLUDE_ROLE:
# combos_4から戦略を読み込む
strategy = load_combo_strategy(roles, player)
if strategy:
ctx_sections.append(f"【戦略】\n{strategy}")
if RAG_INCLUDE_SECRET:
sec = "\n".join(f"- {x}" for x in secret_lines) or "(特になし)"
ctx_sections.append(f"【あなたの役職】\n{ROLE_JP.get(role, role)}\n{sec}")
log_tail = read_log_tail(RAG_LOG_TAIL)
ctx_sections.append(f"【これまでの会話】\n{log_tail}")
ctx = "\n\n".join(ctx_sections)
if len(ctx) > MAX_RAG_CHARS:
ctx = ctx[-MAX_RAG_CHARS:]
return ctx
# ---------- プロンプト ----------
def build_say_prompt(player: str, role: str, secret_lines: List[str], roles: Dict[str, str]) -> str:
ctx = build_rag_context(player, role, secret_lines, roles)
core = SAY_PROMPT_TEMPLATE.format(player=player)
return f"{core}\n\n{ctx}"
def build_vote_prompt(player: str, role: str, secret_lines: List[str],
alive: Set[str], roles: Dict[str, str]) -> str:
ctx = build_rag_context(player, role, secret_lines, roles)
alive_sorted = sorted(alive)
options_str = "|".join(alive_sorted) + "|SKIP"
core = VOTE_PROMPT_TEMPLATE.format(
player=player,
alive=', '.join(alive_sorted),
options=options_str
)
return f"{core}\n\n{ctx}"
def build_question_response_prompt(player: str, role: str, secret_lines: List[str],
question: str, roles: Dict[str, str]) -> str:
ctx = build_rag_context(player, role, secret_lines, roles)
core = QUESTION_PROMPT_TEMPLATE.format(
player=player,
role=ROLE_JP.get(role, role),
question=question
)
return f"{core}\n\n{ctx}"
def build_hunter_execute_prompt(player: str, role: str, secret_lines: List[str],
alive: Set[str], roles: Dict[str, str]) -> str:
ctx = build_rag_context(player, role, secret_lines, roles)
alive_sorted = sorted(alive - {player})
options_str = "|".join(alive_sorted)
core = HUNTER_PROMPT_TEMPLATE.format(
player=player,
alive=', '.join(alive_sorted),
options=options_str
)
return f"{core}\n\n{ctx}"
# ---------- パース ----------
def parse_vote(raw: str, self_player: str, alive: Set[str]) -> str:
alive_sorted = sorted(alive)
# SKIPも選択肢に含める
pattern = r"<vote>\s*(" + "|".join(map(re.escape, alive_sorted + ["SKIP"])) + r")\s*</vote>"
m = re.search(pattern, raw, re.IGNORECASE)
if m:
cand = m.group(1)
if cand.upper() == "SKIP":
return "SKIP"
if cand in alive and cand != self_player:
return cand
# フォールバック:本文中のSKIPまたは生存者名
if re.search(r"\bSKIP\b", raw, re.IGNORECASE):
return "SKIP"
m2 = re.search(r"\b(" + "|".join(map(re.escape, alive_sorted)) + r")\b", raw)
if m2:
cand = m2.group(1)
if cand in alive and cand != self_player:
return cand
# さらにフォールバック:自分以外の生存者からランダム
cands = [x for x in alive_sorted if x != self_player]
return random.choice(cands) if cands else self_player
def parse_execute(raw: str, self_player: str, alive: Set[str]) -> str:
"""狩人の処刑先をパースする"""
alive_sorted = sorted(alive - {self_player})
pattern = r"<execute>\s*(" + "|".join(map(re.escape, alive_sorted)) + r")\s*</execute>"
m = re.search(pattern, raw, re.IGNORECASE)
if m:
cand = m.group(1)
if cand in alive and cand != self_player:
return cand
# フォールバック:本文中の生存者名
m2 = re.search(r"\b(" + "|".join(map(re.escape, alive_sorted)) + r")\b", raw)
if m2:
cand = m2.group(1)
if cand in alive and cand != self_player:
return cand
# さらにフォールバック:自分以外の生存者からランダム
cands = [x for x in alive_sorted if x]
return random.choice(cands) if cands else alive_sorted[0] if alive_sorted else self_player
# ---------- 勝敗判定 ----------
def display_role_info_to_user(roles: Dict[str, str]):
"""難易度設定に基づいてUSERに役職情報を開示"""
print("\n" + "=" * 60)
print("=== 役職情報開示(難易度設定) ===")
print("=" * 60)
disclosed_any = False
for role_en, role_jp in ROLE_JP.items():
if DISCLOSE_TO_USER.get(role_en, False):
# この役職の情報を開示
players_with_role = [p for p, r in roles.items() if r == role_en]
if players_with_role:
print(f"【{role_jp}】: {', '.join(players_with_role)}")
else:
print(f"【{role_jp}】: (誰もいません)")
disclosed_any = True
if not disclosed_any:
print("(開示される役職情報はありません)")
print("=" * 60 + "\n")
def assess_winner(executed: str, roles: Dict[str, str]) -> str:
"""
勝敗判定
- 吊人が処刑された → 吊人の単独勝利
- 人狼が処刑された → 市民陣営の勝利
- 人狼以外が処刑された → 人狼陣営の勝利(人狼がいる場合)
- 人狼以外が処刑された → 市民陣営の勝利(平和村:人狼がいない場合)
- 村長は市民陣営として扱う
"""
if not executed:
return ""
executed_role = roles[executed]
# 吊人の単独勝利
if executed_role == "Hanged_Man":
return "Hanged_Man"
# 人狼が処刑された → 市民陣営の勝利
if executed_role == "Werewolf":
return "Citizens"
# 人狼がゲームに存在するかチェック(平和村判定)
has_werewolf = "Werewolf" in roles.values()
if has_werewolf:
# 人狼以外が処刑された → 人狼陣営の勝利
return "Werewolves"
else:
# 平和村:人狼がいないので市民陣営の勝利
return "Citizens"
# ---------- 質問フェーズ ----------
def question_phase(roles: Dict[str, str], secrets: Dict[str, List[str]]):
"""USERがLLMプレイヤーに質問できるフェーズ"""
print("\n" + "="*50)
print("=== 質問フェーズ ===")
print("="*50)
print("特定のプレイヤーに質問できます。")
print("形式: <プレイヤー名>: <質問内容>")
print("例: A: あなたの役職は何ですか?")
print("終了するには 'done' と入力してください。")
print("="*50 + "\n")
while True:
user_input = input("質問 > ").strip()
if user_input.lower() in ["done", "終了", "end"]:
print("\n質問フェーズを終了します。\n")
break
# パース: "プレイヤー名: 質問内容"
match = re.match(r"^([A-D]):\s*(.+)$", user_input)
if not match:
print("形式が正しくありません。例: A: あなたの役職は何ですか?\n")
continue
target_player = match.group(1)
question = match.group(2).strip()
if target_player not in LLM_PLAYERS:
print(f"プレイヤー {target_player} は存在しません。\n")
continue
print(f"\n[{target_player} への質問を処理中...]")
prompt = build_question_response_prompt(
target_player, roles[target_player], secrets[target_player], question, roles
)
if DEBUG:
print("\n" + "=" * 60)
print(f"[DEBUG] プレイヤー {target_player} の質問回答RAGコンテキスト")
print("=" * 60)
rag_ctx = build_rag_context(target_player, roles[target_player], secrets[target_player], roles)
print(rag_ctx)
print("\n" + "=" * 60)
print("[DEBUG] 質問回答プロンプト(最後の900文字)")
print("=" * 60)
tail = prompt[-900:] if len(prompt) > 900 else prompt
print(tail)
print("=" * 60 + "\n")
raw = call_ollama(prompt)
answer = extract_tagged(raw, "answer")
if not answer:
if DEBUG:
print("----- RAW ANSWER (fallback) -----\n",
(raw[:900] + ("..." if len(raw) > 900 else "")),
"\n---------------------------------")
answer = extract_fallback_say(raw)
if not answer:
answer = "すみません、よく分かりません。"
print(f"{target_player}: {answer}")
append_log(f"[質問] USER → {target_player}: {question}")
append_log(f"{target_player}: {answer}")
print()
# =========================
# ========= MAIN ==========
# =========================
def main():
global DEBUG, MODEL, FIXED_ROLES, AUTO_MODE
if "--debug" in sys.argv:
DEBUG = True
if "--auto" in sys.argv:
AUTO_MODE = True
if "--model" in sys.argv:
i = sys.argv.index("--model")
if i + 1 < len(sys.argv):
MODEL = sys.argv[i + 1]
# --roles オプションで役職を指定(カンマ区切りの日本語または英語)
if "--roles" in sys.argv:
i = sys.argv.index("--roles")
if i + 1 < len(sys.argv):
role_str = sys.argv[i + 1]
role_names = [r.strip() for r in role_str.split(",")]
# 日本語 → 英語に変換
FIXED_ROLES = []
for name in role_names:
if name in ROLE_EN:
FIXED_ROLES.append(ROLE_EN[name])
elif name in ROLE_JP:
FIXED_ROLES.append(name)
else:
print(f"警告: 未知の役職名 '{name}' は無視されます")
if len(FIXED_ROLES) != 4:
print(f"エラー: 役職は4つ指定してください(現在: {len(FIXED_ROLES)}個)")
sys.exit(1)
print("=" * 60)
print("=== USER参加型 LLM駆動ワンナイト人狼 ===")
print("=" * 60)
print(f"LLM: ollama run {MODEL}")
print(f"プレイヤー: {', '.join(LLM_PLAYERS)} + {USER_PLAYER}")
if FIXED_ROLES:
print(f"役職(固定): {', '.join([ROLE_JP[r] for r in FIXED_ROLES])}")
else:
print("役職: ランダム選択")
print("=" * 60 + "\n")
# ログ初期化
write_log_header()
# 役職配布
roles: Dict[str, str] = assign_roles(ALL_PLAYERS, fixed_roles=FIXED_ROLES)
secrets: Dict[str, List[str]] = {p: [] for p in ALL_PLAYERS}
# 初期秘密情報の設定
for p in ALL_PLAYERS:
r = roles[p]
role_jp = ROLE_JP.get(r, r)
if r == "Werewolf":
secrets[p].append("あなたは人狼。市民を欺き、人狼以外を処刑させることが目標。")
elif r == "Seer":
secrets[p].append("あなたは占い師。夜に1人を占い、人狼か否かを知ることができる。")
elif r == "Madman":
secrets[p].append("あなたは狂人(人狼陣営)。占われても白。人狼を勝たせる。")
elif r == "Hunter":
secrets[p].append("あなたは狩人。自分が吊られなかった場合、追加で1人を処刑できる。")
elif r == "Dictator":
secrets[p].append("あなたは独裁者。自分が吊られた場合、投票を無効化し、自分の投票先を処刑できる。")
elif r == "Hanged_Man":
secrets[p].append("あなたは吊人。自分が処刑されれば単独勝利。")
elif r == "Mayor":
secrets[p].append("あなたは村長。議論を観察し、議論を深め、人狼を処刑に導くオブザーバー的存在。")
else:
secrets[p].append("あなたは村人。公開情報から推理し、人狼を処刑に導く。")
# USERへの役職通知
user_role = roles[USER_PLAYER]
user_role_jp = ROLE_JP.get(user_role, user_role)
print("=" * 60)
print(f"あなたの役職は【{user_role_jp}】です。")
print("=" * 60 + "\n")
# 夜のアクション(占い師の占い)
append_log("=== 夜 ===")
night_logs = seer_night_action(roles, secrets)
for line in night_logs:
append_log(line)
# USERが占い師の場合、占い結果を表示(対話モードのみ)
if user_role == "Seer" and not AUTO_MODE:
seer_info = [s for s in secrets[USER_PLAYER] if "占い結果" in s]
if seer_info:
print("【あなたの占い結果】")
for info in seer_info:
print(f" {info}")
print()
# 難易度設定に基づく役職情報の開示
display_role_info_to_user(roles)
append_log("=== 昼 ===")
# 昼のターン:発言2週
alive: Set[str] = set(ALL_PLAYERS)
for rnd in range(1, DAY_SAY_ROUNDS + 1):
print("\n" + "=" * 60)
print(f"=== 発言 Round {rnd} / {DAY_SAY_ROUNDS} ===")
print("=" * 60 + "\n")
for spk in ALL_PLAYERS:
if spk == USER_PLAYER and not AUTO_MODE:
# USERのターン(対話モード)
print(f"[{USER_PLAYER} のターン]")
user_say = input("発言 > ").strip()
if not user_say:
user_say = "様子を見ています。"
print(f"{USER_PLAYER}: {user_say}")
append_log(f"{USER_PLAYER}: {user_say}")
else:
# LLMのターン
print(f"[{spk} の発言生成中...]")
prompt = build_say_prompt(spk, roles[spk], secrets[spk], roles)
if DEBUG:
print("\n" + "=" * 60)
print(f"[DEBUG] プレイヤー {spk} のRAGコンテキスト")
print("=" * 60)
rag_ctx = build_rag_context(spk, roles[spk], secrets[spk], roles)
print(rag_ctx)
print("\n" + "=" * 60)
print("[DEBUG] プロンプト(最後の900文字)")
print("=" * 60)
tail = prompt[-900:] if len(prompt) > 900 else prompt
print(tail)
print("=" * 60 + "\n")
raw = call_ollama(prompt)
utt = extract_tagged(raw, "say")
if not utt:
if DEBUG:
print("----- RAW SAY (fallback) -----\n",
(raw[:900] + ("..." if len(raw) > 900 else "")),
"\n-------------------------------")
utt = extract_fallback_say(raw)
if not utt:
utt = "様子を見ています。"
# 自己客観視を修正(「Aさん」→「私」)
utt = fix_self_reference(utt, spk)
print(f"{spk}: {utt}")
append_log(f"{spk}: {utt}")
# 質問フェーズ(対話モードのみ)
if not AUTO_MODE:
question_phase(roles, secrets)
# 投票フェーズ
print("\n" + "=" * 60)
print("=== 投票フェーズ ===")
print("=" * 60 + "\n")
append_log("--- 投票 ---")
votes: Dict[str, int] = {}
ballot: Dict[str, str] = {}
for p in ALL_PLAYERS:
if p == USER_PLAYER and not AUTO_MODE:
# USERの投票(対話モード)
print(f"生存者: {', '.join(sorted(alive))}")
print("投票先を入力してください(自分以外のプレイヤー名、または処刑なしの場合は SKIP)")
while True:
user_vote = input(f"{USER_PLAYER} の投票先 > ").strip()
if user_vote.upper() == "SKIP":
user_vote = "SKIP"
break
if user_vote in alive and user_vote != USER_PLAYER:
break
print(f"有効な投票先を入力してください: {', '.join(sorted(alive - {USER_PLAYER}))} または SKIP")
ballot[USER_PLAYER] = user_vote
votes[user_vote] = votes.get(user_vote, 0) + 1
print(f"{USER_PLAYER} の投票: {user_vote}\n")
append_log(f"{USER_PLAYER}: (vote) {user_vote}")
else:
# LLMの投票
print(f"[{p} の投票生成中...]")
prompt = build_vote_prompt(p, roles[p], secrets[p], alive, roles)
if DEBUG:
print("\n" + "=" * 60)
print(f"[DEBUG] プレイヤー {p} の投票RAGコンテキスト")
print("=" * 60)
rag_ctx = build_rag_context(p, roles[p], secrets[p], roles)
print(rag_ctx)
print("\n" + "=" * 60)
print("[DEBUG] 投票プロンプト(最後の900文字)")
print("=" * 60)
tail = prompt[-900:] if len(prompt) > 900 else prompt
print(tail)
print("=" * 60 + "\n")
raw = call_ollama(prompt)
choice = parse_vote(raw, self_player=p, alive=alive)
ballot[p] = choice
votes[choice] = votes.get(choice, 0) + 1
print(f"{p} の投票: {choice}")
append_log(f"{p}: (vote) {choice}")
print("\n=== 投票結果 ===")
for n in sorted(alive):
print(f"{n}: {votes.get(n, 0)} 票")
if "SKIP" in votes:
print(f"SKIP(処刑なし): {votes['SKIP']} 票")
# 最多得票者を特定(同票は処刑なし、SKIPが最多なら処刑なし)
executed = ""
if votes:
maxv = max(votes.values())
top = [p for p, c in votes.items() if c == maxv]
if len(top) == 1:
candidate = top[0]
# SKIPが最多得票なら処刑なし
if candidate != "SKIP":
executed = candidate
# 独裁者の能力チェック
if executed and roles[executed] == "Dictator":
dictator_vote = ballot[executed]
print(f"\n【独裁者の能力発動】")
if dictator_vote == "SKIP":
print(f"{executed} は独裁者でした。投票は無効化され、処刑なしとなります。")
append_log(f"【独裁者の能力発動】{executed} がSKIPを選択したため処刑なし")
executed = ""
else:
print(f"{executed} は独裁者でした。投票は無効化され、{executed} の投票先 {dictator_vote} が処刑されます。")
append_log(f"【独裁者の能力発動】{executed} の投票先 {dictator_vote} が処刑される")
executed = dictator_vote