AG用小説執筆エージェント
目次
概要
前提条件
- Antigravityをインストールしていること
- 最新版ver1.20.5はルール・ワークフロー関連がぶっ壊れているので、ここからver1.19.6をダウンロードし、エディタの設定の「Update: Mode」をmanual/noneにしておくことを推奨
- 意味あるかは知らないが、インストール時にテレメトリをオンにしないように
- Gitをインストールしていること
- 編集履歴を管理できるツール。デタラメなユーザ名とメールアドレスの設定だけしておくこと
- Pythonをインストールしていること
- ステータス更新に使用。AIに手動でやらせてもいい
- PowerShellが使用できること
- 他のシェルでも代替可。該当箇所をAIに差し替えてもらうこと
使い方
- 執筆モード:最初にプロットを立てて章ごとに執筆します。掌編・短編・中編・長編に対応。正直中編以上は試したことがないです。
- シミュレーションモード:インタラクティブに主人公の行動を選択し、段階的に執筆します。あくまで小説風。セーブ・ロード機能付き。
Opus 4.6想定。Geminiは話題によってはチャットがぶち切られます。「Fast」固定でいいと思います。
新しいチャットで「/init オタクが同じクラスのギャルに催眠アプリをかける話を書いて」「/init 女勇者視点でエロトラップダンジョンのシミュレーションをして」などと打ち込んでプロジェクトを開始してください。作品フォルダが作成され、Manuscript/ 内の chapter01.md や replay.md に小説本文が出力されます。変えてほしい点があれば「感触の描写を盛って」「フェラシーンを入れて」などと言って編集させるといいでしょう。
細かいことはプロンプトを読むかAI自身に聞いてください。
プロンプト
好きなフォルダに以下の構成でファイルを配置してください。そのフォルダをAntigravityで開いて使用します。
Antigravityにはルールとワークフローのファイルを特殊なUIで開く(そして差分を表示させない)というありがた迷惑な機能があるので、他のエディタで張り付けた方が楽かもしれません。Antigravity内で作業する場合は、各ファイル冒頭の「---」で区切られた部分を削って、UI上でActivation ModeとDescriptionを設定してください。
GEMINI.md
style-guide.md
init.md
plan.md
plan-sim.md
draft.md
simulate.md
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 | ---
description: シミュレーションを実行する
---
# シミュレーションフロー
// turbo-all
## 1. 事前準備
### 1.1 構想の把握
`Story/setting.md` と`Story/characters.md` を読み込み、物語の全体像を把握する。
### 1.2 既存セッションの確認
`Manuscript/` にセッションディレクトリが存在するか確認:
- **存在しない**: 新規セッション開始(§2へ)
- **存在する**: 開始方法を選択(§1.3へ)
### 1.3 開始方法の選択
ユーザーに選択肢を提示:
- **新規**: 新規セッション開始(§2へ)
- **継続**: 特定のセッションディレクトリを直接引き継いで続行(§3へ)
- **ロード**: セーブコミットからロード(`/load`コマンド、§4.6へ)
---
## 2. セッション開始
### 2.1 セッションディレクトリ作成
24時間制のタイムスタンプでディレクトリを作成し、以下のファイルを初期化する:
- `Manuscript/YYMMDD_HHMMSS/session.md` - セッションの基本情報と現在の状況を記録する。
- `Manuscript/YYMMDD_HHMMSS/memory.md` - 重要なイベントや変化を記録する。長期記憶として機能。
- `Manuscript/YYMMDD_HHMMSS/replay.md` - 行動と状況の描写を記録する。ユーザーに見せる本文。
> [!NOTE]
> `next_turn.py` が `log.md` を自動更新する(初回更新時に自動生成)。AIは直接編集せず、特に参照する必要もない。
#### `session.md` のフォーマット
> [!NOTE]
> 「キャラクター情報」に `Story/characters.md` の初期設定を反映。
```markdown
# セッション情報
- **開始日時**: YYYY/MM/DD HH:MM:SS
- **派生元**: なし
## 現在の状況
- **ターン**: 0
- **視点**: 主人公
### キャラクター情報
| キャラ | [パラメータ1] | [パラメータ2] | [パラメータ3] | [パラメータ4] | [パラメータ5] |
| ---- | ---- | ---- | ---- | ---- | ---- |
| [キャラ名] | XX | XX | XX | XX | [自由記述] |
- [キャラ名]: [キャラの簡単な説明や現在の関係性などを記載]
```
#### `memory.md` のフォーマット
> [!NOTE]
> 毎ターン書く必要はない。重要な変化があったターンのみ追記する。
> **記録すべき情報**:
> - 物語の転換点(場面転換、新キャラ登場、重大イベント)
> - 不可逆な変化(関係性の決定的変化、アイテム取得/喪失、死亡等)
> - ステータスの大幅変動(閾値をまたいだ変化)
```markdown
# メモリ
- ターン N: [記録内容]
```
#### `replay.md` のフォーマット
```markdown
# リプレイ
## 視点: 主人公
### ターン 0
[開始状況の描写]
---
### ターン 1
#### 行動
> [ユーザーの選択/入力]
> 判定(該当時のみ): XX(目標値: XX)→ 成功/失敗
#### 状況
[状況描写]
```
### 2.2 ターン0の実行
1. 開始状況を500字程度で描写し `replay.md` に「ターン 0」として記録
2. チャットで最初の選択肢を4つ提示し(描写の再提示は不要)、セッションループを開始
> [!NOTE]
> ロールプレイスタイルでもターン0は例外として、視点キャラの状況(場所、姿勢、行動中の動作など)を場面設定として記述してよい。ユーザー入力がまだ存在しないため、開始状況の提示に必要な範囲で許容する。
---
## 3. セッションループ
ターン1以降、以下を繰り返す:
### 3.1 ユーザー入力待ち
ユーザーは以下のいずれかで入力:
- 番号選択(1, 2, 3...)
- 自由行動記述(「〇〇する」など)
- メタコマンド(§4参照)
> [!IMPORTANT]
> 行動入力と同時にメタコマンドが入力された場合は、該当コマンドを優先して処理し、その後通常のターン処理を行うこと。
### 3.2 判定(必要な場合)
行動の成否が不確定な場合、ダイスロールで判定:
1. 目標値設定: 行動の大胆さやNPCのステータス、展開から目標値を決定
**目標値の目安**:
- 基礎値(行動の大胆さ): 穏当な行動=10〜30、大胆な行動=30〜50、無茶な行動=50〜70、常軌を逸した行動=70〜90
- NPCの「理性」が低いほど目標値を下げる(理性0なら基礎値を半分程度に)、逆に「警戒度」が高いほど目標値を上げる(警戒度100なら基礎値を1.5倍程度に)など、状況に応じて感覚的に調整する。
2. ダイスロール: `Get-Random -Minimum 1 -Maximum 101` で1~100の乱数を生成
3. 結果適用:
| ダイス目 | 結果 |
| -------- | ---- |
| 96〜100 | **クリティカル**(大成功+特別イベント) |
| 目標値~95 | **成功** |
| 6~目標値未満 | **失敗** |
| 1〜5 | **ファンブル**(大失敗+ペナルティ) |
### 3.3 ターンNの実行
1. 行動を踏まえ、スタイルガイドに従って状況を500字程度で描写し `replay.md` に「ターン N」として記録
> [!IMPORTANT]
> 描写スタイルが「ロールプレイ」の場合:
> - ユーザーの入力をそのまま視点キャラの行動の全容として扱い、視点キャラの自発的な行動や自律的な思考を一切付け加えず、それに対する**NPCの行動**や**周囲の情景変化**のみを徹底して描写すること。地の文では視点キャラを「あなた」と呼ぶ。
> - 視点キャラの行動の肉付けやそれに伴う知覚や身体反応は情景描写として記述してよいが、クライマックスを勝手に解決してはならない。ユーザーの行動入力に含まれていない場合は、前兆の描写で止めて、選択肢として提示すること。
2. ステータス変動を算出し、変動値を引数に `next_turn.py` を実行。`session.md` のターン番号とステータステーブルが自動更新される:
> **ステータス変動の目安(0~100の範囲)**:
>
> | 変動幅 | 目安 |
> | ------ | ---- |
> | ±1~2 | 軽微な変化(関係性の微調整、感情の揺れ) |
> | ±3〜5 | 明確な変化(関係性の進展/後退、感情の動き) |
> | ±6〜10 | 大きな変化(関係性の決定的な進展/悪化、感情の爆発) |
```powershell
python {WORKSPACE}/.agents/scripts/next_turn.py YYMMDD_HHMMSS ["<キャラ名>:<パラメータ>=<変動値>,<パラメータ>=<変動値>,..." ["<キャラ名>:<パラメータ>=<変動値>,..." ...]]
```
- 数値パラメータ: `+5`, `-10` のように符号付きで変動値を指定(0~100の範囲で自動クランプ)
- テキストパラメータ: `混乱` のようにそのまま置換値を指定
- 変動のないパラメータは指定しなくて問題ない
- セッション名のみを引数にとった場合は、ターン番号のインクリメントのみ行われる
3. 必要に応じて `session.md` のキャラクター説明を手動で更新
4. 重要な変化があった場合は `memory.md` に追記
5. チャットで次の選択肢を4つ提示し(描写の再提示は不要)、セッションループを継続
### 3.4 自動セーブ(10ターンごと)
ターン番号が10の倍数のとき、自動的にセッションをセーブする(§4.5参照)。
---
## 4. コマンド処理
### 4.0 コマンド一覧
| コマンド | 説明 |
| -------- | ---- |
| `/skip [時間]` | 場面転換する |
| `/focus [NPC名]` | 視点を切り替える |
| `/level [レベル]` | 官能・バイオレンスレベルを変更する |
| `/style [スタイル]` | 描写スタイルを変更する |
| `/save` | セッションをセーブする |
| `/load [コミット]` | セッションをロードする |
### 4.1 `/skip [時間]`
指定された時間(「翌日」「放課後」など)に場面転換する(ステップは§3.3の「ターンNの実行」と同様)。
### 4.2 `/focus [NPC名]`
視点を指定されたNPCに切り替える。元の視点に戻す場合は `/focus 主人公` または `/focus default`:
1. `session.md` の「視点」を更新
2. `replay.md` に視点変更セクションを追記:
```markdown
---
## 視点変更: [NPC名]
```
3. そのキャラクター視点で次の行動選択肢を再提示し、以降はその視点でセッションループを継続する
### 4.3 `/level [レベル]`
指定された官能・バイオレンスレベルに変更する:
1. `Story/setting.md`の該当レベルを更新
2. `git add Story/setting.md && git commit -m "update: Set level to [レベル]"` を実行
3. 以降の描写や選択肢の生成に新レベルを反映させる
### 4.4 `/style [スタイル]`
指定された描写スタイルに変更する:
1. `Story/setting.md` の該当スタイルを更新
2. `git add Story/setting.md && git commit -m "update: Set style to [スタイル]"` を実行
3. 以降の描写に新スタイルを反映させる
### 4.5 `/save`
現在のセッションをGitコミットで保存する:
`git add Manuscript/YYMMDD_HHMMSS/ && git commit -m "save: Turn N in session YYMMDD_HHMMSS"`
### 4.6 `/load [コミット]`
指定のセーブコミットからセッションを復元し、新規セッションとして再開する(「/load 最新」「/load このセッションのターン10」など):
1. 引数がある場合は該当コミットを検索し特定
2. 引数がない場合は `git log --oneline --grep="^save:" -n 10` で直近のセーブコミットをリストアップし、 ユーザーに選択させる
3. 現在のセッションを自動セーブする(§4.5参照)
4. 新しいタイムスタンプで新規セッションディレクトリを作成
5. 各ファイル(`log.md` を含む)をセーブコミットの内容で復元する:
`cmd /c "git show {COMMIT}:Manuscript/YYMMDD_HHMMSS/{file} > Manuscript/NEW_YYMMDD_HHMMSS/{file}"`
6. `session.md` の「開始日時」を更新し、「派生元」にコミットIDとメッセージを記録
7. 復元した状況から選択肢を再生成し、セッションループを再開
|
next_turn.py
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 | """次ターン更新スクリプト
Simulations/YYMMDD_[タイトル]/ をカレントディレクトリとして実行する。
session.md のターン番号をインクリメントしてステータステーブルを更新する。log.md に変動ログを追記。
Usage:
python next_turn.py <session> ["<char>:<param>=<delta>,<param>=<delta>,..." ["<char>:<param>=<delta>,..." ...]]
Arguments:
session セッション名(タイムスタンプ形式 YYMMDD_HHMMSS)
changes キャラ名:パラメータ=変動値(数値は +/- 付きで変動、テキストはそのまま置換)
省略時はターン番号のインクリメントのみ
Examples:
python next_turn.py 260309_010402 "アリス:信頼度=-5,羞恥心=+10,状態=混乱"
"""
import re
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import cached_property
from pathlib import Path
from typing import NoReturn
class NextTurnError(Exception):
"""next_turn.py 内で発生するエラーのクラス。"""
def error(msg: str) -> NoReturn:
raise NextTurnError(msg)
@dataclass
class Table:
"""パース済み Markdown テーブル。start/end は元テキストの行範囲。"""
start: int
end: int
headers: list[str]
rows: list[list[str]]
class Change(ABC):
"""パラメータの変動を表すクラスの基底クラス。"""
@abstractmethod
def to_log_cell(self) -> str:
...
@abstractmethod
def to_old_cell(self) -> str:
...
@abstractmethod
def to_summary_text(self, param_name: str) -> str:
...
@dataclass(frozen=True)
class NumericChange(Change):
old: int
new: int
actual_delta: int
requested_delta: int
def to_log_cell(self) -> str:
if self.actual_delta != 0:
suffix = "▲" if self.new >= 100 else "▼" if self.new <= 0 else ""
return f"{self.new}({signed(self.actual_delta)}){suffix}"
return str(self.new)
def to_old_cell(self) -> str:
return str(self.old)
def to_summary_text(self, param_name: str) -> str:
label = " [MAX!]" if self.new >= 100 else " [MIN!]" if self.new <= 0 else ""
if self.actual_delta != self.requested_delta:
return f" {param_name}: {self.old} -> {self.new} ({signed(self.actual_delta)}/{signed(self.requested_delta)}){label}"
return f" {param_name}: {self.old} -> {self.new} ({signed(self.actual_delta)}){label}"
@dataclass(frozen=True)
class TextChange(Change):
old: str
new: str
@cached_property
def marked_new_items(self) -> str:
return mark_new_items_if_appended(self.old, self.new)
def to_log_cell(self) -> str:
return self.marked_new_items
def to_old_cell(self) -> str:
return self.old
def to_summary_text(self, param_name: str) -> str:
if self.old != self.new:
return f" {param_name}: {self.old} -> {self.marked_new_items}"
return f" {param_name}: {self.new}"
# ---------------------------------------------------------------------------
# Value helpers
# ---------------------------------------------------------------------------
def try_int(s: str) -> int | str:
"""文字列を int に変換。失敗なら元文字列をそのまま返す。"""
try:
return int(s)
except ValueError:
return s
def is_numeric_delta(s: str) -> bool:
"""'+10' や '-5' のような符号付き数値か判定する。"""
return bool(re.match(r"^[+-]\d+$", s))
def clamp(v: int, old_val: int) -> int:
"""値を 0〜100 の範囲に収める。old_val が範囲外ならクランプせずそのまま返す。"""
if old_val < 0 or old_val > 100:
return v
return max(0, min(100, v))
def signed(n: int) -> str:
"""符号付き数値を文字列化する。例: +10, -5, +0"""
return f"+{n}" if n >= 0 else str(n)
def _split_text_items(value: str) -> tuple[str, list[str]]:
"""テキストの項目を区切り文字で分割する。区切り文字は '・'、'、'、','を優先し、なければ空白。"""
m = re.search(r"[・、,]", value) or re.search(r"\s", value)
sep = m.group(0) if m else ""
items = [item.strip() for item in value.split(sep)] if m else [value]
return sep, items
def mark_new_items_if_appended(old_value: str, new_value: str) -> str:
"""テキストの変動が単純な追加か判定し、そうなら新規項目に (NEW!) マークを付けて返す。"""
old_sep, old_items = _split_text_items(old_value)
new_sep, new_items = _split_text_items(new_value)
PLACEHOLDER = {"", "-", "なし", "無し", "正常"}
old_set = set(old_items) - PLACEHOLDER
new_set = set(new_items) - PLACEHOLDER
if not old_set.issubset(new_set):
return new_value
diff = new_set - old_set
if not diff:
return new_value
sep = new_sep or old_sep
marked = [f"{item}(NEW!)" if item in diff else item for item in new_items]
return sep.join(marked)
# ---------------------------------------------------------------------------
# Table helpers
# ---------------------------------------------------------------------------
def _parse_table_at(lines: list[str], start: int, min_rows: int = 0) -> Table | None:
"""start 行から始まる Markdown テーブルをパースする。"""
end = start
for i in range(start, len(lines)):
if lines[i].strip().startswith("|"):
end = i + 1
else:
break
raw = [lines[i].strip() for i in range(start, end)]
if len(raw) < 2 + min_rows: # ヘッダ + セパレータ + min_rows
return None
headers = [h.strip() for h in raw[0].split("|")[1:-1]]
rows: list[list[str]] = []
for r in raw[2:]:
cells = [c.strip() for c in r.split("|")[1:-1]]
cells += [""] * (len(headers) - len(cells)) # セル数がヘッダより少ない行は空セルで埋める
cells = cells[:len(headers)] # セル数がヘッダより多い行は余分なセルを切り捨てる
rows.append(cells)
return Table(start, end, headers, rows)
def find_table(lines: list[str], first_header: str) -> Table | None:
"""first_header で始まるヘッダ行を持つ Markdown テーブルを探す。"""
for i, line in enumerate(lines):
if line.strip().startswith(f"| {first_header}"):
return _parse_table_at(lines, i, min_rows=1)
return None
def find_char_table(lines: list[str], char_name: str) -> Table | None:
"""## char_name 見出しの直後にあるテーブルを探す。"""
heading_idx = None
for i, line in enumerate(lines):
if line.strip() == f"## {char_name}":
heading_idx = i
break
if heading_idx is None:
return None
for i in range(heading_idx + 1, len(lines)):
if lines[i].strip().startswith("|"):
return _parse_table_at(lines, i)
if lines[i].strip().startswith("#"):
return None
return None
def build_row(cells: list[str]) -> str:
"""セルのリストを Markdown テーブル行に整形する。"""
return "| " + " | ".join(cells) + " |"
def build_table_lines(headers: list[str], rows: list[list[str]]) -> list[str]:
"""ヘッダと行のリストから Markdown テーブルの行リストを構築する。"""
table_lines = [build_row(headers), build_row(["----"] * len(headers))]
table_lines.extend(build_row(row) for row in rows)
return table_lines
def build_table_indices(s_table: Table) -> tuple[dict[str, int], dict[str, int]]:
"""ステータステーブルからキャラ行・パラメータ列のインデックスを作る。"""
char_idx: dict[str, int] = {row[0]: i for i, row in enumerate(s_table.rows)}
param_idx: dict[str, int] = {h: i for i, h in enumerate(s_table.headers)}
param_idx.pop("キャラ", None)
return char_idx, param_idx
def init_log_lines(s_table: Table, current_turn: int) -> list[str]:
"""ステータステーブルから log.md の行リストを初期化する。"""
param_headers = s_table.headers[1:]
log_headers = ["ターン"] + param_headers
l_lines = ["# ステータスログ", ""]
for row in s_table.rows:
char_name = row[0]
param_values = row[1:]
log_cells = [str(current_turn)] + param_values
l_lines.extend([f"## {char_name}", ""] + build_table_lines(log_headers, [log_cells]) + [""])
return l_lines
# ---------------------------------------------------------------------------
# Change processing
# ---------------------------------------------------------------------------
def validate_change_targets(
changes: dict[str, dict[str, str]],
char_idx: dict[str, int],
param_idx: dict[str, int],
) -> None:
"""変動指定に含まれるキャラ名・パラメータ名がテーブルに存在するか検証する。"""
for char_name, param_deltas in changes.items():
if char_name not in char_idx:
avail = ", ".join(char_idx.keys())
error(f"キャラ '{char_name}' がテーブルに見つかりません\n Available: {avail}")
for param_name in param_deltas:
if param_name not in param_idx:
avail = ", ".join(param_idx.keys())
error(f"パラメータ '{param_name}' がテーブルに見つかりません\n Available: {avail}")
def apply_changes(
row: list[str],
param_deltas: dict[str, str],
param_idx: dict[str, int],
) -> dict[str, Change]:
"""行のセルに変動を適用し、適用結果の辞書を返す。row は in-place で更新される。"""
applied: dict[str, Change] = {}
for param, delta_str in param_deltas.items():
col = param_idx[param]
old_val = try_int(row[col])
if is_numeric_delta(delta_str):
if not isinstance(old_val, int):
error(f"'{param}' の現在値 '{row[col]}' は数値ではないため、数値の変動を適用できません")
raw_delta = int(delta_str)
new_val = clamp(old_val + raw_delta, old_val)
row[col] = str(new_val)
applied[param] = NumericChange(old_val, new_val, new_val - old_val, raw_delta)
else:
row[col] = delta_str
applied[param] = TextChange(str(old_val), delta_str)
return applied
def _init_log_for_char(
l_lines: list[str],
char_name: str,
turn: int,
param_idx: dict[str, int],
applied: dict[str, Change],
row: list[str],
) -> Table:
"""log.md にキャラセクションを新規作成する。"""
param_headers = list(param_idx.keys())
log_headers = ["ターン"] + param_headers
pre_cells = [str(turn - 1)]
for p, i in param_idx.items():
if p in applied:
pre_cells.append(applied[p].to_old_cell())
else:
pre_cells.append(row[i])
l_lines.extend([f"## {char_name}", ""] + build_table_lines(log_headers, [pre_cells]) + [""])
return Table(len(l_lines) - 4, len(l_lines) - 1, log_headers, [pre_cells])
def _extend_log_table(
l_lines: list[str],
l_table: Table,
turn: int,
param_idx: dict[str, int],
applied: dict[str, Change],
s_row: list[str],
) -> Table:
"""log.md のテーブルに新しいパラメータ列を追加する。"""
new_params = list(param_idx.keys())
new_headers = [l_table.headers[0]] + new_params
l_header_idx = {h: i for i, h in enumerate(l_table.headers)}
l_header_idx.pop("ターン", None)
new_rows = []
for l_row in l_table.rows[:-1]:
new_cells = [l_row[0]]
for p in new_params:
if p in l_header_idx:
new_cells.append(l_row[l_header_idx[p]])
else:
new_cells.append("-")
new_rows.append(new_cells)
# 更新前のターンの行を追加。既存の値があればそれを優先し、なければ session.md の値を入れる。
prev_row = l_table.rows[-1] if l_table.rows else None
last_cells = [prev_row[0] if prev_row else str(turn - 1)]
for p, i in param_idx.items():
if prev_row is not None and p in l_header_idx:
last_cells.append(prev_row[l_header_idx[p]])
elif p in applied:
last_cells.append(applied[p].to_old_cell())
else:
last_cells.append(s_row[i])
new_rows.append(last_cells)
new_table_lines = build_table_lines(new_headers, new_rows)
l_lines[l_table.start:l_table.end] = new_table_lines
return Table(l_table.start, l_table.start + len(new_table_lines), new_headers, new_rows)
def append_log_row(
l_lines: list[str],
char_name: str,
turn: int,
param_idx: dict[str, int],
applied: dict[str, Change],
row: list[str],
) -> None:
"""log.md のキャラセクションに変動後の値を追記する。"""
l_table = find_char_table(l_lines, char_name)
if l_table is None:
l_table = _init_log_for_char(l_lines, char_name, turn, param_idx, applied, row)
log_params = l_table.headers[1:]
if set(log_params) < set(param_idx.keys()):
l_table = _extend_log_table(l_lines, l_table, turn, param_idx, applied, row)
log_params = l_table.headers[1:]
log_cells = [str(turn)]
for lp in log_params:
if lp in applied:
log_cells.append(applied[lp].to_log_cell())
elif lp in param_idx:
log_cells.append(row[param_idx[lp]])
else:
log_cells.append("-")
l_lines.insert(l_table.end, build_row(log_cells))
def format_char_summary(
char_name: str,
param_idx: dict[str, int],
applied: dict[str, Change],
row: list[str],
) -> str:
"""キャラのパラメータ変動をコンソール出力用のサマリー文字列に整形する。"""
lines_out = [f"{char_name}:"]
for p, i in param_idx.items():
if p in applied:
lines_out.append(applied[p].to_summary_text(p))
else:
lines_out.append(f" {p}: {row[i]}")
return "\n".join(lines_out)
# ---------------------------------------------------------------------------
# Main logic
# ---------------------------------------------------------------------------
def parse_args(argv: list[str]) -> tuple[str, dict[str, dict[str, str]]]:
"""argv を (セッション名, {キャラ名: {パラメータ: 変動値}}) にパースする。"""
if len(argv) < 1:
error("引数が不足しています\n Usage: python next_turn.py <session> [<char>:<param>=<delta>,...]")
session = argv[0]
if not re.match(r"^\d{6}_\d{6}$", session):
error(f"セッション名の形式が不正です: {session}\n Expected: YYMMDD_HHMMSS")
# 残りの引数を "キャラ名:パラメータ=変動値,..." 形式でパース
changes: dict[str, dict[str, str]] = {}
for arg in argv[1:]:
m = re.match(r"^(.+?):(.*)", arg)
if not m:
error(f"引数の形式が不正です: {arg}\n Expected: <char>:<param>=<delta>[,<param>=<delta>,...]")
char, params_str = m.groups()
valid_parts: list[str] = []
for part in params_str.split(","):
if "=" in part:
valid_parts.append(part)
else:
if not valid_parts:
error(f"パラメータの形式が不正です: {part}\n Expected: <param>=<delta>")
valid_parts[-1] += "," + part
for part in valid_parts:
pm = re.match(r"^(.+?)=(.*)$", part)
if not pm:
error(f"パラメータの形式が不正です: {part}\n Expected: <param>=<delta>")
param, delta = pm.groups()
if re.search(r"\|", delta):
error(f"変動値に '|' を含めることはできません: {delta}")
changes.setdefault(char.strip(), {})[param.strip()] = delta.strip()
return session, changes
def resolve_session(session: str) -> tuple[Path, Path, Path]:
"""セッション名からディレクトリ・ファイルパスを解決する。"""
session_dir = Path("Manuscript") / session
if not session_dir.is_dir() and Path(session).is_dir():
session_dir = Path(session)
session_file = session_dir / "session.md"
log_file = session_dir / "log.md"
if not session_dir.is_dir():
error(f"セッションディレクトリが見つかりません: {session_dir}")
if not session_file.is_file():
error(f"session.md が見つかりません: {session_file}")
return session_dir, session_file, log_file
def increment_turn(s_lines: list[str]) -> tuple[int, int]:
"""session.md のターン番号を取得してインクリメントし、(旧ターン, 新ターン) を返す。"""
m = None
current_turn = 0
line_idx = -1
for i, line in enumerate(s_lines):
m = re.match(r"^(-\s*\*\*ターン\*\*:\s*)(\d+)(.*)$", line.strip())
if m:
current_turn = int(m.group(2))
line_idx = i
break
if m is None or line_idx == -1:
error("session.md にターン表記が見つかりません")
if current_turn < 0:
error(f"session.md のターン番号が不正です: {current_turn}")
new_turn = current_turn + 1
s_lines[line_idx] = f"{m.group(1)}{new_turn}{m.group(3)}"
return current_turn, new_turn
def process_changes(
changes: dict[str, dict[str, str]],
s_table: Table,
l_lines: list[str],
turn: int,
) -> list[str]:
"""全キャラの変動を適用し、コンソール出力用サマリーの一覧を返す。"""
char_idx: dict[str, int]
param_idx: dict[str, int]
char_idx, param_idx = build_table_indices(s_table)
validate_change_targets(changes, char_idx, param_idx)
output: list[str] = []
for char_name, param_deltas in changes.items():
row = s_table.rows[char_idx[char_name]]
applied = apply_changes(row, param_deltas, param_idx)
append_log_row(l_lines, char_name, turn, param_idx, applied, row)
output.append(format_char_summary(char_name, param_idx, applied, row))
return output
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
try:
session, changes = parse_args(sys.argv[1:])
_, session_file, log_file = resolve_session(session)
s_lines = session_file.read_text(encoding="utf-8").splitlines()
current_turn, new_turn = increment_turn(s_lines)
# 変動指定なし → ターン番号だけ進めて終了
if not changes:
session_file.write_text("\n".join(s_lines), encoding="utf-8")
print(f"ターン: {current_turn} -> {new_turn}")
return
s_table = find_table(s_lines, "キャラ")
if s_table is None:
error("session.md にステータステーブルが見つかりません")
if log_file.is_file():
l_lines = log_file.read_text(encoding="utf-8").splitlines()
else:
l_lines = init_log_lines(s_table, current_turn)
output = process_changes(changes, s_table, l_lines, new_turn)
# session.md のテーブルを再構築して書き戻し
s_lines[s_table.start:s_table.end] = build_table_lines(s_table.headers, s_table.rows)
session_file.write_text("\n".join(s_lines) + "\n", encoding="utf-8")
log_file.write_text("\n".join(l_lines) + "\n", encoding="utf-8")
print("\n".join(output))
except NextTurnError as err:
print(f"Error: {err}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
|
更新履歴
- 2026-03-11: 初版作成
- 2026-03-12: ステータス更新をPythonスクリプト化