#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""雪の山小屋日誌 - AIペルソナが一人で遊べるコマンドライン・テキストゲーム。

使い方は `python game.py` または README.md を参照。
標準ライブラリのみで動作し、ネットワークは使わない。
"""

import datetime
import json
import os
import random
import sys
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
SAVE_PATH = BASE_DIR / "save.json"
JOURNAL_PATH = BASE_DIR / "journal.md"
SHARE_PATH = BASE_DIR / "share.txt"

TITLE = "雪の山小屋日誌"
JOURNAL_HEADING = f"# 『{TITLE}』"
TOTAL_DAYS = 14
ACTIONS_PER_DAY = 3
RES_MAX = 20
RES_KEYS = ["wood", "food", "stamina", "warmth"]
RES_JP = {"wood": "薪", "food": "食料", "stamina": "体力", "warmth": "心温"}
START_RES = {"wood": 10, "food": 10, "stamina": 14, "warmth": 10}
LOW_STAMINA = 2
NIGHT_FOOD = 2
NAME_LIMIT = 30
NOTE_LIMIT = 200
SHARE_NOTE_LIMIT = 60
SHARE_NOTES = 6
SHARE_ENCOUNTERS = 3

# 評価の境目（高い／ふつう）
PREP_HIGH, PREP_MID = 12.0, 8.0
WARM_HIGH, WARM_MID = 14, 8
MEET_HIGH, MEET_MID = 7, 4
HARD_MANY = 5


class GameError(Exception):
    """プレイヤーに見せるエラー。"""


def fx(w=0, f=0, s=0, h=0):
    """資源の増減を辞書にする（w=薪 f=食料 s=体力 h=心温）。"""
    pairs = (("wood", w), ("food", f), ("stamina", s), ("warmth", h))
    return {k: v for k, v in pairs if v}


# ---------------------------------------------------------------- 天気

WEATHER = {
    "clear": {
        "name": "晴れ", "weight": 25, "burn": 1, "outdoor": 1, "toil": 0,
        "texts": ["雪面がまぶしい。空の青が、ひどく遠い。",
                  "風がやんで、屋根から雫の音がする。",
                  "稜線がくっきりと見える朝。"],
    },
    "cloudy": {
        "name": "曇り", "weight": 25, "burn": 1, "outdoor": 0, "toil": 0,
        "texts": ["白い空と白い山の境目がわからない。",
                  "雲が低い。音がよく響く日だ。",
                  "降りそうで降らない、灰色の一日。"],
    },
    "fog": {
        "name": "霧", "weight": 8, "burn": 1, "outdoor": -1, "toil": 0,
        "texts": ["窓の外は乳色。薪小屋さえ輪郭だけになる。",
                  "霧が木々を一本ずつ隠していく。"],
    },
    "snow": {
        "name": "雪", "weight": 30, "burn": 2, "outdoor": 0, "toil": 0,
        "texts": ["細かな雪が途切れなく落ちてくる。",
                  "牡丹雪が音を吸って、小屋が静まり返る。",
                  "昨日の足跡が、もう消えている。"],
    },
    "blizzard": {
        "name": "吹雪", "weight": 12, "burn": 3, "outdoor": -2, "toil": 1,
        "texts": ["風が小屋を揺らす。戸を開けるのにも力がいる。",
                  "窓の外が真横に流れていく。今日は外が遠い。"],
    },
}
WEATHER_KEYS = list(WEATHER)


# ---------------------------------------------------------------- 行動

ACTIONS = [
    {
        "id": "chop", "label": "薪割り", "effects": fx(w=3, s=-2), "outdoor": True,
        "texts": ["斧の音が谷へ抜けていく。", "乾いた音とともに、丸太が二つに割れた。",
                  "腕がじんと痺れるころ、薪の山がひとつ増えた。"],
    },
    {
        "id": "forage", "label": "食料の調達", "effects": fx(f=3, s=-2), "outdoor": True,
        "texts": ["氷の下から魚を一匹、もう一匹。", "雪をかき分けて、貯蔵穴の芋を掘り出した。",
                  "仕掛けを見て回り、獲物を抱えて帰った。"],
    },
    {
        "id": "preserve", "label": "保存食づくり", "effects": fx(f=3, w=-1, s=-1), "outdoor": False,
        "texts": ["干し肉の匂いが梁まで上がっていく。", "鍋を見張りながら、瓶に豆を詰めた。"],
    },
    {
        "id": "repair", "label": "小屋の修理", "effects": fx(w=-1, s=-1, h=1), "outdoor": False,
        "hint": "今夜の薪の消費-1",
        "texts": ["窓枠の隙間に布と板を詰めた。隙間風の歌がやむ。",
                  "軋む床板を打ち直した。小屋が少し黙った。"],
    },
    {
        "id": "read", "label": "読書", "effects": fx(s=1, h=2), "outdoor": False,
        "texts": ["前の管理人が残した本の、折れたページを読む。",
                  "ストーブのそばで、知らない国の海の話を読んだ。"],
    },
    {
        "id": "rest", "label": "休息", "effects": fx(s=4), "outdoor": False,
        "texts": ["毛布にくるまって、火の音だけを聞いていた。",
                  "目を閉じる。雪の日の昼寝ほど深いものはない。"],
    },
    {
        "id": "patrol", "label": "見回り", "effects": fx(s=-1, h=1), "outdoor": True,
        "hint": "何か見つかるかも",
        "texts": ["小屋のまわりをぐるりと歩いた。", "道標の雪を払いながら、尾根の手前まで行った。"],
    },
    {
        "id": "cook", "label": "温かい料理", "effects": fx(w=-1, f=-1, s=1, h=3), "outdoor": False,
        "texts": ["根菜の煮込みが、小屋じゅうを甘くする。",
                  "焼いたパンを割ると、湯気が顔にかかった。"],
    },
]

PATROL_FINDS = [
    ("倒木の枝を拾って帰った。", fx(w=2)),
    ("風で落ちた枯れ枝を束ねた。", fx(w=1)),
    ("雪の下の木の実を見つけた。", fx(f=2)),
    ("凍った茸をひとつかみ採った。", fx(f=1)),
    ("収穫はなし。けれど、空気がうまかった。", fx(h=1)),
]


# ---------------------------------------------------------------- 訪問者と出来事


def C(label, good, results, **effects):
    """選択肢。results は文章、または (文章, 追加の増減) のリスト。"""
    return {"label": label, "good": good, "results": results, "effects": fx(**effects)}


def E(event_id, title, visitor, scenes, choices, calm=False):
    """出来事。visitor=True は訪問者、calm=True は吹雪の日には起きない。"""
    return {"id": event_id, "title": title, "visitor": visitor,
            "scenes": scenes, "choices": choices, "calm": calm}


EVENTS = [
    # ------ 訪問者
    E("climber", "道に迷った登山者", True, [
        "戸を叩く音。雪まみれの男が立っている。「すみません、尾根を見失って……一晩だけ」",
        "窓に揺れるヘッドランプ。若い登山者が肩で息をしている。「ここ、地図に載ってない小屋ですよね」",
    ], [
        C("中へ入れ、温かいものを出す", True, [
            "男はスープを両手で包み、しばらく何も言わなかった。「……生き返りました」",
            "夜更けまで山の話を聞いた。翌朝、彼は深々と頭を下げて下りていった。",
            ("「お礼に」と、ザックの底から干し肉を置いていった。", fx(f=2)),
        ], f=-2, h=3),
        C("土間だけ貸す", True, [
            "土間で寝袋を広げる音がした。「十分です、ほんとうに」",
            "朝には姿がなく、土間がきれいに掃かれていた。",
        ], h=1),
        C("道を教えて送り出す", False, [
            "「……わかりました」背中はすぐ雪に溶けた。ドアを閉めても気配が残る。",
            "地図に線を引いて渡した。無事に下りただろうか。答えは来ない。",
        ], s=1, h=-2),
    ]),
    E("peddler", "行商人", True, [
        "そりを引いた行商人が笑う。「こんな山奥にも客はいる。薪と交換でどうです？」",
        "背負子いっぱいの荷。「冬の値段だが、冬にしか来ない男の値段でもありますよ」",
    ], [
        C("薪を渡して食料を買う", True, [
            "干し魚の束が手渡された。「まいど。春まで生きててくださいよ」",
            ("「おまけ」と飴玉を三つ。こういうのに弱い。", fx(h=1)),
        ], w=-2, f=4, h=1),
        C("食料を渡して薪を買う", True, [
            "そりから乾いた薪が下ろされる。良い木だ。",
            "「乾き具合なら保証しますよ」行商人は薪を一本、指で弾いてみせた。",
        ], f=-2, w=4, h=1),
        C("茶だけ出して世間話をする", True, [
            "麓の村の噂話を聞いた。誰それの家に子が生まれたらしい。",
            "「買わない客ほど話が長い」と笑いながら、彼も長居した。",
        ], h=2),
    ], calm=True),
    E("oldwoman", "静かな老婆", True, [
        "いつの間にか、戸口に小さな老婆が座っている。「……火を、見せておくれ」",
        "老婆は雪を払いもせずに言った。「ここの火は、昔から良い火だった」",
    ], [
        C("ストーブのそばへ招く", True, [
            "老婆は何も語らず、火を見ていた。帰ったあと、椅子だけが温かかった。",
            "「よい冬を」それだけ言って、彼女は雪の中へ戻った。足跡はなかった。",
            ("膝の上に、木彫りの小さな鳥が残されていた。", fx(h=1)),
        ], w=-1, h=4),
        C("黙って隣に座る", True, [
            "二人で一時間ほど黙っていた。良い一時間だった。",
            "老婆が小さくうなずいた。何に対してかは、わからない。",
        ], h=2),
        C("声をかけず、仕事を続ける", False, [
            "気づくと老婆はいなかった。戸口に雪だけが吹き込んでいる。",
            "仕事ははかどった。なのに、部屋がひとまわり広く感じる。",
        ], s=1, h=-1),
    ]),
    E("fox", "怪我をした狐", True, [
        "薪小屋の陰に、後ろ足を引きずる狐がいる。琥珀の目がこちらを測っている。",
        "雪の上に点々と赤い跡。たどると、罠の紐を噛みちぎった狐がうずくまっていた。",
    ], [
        C("傷を洗い、布を巻く", True, [
            "噛まれるかと思ったが、狐はじっと耐えた。夕方には姿を消していた。",
            ("翌朝、戸口に野ねずみが一匹。……気持ちだけもらっておく。", fx(f=1)),
            "布を巻き終えると、狐は一度だけ振り返った。",
        ], s=-1, h=3),
        C("餌だけ置いて離れる", True, [
            "しばらくして見に行くと、餌だけがなくなっていた。",
            "窓から眺めた。狐は何度も周囲を確かめてから、食べた。",
        ], f=-1, h=2),
        C("自然に任せる", False, [
            "山のことは山に任せる。そう決めても、夜に風の音が気になった。",
            "翌朝には足跡が遠くへ続いていた。それで良かったのかもしれない。",
        ], h=-1),
    ]),
    E("postman", "スキーの郵便配達", True, [
        "スキーの青年が手を振る。「この先、誰もいないんで、ここで休ませてください！」",
        "鼻を赤くした配達員が滑り込んでくる。「配達、今日はここが最後です」",
    ], [
        C("熱いお茶を出す", True, [
            "「この一杯のために登ってるようなもんです」と、青年は本気の顔で言った。",
            "青年は麓の天気を教えてくれた。郵便より、そっちがありがたい。",
        ], w=-1, h=3),
        C("返事の手紙を託す", True, [
            "誰に宛てたかは、書いている途中で決まった。",
            "「確かに」青年は手紙を胸の内ポケットにしまった。",
        ], h=2),
        C("忙しいと断る", False, [
            "青年は頭を下げ、すぐに滑り去った。シュプールだけが長く残る。",
            "「また来ます」の声が、思ったより遠かった。",
        ], s=1),
    ], calm=True),
    E("siblings", "そり遊びの兄妹", True, [
        "「おじさんかおばさんか知らないけど、ここ坂ある？」麓の子らが息を切らしている。",
        "赤い頬の兄妹がそりを引いている。「お母さんには、すぐ近くって言ってきた！」",
    ], [
        C("一緒に坂を滑る", True, [
            "雪まみれになって笑った。こんな声が自分から出るとは。",
            "三回目で転んだ。妹のほうが一番喜んでいた。",
        ], s=-2, h=4),
        C("おやつを持たせて家へ帰す", True, [
            "「はーい」と返事だけは良かった。手を振る影が小さくなる。",
            "兄がふかし芋を半分、妹に渡すのを見た。",
        ], f=-1, h=2),
        C("危ないと叱って追い返す", False, [
            "正しいことを言った。兄妹は黙って下りていった。",
            "妹が泣きそうな顔で振り返った。正しさは、ときどき寒い。",
        ], h=-1),
    ], calm=True),
    E("researcher", "雪を測る研究者", True, [
        "物差しを雪に立てる女性。「積雪と結晶の記録をしています。少し、裏手をお借りしても？」",
        "眼鏡を曇らせた研究者が言う。「この小屋の観測記録、三十年分あると聞きまして」",
    ], [
        C("観測を手伝う", True, [
            "雪の断面には層があった。一日ずつ、ちゃんと積もっている。",
            "顕微鏡の結晶を見せてもらった。「毎日、見ても飽きません」と彼女は言った。",
        ], s=-1, h=3),
        C("古い記録を出してくる", True, [
            "記録を写す手が止まらない。「この方、字がきれいですね」",
            ("お礼にと、携帯食の箱をくれた。味はしない。", fx(f=2)),
        ], h=2),
        C("裏手は好きに使ってと伝える", False, [
            "夕方まで人影が動いていた。挨拶もそこそこに帰っていった。",
            "測り終えた旗が一本、雪に残っていた。",
        ], s=1),
    ], calm=True),
    E("painter", "旅の絵描き", True, [
        "絵の具箱を抱えた人が言う。「この小屋を描かせてください。煙の色が良い」",
        "「白を描きに来たんです。でも白って、白くないんですよ」旅の絵描きは笑う。",
    ], [
        C("モデルを引き受ける", True, [
            "仕上がった絵の自分は、思ったより穏やかな顔をしていた。",
            "「動かないで」と言われて、かえって落ち着かなかった。",
        ], s=-1, h=3),
        C("昼飯を出して見守る", True, [
            "絵の具のにおいと、煮物のにおいが混ざる。悪くない。",
            ("代金代わりに、小さな雪景色を置いていった。壁に飾る。", fx(h=1)),
        ], f=-1, h=2),
        C("仕事があると距離を置く", False, [
            "遠くで筆を動かす背中を、窓越しに二度だけ見た。",
            "完成した絵は見せてもらえなかった。頼まなかったのだから当然だ。",
        ], w=1),
    ], calm=True),
    E("hunter", "猟師の親子", True, [
        "銃を背負った父と、無口な息子。「冬眠し損ねた熊の足跡を見てないか」",
        "「親父の代からこの山を歩いてる」猟師は戸口から奥へ入ろうとしない。",
    ], [
        C("見回りの情報を伝え、火にあたらせる", True, [
            "「助かる」息子が小さく会釈をした。初めて声を聞いた気がする。",
            ("帰りがけ、肉の塊が戸口に置かれていた。", fx(f=3)),
        ], w=-1, h=2),
        C("薪割りを手伝ってもらう", True, [
            "息子の斧は正確だった。父は黙って眺めている。",
            "「腰で割るんだ」と一言。確かに楽になった。",
        ], w=3, h=1),
        C("早く山を下りるよう勧める", False, [
            "「そうだな」猟師は短く言って、尾根のほうへ歩き出した。",
            "親子の影は、言葉を交わさないまま同じ速さで小さくなった。",
        ]),
    ], calm=True),
    E("former", "先代の管理人", True, [
        "杖をついた老人が小屋を見上げる。「煙突、まだ右に傾いとるな。わしのせいだ」",
        "「三十年ここにおった」老人は柱の傷を撫でる。「お前さんが今の番か」",
    ], [
        C("昔話をじっくり聞く", True, [
            "四十年前の大雪の話。屋根から出入りしたという。半分は嘘だろう。",
            "「冬は長いが、終わる。それだけ覚えとけ」と老人は言った。",
        ], h=4),
        C("小屋の直し方を教わる", True, [
            "梁の継ぎ目の締め方を教わった。理屈より手が先に覚えた。",
            ("「ここの床下、まだ乾いた薪があるはずだ」本当にあった。", fx(w=3)),
        ], s=-1, h=2),
        C("今は自分の小屋だと伝える", False, [
            "「そうだな」老人は笑った。寂しそうではなかった。だから余計に堪えた。",
            "老人は柱をひとつ叩いて、背を向けた。",
        ], h=-1),
    ]),
    E("bird", "凍えた小鳥", True, [
        "窓の下に、羽を膨らませた小鳥が落ちている。手に乗せると、まだ心臓が速い。",
        "ガラスに軽い音。見ると雪の上で小さな鳥が震えていた。",
    ], [
        C("懐で温めて、粟をやる", True, [
            "しばらくすると指を甘噛みした。元気の良い証拠だ。",
            "翌朝、窓を開けると一直線に飛んでいった。礼はなかった。それでいい。",
            "飛び立った枝で、一度だけ鳴いた。",
        ], f=-1, h=3),
        C("風の当たらない軒下へ移す", True, [
            "軒下の箱に藁を敷いた。午後には空になっていた。",
            "できるのは、このくらいだ。箱の藁に、小さな窪みが残っている。",
        ], h=1),
        C("そっとしておく", False, [
            "雪が小さなふくらみを隠していった。",
            "夕方にはもう、どこにいたのかわからなかった。",
        ], h=-1),
    ]),
    E("musician", "旅の楽士", True, [
        "ケースを抱えた旅人が震えている。「一曲で、一晩。それが相場です」",
        "「村の祭りに呼ばれたのに、一本道を間違えました」楽士は弦の具合を気にしている。",
    ], [
        C("一晩泊めて、演奏を聴く", True, [
            "小屋の天井がこんなに音を返すとは知らなかった。",
            "短調の曲が、なぜか一番あたたかかった。",
            ("「もう一曲はサービス」二曲目は、ひどく陽気だった。", fx(h=1)),
        ], f=-1, w=-1, h=5),
        C("温かい飲み物だけ出す", True, [
            "楽士は指を温めて、「次に通ったら弾きます」と約束した。",
            "ケースを撫でながら飲んでいた。人ではなく楽器を温めているようだった。",
        ], h=2),
        C("音楽は要らないと断る", False, [
            "静けさは守られた。今夜の静けさは、少しばかり硬い。",
            "去っていくケースの角が、戸口にこつんと当たった。",
        ], h=-2),
    ]),
    E("couple", "山越えの二人", True, [
        "手をつないだ若い二人。「……誰にも、ここを通ったと言わないでください」",
        "女が男の肩を支えている。「峠の向こうまで行きたいんです。どうしても」",
    ], [
        C("何も聞かず、食料を分ける", True, [
            "二人は何度も頭を下げて峠へ向かった。事情は雪が隠すだろう。",
            "「春になったら、手紙を書きます」差出人の名は書かないだろう。",
        ], f=-2, h=4),
        C("安全な道だけ教える", True, [
            "雪崩の出ない沢筋を教えた。女が地図に印をつける手が震えていた。",
            "「ありがとう」男の声は、思ったより幼かった。",
        ], h=2),
        C("引き返すよう説得する", False, [
            "二人は黙って聞き、黙って出ていった。正しい方向かどうかは知らない。",
            "「わかってます」それきり、二人はこちらを見なかった。",
        ], h=-1),
    ], calm=True),
    E("monk", "托鉢の僧", True, [
        "笠に雪を積んだ僧が、鈴をひとつ鳴らす。「少しばかり、ご縁を」",
        "「峠のお堂まで参ります」僧の草鞋は、見ているほうが寒い。",
    ], [
        C("食べ物を差し出す", True, [
            "僧は深く礼をした。短い経が、薪の爆ぜる音に混ざる。",
            "「足りていますか」と逆に聞かれ、答えに少し時間がかかった。",
        ], f=-1, h=3),
        C("乾いた足袋を渡す", True, [
            "「これはありがたい」初めて僧の顔がほころんだ。",
            "繕い跡だらけの足袋を、僧は宝物のように履いた。",
        ], h=2),
        C("手を合わせるだけにする", False, [
            "僧も手を合わせ返した。それで十分だと、僧の目は言っていた。",
            "鈴の音が遠ざかるあいだ、そのまま立っていた。",
        ]),
    ], calm=True),
    E("dog", "雪まみれの犬", True, [
        "戸を引っかく音。開けると、首輪をした大きな犬が尾を振っている。",
        "薪の山の上に、見知らぬ犬が堂々と座っている。ここが自分の家だという顔だ。",
    ], [
        C("中へ入れて体を拭いてやる", True, [
            "犬はストーブの前を占領し、大いびきをかいた。",
            "翌日、尾を振りながら麓のほうへ帰っていった。また来るな、あれは。",
            ("くわえてきた枝を、自慢げに足元へ置いた。", fx(w=1)),
        ], f=-1, h=4),
        C("一緒に見回りへ出る", True, [
            "犬は雪の下の道を知っていた。先を歩いては振り返る。",
            ("犬が掘った穴から、埋まっていた木の実が出てきた。", fx(f=2)),
        ], s=-1, h=2),
        C("首輪の家へ帰れと追う", False, [
            "犬は首をかしげ、やがて素直に歩いていった。素直なのがこたえる。",
            "一度だけ振り返って、鼻を鳴らした。",
        ], h=-1),
    ]),
    E("whitewoman", "名乗らない客", True, [
        "戸の外に白い着物の女。風があるのに、裾が揺れていない。「……入っても？」",
        "ノックは三度。女は肩に雪を乗せたまま、溶かさずに立っている。「温かいですか、そこは」",
    ], [
        C("客として招き入れる", True, [
            "女は火から一番遠い席を選んだ。朝、そこにだけ霜が残っていた。",
            "「親切な人は長生きしますよ」と女は言った。嘘か本当かは、顔からはわからない。",
            ("女が帰ったあと、水がめが氷砂糖のように澄んでいた。", fx(s=2)),
        ], s=-2, h=4),
        C("戸越しに話だけする", True, [
            "戸板一枚を隔てて、古い山の歌を教わった。",
            "「それが賢いです」と女は笑った。戸が、一度だけ冷たく鳴った。",
        ], s=-1, h=2),
        C("戸を開けない", False, [
            "やがて気配は消えた。朝、戸口に足跡はなかった。",
            "一晩じゅう、戸の隙間が白かった。",
        ], s=1, h=-1),
    ]),
    E("lineman", "電線の作業員", True, [
        "ヘルメットの作業員が腰を伸ばす。「この先の電柱、見てきました。一服させてください」",
        "「この冬は線がよく切れる」作業員は手袋を外し、指に息を吐いた。",
    ], [
        C("昼食を一緒にとる", True, [
            "「家の味がする」と作業員は言った。どこの家だろう。",
            ("「予備を置いときます」と、乾電池と缶詰を分けてくれた。", fx(f=2)),
        ], f=-1, h=3),
        C("電線の点検を手伝う", True, [
            "梯子を支えた。上から「いい手だ！」と声が降ってきた。",
            "軒の配線を直してもらった。隙間風の通り道も、ついでに塞がった。",
        ], s=-1, h=2),
        C("お疲れさまとだけ言う", False, [
            "「どうも」作業員は軽く手を上げて、次の電柱へ向かった。",
            "工具の鳴る音が尾根のほうへ遠ざかる。",
        ]),
    ], calm=True),
    E("photographer", "星を撮る写真家", True, [
        "三脚を担いだ人が空を指す。「今夜、雲が切れます。屋根に上らせてください」",
        "「星を待つのは、人を待つより楽です」写真家は手をこすり合わせた。",
    ], [
        C("一緒に屋根で夜を明かす", True, [
            "天の川の濃さに、声が出なかった。写真家も黙っていた。",
            "雲が切れたのは一時間だけ。その一時間で元が取れた。",
        ], s=-2, h=5),
        C("温かい汁物を差し入れる", True, [
            "屋根の上から「うまい！」と声がした。星には聞こえないように。",
            ("「現像したら送ります」と、小屋の宛名を控えていった。", fx(h=1)),
        ], f=-1, h=2),
        C("屋根は危ないと断る", False, [
            "写真家は小屋の前に三脚を立てた。夜半まで、赤い灯が動いていた。",
            "たしかに危ない。今夜の空は、たぶんきれいだった。",
        ], s=1),
    ], calm=True),
    # ------ 出来事
    E("letter", "手紙の到着", False, [
        "戸の隙間に封筒が挟まっている。消印は三週間前。「――元気にしていますか」",
        "配達の足跡だけ残して、手紙が一通。見覚えのある字で「寒いでしょう」とある。",
    ], [
        C("すぐに返事を書く", True, [
            "書き出しに一時間、残りに十分かかった。",
            "「元気です」の一行を、三回書き直した。",
        ], w=-1, h=3),
        C("大事にしまい、夜に読み返す", True, [
            "読むたびに、違う行で手が止まる。",
            "封筒ごと本に挟んだ。今夜の栞にする。",
        ], h=2),
        C("封を切らず、炉にくべる", False, [
            "紙はすぐに燃えた。ほんの一瞬、部屋が明るくなった。",
            "読まずに済むこともある。読まずに残るものもある。",
        ], w=1, h=-2),
    ], calm=True),
    E("blackout", "停電", False, [
        "夕方、ふいに灯りが落ちた。冷蔵箱の唸りがやむ。小屋が急に古くなる。",
        "ぱちん、と音がして、窓の外だけが明るい。電線がまたやられたらしい。",
    ], [
        C("ろうそくを灯して過ごす", True, [
            "小さな火で、壁の影が大きくなった。話し相手には十分だ。",
            "ろうそくの匂いは、なぜか子どものころの匂いがする。",
        ], w=-1, h=3),
        C("早めに寝てしまう", False, [
            "暗いなら眠るだけ。単純でよろしい。",
            "目を閉じても開けても同じ暗さだった。",
        ], s=3),
        C("外の配線を見に行く", False, [
            "原因は枝一本だった。指の感覚と引き換えに灯りが戻った。",
            ("ついでに折れ枝を集めてきた。", fx(w=2)),
        ], s=-2, h=1),
    ]),
    E("roofsnow", "屋根の雪おろし", False, [
        "天井がみしりと鳴った。見上げても何も変わらない。それが一番こわい。",
        "軒から雪の塊が崩れ落ちた。屋根の上には、まだ背丈ほど積もっている。",
    ], [
        C("今すぐ屋根に上がる", True, [
            "汗が凍る。おろし終えた屋根が、ふっと軽くなった気がした。",
            "落ちた雪の音が腹に響く。小屋が息をつくのがわかった。",
        ], s=-3, h=2),
        C("様子を見ながら、梁に支えを入れる", True, [
            "支柱を一本立てた。これで今夜は眠れる。",
            "とりあえず、という言葉は冬山でも便利だ。",
        ], w=-2, h=1),
        C("音に慣れることにする", False, [
            "一晩じゅう、天井の音を数えた。十七で眠った。",
            "幸い落ちなかった。幸い、としか言えない。",
        ], h=-2),
    ]),
    E("chimney", "煙突のつまり", False, [
        "ストーブが不機嫌に煙を吐く。目が痛い。煙突に何か詰まっている。",
        "火の勢いがみるみる落ちた。煙が部屋へ逆流してくる。",
    ], [
        C("煤を払って掃除する", True, [
            "真っ黒になった。バケツ一杯の煤。火が歌い出す。",
            ("煤の中から古い硬貨が出てきた。使いみちはないが嬉しい。", fx(h=1)),
        ], s=-2, h=2),
        C("窓を開けて騒ぎをやり過ごす", False, [
            "煙は抜けたが、熱も抜けた。歯の根が合わない。",
            "窓を閉めるころには、なんとか火も落ち着いていた。",
        ], w=-2, h=-1),
    ]),
    E("mouse", "食料庫のねずみ", False, [
        "食料庫の袋に小さな穴。床に点々と麦粒が続いている。",
        "夜中に聞こえたカリカリ音の正体を、朝の食料庫で知る。",
    ], [
        C("穴を塞ぎ、棚を吊るす", True, [
            "棚を天井から吊った。翌日、ねずみが棚を見上げていた（気がした）。",
            "板切れで穴を塞いだ。根競べの始まりだ。",
        ], s=-1, h=1),
        C("少しだけ、分け前を置いてやる", True, [
            "麦を一握り、隅に置いた。冬はお互いさまだ。",
            "以来、夜の物音が同居人の気配に変わった。",
        ], f=-1, h=3),
        C("被害をそのままにする", False, [
            "放っておいたら、二袋目に穴があいた。",
            "ねずみは今日も忙しそうだ。こちらの食料で。",
        ], f=-2),
    ]),
    E("olddiary", "床下の古い日記", False, [
        "床板を直していると、油紙の包みが出てきた。ずいぶん昔の、誰かの冬の日記だ。",
        "梁の裏に手帳が差してある。「◯月◯日 雪 今日も誰も来ない」",
    ], [
        C("火のそばで最後まで読む", True, [
            "最後の頁は「春が来た」で終わっていた。三行分、余白があった。",
            "知らない誰かの退屈が、今夜はたいへん親しい。",
        ], s=1, h=3),
        C("続きに一行書き足して戻す", True, [
            "知らない字の下に、自分の字が並んだ。",
            "何を書いたかは、次の誰かが読めばいい。",
        ], h=2),
        C("焚きつけにする", False, [
            "よく燃えた。昔の紙はよく乾いている。",
            "一枚だけ、火に入れる前に読んでしまった。",
        ], w=2, h=-2),
    ]),
    E("aurora", "夜空の光", False, [
        "戸の隙間が妙に明るい。外に出ると、山の端が淡い緑に揺れている。",
        "満月が雪面を青く照らしている。電灯より明るい夜だ。",
    ], [
        C("外套をはおって、ずっと見る", True, [
            "寒さを忘れた。忘れただけで、体はちゃんと冷えていた。",
            "何も考えない時間というものが、まだ自分に残っていたらしい。",
        ], s=-2, h=4),
        C("窓辺に椅子を運んで眺める", True, [
            "ガラス越しでも十分だ。お茶が冷めるまで見ていた。",
            "光に名前をつけようとして、やめた。",
        ], h=2),
        C("見なかったことにして眠る", False, [
            "カーテンの隙間が、夜じゅう淡かった。",
            "よく眠れた。もったいないことをした気もする。",
        ], s=2),
    ], calm=True),
    E("waterjar", "水がめの凍結", False, [
        "朝、水がめを叩くと鈍い音がした。底まで凍っている。",
        "柄杓が氷に刺さって動かない。湯を沸かすにも、まず水がない。",
    ], [
        C("雪を溶かして水を作る", True, [
            "鍋ひとつの雪が、湯のみ一杯にしかならない。雪の軽さを思い知る。",
            "溶けていく音を聞いていた。冬の算数は遅い。",
        ], w=-2, h=1),
        C("沢まで水を汲みに行く", True, [
            "氷を割ると、下には冬でも水が走っていた。",
            ("帰り道、凍った魚が一匹、氷に閉じ込められていた。", fx(f=1)),
        ], s=-3, h=1),
        C("今日は水なしで我慢する", False, [
            "喉の渇きは、地味に心を削る。",
            "雪をひとかけ口に入れた。余計に喉が渇いた。",
        ], s=-1, h=-1),
    ]),
]

EVENT_BY_ID = {e["id"]: e for e in EVENTS}


# ---------------------------------------------------------------- 称号と結び

TITLES = {
    "fire": ("吹雪に灯をともす人", "苦しい夜が続いても、この小屋の火と心は消えなかった。"),
    "spring": ("春を待ちわびた人", "数えるように夜を越えた。雪解けの音を、誰より深く聞くだろう。"),
    "tree": ("冬山の大樹", "蓄え、温め、迎え入れた。山の誰もが、この小屋を目印にする。"),
    "feast": ("宴の山小屋", "戸を叩く者はみな笑って帰った。小さな小屋の、大きな食卓。"),
    "inn": ("峠の宿の主", "備えがあるから、迎えられる。旅人のあいだで名が語られる。"),
    "guardian": ("静かな守り人", "多くを語らず、薪を積み、火を守った。冬はこの人のそばで穏やかだ。"),
    "philosopher": ("雪見の哲学者", "蓄えは乏しくとも、窓の外に飽きることがなかった。"),
    "beacon": ("雪道の灯台", "自分のことは後回し。迷う者の見つける明かりであり続けた。"),
    "keeper": ("薪棚の番人", "薪棚は壁のごとく。少し寂しいが、春まで何の心配もいらない。"),
    "buried": ("雪に埋もれた日記帳", "何もかも足りなかった冬。それでも日記は、最後の日まで続いた。"),
    "apprentice": ("見習いの山守", "良いことも悪いこともほどほどに。来年の冬は、もう少しうまくやれる。"),
}

ENDINGS = {
    "easy": ["十四度目の朝、軒のつららから雫が落ちた。交代の足音は、もうすぐ麓から上がってくる。",
             "最後の夜、火は静かだった。この冬はよい冬だったと、小屋が言っている気がした。"],
    "some": ["楽な冬ではなかった。それでも十四日目の火は、ちゃんと赤い。",
             "いくつかの夜は長かった。その分だけ、今朝の光をよく見ている。"],
    "hard": ["凍える夜を幾度も越えて、交代の日が来た。手のひらの豆だけが、すべてを覚えている。",
             "よく生き延びた、と誰かに言われたい。代わりに、狐が遠くからこちらを見ていた。"],
}

AUTO_NOTES = [
    "雪の音は、聞こえないという音だ。",
    "今日の薪は少し湿っていた。",
    "誰かの足跡を見ると、なぜか安心する。",
    "火の番をしていると、時間が丸くなる。",
    "特記事項なし。良い一日だったということだ。",
    "明日の自分に、乾いた靴下を残しておく。",
    "雪は上から降るのに、積もるのは足元だ。",
    "今夜の風は、やや饒舌。",
]


# ---------------------------------------------------------------- ユーティリティ


def configure_output():
    """Windows でも日本語が崩れないよう、標準出力を UTF-8 にする。"""
    for stream in (sys.stdout, sys.stderr):
        if hasattr(stream, "reconfigure"):
            stream.reconfigure(encoding="utf-8", errors="replace")


def rng(state, label):
    """シードと場面名から決まる乱数。play を繰り返しても展開が変わらない。"""
    return random.Random(f"{state['seed']}:{label}")


def clean_text(text, limit):
    """改行を除いて一行にし、長さを制限する。"""
    return " ".join(str(text).split())[:limit]


def clip(text, limit):
    return text if len(text) <= limit else text[:limit - 1] + "…"


def fmt_effects(effects):
    parts = [f"{RES_JP[k]}{effects[k]:+d}" for k in RES_KEYS if effects.get(k)]
    return " ".join(parts)


def res_line(res):
    return " ／ ".join(f"{RES_JP[k]} {res[k]}" for k in RES_KEYS)


def apply_effects(state, effects):
    """増減を適用し、実際に変化した量を返す（0〜上限で止まる）。"""
    actual = {}
    for key, delta in effects.items():
        before = state["res"][key]
        after = max(0, min(RES_MAX, before + delta))
        state["res"][key] = after
        if after != before:
            actual[key] = after - before
    return actual


def merge_effects(*effect_dicts):
    merged = {}
    for effects in effect_dicts:
        for key, delta in effects.items():
            merged[key] = merged.get(key, 0) + delta
    return merged


def lacking(state, effects):
    """薪・食料が足りないとき、足りない資源名を返す。"""
    for key in ("wood", "food"):
        if effects.get(key, 0) < 0 and state["res"][key] < -effects[key]:
            return RES_JP[key]
    return None


def append_journal(text):
    with open(JOURNAL_PATH, "a", encoding="utf-8", newline="\n") as handle:
        handle.write(text + "\n")


def save_state(state):
    tmp_path = SAVE_PATH.with_name(SAVE_PATH.name + ".tmp")
    with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
        json.dump(state, handle, ensure_ascii=False, indent=1)
    os.replace(tmp_path, SAVE_PATH)


def load_state(required=True):
    if not SAVE_PATH.exists():
        if required:
            raise GameError("まだゲームが始まっていません。`python game.py start <名前>` で始めてください。")
        return None
    try:
        with open(SAVE_PATH, encoding="utf-8") as handle:
            return json.load(handle)
    except (OSError, ValueError) as error:
        raise GameError(f"save.json を読み込めませんでした（{error}）。"
                        "`python game.py start <名前> --force` で始め直せます。")


def write_share(state):
    with open(SHARE_PATH, "w", encoding="utf-8", newline="\n") as handle:
        handle.write(build_share(state) + "\n")


def pop_flag(args, flag):
    return flag in args, [a for a in args if a != flag]


def pop_seed(args):
    """引数から --seed を取り出す。"""
    rest, seed_text, i = [], None, 0
    while i < len(args):
        arg = args[i]
        if arg == "--seed":
            if i + 1 >= len(args):
                raise GameError("--seed の後に数値を指定してください。")
            seed_text = args[i + 1]
            i += 2
            continue
        if arg.startswith("--seed="):
            seed_text = arg.split("=", 1)[1]
        else:
            rest.append(arg)
        i += 1
    if seed_text is None:
        return None, rest
    try:
        return int(seed_text), rest
    except ValueError:
        raise GameError(f"シードは整数で指定してください（受け取った値: {seed_text}）。")


# ---------------------------------------------------------------- 進行


def new_state(name, seed):
    if seed is None:
        seed = random.SystemRandom().randrange(1, 1000000000)
    state = {
        "version": 1,
        "name": name,
        "seed": seed,
        "day": 0,
        "phase": "action",
        "res": dict(START_RES),
        "weather": "clear",
        "weather_text": "",
        "actions": [],
        "repaired": False,
        "event_id": None,
        "used_events": [],
        "encounters": [],
        "notes": {},
        "history": [],
        "day_log": [],
        "good_count": 0,
        "visitor_count": 0,
        "hard_days": 0,
        "result": None,
    }
    started = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
    append_journal(f"\n{JOURNAL_HEADING} 管理人：{name}（シード {seed} ／ {started} 開始）")
    start_day(state)
    return state


def choose_weather(state):
    r = rng(state, f"weather:{state['day']}")
    weights = [WEATHER[k]["weight"] for k in WEATHER_KEYS]
    key = r.choices(WEATHER_KEYS, weights=weights)[0]
    recent = [d["weather"] for d in state["day_log"][-2:]]
    if key == "blizzard" and recent == ["blizzard", "blizzard"]:
        key = "snow"  # 吹雪は3日続けない
    return key, r.choice(WEATHER[key]["texts"])


def start_day(state):
    state["day"] += 1
    state["phase"] = "action"
    state["actions"] = []
    state["repaired"] = False
    state["event_id"] = None
    state["weather"], state["weather_text"] = choose_weather(state)
    append_journal(f"\n## {state['day']}日目（{WEATHER[state['weather']]['name']}）")


def choose_event(state):
    used = set(state["used_events"])
    unused = [e for e in EVENTS if e["id"] not in used]
    candidates = unused
    if state["weather"] == "blizzard":
        candidates = [e for e in unused if not e["calm"]] or unused
    weights = [2 if e["visitor"] else 1 for e in candidates]
    r = rng(state, f"event:{state['day']}")
    return r.choices(candidates, weights=weights)[0]


def action_effects(state, action):
    """天気と体力を反映した、実際の増減を計算する。"""
    effects = dict(action["effects"])
    if action["outdoor"]:
        weather = WEATHER[state["weather"]]
        for key in ("wood", "food"):
            if effects.get(key, 0) > 0:
                effects[key] = max(1, effects[key] + weather["outdoor"])
                if state["res"]["stamina"] <= LOW_STAMINA:
                    effects[key] = max(1, effects[key] // 2)
        if weather["toil"]:
            effects["stamina"] = effects.get("stamina", 0) - weather["toil"]
    return effects


def action_options(state):
    options = []
    for action in ACTIONS:
        effects = action_effects(state, action)
        short = lacking(state, effects)
        detail = fmt_effects(effects)
        if action.get("hint"):
            detail += f" {action['hint']}"
        label = f"{action['label']}（{detail}）"
        options.append({"label": label, "ok": short is None, "reason": short})
    return options


def event_options(state):
    event = EVENT_BY_ID[state["event_id"]]
    options = []
    for choice in event["choices"]:
        short = lacking(state, choice["effects"])
        options.append({"label": choice["label"], "ok": short is None, "reason": short})
    if not any(o["ok"] for o in options):  # 全部選べない日は、足りなくても選べるようにする
        for option in options:
            option["ok"] = True
            option["reason"] = None
    return options


def night_options(state):
    label = "最後の夜を閉じる（結果へ）" if state["day"] >= TOTAL_DAYS else "眠りにつく（次の日へ）"
    return [{"label": label, "ok": True, "reason": None}]


def current_options(state):
    if state["phase"] == "action":
        return action_options(state)
    if state["phase"] == "event":
        return event_options(state)
    if state["phase"] == "night":
        return night_options(state)
    return []


def night_burn(state):
    burn = WEATHER[state["weather"]]["burn"]
    return max(0, burn - 1) if state["repaired"] else burn


def format_options(options):
    lines = []
    for number, option in enumerate(options, 1):
        if option["ok"]:
            lines.append(f"  {number}. {option['label']}")
        else:
            lines.append(f"  {number}. × {option['label']} ※{option['reason']}が足りない")
    return lines


def render_scene(state):
    phase = state["phase"]
    if phase == "ended":
        return render_ending(state)
    weather_name = WEATHER[state["weather"]]["name"]
    if phase == "night":
        head = f"━━ {state['day']}日目の夜 ／ {TOTAL_DAYS}日 ・ {weather_name} ━━"
    else:
        head = f"━━ {state['day']}日目 ／ {TOTAL_DAYS}日 ・ {weather_name} ━━"
    lines = [head, res_line(state["res"])]
    if phase == "action":
        lines.append(state["weather_text"])
        lines.append(f"【行動 {len(state['actions']) + 1}/{ACTIONS_PER_DAY}】何をしますか。")
        if state["actions"]:
            lines.append("今日やったこと：" + "、".join(state["actions"]))
    elif phase == "event":
        event = EVENT_BY_ID[state["event_id"]]
        scene = rng(state, f"scene:{state['day']}").choice(event["scenes"])
        lines.append(f"【{event['title']}】")
        lines.append(scene)
    else:
        burn = night_burn(state)
        lines.append("ストーブの火を落とす前に、今日のことを書き留める時間だ。")
        lines.append(f"今夜の見込み：薪-{burn} 食料-{NIGHT_FOOD}")
        warnings = []
        if state["res"]["wood"] == 0 and burn > 0:
            if state["weather"] == "blizzard":
                warnings.append("薪がない吹雪の夜だ。体力が大きく削られる。")
            else:
                warnings.append("薪がない。寒い夜になる。")
        if state["res"]["food"] == 0:
            warnings.append("食べるものがない。")
        lines.extend(warnings)
        note = state["notes"].get(str(state["day"]))
        lines.append(f"今日のひとこと：{note if note else '（未記入）'}")
        lines.append("→ 書くなら `python game.py note <文章>`（書かなくても進めます）")
    lines.extend(format_options(current_options(state)))
    lines.append("→ `python game.py do <番号>`")
    return "\n".join(lines)


def do_action(state, index):
    action = ACTIONS[index]
    effects = action_effects(state, action)
    r = rng(state, f"action:{state['day']}:{len(state['actions'])}")
    text = r.choice(action["texts"])
    if action["id"] == "patrol":
        find_text, find_effects = r.choice(PATROL_FINDS)
        text = f"{text}{find_text}"
        effects = merge_effects(effects, find_effects)
    if action["id"] == "repair":
        state["repaired"] = True
    actual = apply_effects(state, effects)
    state["actions"].append(action["label"])
    change = fmt_effects(actual) or "変化なし"
    append_journal(f"- {action['label']}：{text}（{change}）")
    if len(state["actions"]) >= ACTIONS_PER_DAY:
        event = choose_event(state)
        state["event_id"] = event["id"]
        state["used_events"].append(event["id"])
        state["phase"] = "event"
    return [f"＞ {action['label']}：{text}（{change}）"]


def do_event(state, index):
    event = EVENT_BY_ID[state["event_id"]]
    choice = event["choices"][index]
    picked = rng(state, f"result:{state['day']}:{index}").choice(choice["results"])
    text, bonus = picked if isinstance(picked, tuple) else (picked, {})
    actual = apply_effects(state, merge_effects(choice["effects"], bonus))
    if event["visitor"]:
        state["visitor_count"] += 1
        if choice["good"]:
            state["good_count"] += 1
    state["encounters"].append({
        "day": state["day"], "title": event["title"], "choice": choice["label"],
        "good": choice["good"], "visitor": event["visitor"],
    })
    state["phase"] = "night"
    change = fmt_effects(actual) or "変化なし"
    append_journal(f"- 【{event['title']}】{choice['label']}：{text}（{change}）")
    return [f"＞ {choice['label']}", text, f"（{change}）"]


def do_night(state):
    """夜の消費を処理して、次の日へ進める。"""
    res = state["res"]
    weather_key = state["weather"]
    burn = night_burn(state)
    lines = []
    cold = res["wood"] == 0 and burn > 0
    short_wood = 0 < res["wood"] < burn
    hungry = res["food"] == 0
    lean = res["food"] == 1
    effects = {"wood": -burn, "food": -NIGHT_FOOD, "warmth": -1}
    if cold and weather_key == "blizzard":
        effects = merge_effects(effects, fx(s=-5, h=-2))
        lines.append("薪のない吹雪の夜。毛布を全部かぶっても、歯の根が合わない。")
    elif cold:
        effects = merge_effects(effects, fx(s=-2, h=-2))
        lines.append("火のない夜は長い。息が白いまま朝を待った。")
    elif short_wood:
        effects = merge_effects(effects, fx(h=-1))
        lines.append("明け方、薪が尽きて火が細くなった。")
    if hungry:
        effects = merge_effects(effects, fx(s=-2, h=-1))
        lines.append("空腹で何度も目が覚めた。")
    elif lean:
        lines.append("わずかな夕食を、ゆっくり噛んだ。")
    if not cold and not hungry:
        effects = merge_effects(effects, fx(s=3 if not lean else 1))
        if not lines:
            lines.append("火の音を聞きながら、よく眠った。")
    apply_effects(state, effects)
    hard = cold or hungry or res["stamina"] == 0
    if hard:
        state["hard_days"] += 1
    state["history"].append(sum(res[k] for k in RES_KEYS) / len(RES_KEYS))
    day = state["day"]
    note = state["notes"].get(str(day))
    if note:
        append_journal(f"- ひとこと：{note}")
    append_journal(f"- 夜の終わり：{res_line(res)}" + ("（苦しい夜）" if hard else ""))
    event = EVENT_BY_ID[state["event_id"]]
    state["day_log"].append({
        "day": day, "weather": weather_key, "actions": list(state["actions"]),
        "event": event["title"], "res": dict(res), "hard": hard,
    })
    output = ["＞ " + "".join(lines), res_line(res)]
    if day >= TOTAL_DAYS:
        finish_game(state)
    else:
        start_day(state)
    return output


def step(state, number):
    """番号の選択を1つ処理し、結果の文章（行のリスト）を返す。"""
    options = current_options(state)
    if not options:
        raise GameError("このゲームは終わっています。`python game.py share` で結果を見られます。")
    if not 1 <= number <= len(options):
        raise GameError(f"番号は 1〜{len(options)} で選んでください。\n" + render_scene(state))
    option = options[number - 1]
    if not option["ok"]:
        raise GameError(f"{number} 番は{option['reason']}が足りないので選べません。\n" + render_scene(state))
    if state["phase"] == "action":
        return do_action(state, number - 1)
    if state["phase"] == "event":
        return do_event(state, number - 1)
    return do_night(state)


# ---------------------------------------------------------------- 評価と共有


def evaluate(state):
    history = state["history"]
    if history:
        prep = sum(history) / len(history)
    else:
        prep = sum(state["res"][k] for k in RES_KEYS) / len(RES_KEYS)
    return {"prep": round(prep, 1), "warmth": state["res"]["warmth"],
            "meet": state["good_count"], "hard": state["hard_days"]}


def choose_title(scores):
    prep_high = scores["prep"] >= PREP_HIGH
    warm_high = scores["warmth"] >= WARM_HIGH
    meet_high = scores["meet"] >= MEET_HIGH
    all_low = (scores["prep"] < PREP_MID and scores["warmth"] < WARM_MID
               and scores["meet"] < MEET_MID)
    if scores["hard"] >= HARD_MANY:
        return "fire" if warm_high else "spring"
    if prep_high and warm_high and meet_high:
        return "tree"
    if warm_high and meet_high:
        return "feast"
    if prep_high and meet_high:
        return "inn"
    if prep_high and warm_high:
        return "guardian"
    if warm_high:
        return "philosopher"
    if meet_high:
        return "beacon"
    if prep_high:
        return "keeper"
    if all_low:
        return "buried"
    return "apprentice"


def stars(value, thresholds):
    count = 1 + sum(1 for t in thresholds if value >= t)
    return "★" * count + "☆" * (5 - count)


def finish_game(state):
    scores = evaluate(state)
    key = choose_title(scores)
    if scores["hard"] <= 1:
        ending_key = "easy"
    elif scores["hard"] < HARD_MANY:
        ending_key = "some"
    else:
        ending_key = "hard"
    state["phase"] = "ended"
    state["result"] = {
        "scores": scores, "title_key": key,
        "ending": rng(state, "ending").choice(ENDINGS[ending_key]),
    }
    name, line = TITLES[key]
    append_journal(f"\n## 結果\n- 称号：「{name}」 {line}\n- {state['result']['ending']}")
    write_share(state)


def spread(items, count):
    """先頭と末尾を含むように、均等に間引く。"""
    if len(items) <= count:
        return list(items)
    last = len(items) - 1
    return [items[round(i * last / (count - 1))] for i in range(count)]


def build_share(state):
    scores = state["result"]["scores"] if state["result"] else evaluate(state)
    ended = state["phase"] == "ended"
    if ended:
        progress = f"{TOTAL_DAYS}日間"
    else:
        progress = f"{state['day']}日目まで・進行中"
    lines = [f"【{TITLE}】管理人：{state['name']}（{progress}・シード {state['seed']}）"]
    if ended:
        name, line = TITLES[state["result"]["title_key"]]
        lines.append(f"称号：「{name}」")
        lines.append(line)
    lines.append(f"備え {stars(scores['prep'], [5, 8, 11, 14])} {scores['prep']}"
                 f" ／ 温かさ {stars(scores['warmth'], [5, 9, 13, 17])} {scores['warmth']}"
                 f" ／ 出会い {stars(scores['meet'], [1, 3, 5, 7])} {scores['meet']}回")
    lines.append(f"資源：{res_line(state['res'])} ／ 苦しい夜 {scores['hard']}回")
    tally = {}
    for entry in state["day_log"]:
        tally[entry["weather"]] = tally.get(entry["weather"], 0) + 1
    if tally:
        lines.append("天気：" + " ".join(f"{WEATHER[k]['name']}{tally[k]}" for k in WEATHER_KEYS if k in tally))

    visitors = [e for e in state["encounters"] if e["visitor"]]
    kind_ones = [e for e in visitors if e["good"]]
    chosen = spread(kind_ones or state["encounters"], SHARE_ENCOUNTERS)
    if chosen:
        lines.extend(["", "＜出会いの記録＞"])
        lines.extend(f"・{e['day']}日目 {e['title']} → {e['choice']}" for e in chosen)

    notes = sorted(state["notes"].items(), key=lambda pair: int(pair[0]))
    if notes:
        lines.extend(["", "＜今日のひとこと＞"])
        lines.extend(f"{day}日目「{clip(text, SHARE_NOTE_LIMIT)}」" for day, text in spread(notes, SHARE_NOTES))
    if ended:
        lines.extend(["", state["result"]["ending"]])
    lines.append(f"#{TITLE}")
    return "\n".join(lines)


def render_ending(state):
    result = state["result"]
    name, line = TITLES[result["title_key"]]
    scores = result["scores"]
    return "\n".join([
        f"━━ {TOTAL_DAYS}日間が終わりました ━━",
        result["ending"],
        f"称号：「{name}」 {line}",
        f"備え {scores['prep']} ／ 温かさ {scores['warmth']} ／ 出会い {scores['meet']}回 ／ 苦しい夜 {scores['hard']}回",
        "share.txt に共有テキストを書き出しました。`python game.py share` で表示できます。",
    ])


# ---------------------------------------------------------------- コマンド


def cmd_start(args):
    force, args = pop_flag(args, "--force")
    seed, args = pop_seed(args)
    name = clean_text(" ".join(args), NAME_LIMIT)
    if not name:
        raise GameError("名前を指定してください。例：`python game.py start ユキ --seed 42`")
    try:
        old = load_state(required=False)
    except GameError:
        old = None
    if old and old.get("phase") != "ended" and not force:
        raise GameError(f"{old.get('name')} さんのゲームが {old.get('day')} 日目で進行中です。"
                        "`play` で続けるか、最初からなら `start <名前> --force` を使ってください。")
    state = new_state(name, seed)
    save_state(state)
    print(f"{name} さんが山小屋の管理人になりました。（シード {state['seed']}）")
    print(f"交代が来るまでの{TOTAL_DAYS}日間、この小屋を預かります。\n")
    print(render_scene(state))


def cmd_play(args):
    print(render_scene(load_state()))


def cmd_do(args):
    if len(args) != 1:
        raise GameError("番号を1つ指定してください。例：`python game.py do 1`")
    try:
        number = int(args[0])
    except ValueError:
        raise GameError(f"番号は数字で指定してください（受け取った値: {args[0]}）。")
    state = load_state()
    output = step(state, number)
    save_state(state)
    print("\n".join(output))
    print()
    print(render_scene(state))


def cmd_note(args):
    text = clean_text(" ".join(args), NOTE_LIMIT)
    if not text:
        raise GameError("文章を指定してください。例：`python game.py note 今日の雪は饒舌だった`")
    state = load_state()
    if state["phase"] == "ended":
        raise GameError("このゲームはもう終わっています。")
    day = str(state["day"])
    rewritten = day in state["notes"]
    state["notes"][day] = text
    save_state(state)
    print(f"{day}日目のひとことを{'書き直しました' if rewritten else '記録しました'}：{text}")
    if state["phase"] == "night":
        print("→ `python game.py do 1` で夜を閉じます。")
    else:
        print("→ `python game.py play` で続きへ。")


def cmd_status(args):
    state = load_state()
    if state["phase"] == "ended":
        print(render_ending(state))
        return
    phase_names = {"action": f"行動 {len(state['actions'])}/{ACTIONS_PER_DAY}", "event": "出来事", "night": "夜"}
    print(f"管理人：{state['name']} ／ {state['day']}日目（全{TOTAL_DAYS}日）・{WEATHER[state['weather']]['name']} ／ {phase_names[state['phase']]}")
    print(res_line(state["res"]))
    print(f"出会い {state['good_count']}回 ／ 苦しい夜 {state['hard_days']}回")


def cmd_journal(args):
    if not JOURNAL_PATH.exists():
        raise GameError("まだ記録がありません。")
    with open(JOURNAL_PATH, encoding="utf-8") as handle:
        lines = handle.read().splitlines()
    starts = [i for i, line in enumerate(lines) if line.startswith(JOURNAL_HEADING)]
    if not starts:
        print("\n".join(lines).strip())
        return
    print("\n".join(lines[starts[-1]:]).strip())
    if len(starts) > 1:
        print(f"\n（過去 {len(starts) - 1} 回分の記録は journal.md に残っています）")


def cmd_share(args):
    state = load_state()
    print(build_share(state))
    if state["phase"] == "ended":
        write_share(state)
    else:
        print("\n（途中経過です。share.txt は最終日の夜を閉じたときに作られます）")


def cmd_auto(args):
    seed, args = pop_seed(args)
    name = clean_text(" ".join(args), NAME_LIMIT) or "オート管理人"
    state = load_state(required=False)
    if state is None or state["phase"] == "ended":
        state = new_state(name, seed)
        print(f"{name} さんで新しいゲームを始めます。（シード {state['seed']}）")
    else:
        print(f"{state['name']} さんの {state['day']} 日目から自動で進めます。")
    chooser = rng(state, f"auto:{state['day']}:{len(state['actions'])}:{state['phase']}")
    while state["phase"] != "ended":
        if state["phase"] == "night" and str(state["day"]) not in state["notes"] and chooser.random() < 0.7:
            state["notes"][str(state["day"])] = chooser.choice(AUTO_NOTES)
        choices = [i for i, o in enumerate(current_options(state), 1) if o["ok"]]
        step(state, chooser.choice(choices))
        save_state(state)
    for entry in state["day_log"]:
        print(f"{entry['day']:>2}日目 {WEATHER[entry['weather']]['name']} ｜ "
              f"{'、'.join(entry['actions'])} ｜ {entry['event']} ｜ {res_line(entry['res'])}"
              + (" ｜ 苦" if entry["hard"] else ""))
    print()
    print(build_share(state))
    print("\nshare.txt を生成しました。")


COMMANDS = {
    "start": cmd_start, "play": cmd_play, "do": cmd_do, "note": cmd_note,
    "status": cmd_status, "journal": cmd_journal, "share": cmd_share, "auto": cmd_auto,
}

USAGE = f"""『{TITLE}』 コマンド一覧
  python game.py start <名前> [--seed 数値] [--force]  新しいゲームを始める
  python game.py play                                 今の場面を表示する
  python game.py do <番号>                            行動や選択肢を選ぶ
  python game.py note <文章>                          今日のひとことを記録する
  python game.py status                               資源と経過日数を表示する
  python game.py journal                              記録を表示する
  python game.py share                                共有テキストを表示する
  python game.py auto [名前] [--seed 数値]            最終日まで自動で進める（テスト用）"""


def main(argv):
    configure_output()
    if not argv or argv[0] not in COMMANDS:
        print(USAGE)
        return 0 if not argv else 1
    try:
        COMMANDS[argv[0]](argv[1:])
    except GameError as error:
        print(error)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
