Merge branch 'dev'

This commit is contained in:
XcantloadX 2025-07-09 12:57:48 +08:00
commit 8216310173
38 changed files with 3197 additions and 1211 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ kotonebot-ui/.vite
dumps*/
config.json
config.v*.json
conf/
reports/
tmp/
res/sprites_compiled/

View File

@ -285,11 +285,17 @@ class ContextOcr:
self.context = context
self.__engine = jp()
def raw(self, lang: OcrLanguage = 'jp') -> Ocr:
def _get_engine(self, lang: OcrLanguage | None = None) -> Ocr:
"""获取指定语言的OCR引擎如果lang为None则使用默认引擎。"""
return self.__engine if lang is None else self.raw(lang)
def raw(self, lang: OcrLanguage | None = None) -> Ocr:
"""
返回 `kotonebot.backend.ocr` 中的 Ocr 对象\n
Ocr 对象与此对象ContextOcr的区别是此对象会自动截图 Ocr 对象需要手动传入图像参数
"""
if lang is None:
lang = 'jp'
match lang:
case 'jp':
return jp()
@ -301,9 +307,11 @@ class ContextOcr:
def ocr(
self,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> OcrResultList:
"""OCR 当前设备画面或指定图像。"""
return self.__engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
engine = self._get_engine(lang)
return engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
def find(
self,
@ -311,9 +319,11 @@ class ContextOcr:
*,
hint: HintBox | None = None,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult | None:
"""检查当前设备画面是否包含指定文本。"""
ret = self.__engine.find(
engine = self._get_engine(lang)
ret = engine.find(
ContextStackVars.ensure_current().screenshot,
pattern,
hint=hint,
@ -328,9 +338,10 @@ class ContextOcr:
*,
hint: HintBox | None = None,
rect: Rect | None = None,
lang: OcrLanguage | None = None,
) -> list[OcrResult | None]:
return self.__engine.find_all(
engine = self._get_engine(lang)
return engine.find_all(
ContextStackVars.ensure_current().screenshot,
list(patterns),
hint=hint,
@ -343,6 +354,7 @@ class ContextOcr:
*,
rect: Rect | None = None,
hint: HintBox | None = None,
lang: OcrLanguage | None = None,
) -> OcrResult:
"""
@ -350,7 +362,8 @@ class ContextOcr:
`find()` 的区别在于`expect()` 未找到时会抛出异常
"""
ret = self.__engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
engine = self._get_engine(lang)
ret = engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
self.context.device.last_find = ret.original_rect if ret else None
return ret

View File

@ -11,986 +11,12 @@ from pydantic import BaseModel, ConfigDict
# TODO: from kotonebot import config (context) 会和 kotonebot.config 冲突
from kotonebot import logging
from kotonebot.backend.context import config
from kotonebot.kaa.config.schema import BaseConfig
logger = logging.getLogger(__name__)
T = TypeVar('T')
class ConfigEnum(Enum):
def display(self) -> str:
return self.value[1]
class Priority(IntEnum):
"""
任务优先级数字越大优先级越高越先执行
"""
START_GAME = 1
DEFAULT = 0
CLAIM_MISSION_REWARD = -1
END_GAME = -2
class APShopItems(IntEnum):
PRODUCE_PT_UP = 0
"""获取支援强化 Pt 提升"""
PRODUCE_NOTE_UP = 1
"""获取笔记数提升"""
RECHALLENGE = 2
"""再挑战券"""
REGENERATE_MEMORY = 3
"""回忆再生成券"""
class DailyMoneyShopItems(IntEnum):
"""日常商店物品"""
Recommendations = -1
"""所有推荐商品"""
LessonNote = 0
"""レッスンノート"""
VeteranNote = 1
"""ベテランノート"""
SupportEnhancementPt = 2
"""サポート強化Pt 支援强化Pt"""
SenseNoteVocal = 3
"""センスノート(ボーカル)感性笔记(声乐)"""
SenseNoteDance = 4
"""センスノート(ダンス)感性笔记(舞蹈)"""
SenseNoteVisual = 5
"""センスノート(ビジュアル)感性笔记(形象)"""
LogicNoteVocal = 6
"""ロジックノート(ボーカル)理性笔记(声乐)"""
LogicNoteDance = 7
"""ロジックノート(ダンス)理性笔记(舞蹈)"""
LogicNoteVisual = 8
"""ロジックノート(ビジュアル)理性笔记(形象)"""
AnomalyNoteVocal = 9
"""アノマリーノート(ボーカル)非凡笔记(声乐)"""
AnomalyNoteDance = 10
"""アノマリーノート(ダンス)非凡笔记(舞蹈)"""
AnomalyNoteVisual = 11
"""アノマリーノート(ビジュアル)非凡笔记(形象)"""
RechallengeTicket = 12
"""再挑戦チケット 重新挑战券"""
RecordKey = 13
"""記録の鍵 解锁交流的物品"""
# 碎片
IdolPiece_倉本千奈_WonderScale = 14
"""倉本千奈 WonderScale 碎片"""
IdolPiece_篠泽广_光景 = 15
"""篠泽广 光景 碎片"""
IdolPiece_紫云清夏_TameLieOneStep = 16
"""紫云清夏 Tame-Lie-One-Step 碎片"""
IdolPiece_葛城リーリヤ_白線 = 17
"""葛城リーリヤ 白線 碎片"""
IdolPiece_姬崎莉波_clumsy_trick = 18
"""姫崎薪波 cIclumsy trick 碎片"""
IdolPiece_花海咲季_FightingMyWay = 19
"""花海咲季 FightingMyWay 碎片"""
IdolPiece_藤田ことね_世界一可愛い私 = 20
"""藤田ことね 世界一可愛い私 碎片"""
IdolPiece_花海佑芽_TheRollingRiceball = 21
"""花海佑芽 The Rolling Riceball 碎片"""
IdolPiece_月村手毬_LunaSayMaybe = 22
"""月村手毬 Luna say maybe 碎片"""
IdolPiece_有村麻央_Fluorite = 23
"""有村麻央 Fluorite 碎片"""
@classmethod
def to_ui_text(cls, item: "DailyMoneyShopItems") -> str:
"""获取枚举值对应的UI显示文本"""
match item:
case cls.Recommendations:
return "所有推荐商品"
case cls.LessonNote:
return "课程笔记"
case cls.VeteranNote:
return "老手笔记"
case cls.SupportEnhancementPt:
return "支援强化点数"
case cls.SenseNoteVocal:
return "感性笔记(声乐)"
case cls.SenseNoteDance:
return "感性笔记(舞蹈)"
case cls.SenseNoteVisual:
return "感性笔记(形象)"
case cls.LogicNoteVocal:
return "理性笔记(声乐)"
case cls.LogicNoteDance:
return "理性笔记(舞蹈)"
case cls.LogicNoteVisual:
return "理性笔记(形象)"
case cls.AnomalyNoteVocal:
return "非凡笔记(声乐)"
case cls.AnomalyNoteDance:
return "非凡笔记(舞蹈)"
case cls.AnomalyNoteVisual:
return "非凡笔记(形象)"
case cls.RechallengeTicket:
return "重新挑战券"
case cls.RecordKey:
return "记录钥匙"
case cls.IdolPiece_倉本千奈_WonderScale:
return "倉本千奈 WonderScale 碎片"
case cls.IdolPiece_篠泽广_光景:
return "篠泽广 光景 碎片"
case cls.IdolPiece_紫云清夏_TameLieOneStep:
return "紫云清夏 Tame-Lie-One-Step 碎片"
case cls.IdolPiece_葛城リーリヤ_白線:
return "葛城リーリヤ 白線 碎片"
case cls.IdolPiece_姬崎莉波_clumsy_trick:
return "姫崎薪波 clumsy trick 碎片"
case cls.IdolPiece_花海咲季_FightingMyWay:
return "花海咲季 FightingMyWay 碎片"
case cls.IdolPiece_藤田ことね_世界一可愛い私:
return "藤田ことね 世界一可愛い私 碎片"
case cls.IdolPiece_花海佑芽_TheRollingRiceball:
return "花海佑芽 The Rolling Riceball 碎片"
case cls.IdolPiece_月村手毬_LunaSayMaybe:
return "月村手毬 Luna say maybe 碎片"
case cls.IdolPiece_有村麻央_Fluorite:
return "有村麻央 Fluorite 碎片"
case _:
assert_never(item)
@classmethod
def all(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls]
@classmethod
def _is_note(cls, item: 'DailyMoneyShopItems') -> bool:
"""判断是否为笔记"""
return 'Note' in item.name and not item.name.startswith('Note') and not item.name.endswith('Note')
@classmethod
def note_items(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls if cls._is_note(item)]
def to_resource(self):
from kotonebot.kaa.tasks import R
match self:
case DailyMoneyShopItems.Recommendations:
return R.Daily.TextShopRecommended
case DailyMoneyShopItems.LessonNote:
return R.Shop.ItemLessonNote
case DailyMoneyShopItems.VeteranNote:
return R.Shop.ItemVeteranNote
case DailyMoneyShopItems.SupportEnhancementPt:
return R.Shop.ItemSupportEnhancementPt
case DailyMoneyShopItems.SenseNoteVocal:
return R.Shop.ItemSenseNoteVocal
case DailyMoneyShopItems.SenseNoteDance:
return R.Shop.ItemSenseNoteDance
case DailyMoneyShopItems.SenseNoteVisual:
return R.Shop.ItemSenseNoteVisual
case DailyMoneyShopItems.LogicNoteVocal:
return R.Shop.ItemLogicNoteVocal
case DailyMoneyShopItems.LogicNoteDance:
return R.Shop.ItemLogicNoteDance
case DailyMoneyShopItems.LogicNoteVisual:
return R.Shop.ItemLogicNoteVisual
case DailyMoneyShopItems.AnomalyNoteVocal:
return R.Shop.ItemAnomalyNoteVocal
case DailyMoneyShopItems.AnomalyNoteDance:
return R.Shop.ItemAnomalyNoteDance
case DailyMoneyShopItems.AnomalyNoteVisual:
return R.Shop.ItemAnomalyNoteVisual
case DailyMoneyShopItems.RechallengeTicket:
return R.Shop.ItemRechallengeTicket
case DailyMoneyShopItems.RecordKey:
return R.Shop.ItemRecordKey
case DailyMoneyShopItems.IdolPiece_倉本千奈_WonderScale:
return R.Shop.IdolPiece.倉本千奈_WonderScale
case DailyMoneyShopItems.IdolPiece_篠泽广_光景:
return R.Shop.IdolPiece.篠泽广_光景
case DailyMoneyShopItems.IdolPiece_紫云清夏_TameLieOneStep:
return R.Shop.IdolPiece.紫云清夏_TameLieOneStep
case DailyMoneyShopItems.IdolPiece_葛城リーリヤ_白線:
return R.Shop.IdolPiece.葛城リーリヤ_白線
case DailyMoneyShopItems.IdolPiece_姬崎莉波_clumsy_trick:
return R.Shop.IdolPiece.姬崎莉波_clumsy_trick
case DailyMoneyShopItems.IdolPiece_花海咲季_FightingMyWay:
return R.Shop.IdolPiece.花海咲季_FightingMyWay
case DailyMoneyShopItems.IdolPiece_藤田ことね_世界一可愛い私:
return R.Shop.IdolPiece.藤田ことね_世界一可愛い私
case DailyMoneyShopItems.IdolPiece_花海佑芽_TheRollingRiceball:
return R.Shop.IdolPiece.花海佑芽_TheRollingRiceball
case DailyMoneyShopItems.IdolPiece_月村手毬_LunaSayMaybe:
return R.Shop.IdolPiece.月村手毬_LunaSayMaybe
case DailyMoneyShopItems.IdolPiece_有村麻央_Fluorite:
return R.Shop.IdolPiece.有村麻央_Fluorite
case _:
assert_never(self)
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class PurchaseConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用商店购买"""
money_enabled: bool = False
"""是否启用金币购买"""
money_items: list[DailyMoneyShopItems] = []
"""金币商店要购买的物品"""
money_refresh: bool = True
"""
是否使用每日一次免费刷新金币商店
"""
ap_enabled: bool = False
"""是否启用AP购买"""
ap_items: Sequence[Literal[0, 1, 2, 3]] = []
"""AP商店要购买的物品"""
class ActivityFundsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取活动费"""
class PresentsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取礼物"""
class AssignmentConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用工作"""
mini_live_reassign_enabled: bool = False
"""是否启用重新分配 MiniLive"""
mini_live_duration: Literal[4, 6, 12] = 12
"""MiniLive 工作时长"""
online_live_reassign_enabled: bool = False
"""是否启用重新分配 OnlineLive"""
online_live_duration: Literal[4, 6, 12] = 12
"""OnlineLive 工作时长"""
class ContestConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用竞赛"""
select_which_contestant: Literal[1, 2, 3] = 1
"""选择第几个挑战者"""
class ProduceAction(Enum):
RECOMMENDED = 'recommended'
VISUAL = 'visual'
VOCAL = 'vocal'
DANCE = 'dance'
# VISUAL_SP = 'visual_sp'
# VOCAL_SP = 'vocal_sp'
# DANCE_SP = 'dance_sp'
OUTING = 'outing'
STUDY = 'study'
ALLOWANCE = 'allowance'
REST = 'rest'
CONSULT = 'consult'
@property
def display_name(self):
MAP = {
ProduceAction.RECOMMENDED: '推荐行动',
ProduceAction.VISUAL: '形象课程',
ProduceAction.VOCAL: '声乐课程',
ProduceAction.DANCE: '舞蹈课程',
ProduceAction.OUTING: '外出(おでかけ)',
ProduceAction.STUDY: '文化课(授業)',
ProduceAction.ALLOWANCE: '活动支给(活動支給)',
ProduceAction.REST: '休息',
ProduceAction.CONSULT: '咨询(相談)',
}
return MAP[self]
class RecommendCardDetectionMode(Enum):
NORMAL = 'normal'
STRICT = 'strict'
@property
def display_name(self):
MAP = {
RecommendCardDetectionMode.NORMAL: '正常模式',
RecommendCardDetectionMode.STRICT: '严格模式',
}
return MAP[self]
class ProduceConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用培育"""
mode: Literal['regular', 'pro', 'master'] = 'regular'
"""
培育模式
进行一次 REGULAR 培育需要 ~30min进行一次 PRO 培育需要 ~1h具体视设备性能而定
"""
produce_count: int = 1
"""培育的次数。"""
idols: list[str] = []
"""
要培育偶像的 IdolCardSkin.id将会按顺序循环选择培育
"""
memory_sets: list[int] = []
"""要使用的回忆编成编号,从 1 开始。将会按顺序循环选择使用。"""
support_card_sets: list[int] = []
"""要使用的支援卡编成编号,从 1 开始。将会按顺序循环选择使用。"""
auto_set_memory: bool = False
"""是否自动编成回忆。此选项优先级高于回忆编成编号。"""
auto_set_support_card: bool = False
"""是否自动编成支援卡。此选项优先级高于支援卡编成编号。"""
use_pt_boost: bool = False
"""是否使用支援强化 Pt 提升。"""
use_note_boost: bool = False
"""是否使用笔记数提升。"""
follow_producer: bool = False
"""是否关注租借了支援卡的制作人。"""
self_study_lesson: Literal['dance', 'visual', 'vocal'] = 'dance'
"""自习课类型。"""
prefer_lesson_ap: bool = False
"""
优先 SP 课程
启用后若出现 SP 课程则会优先执行 SP 课程而不是推荐课程
若出现多个 SP 课程随机选择一个
"""
actions_order: list[ProduceAction] = [
ProduceAction.RECOMMENDED,
ProduceAction.VISUAL,
ProduceAction.VOCAL,
ProduceAction.DANCE,
ProduceAction.ALLOWANCE,
ProduceAction.OUTING,
ProduceAction.STUDY,
ProduceAction.CONSULT,
ProduceAction.REST,
]
"""
行动优先级
每一周的行动将会按这里设置的优先级执行
"""
recommend_card_detection_mode: RecommendCardDetectionMode = RecommendCardDetectionMode.NORMAL
"""
推荐卡检测模式
严格模式下识别速度会降低但识别准确率会提高
"""
use_ap_drink: bool = False
"""
AP 不足时自动使用 AP 饮料
"""
skip_commu: bool = True
"""检测并跳过交流"""
class MissionRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取任务奖励"""
class ClubRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取社团奖励"""
selected_note: DailyMoneyShopItems = DailyMoneyShopItems.AnomalyNoteVisual
"""想在社团奖励中获取到的笔记"""
class UpgradeSupportCardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用支援卡升级"""
class CapsuleToysConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用扭蛋机"""
friend_capsule_toys_count: int = 0
"""好友扭蛋机次数"""
sense_capsule_toys_count: int = 0
"""感性扭蛋机次数"""
logic_capsule_toys_count: int = 0
"""理性扭蛋机次数"""
anomaly_capsule_toys_count: int = 0
"""非凡扭蛋机次数"""
class TraceConfig(ConfigBaseModel):
recommend_card_detection: bool = False
"""跟踪推荐卡检测"""
class StartGameConfig(ConfigBaseModel):
enabled: bool = True
"""是否启用自动启动游戏。默认为True"""
start_through_kuyo: bool = False
"""是否通过Kuyo来启动游戏"""
game_package_name: str = 'com.bandainamcoent.idolmaster_gakuen'
"""游戏包名"""
kuyo_package_name: str = 'org.kuyo.game'
"""Kuyo包名"""
disable_gakumas_localify: bool = False
"""
自动检测并禁用 Gakumas Localify 汉化插件
目前仅对 DMM 版有效
"""
dmm_game_path: str | None = None
"""
DMM 版游戏路径若不填写会自动检测
`F:\\Games\\gakumas\\gakumas.exe`
"""
class EndGameConfig(ConfigBaseModel):
exit_kaa: bool = False
"""退出 kaa"""
kill_game: bool = False
"""关闭游戏"""
kill_dmm: bool = False
"""关闭 DMMGamePlayer"""
kill_emulator: bool = False
"""关闭模拟器"""
shutdown: bool = False
"""关闭系统"""
hibernate: bool = False
"""休眠系统"""
restore_gakumas_localify: bool = False
"""
恢复 Gakumas Localify 汉化插件状态至启动前通常与
`disable_gakumas_localify` 配对使用
目前仅对 DMM 版有效
"""
class MiscConfig(ConfigBaseModel):
check_update: Literal['never', 'startup'] = 'startup'
"""
检查更新时机
* never: 从不检查更新
* startup: 启动时检查更新
"""
auto_install_update: bool = True
"""
是否自动安装更新
若启用则每次自动检查更新时若有新版本会自动安装否则只是会提示
"""
expose_to_lan: bool = False
"""
是否允许局域网访问 Web 界面
启用后局域网内的其他设备可以通过本机 IP 地址访问 Web 界面
"""
class BaseConfig(ConfigBaseModel):
purchase: PurchaseConfig = PurchaseConfig()
"""商店购买配置"""
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
"""活动费配置"""
presents: PresentsConfig = PresentsConfig()
"""收取礼物配置"""
assignment: AssignmentConfig = AssignmentConfig()
"""工作配置"""
contest: ContestConfig = ContestConfig()
"""竞赛配置"""
produce: ProduceConfig = ProduceConfig()
"""培育配置"""
mission_reward: MissionRewardConfig = MissionRewardConfig()
"""领取任务奖励配置"""
club_reward: ClubRewardConfig = ClubRewardConfig()
"""领取社团奖励配置"""
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
"""支援卡升级配置"""
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
"""扭蛋机配置"""
trace: TraceConfig = TraceConfig()
"""跟踪配置"""
start_game: StartGameConfig = StartGameConfig()
"""启动游戏配置"""
end_game: EndGameConfig = EndGameConfig()
"""关闭游戏配置"""
misc: MiscConfig = MiscConfig()
"""杂项配置"""
def conf() -> BaseConfig:
"""获取当前配置数据"""
c = config.to(BaseConfig).current
return c.options
def sprite_path(path: str) -> str:
standalone = os.path.join('kotonebot/kaa/sprites', path)
if os.path.exists(standalone):
return standalone
return str(resources.files('kotonebot.kaa.sprites') / path)
def upgrade_config() -> str | None:
"""
升级配置文件
"""
if not os.path.exists('config.json'):
return None
with open('config.json', 'r', encoding='utf-8') as f:
root = json.load(f)
user_configs = root['user_configs']
old_version = root['version']
messages = []
def upgrade_user_config(version: int, user_config: dict[str, Any]) -> int:
nonlocal messages
while True:
match version:
case 1:
logger.info('Upgrading config: v1 -> v2')
user_config, msg = upgrade_v1_to_v2(user_config['options'])
messages.append(msg)
version = 2
case 2:
logger.info('Upgrading config: v2 -> v3')
user_config, msg = upgrade_v2_to_v3(user_config['options'])
messages.append(msg)
version = 3
case 3:
logger.info('Upgrading config: v3 -> v4')
user_config, msg = upgrade_v3_to_v4(user_config['options'])
messages.append(msg)
version = 4
case 4:
logger.info('Upgrading config: v4 -> v5')
user_config, msg = upgrade_v4_to_v5(user_config, user_config['options'])
messages.append(msg)
version = 5
case _:
logger.info('No config upgrade needed.')
return version
for user_config in user_configs:
new_version = upgrade_user_config(old_version, user_config)
root['version'] = new_version
with open('config.json', 'w', encoding='utf-8') as f:
json.dump(root, f, ensure_ascii=False, indent=4)
return '\n'.join(messages)
倉本千奈_BASE = 0
十王星南_BASE = 100
姫崎莉波_BASE = 200
月村手毬_BASE = 300
有村麻央_BASE = 400
篠泽广_BASE = 500
紫云清夏_BASE = 600
花海佑芽_BASE = 700
花海咲季_BASE = 800
葛城リーリヤ_BASE = 900
藤田ことね_BASE = 1000
class PIdol(IntEnum):
"""
P偶像已废弃仅为 upgrade_v1_to_v2()upgrade_v2_to_v3() 而保留
"""
倉本千奈_Campusmode = 倉本千奈_BASE + 0
倉本千奈_WonderScale = 倉本千奈_BASE + 1
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
倉本千奈_初心 = 倉本千奈_BASE + 4
倉本千奈_学園生活 = 倉本千奈_BASE + 5
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
十王星南_Campusmode = 十王星南_BASE + 0
十王星南_一番星 = 十王星南_BASE + 1
十王星南_学園生活 = 十王星南_BASE + 2
十王星南_小さな野望 = 十王星南_BASE + 3
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
姫崎莉波_LUV = 姫崎莉波_BASE + 4
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
姫崎莉波_初心 = 姫崎莉波_BASE + 7
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
月村手毬_一匹狼 = 月村手毬_BASE + 1
月村手毬_Campusmode = 月村手毬_BASE + 2
月村手毬_アイヴイ = 月村手毬_BASE + 3
月村手毬_初声 = 月村手毬_BASE + 4
月村手毬_学園生活 = 月村手毬_BASE + 5
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
有村麻央_Fluorite = 有村麻央_BASE + 0
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
有村麻央_Campusmode = 有村麻央_BASE + 2
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
有村麻央_初恋 = 有村麻央_BASE + 5
有村麻央_学園生活 = 有村麻央_BASE + 6
篠泽广_コントラスト = 篠泽广_BASE + 0
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
篠泽广_光景 = 篠泽广_BASE + 2
篠泽广_Campusmode = 篠泽广_BASE + 3
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
篠泽广_初恋 = 篠泽广_BASE + 6
篠泽广_学園生活 = 篠泽广_BASE + 7
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
紫云清夏_Campusmode = 紫云清夏_BASE + 3
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
紫云清夏_初恋 = 紫云清夏_BASE + 5
紫云清夏_学園生活 = 紫云清夏_BASE + 6
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
花海佑芽_学園生活 = 花海佑芽_BASE + 1
花海佑芽_Campusmode = 花海佑芽_BASE + 2
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
花海咲季_Campusmode = 花海咲季_BASE + 1
花海咲季_FightingMyWay = 花海咲季_BASE + 2
花海咲季_わたしが一番 = 花海咲季_BASE + 3
花海咲季_冠菊 = 花海咲季_BASE + 4
花海咲季_初声 = 花海咲季_BASE + 5
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
花海咲季_学園生活 = 花海咲季_BASE + 7
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
藤田ことね_Campusmode = 藤田ことね_BASE + 2
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
藤田ことね_WhiteNightWhiteWish = 藤田ことね_BASE + 4
藤田ことね_冠菊 = 藤田ことね_BASE + 5
藤田ことね_初声 = 藤田ことね_BASE + 6
藤田ことね_学園生活 = 藤田ことね_BASE + 7
def upgrade_v1_to_v2(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v1 -> v2 变更
1. PIdol 的枚举值改为整数
"""
msg = ''
# 转换 PIdol 的枚举值
def map_idol(idol: list[str]) -> PIdol | None:
logger.debug("Converting %s", idol)
match idol:
case ["倉本千奈", "Campus mode!!"]:
return PIdol.倉本千奈_Campusmode
case ["倉本千奈", "Wonder Scale"]:
return PIdol.倉本千奈_WonderScale
case ["倉本千奈", "ようこそ初星温泉"]:
return PIdol.倉本千奈_ようこそ初星温泉
case ["倉本千奈", "仮装狂騒曲"]:
return PIdol.倉本千奈_仮装狂騒曲
case ["倉本千奈", "初心"]:
return PIdol.倉本千奈_初心
case ["倉本千奈", "学園生活"]:
return PIdol.倉本千奈_学園生活
case ["倉本千奈", "日々、発見的ステップ!"]:
return PIdol.倉本千奈_日々_発見的ステップ
case ["倉本千奈", "胸を張って一歩ずつ"]:
return PIdol.倉本千奈_胸を張って一歩ずつ
case ["十王星南", "Campus mode!!"]:
return PIdol.十王星南_Campusmode
case ["十王星南", "一番星"]:
return PIdol.十王星南_一番星
case ["十王星南", "学園生活"]:
return PIdol.十王星南_学園生活
case ["十王星南", "小さな野望"]:
return PIdol.十王星南_小さな野望
case ["姫崎莉波", "clumsy trick"]:
return PIdol.姫崎莉波_clumsytrick
case ["姫崎莉波", "『私らしさ』のはじまり"]:
return PIdol.姫崎莉波_私らしさのはじまり
case ["姫崎莉波", "キミとセミブルー"]:
return PIdol.姫崎莉波_キミとセミブルー
case ["姫崎莉波", "Campus mode!!"]:
return PIdol.姫崎莉波_Campusmode
case ["姫崎莉波", "L.U.V"]:
return PIdol.姫崎莉波_LUV
case ["姫崎莉波", "ようこそ初星温泉"]:
return PIdol.姫崎莉波_ようこそ初星温泉
case ["姫崎莉波", "ハッピーミルフィーユ"]:
return PIdol.姫崎莉波_ハッピーミルフィーユ
case ["姫崎莉波", "初心"]:
return PIdol.姫崎莉波_初心
case ["姫崎莉波", "学園生活"]:
return PIdol.姫崎莉波_学園生活
case ["月村手毬", "Luna say maybe"]:
return PIdol.月村手毬_Lunasaymaybe
case ["月村手毬", "一匹狼"]:
return PIdol.月村手毬_一匹狼
case ["月村手毬", "Campus mode!!"]:
return PIdol.月村手毬_Campusmode
case ["月村手毬", "アイヴイ"]:
return PIdol.月村手毬_アイヴイ
case ["月村手毬", "初声"]:
return PIdol.月村手毬_初声
case ["月村手毬", "学園生活"]:
return PIdol.月村手毬_学園生活
case ["月村手毬", "仮装狂騒曲"]:
return PIdol.月村手毬_仮装狂騒曲
case ["有村麻央", "Fluorite"]:
return PIdol.有村麻央_Fluorite
case ["有村麻央", "はじまりはカッコよく"]:
return PIdol.有村麻央_はじまりはカッコよく
case ["有村麻央", "Campus mode!!"]:
return PIdol.有村麻央_Campusmode
case ["有村麻央", "Feel Jewel Dream"]:
return PIdol.有村麻央_FeelJewelDream
case ["有村麻央", "キミとセミブルー"]:
return PIdol.有村麻央_キミとセミブルー
case ["有村麻央", "初恋"]:
return PIdol.有村麻央_初恋
case ["有村麻央", "学園生活"]:
return PIdol.有村麻央_学園生活
case ["篠泽广", "コントラスト"]:
return PIdol.篠泽广_コントラスト
case ["篠泽广", "一番向いていないこと"]:
return PIdol.篠泽广_一番向いていないこと
case ["篠泽广", "光景"]:
return PIdol.篠泽广_光景
case ["篠泽广", "Campus mode!!"]:
return PIdol.篠泽广_Campusmode
case ["篠泽广", "仮装狂騒曲"]:
return PIdol.篠泽广_仮装狂騒曲
case ["篠泽广", "ハッピーミルフィーユ"]:
return PIdol.篠泽广_ハッピーミルフィーユ
case ["篠泽广", "初恋"]:
return PIdol.篠泽广_初恋
case ["篠泽广", "学園生活"]:
return PIdol.篠泽广_学園生活
case ["紫云清夏", "Tame-Lie-One-Step"]:
return PIdol.紫云清夏_TameLieOneStep
case ["紫云清夏", "カクシタワタシ"]:
return PIdol.紫云清夏_カクシタワタシ
case ["紫云清夏", "夢へのリスタート"]:
return PIdol.紫云清夏_夢へのリスタート
case ["紫云清夏", "Campus mode!!"]:
return PIdol.紫云清夏_Campusmode
case ["紫云清夏", "キミとセミブルー"]:
return PIdol.紫云清夏_キミとセミブルー
case ["紫云清夏", "初恋"]:
return PIdol.紫云清夏_初恋
case ["紫云清夏", "学園生活"]:
return PIdol.紫云清夏_学園生活
case ["花海佑芽", "White Night! White Wish!"]:
return PIdol.花海佑芽_WhiteNightWhiteWish
case ["花海佑芽", "学園生活"]:
return PIdol.花海佑芽_学園生活
case ["花海佑芽", "Campus mode!!"]:
return PIdol.花海佑芽_Campusmode
case ["花海佑芽", "The Rolling Riceball"]:
return PIdol.花海佑芽_TheRollingRiceball
case ["花海佑芽", "アイドル、はじめっ!"]:
return PIdol.花海佑芽_アイドル_はじめっ
case ["花海咲季", "Boom Boom Pow"]:
return PIdol.花海咲季_BoomBoomPow
case ["花海咲季", "Campus mode!!"]:
return PIdol.花海咲季_Campusmode
case ["花海咲季", "Fighting My Way"]:
return PIdol.花海咲季_FightingMyWay
case ["花海咲季", "わたしが一番!"]:
return PIdol.花海咲季_わたしが一番
case ["花海咲季", "冠菊"]:
return PIdol.花海咲季_冠菊
case ["花海咲季", "初声"]:
return PIdol.花海咲季_初声
case ["花海咲季", "古今東西ちょちょいのちょい"]:
return PIdol.花海咲季_古今東西ちょちょいのちょい
case ["花海咲季", "学園生活"]:
return PIdol.花海咲季_学園生活
case ["葛城リーリヤ", "一つ踏み出した先に"]:
return PIdol.葛城リーリヤ_一つ踏み出した先に
case ["葛城リーリヤ", "白線"]:
return PIdol.葛城リーリヤ_白線
case ["葛城リーリヤ", "Campus mode!!"]:
return PIdol.葛城リーリヤ_Campusmode
case ["葛城リーリヤ", "White Night! White Wish!"]:
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
case ["葛城リーリヤ", "冠菊"]:
return PIdol.葛城リーリヤ_冠菊
case ["葛城リーリヤ", "初心"]:
return PIdol.葛城リーリヤ_初心
case ["葛城リーリヤ", "学園生活"]:
return PIdol.葛城リーリヤ_学園生活
case ["藤田ことね", "カワイイ", "はじめました"]:
return PIdol.藤田ことね_カワイイ_はじめました
case ["藤田ことね", "世界一可愛い私"]:
return PIdol.藤田ことね_世界一可愛い私
case ["藤田ことね", "Campus mode!!"]:
return PIdol.藤田ことね_Campusmode
case ["藤田ことね", "Yellow Big Bang"]:
return PIdol.藤田ことね_YellowBigBang
case ["藤田ことね", "White Night! White Wish!"]:
return PIdol.藤田ことね_WhiteNightWhiteWish
case ["藤田ことね", "冠菊"]:
return PIdol.藤田ことね_冠菊
case ["藤田ことね", "初声"]:
return PIdol.藤田ことね_初声
case ["藤田ことね", "学園生活"]:
return PIdol.藤田ことね_学園生活
case _:
nonlocal msg
if msg == '':
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
msg += f'{idol} 未找到\n'
return None
old_idols = options['produce']['idols']
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
options['produce']['idols'] = new_idols
shutil.copy('config.json', 'config.v1.json')
return options, msg
def upgrade_v2_to_v3(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v2 -> v3 变更\n
引入了游戏解包数据因此 PIdol 枚举废弃直接改用游戏内 ID
"""
msg = ''
def map_idol(idol: PIdol) -> str | None:
match idol:
case PIdol.倉本千奈_Campusmode: return "i_card-skin-kcna-3-007"
case PIdol.倉本千奈_WonderScale: return "i_card-skin-kcna-3-000"
case PIdol.倉本千奈_ようこそ初星温泉: return "i_card-skin-kcna-3-005"
case PIdol.倉本千奈_仮装狂騒曲: return "i_card-skin-kcna-3-002"
case PIdol.倉本千奈_初心: return "i_card-skin-kcna-1-001"
case PIdol.倉本千奈_学園生活: return "i_card-skin-kcna-1-000"
case PIdol.倉本千奈_日々_発見的ステップ: return "i_card-skin-kcna-3-001"
case PIdol.倉本千奈_胸を張って一歩ずつ: return "i_card-skin-kcna-2-000"
case PIdol.十王星南_Campusmode: return "i_card-skin-jsna-3-002"
case PIdol.十王星南_一番星: return "i_card-skin-jsna-2-000"
case PIdol.十王星南_学園生活: return "i_card-skin-jsna-1-000"
case PIdol.十王星南_小さな野望: return "i_card-skin-jsna-3-000"
case PIdol.姫崎莉波_clumsytrick: return "i_card-skin-hrnm-3-000"
case PIdol.姫崎莉波_私らしさのはじまり: return "i_card-skin-hrnm-2-000"
case PIdol.姫崎莉波_キミとセミブルー: return "i_card-skin-hrnm-3-001"
case PIdol.姫崎莉波_Campusmode: return "i_card-skin-hrnm-3-007"
case PIdol.姫崎莉波_LUV: return "i_card-skin-hrnm-3-002"
case PIdol.姫崎莉波_ようこそ初星温泉: return "i_card-skin-hrnm-3-004"
case PIdol.姫崎莉波_ハッピーミルフィーユ: return "i_card-skin-hrnm-3-008"
case PIdol.姫崎莉波_初心: return "i_card-skin-hrnm-1-001"
case PIdol.姫崎莉波_学園生活: return "i_card-skin-hrnm-1-000"
case PIdol.月村手毬_Lunasaymaybe: return "i_card-skin-ttmr-3-000"
case PIdol.月村手毬_一匹狼: return "i_card-skin-ttmr-2-000"
case PIdol.月村手毬_Campusmode: return "i_card-skin-ttmr-3-007"
case PIdol.月村手毬_アイヴイ: return "i_card-skin-ttmr-3-001"
case PIdol.月村手毬_初声: return "i_card-skin-ttmr-1-001"
case PIdol.月村手毬_学園生活: return "i_card-skin-ttmr-1-000"
case PIdol.月村手毬_仮装狂騒曲: return "i_card-skin-ttmr-3-002"
case PIdol.有村麻央_Fluorite: return "i_card-skin-amao-3-000"
case PIdol.有村麻央_はじまりはカッコよく: return "i_card-skin-amao-2-000"
case PIdol.有村麻央_Campusmode: return "i_card-skin-amao-3-007"
case PIdol.有村麻央_FeelJewelDream: return "i_card-skin-amao-3-002"
case PIdol.有村麻央_キミとセミブルー: return "i_card-skin-amao-3-001"
case PIdol.有村麻央_初恋: return "i_card-skin-amao-1-001"
case PIdol.有村麻央_学園生活: return "i_card-skin-amao-1-000"
case PIdol.篠泽广_コントラスト: return "i_card-skin-shro-3-001"
case PIdol.篠泽广_一番向いていないこと: return "i_card-skin-shro-2-000"
case PIdol.篠泽广_光景: return "i_card-skin-shro-3-000"
case PIdol.篠泽广_Campusmode: return "i_card-skin-shro-3-007"
case PIdol.篠泽广_仮装狂騒曲: return "i_card-skin-shro-3-002"
case PIdol.篠泽广_ハッピーミルフィーユ: return "i_card-skin-shro-3-008"
case PIdol.篠泽广_初恋: return "i_card-skin-shro-1-001"
case PIdol.篠泽广_学園生活: return "i_card-skin-shro-1-000"
case PIdol.紫云清夏_TameLieOneStep: return "i_card-skin-ssmk-3-000"
case PIdol.紫云清夏_カクシタワタシ: return "i_card-skin-ssmk-3-002"
case PIdol.紫云清夏_夢へのリスタート: return "i_card-skin-ssmk-2-000"
case PIdol.紫云清夏_Campusmode: return "i_card-skin-ssmk-3-007"
case PIdol.紫云清夏_キミとセミブルー: return "i_card-skin-ssmk-3-001"
case PIdol.紫云清夏_初恋: return "i_card-skin-ssmk-1-001"
case PIdol.紫云清夏_学園生活: return "i_card-skin-ssmk-1-000"
case PIdol.花海佑芽_WhiteNightWhiteWish: return "i_card-skin-hume-3-005"
case PIdol.花海佑芽_学園生活: return "i_card-skin-hume-1-000"
case PIdol.花海佑芽_Campusmode: return "i_card-skin-hume-3-006"
case PIdol.花海佑芽_TheRollingRiceball: return "i_card-skin-hume-3-000"
case PIdol.花海佑芽_アイドル_はじめっ: return "i_card-skin-hume-2-000"
case PIdol.花海咲季_BoomBoomPow: return "i_card-skin-hski-3-001"
case PIdol.花海咲季_Campusmode: return "i_card-skin-hski-3-008"
case PIdol.花海咲季_FightingMyWay: return "i_card-skin-hski-3-000"
case PIdol.花海咲季_わたしが一番: return "i_card-skin-hski-2-000"
case PIdol.花海咲季_冠菊: return "i_card-skin-hski-3-002"
case PIdol.花海咲季_初声: return "i_card-skin-hski-1-001"
case PIdol.花海咲季_古今東西ちょちょいのちょい: return "i_card-skin-hski-3-006"
case PIdol.花海咲季_学園生活: return "i_card-skin-hski-1-000"
case PIdol.葛城リーリヤ_一つ踏み出した先に: return "i_card-skin-kllj-2-000"
case PIdol.葛城リーリヤ_白線: return "i_card-skin-kllj-3-000"
case PIdol.葛城リーリヤ_Campusmode: return "i_card-skin-kllj-3-006"
case PIdol.葛城リーリヤ_WhiteNightWhiteWish: return "i_card-skin-kllj-3-005"
case PIdol.葛城リーリヤ_冠菊: return "i_card-skin-kllj-3-001"
case PIdol.葛城リーリヤ_初心: return "i_card-skin-kllj-1-001"
case PIdol.葛城リーリヤ_学園生活: return "i_card-skin-kllj-1-000"
case PIdol.藤田ことね_カワイイ_はじめました: return "i_card-skin-fktn-2-000"
case PIdol.藤田ことね_世界一可愛い私: return "i_card-skin-fktn-3-000"
case PIdol.藤田ことね_Campusmode: return "i_card-skin-fktn-3-007"
case PIdol.藤田ことね_YellowBigBang: return "i_card-skin-fktn-3-001"
case PIdol.藤田ことね_WhiteNightWhiteWish: return "i_card-skin-fktn-3-006"
case PIdol.藤田ことね_冠菊: return "i_card-skin-fktn-3-002"
case PIdol.藤田ことね_初声: return "i_card-skin-fktn-1-001"
case PIdol.藤田ことね_学園生活: return "i_card-skin-fktn-1-000"
case _:
nonlocal msg
if msg == '':
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
msg += f'{idol} 未找到\n'
return None
old_idols = options['produce']['idols']
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
options['produce']['idols'] = new_idols
shutil.copy('config.json', 'config.v2.json')
return options, msg
def upgrade_v3_to_v4(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v3 -> v4 变更
自动纠正错误游戏包名
"""
shutil.copy('config.json', 'config.v3.json')
if options['start_game']['game_package_name'] == 'com.bandinamcoent.idolmaster_gakuen':
options['start_game']['game_package_name'] = 'com.bandainamcoent.idolmaster_gakuen'
logger.info('Corrected game package name to com.bandainamcoent.idolmaster_gakuen')
return options, ''
def upgrade_v4_to_v5(user_config: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
v4 -> v5 变更
windows windows_remote 截图方式的 type 设置为 dmm
"""
shutil.copy('config.json', 'config.v4.json')
if user_config['backend']['screenshot_impl'] in ['windows', 'remote_windows']:
logger.info('Set backend type to dmm.')
user_config['backend']['type'] = 'dmm'
return options, ''
if __name__ == '__main__':
print(PurchaseConfig.model_fields['money_refresh_on'].description)
return str(resources.files('kotonebot.kaa.sprites') / path)

View File

@ -0,0 +1,62 @@
from .schema import (
BaseConfig,
PurchaseConfig,
ActivityFundsConfig,
PresentsConfig,
AssignmentConfig,
ContestConfig,
ProduceConfig,
MissionRewardConfig,
ClubRewardConfig,
UpgradeSupportCardConfig,
CapsuleToysConfig,
TraceConfig,
StartGameConfig,
EndGameConfig,
MiscConfig,
conf,
)
from .const import (
ConfigEnum,
Priority,
APShopItems,
DailyMoneyShopItems,
ProduceAction,
RecommendCardDetectionMode,
)
# 配置升级逻辑
from .upgrade import upgrade_config
from .migrations import MIGRATION_REGISTRY, LATEST_VERSION
__all__ = [
# schema 导出
"BaseConfig",
"PurchaseConfig",
"ActivityFundsConfig",
"PresentsConfig",
"AssignmentConfig",
"ContestConfig",
"ProduceConfig",
"MissionRewardConfig",
"ClubRewardConfig",
"UpgradeSupportCardConfig",
"CapsuleToysConfig",
"TraceConfig",
"StartGameConfig",
"EndGameConfig",
"MiscConfig",
"conf",
# const 导出
"ConfigEnum",
"Priority",
"APShopItems",
"DailyMoneyShopItems",
"ProduceAction",
"RecommendCardDetectionMode",
# upgrade 导出
"upgrade_config",
"migrations",
"MIGRATION_REGISTRY",
"LATEST_VERSION",
]

View File

@ -0,0 +1,255 @@
from enum import IntEnum, Enum
from typing_extensions import assert_never
class ConfigEnum(Enum):
def display(self) -> str:
return self.value[1]
class Priority(IntEnum):
"""
任务优先级数字越大优先级越高越先执行
"""
START_GAME = 1
DEFAULT = 0
CLAIM_MISSION_REWARD = -1
END_GAME = -2
class APShopItems(IntEnum):
PRODUCE_PT_UP = 0
"""获取支援强化 Pt 提升"""
PRODUCE_NOTE_UP = 1
"""获取笔记数提升"""
RECHALLENGE = 2
"""再挑战券"""
REGENERATE_MEMORY = 3
"""回忆再生成券"""
class DailyMoneyShopItems(IntEnum):
"""日常商店物品"""
Recommendations = -1
"""所有推荐商品"""
LessonNote = 0
"""レッスンノート"""
VeteranNote = 1
"""ベテランノート"""
SupportEnhancementPt = 2
"""サポート強化Pt 支援强化Pt"""
SenseNoteVocal = 3
"""センスノート(ボーカル)感性笔记(声乐)"""
SenseNoteDance = 4
"""センスノート(ダンス)感性笔记(舞蹈)"""
SenseNoteVisual = 5
"""センスノート(ビジュアル)感性笔记(形象)"""
LogicNoteVocal = 6
"""ロジックノート(ボーカル)理性笔记(声乐)"""
LogicNoteDance = 7
"""ロジックノート(ダンス)理性笔记(舞蹈)"""
LogicNoteVisual = 8
"""ロジックノート(ビジュアル)理性笔记(形象)"""
AnomalyNoteVocal = 9
"""アノマリーノート(ボーカル)非凡笔记(声乐)"""
AnomalyNoteDance = 10
"""アノマリーノート(ダンス)非凡笔记(舞蹈)"""
AnomalyNoteVisual = 11
"""アノマリーノート(ビジュアル)非凡笔记(形象)"""
RechallengeTicket = 12
"""再挑戦チケット 重新挑战券"""
RecordKey = 13
"""記録の鍵 解锁交流的物品"""
# 碎片
IdolPiece_倉本千奈_WonderScale = 14
"""倉本千奈 WonderScale 碎片"""
IdolPiece_篠泽广_光景 = 15
"""篠泽广 光景 碎片"""
IdolPiece_紫云清夏_TameLieOneStep = 16
"""紫云清夏 Tame-Lie-One-Step 碎片"""
IdolPiece_葛城リーリヤ_白線 = 17
"""葛城リーリヤ 白線 碎片"""
IdolPiece_姬崎莉波_clumsy_trick = 18
"""姫崎薪波 cIclumsy trick 碎片"""
IdolPiece_花海咲季_FightingMyWay = 19
"""花海咲季 FightingMyWay 碎片"""
IdolPiece_藤田ことね_世界一可愛い私 = 20
"""藤田ことね 世界一可愛い私 碎片"""
IdolPiece_花海佑芽_TheRollingRiceball = 21
"""花海佑芽 The Rolling Riceball 碎片"""
IdolPiece_月村手毬_LunaSayMaybe = 22
"""月村手毬 Luna say maybe 碎片"""
IdolPiece_有村麻央_Fluorite = 23
"""有村麻央 Fluorite 碎片"""
@classmethod
def to_ui_text(cls, item: "DailyMoneyShopItems") -> str:
"""获取枚举值对应的UI显示文本"""
match item:
case cls.Recommendations:
return "所有推荐商品"
case cls.LessonNote:
return "课程笔记"
case cls.VeteranNote:
return "老手笔记"
case cls.SupportEnhancementPt:
return "支援强化点数"
case cls.SenseNoteVocal:
return "感性笔记(声乐)"
case cls.SenseNoteDance:
return "感性笔记(舞蹈)"
case cls.SenseNoteVisual:
return "感性笔记(形象)"
case cls.LogicNoteVocal:
return "理性笔记(声乐)"
case cls.LogicNoteDance:
return "理性笔记(舞蹈)"
case cls.LogicNoteVisual:
return "理性笔记(形象)"
case cls.AnomalyNoteVocal:
return "非凡笔记(声乐)"
case cls.AnomalyNoteDance:
return "非凡笔记(舞蹈)"
case cls.AnomalyNoteVisual:
return "非凡笔记(形象)"
case cls.RechallengeTicket:
return "重新挑战券"
case cls.RecordKey:
return "记录钥匙"
case cls.IdolPiece_倉本千奈_WonderScale:
return "倉本千奈 WonderScale 碎片"
case cls.IdolPiece_篠泽广_光景:
return "篠泽广 光景 碎片"
case cls.IdolPiece_紫云清夏_TameLieOneStep:
return "紫云清夏 Tame-Lie-One-Step 碎片"
case cls.IdolPiece_葛城リーリヤ_白線:
return "葛城リーリヤ 白線 碎片"
case cls.IdolPiece_姬崎莉波_clumsy_trick:
return "姫崎薪波 clumsy trick 碎片"
case cls.IdolPiece_花海咲季_FightingMyWay:
return "花海咲季 FightingMyWay 碎片"
case cls.IdolPiece_藤田ことね_世界一可愛い私:
return "藤田ことね 世界一可愛い私 碎片"
case cls.IdolPiece_花海佑芽_TheRollingRiceball:
return "花海佑芽 The Rolling Riceball 碎片"
case cls.IdolPiece_月村手毬_LunaSayMaybe:
return "月村手毬 Luna say maybe 碎片"
case cls.IdolPiece_有村麻央_Fluorite:
return "有村麻央 Fluorite 碎片"
case _:
assert_never(item)
@classmethod
def all(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls]
@classmethod
def _is_note(cls, item: 'DailyMoneyShopItems') -> bool:
"""判断是否为笔记"""
return 'Note' in item.name and not item.name.startswith('Note') and not item.name.endswith('Note')
@classmethod
def note_items(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
"""获取所有枚举值及其对应的UI显示文本"""
return [(cls.to_ui_text(item), item) for item in cls if cls._is_note(item)]
def to_resource(self):
from kotonebot.kaa.tasks import R
match self:
case DailyMoneyShopItems.Recommendations:
return R.Daily.TextShopRecommended
case DailyMoneyShopItems.LessonNote:
return R.Shop.ItemLessonNote
case DailyMoneyShopItems.VeteranNote:
return R.Shop.ItemVeteranNote
case DailyMoneyShopItems.SupportEnhancementPt:
return R.Shop.ItemSupportEnhancementPt
case DailyMoneyShopItems.SenseNoteVocal:
return R.Shop.ItemSenseNoteVocal
case DailyMoneyShopItems.SenseNoteDance:
return R.Shop.ItemSenseNoteDance
case DailyMoneyShopItems.SenseNoteVisual:
return R.Shop.ItemSenseNoteVisual
case DailyMoneyShopItems.LogicNoteVocal:
return R.Shop.ItemLogicNoteVocal
case DailyMoneyShopItems.LogicNoteDance:
return R.Shop.ItemLogicNoteDance
case DailyMoneyShopItems.LogicNoteVisual:
return R.Shop.ItemLogicNoteVisual
case DailyMoneyShopItems.AnomalyNoteVocal:
return R.Shop.ItemAnomalyNoteVocal
case DailyMoneyShopItems.AnomalyNoteDance:
return R.Shop.ItemAnomalyNoteDance
case DailyMoneyShopItems.AnomalyNoteVisual:
return R.Shop.ItemAnomalyNoteVisual
case DailyMoneyShopItems.RechallengeTicket:
return R.Shop.ItemRechallengeTicket
case DailyMoneyShopItems.RecordKey:
return R.Shop.ItemRecordKey
case DailyMoneyShopItems.IdolPiece_倉本千奈_WonderScale:
return R.Shop.IdolPiece.倉本千奈_WonderScale
case DailyMoneyShopItems.IdolPiece_篠泽广_光景:
return R.Shop.IdolPiece.篠泽广_光景
case DailyMoneyShopItems.IdolPiece_紫云清夏_TameLieOneStep:
return R.Shop.IdolPiece.紫云清夏_TameLieOneStep
case DailyMoneyShopItems.IdolPiece_葛城リーリヤ_白線:
return R.Shop.IdolPiece.葛城リーリヤ_白線
case DailyMoneyShopItems.IdolPiece_姬崎莉波_clumsy_trick:
return R.Shop.IdolPiece.姬崎莉波_clumsy_trick
case DailyMoneyShopItems.IdolPiece_花海咲季_FightingMyWay:
return R.Shop.IdolPiece.花海咲季_FightingMyWay
case DailyMoneyShopItems.IdolPiece_藤田ことね_世界一可愛い私:
return R.Shop.IdolPiece.藤田ことね_世界一可愛い私
case DailyMoneyShopItems.IdolPiece_花海佑芽_TheRollingRiceball:
return R.Shop.IdolPiece.花海佑芽_TheRollingRiceball
case DailyMoneyShopItems.IdolPiece_月村手毬_LunaSayMaybe:
return R.Shop.IdolPiece.月村手毬_LunaSayMaybe
case DailyMoneyShopItems.IdolPiece_有村麻央_Fluorite:
return R.Shop.IdolPiece.有村麻央_Fluorite
case _:
assert_never(self)
class ProduceAction(Enum):
RECOMMENDED = 'recommended'
VISUAL = 'visual'
VOCAL = 'vocal'
DANCE = 'dance'
# VISUAL_SP = 'visual_sp'
# VOCAL_SP = 'vocal_sp'
# DANCE_SP = 'dance_sp'
OUTING = 'outing'
STUDY = 'study'
ALLOWANCE = 'allowance'
REST = 'rest'
CONSULT = 'consult'
@property
def display_name(self):
MAP = {
ProduceAction.RECOMMENDED: '推荐行动',
ProduceAction.VISUAL: '形象课程',
ProduceAction.VOCAL: '声乐课程',
ProduceAction.DANCE: '舞蹈课程',
ProduceAction.OUTING: '外出(おでかけ)',
ProduceAction.STUDY: '文化课(授業)',
ProduceAction.ALLOWANCE: '活动支给(活動支給)',
ProduceAction.REST: '休息',
ProduceAction.CONSULT: '咨询(相談)',
}
return MAP[self]
class RecommendCardDetectionMode(Enum):
NORMAL = 'normal'
STRICT = 'strict'
@property
def display_name(self):
MAP = {
RecommendCardDetectionMode.NORMAL: '正常模式',
RecommendCardDetectionMode.STRICT: '严格模式',
}
return MAP[self]

View File

@ -0,0 +1,28 @@
from typing import Callable, Any, Dict
# 迁移函数类型:接收单个 user_config(dict),就地修改并返回提示信息
Migration = Callable[[dict[str, Any]], str | None]
# 导入各版本迁移实现
from . import _v1_to_v2
from . import _v2_to_v3
from . import _v3_to_v4
from . import _v4_to_v5
from . import _v5_to_v6
# 注册表:键为旧版本号,值为迁移函数
MIGRATION_REGISTRY: Dict[int, Migration] = {
1: _v1_to_v2.migrate,
2: _v2_to_v3.migrate,
3: _v3_to_v4.migrate,
4: _v4_to_v5.migrate,
5: _v5_to_v6.migrate,
}
# 当前最新配置版本
LATEST_VERSION: int = 6
__all__ = [
"MIGRATION_REGISTRY",
"LATEST_VERSION",
]

View File

@ -0,0 +1,106 @@
from enum import IntEnum
倉本千奈_BASE = 0
十王星南_BASE = 100
姫崎莉波_BASE = 200
月村手毬_BASE = 300
有村麻央_BASE = 400
篠泽广_BASE = 500
紫云清夏_BASE = 600
花海佑芽_BASE = 700
花海咲季_BASE = 800
葛城リーリヤ_BASE = 900
藤田ことね_BASE = 1000
class PIdol(IntEnum):
"""P 偶像。(仅用于旧版配置升级。)"""
倉本千奈_Campusmode = 倉本千奈_BASE + 0
倉本千奈_WonderScale = 倉本千奈_BASE + 1
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
倉本千奈_初心 = 倉本千奈_BASE + 4
倉本千奈_学園生活 = 倉本千奈_BASE + 5
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
十王星南_Campusmode = 十王星南_BASE + 0
十王星南_一番星 = 十王星南_BASE + 1
十王星南_学園生活 = 十王星南_BASE + 2
十王星南_小さな野望 = 十王星南_BASE + 3
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
姫崎莉波_LUV = 姫崎莉波_BASE + 4
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
姫崎莉波_初心 = 姫崎莉波_BASE + 7
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
月村手毬_一匹狼 = 月村手毬_BASE + 1
月村手毬_Campusmode = 月村手毬_BASE + 2
月村手毬_アイヴイ = 月村手毬_BASE + 3
月村手毬_初声 = 月村手毬_BASE + 4
月村手毬_学園生活 = 月村手毬_BASE + 5
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
有村麻央_Fluorite = 有村麻央_BASE + 0
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
有村麻央_Campusmode = 有村麻央_BASE + 2
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
有村麻央_初恋 = 有村麻央_BASE + 5
有村麻央_学園生活 = 有村麻央_BASE + 6
篠泽广_コントラスト = 篠泽广_BASE + 0
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
篠泽广_光景 = 篠泽广_BASE + 2
篠泽广_Campusmode = 篠泽广_BASE + 3
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
篠泽广_初恋 = 篠泽广_BASE + 6
篠泽广_学園生活 = 篠泽广_BASE + 7
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
紫云清夏_Campusmode = 紫云清夏_BASE + 3
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
紫云清夏_初恋 = 紫云清夏_BASE + 5
紫云清夏_学園生活 = 紫云清夏_BASE + 6
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
花海佑芽_学園生活 = 花海佑芽_BASE + 1
花海佑芽_Campusmode = 花海佑芽_BASE + 2
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
花海咲季_Campusmode = 花海咲季_BASE + 1
花海咲季_FightingMyWay = 花海咲季_BASE + 2
花海咲季_わたしが一番 = 花海咲季_BASE + 3
花海咲季_冠菊 = 花海咲季_BASE + 4
花海咲季_初声 = 花海咲季_BASE + 5
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
花海咲季_学園生活 = 花海咲季_BASE + 7
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
藤田ことね_Campusmode = 藤田ことね_BASE + 2
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
藤田ことね_WhiteNightWhiteWish =藤田ことね_BASE + 4
藤田ことね_冠菊 = 藤田ことね_BASE + 5
藤田ことね_初声 = 藤田ことね_BASE + 6
藤田ことね_学園生活 = 藤田ことね_BASE + 7
__all__ = ["PIdol"]

View File

@ -0,0 +1,203 @@
"""v1 -> v2 迁移脚本
1. PIdol 字符串列表转换为整数枚举值
"""
from __future__ import annotations
import logging
from typing import Any
from ._idol import PIdol
logger = logging.getLogger(__name__)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v1→v2 迁移。
参数 ``user_config`` 为单个用户配置 (dict)本函数允许就地修改
返回提示信息 (str)若无需提示可返回 ``None``
"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v1→v2 migration.")
return None
msg: str = ""
# 将旧格式的 idol 描述 (list[str]) 映射到 PIdol 枚举
def map_idol(idol: list[str]) -> PIdol | None:
logger.debug("Converting idol spec: %s", idol)
# 以下内容直接复制自旧实现
match idol:
case ["倉本千奈", "Campus mode!!"]:
return PIdol.倉本千奈_Campusmode
case ["倉本千奈", "Wonder Scale"]:
return PIdol.倉本千奈_WonderScale
case ["倉本千奈", "ようこそ初星温泉"]:
return PIdol.倉本千奈_ようこそ初星温泉
case ["倉本千奈", "仮装狂騒曲"]:
return PIdol.倉本千奈_仮装狂騒曲
case ["倉本千奈", "初心"]:
return PIdol.倉本千奈_初心
case ["倉本千奈", "学園生活"]:
return PIdol.倉本千奈_学園生活
case ["倉本千奈", "日々、発見的ステップ!"]:
return PIdol.倉本千奈_日々_発見的ステップ
case ["倉本千奈", "胸を張って一歩ずつ"]:
return PIdol.倉本千奈_胸を張って一歩ずつ
case ["十王星南", "Campus mode!!"]:
return PIdol.十王星南_Campusmode
case ["十王星南", "一番星"]:
return PIdol.十王星南_一番星
case ["十王星南", "学園生活"]:
return PIdol.十王星南_学園生活
case ["十王星南", "小さな野望"]:
return PIdol.十王星南_小さな野望
case ["姫崎莉波", "clumsy trick"]:
return PIdol.姫崎莉波_clumsytrick
case ["姫崎莉波", "『私らしさ』のはじまり"]:
return PIdol.姫崎莉波_私らしさのはじまり
case ["姫崎莉波", "キミとセミブルー"]:
return PIdol.姫崎莉波_キミとセミブルー
case ["姫崎莉波", "Campus mode!!"]:
return PIdol.姫崎莉波_Campusmode
case ["姫崎莉波", "L.U.V"]:
return PIdol.姫崎莉波_LUV
case ["姫崎莉波", "ようこそ初星温泉"]:
return PIdol.姫崎莉波_ようこそ初星温泉
case ["姫崎莉波", "ハッピーミルフィーユ"]:
return PIdol.姫崎莉波_ハッピーミルフィーユ
case ["姫崎莉波", "初心"]:
return PIdol.姫崎莉波_初心
case ["姫崎莉波", "学園生活"]:
return PIdol.姫崎莉波_学園生活
case ["月村手毬", "Luna say maybe"]:
return PIdol.月村手毬_Lunasaymaybe
case ["月村手毬", "一匹狼"]:
return PIdol.月村手毬_一匹狼
case ["月村手毬", "Campus mode!!"]:
return PIdol.月村手毬_Campusmode
case ["月村手毬", "アイヴイ"]:
return PIdol.月村手毬_アイヴイ
case ["月村手毬", "初声"]:
return PIdol.月村手毬_初声
case ["月村手毬", "学園生活"]:
return PIdol.月村手毬_学園生活
case ["月村手毬", "仮装狂騒曲"]:
return PIdol.月村手毬_仮装狂騒曲
case ["有村麻央", "Fluorite"]:
return PIdol.有村麻央_Fluorite
case ["有村麻央", "はじまりはカッコよく"]:
return PIdol.有村麻央_はじまりはカッコよく
case ["有村麻央", "Campus mode!!"]:
return PIdol.有村麻央_Campusmode
case ["有村麻央", "Feel Jewel Dream"]:
return PIdol.有村麻央_FeelJewelDream
case ["有村麻央", "キミとセミブルー"]:
return PIdol.有村麻央_キミとセミブルー
case ["有村麻央", "初恋"]:
return PIdol.有村麻央_初恋
case ["有村麻央", "学園生活"]:
return PIdol.有村麻央_学園生活
case ["篠泽广", "コントラスト"]:
return PIdol.篠泽广_コントラスト
case ["篠泽广", "一番向いていないこと"]:
return PIdol.篠泽广_一番向いていないこと
case ["篠泽广", "光景"]:
return PIdol.篠泽广_光景
case ["篠泽广", "Campus mode!!"]:
return PIdol.篠泽广_Campusmode
case ["篠泽广", "仮装狂騒曲"]:
return PIdol.篠泽广_仮装狂騒曲
case ["篠泽广", "ハッピーミルフィーユ"]:
return PIdol.篠泽广_ハッピーミルフィーユ
case ["篠泽广", "初恋"]:
return PIdol.篠泽广_初恋
case ["篠泽广", "学園生活"]:
return PIdol.篠泽广_学園生活
case ["紫云清夏", "Tame Lie One Step"]:
return PIdol.紫云清夏_TameLieOneStep
case ["紫云清夏", "カクシタワタシ"]:
return PIdol.紫云清夏_カクシタワタシ
case ["紫云清夏", "夢へのリスタート"]:
return PIdol.紫云清夏_夢へのリスタート
case ["紫云清夏", "Campus mode!!"]:
return PIdol.紫云清夏_Campusmode
case ["紫云清夏", "キミとセミブルー"]:
return PIdol.紫云清夏_キミとセミブルー
case ["紫云清夏", "初恋"]:
return PIdol.紫云清夏_初恋
case ["紫云清夏", "学園生活"]:
return PIdol.紫云清夏_学園生活
case ["花海佑芽", "White Night! White Wish!"]:
return PIdol.花海佑芽_WhiteNightWhiteWish
case ["花海佑芽", "学園生活"]:
return PIdol.花海佑芽_学園生活
case ["花海佑芽", "Campus mode!!"]:
return PIdol.花海佑芽_Campusmode
case ["花海佑芽", "The Rolling Riceball"]:
return PIdol.花海佑芽_TheRollingRiceball
case ["花海佑芽", "アイドル、はじめっ!"]:
return PIdol.花海佑芽_アイドル_はじめっ
case ["花海咲季", "Boom Boom Pow"]:
return PIdol.花海咲季_BoomBoomPow
case ["花海咲季", "Campus mode!!"]:
return PIdol.花海咲季_Campusmode
case ["花海咲季", "Fighting My Way"]:
return PIdol.花海咲季_FightingMyWay
case ["花海咲季", "わたしが一番!"]:
return PIdol.花海咲季_わたしが一番
case ["花海咲季", "冠菊"]:
return PIdol.花海咲季_冠菊
case ["花海咲季", "初声"]:
return PIdol.花海咲季_初声
case ["花海咲季", "古今東西ちょちょいのちょい"]:
return PIdol.花海咲季_古今東西ちょちょいのちょい
case ["花海咲季", "学園生活"]:
return PIdol.花海咲季_学園生活
case ["葛城リーリヤ", "一つ踏み出した先に"]:
return PIdol.葛城リーリヤ_一つ踏み出した先に
case ["葛城リーリヤ", "白線"]:
return PIdol.葛城リーリヤ_白線
case ["葛城リーリヤ", "Campus mode!!"]:
return PIdol.葛城リーリヤ_Campusmode
case ["葛城リーリヤ", "White Night! White Wish!"]:
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
case ["葛城リーリヤ", "冠菊"]:
return PIdol.葛城リーリヤ_冠菊
case ["葛城リーリヤ", "初心"]:
return PIdol.葛城リーリヤ_初心
case ["葛城リーリヤ", "学園生活"]:
return PIdol.葛城リーリヤ_学園生活
case ["藤田ことね", "カワイイ", "はじめました"]:
return PIdol.藤田ことね_カワイイ_はじめました
case ["藤田ことね", "世界一可愛い私"]:
return PIdol.藤田ことね_世界一可愛い私
case ["藤田ことね", "Campus mode!!"]:
return PIdol.藤田ことね_Campusmode
case ["藤田ことね", "Yellow Big Bang"]:
return PIdol.藤田ことね_YellowBigBang
case ["藤田ことね", "White Night! White Wish!"]:
return PIdol.藤田ことね_WhiteNightWhiteWish
case ["藤田ことね", "冠菊"]:
return PIdol.藤田ことね_冠菊
case ["藤田ことね", "初声"]:
return PIdol.藤田ことね_初声
case ["藤田ことね", "学園生活"]:
return PIdol.藤田ことね_学園生活
case _:
nonlocal msg
if msg == "":
msg = "培育设置中的以下偶像升级失败。请尝试手动添加。\n"
msg += f"{idol} 未找到\n"
return None
produce_conf = options.get("produce", {})
old_idols = produce_conf.get("idols", [])
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
produce_conf["idols"] = new_idols
options["produce"] = produce_conf
user_config["options"] = options
return msg or None

View File

@ -0,0 +1,126 @@
"""v2 → v3 迁移脚本
引入游戏解包数据后`produce.idols` 不再使用 `PIdol` 枚举而是直接使用
游戏内的 idol skin id (字符串)这里负责完成枚举到字符串的转换
"""
from __future__ import annotations
import logging
from typing import Any
from ._idol import PIdol
logger = logging.getLogger(__name__)
# 枚举 → skin_id 映射表(复制自旧实现)。
_PIDOL_TO_SKIN: dict[PIdol, str] = {
PIdol.倉本千奈_Campusmode: "i_card-skin-kcna-3-007",
PIdol.倉本千奈_WonderScale: "i_card-skin-kcna-3-000",
PIdol.倉本千奈_ようこそ初星温泉: "i_card-skin-kcna-3-005",
PIdol.倉本千奈_仮装狂騒曲: "i_card-skin-kcna-3-002",
PIdol.倉本千奈_初心: "i_card-skin-kcna-1-001",
PIdol.倉本千奈_学園生活: "i_card-skin-kcna-1-000",
PIdol.倉本千奈_日々_発見的ステップ: "i_card-skin-kcna-3-001",
PIdol.倉本千奈_胸を張って一歩ずつ: "i_card-skin-kcna-2-000",
PIdol.十王星南_Campusmode: "i_card-skin-jsna-3-002",
PIdol.十王星南_一番星: "i_card-skin-jsna-2-000",
PIdol.十王星南_学園生活: "i_card-skin-jsna-1-000",
PIdol.十王星南_小さな野望: "i_card-skin-jsna-3-000",
PIdol.姫崎莉波_clumsytrick: "i_card-skin-hrnm-3-000",
PIdol.姫崎莉波_私らしさのはじまり: "i_card-skin-hrnm-2-000",
PIdol.姫崎莉波_キミとセミブルー: "i_card-skin-hrnm-3-001",
PIdol.姫崎莉波_Campusmode: "i_card-skin-hrnm-3-007",
PIdol.姫崎莉波_LUV: "i_card-skin-hrnm-3-002",
PIdol.姫崎莉波_ようこそ初星温泉: "i_card-skin-hrnm-3-004",
PIdol.姫崎莉波_ハッピーミルフィーユ: "i_card-skin-hrnm-3-008",
PIdol.姫崎莉波_初心: "i_card-skin-hrnm-1-001",
PIdol.姫崎莉波_学園生活: "i_card-skin-hrnm-1-000",
PIdol.月村手毬_Lunasaymaybe: "i_card-skin-ttmr-3-000",
PIdol.月村手毬_一匹狼: "i_card-skin-ttmr-2-000",
PIdol.月村手毬_Campusmode: "i_card-skin-ttmr-3-007",
PIdol.月村手毬_アイヴイ: "i_card-skin-ttmr-3-001",
PIdol.月村手毬_初声: "i_card-skin-ttmr-1-001",
PIdol.月村手毬_学園生活: "i_card-skin-ttmr-1-000",
PIdol.月村手毬_仮装狂騒曲: "i_card-skin-ttmr-3-002",
PIdol.有村麻央_Fluorite: "i_card-skin-amao-3-000",
PIdol.有村麻央_はじまりはカッコよく: "i_card-skin-amao-2-000",
PIdol.有村麻央_Campusmode: "i_card-skin-amao-3-007",
PIdol.有村麻央_FeelJewelDream: "i_card-skin-amao-3-002",
PIdol.有村麻央_キミとセミブルー: "i_card-skin-amao-3-001",
PIdol.有村麻央_初恋: "i_card-skin-amao-1-001",
PIdol.有村麻央_学園生活: "i_card-skin-amao-1-000",
PIdol.篠泽广_コントラスト: "i_card-skin-shro-3-001",
PIdol.篠泽广_一番向いていないこと: "i_card-skin-shro-2-000",
PIdol.篠泽广_光景: "i_card-skin-shro-3-000",
PIdol.篠泽广_Campusmode: "i_card-skin-shro-3-007",
PIdol.篠泽广_仮装狂騒曲: "i_card-skin-shro-3-002",
PIdol.篠泽广_ハッピーミルフィーユ: "i_card-skin-shro-3-008",
PIdol.篠泽广_初恋: "i_card-skin-shro-1-001",
PIdol.篠泽广_学園生活: "i_card-skin-shro-1-000",
PIdol.紫云清夏_TameLieOneStep: "i_card-skin-ssmk-3-000",
PIdol.紫云清夏_カクシタワタシ: "i_card-skin-ssmk-3-002",
PIdol.紫云清夏_夢へのリスタート: "i_card-skin-ssmk-2-000",
PIdol.紫云清夏_Campusmode: "i_card-skin-ssmk-3-007",
PIdol.紫云清夏_キミとセミブルー: "i_card-skin-ssmk-3-001",
PIdol.紫云清夏_初恋: "i_card-skin-ssmk-1-001",
PIdol.紫云清夏_学園生活: "i_card-skin-ssmk-1-000",
PIdol.花海佑芽_WhiteNightWhiteWish: "i_card-skin-hume-3-005",
PIdol.花海佑芽_学園生活: "i_card-skin-hume-1-000",
PIdol.花海佑芽_Campusmode: "i_card-skin-hume-3-006",
PIdol.花海佑芽_TheRollingRiceball: "i_card-skin-hume-3-000",
PIdol.花海佑芽_アイドル_はじめっ: "i_card-skin-hume-2-000",
PIdol.花海咲季_BoomBoomPow: "i_card-skin-hski-3-001",
PIdol.花海咲季_Campusmode: "i_card-skin-hski-3-008",
PIdol.花海咲季_FightingMyWay: "i_card-skin-hski-3-000",
PIdol.花海咲季_わたしが一番: "i_card-skin-hski-2-000",
PIdol.花海咲季_冠菊: "i_card-skin-hski-3-001",
PIdol.花海咲季_初声: "i_card-skin-hski-1-001",
PIdol.花海咲季_古今東西ちょちょいのちょい: "i_card-skin-hski-3-006",
PIdol.花海咲季_学園生活: "i_card-skin-hski-1-000",
PIdol.葛城リーリヤ_一つ踏み出した先に: "i_card-skin-kllj-2-000",
PIdol.葛城リーリヤ_白線: "i_card-skin-kllj-3-000",
PIdol.葛城リーリヤ_Campusmode: "i_card-skin-kllj-3-006",
PIdol.葛城リーリヤ_WhiteNightWhiteWish: "i_card-skin-kllj-3-005",
PIdol.葛城リーリヤ_冠菊: "i_card-skin-kllj-3-001",
PIdol.葛城リーリヤ_初心: "i_card-skin-kllj-1-001",
PIdol.葛城リーリヤ_学園生活: "i_card-skin-kllj-1-000",
PIdol.藤田ことね_カワイイ_はじめました: "i_card-skin-fktn-2-000",
PIdol.藤田ことね_世界一可愛い私: "i_card-skin-fktn-3-000",
PIdol.藤田ことね_Campusmode: "i_card-skin-fktn-3-007",
PIdol.藤田ことね_YellowBigBang: "i_card-skin-fktn-3-001",
PIdol.藤田ことね_WhiteNightWhiteWish: "i_card-skin-fktn-3-006",
PIdol.藤田ことね_冠菊: "i_card-skin-fktn-3-002",
PIdol.藤田ことね_初声: "i_card-skin-fktn-1-001",
PIdol.藤田ことね_学園生活: "i_card-skin-fktn-1-000",
}
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v2→v3 迁移。"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v2→v3 migration.")
return None
produce_conf = options.get("produce", {})
old_idols = produce_conf.get("idols", [])
msg = ""
new_idols: list[str] = []
for idol in old_idols:
if isinstance(idol, int): # 原本已是 int(PIdol)
try:
skin = _PIDOL_TO_SKIN[PIdol(idol)]
new_idols.append(skin)
except (ValueError, KeyError):
msg += f"未知 PIdol: {idol}\n"
else:
msg += f"旧 idol 数据格式异常: {idol}\n"
produce_conf["idols"] = new_idols
options["produce"] = produce_conf
user_config["options"] = options
return msg or None

View File

@ -0,0 +1,29 @@
"""v3 -> v4 迁移脚本
修正游戏包名错误
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v3→v4 迁移:修正错误的游戏包名。"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v3→v4 migration.")
return None
start_conf = options.get("start_game", {})
old_pkg = start_conf.get("game_package_name")
if old_pkg == "com.bandinamcoent.idolmaster_gakuen":
start_conf["game_package_name"] = "com.bandainamcoent.idolmaster_gakuen"
logger.info("Corrected game package name to com.bandainamcoent.idolmaster_gakuen")
options["start_game"] = start_conf
user_config["options"] = options
return None

View File

@ -0,0 +1,26 @@
"""v4 -> v5 迁移脚本
Windows 截图方式的配置统一设置 backend.type = 'dmm'
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v4→v5 迁移:
当截图方式为 windows / remote_windows backend.type 统一设置为 'dmm'
"""
backend = user_config.get("backend", {})
impl = backend.get("screenshot_impl")
if impl in {"windows", "remote_windows"}:
logger.info("Set backend type to dmm for screenshot_impl=%s", impl)
backend["type"] = "dmm"
user_config["backend"] = backend
# v4→v5 无 options 结构更改,直接返回
return None

View File

@ -0,0 +1,134 @@
"""v5 -> v6 迁移脚本
重构培育配置将原有的 ProduceConfig 中的培育参数迁移到新的 ProduceSolution 结构中
"""
from __future__ import annotations
import logging
import os
import json
import uuid
import re
from typing import Any
logger = logging.getLogger(__name__)
def _sanitize_filename(name: str) -> str:
"""
清理文件名中的非法字符
:param name: 原始名称
:return: 清理后的文件名
"""
# 替换 \/:*?"<>| 为下划线
return re.sub(r'[\\/:*?"<>|]', '_', name)
def _create_default_solution(old_produce_config: dict[str, Any]) -> tuple[dict[str, Any], str]:
"""
根据旧的培育配置创建默认的培育方案
:param old_produce_config: 旧的培育配置
:return: (新的培育方案数据, 方案ID)
"""
# 生成唯一ID
solution_id = uuid.uuid4().hex
# 构建培育数据
produce_data = {
"mode": old_produce_config.get("mode", "regular"),
"idol": old_produce_config.get("idols", [None])[0] if old_produce_config.get("idols") else None,
"memory_set": old_produce_config.get("memory_sets", [None])[0] if old_produce_config.get("memory_sets") else None,
"support_card_set": old_produce_config.get("support_card_sets", [None])[0] if old_produce_config.get("support_card_sets") else None,
"auto_set_memory": old_produce_config.get("auto_set_memory", False),
"auto_set_support_card": old_produce_config.get("auto_set_support_card", False),
"use_pt_boost": old_produce_config.get("use_pt_boost", False),
"use_note_boost": old_produce_config.get("use_note_boost", False),
"follow_producer": old_produce_config.get("follow_producer", False),
"self_study_lesson": old_produce_config.get("self_study_lesson", "dance"),
"prefer_lesson_ap": old_produce_config.get("prefer_lesson_ap", False),
"actions_order": old_produce_config.get("actions_order", [
"recommended", "visual", "vocal", "dance",
"allowance", "outing", "study", "consult", "rest"
]),
"recommend_card_detection_mode": old_produce_config.get("recommend_card_detection_mode", "normal"),
"use_ap_drink": old_produce_config.get("use_ap_drink", False),
"skip_commu": old_produce_config.get("skip_commu", True)
}
# 构建方案对象
solution = {
"type": "produce_solution",
"id": solution_id,
"name": "默认方案",
"description": "从旧配置迁移的默认培育方案",
"data": produce_data
}
return solution, solution_id
def _save_solution_to_file(solution: dict[str, Any]) -> None:
"""
将培育方案保存到文件
:param solution: 培育方案数据
"""
solutions_dir = "conf/produce"
os.makedirs(solutions_dir, exist_ok=True)
safe_name = _sanitize_filename(solution["name"])
file_path = os.path.join(solutions_dir, f"{safe_name}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(solution, f, ensure_ascii=False, indent=4)
def migrate(user_config: dict[str, Any]) -> str | None: # noqa: D401
"""执行 v5→v6 迁移:重构培育配置结构。
将原有的 ProduceConfig 中的培育参数迁移到新的 ProduceSolution 结构中
"""
options = user_config.get("options")
if options is None:
logger.debug("No 'options' in user_config, skip v5→v6 migration.")
return None
produce_conf = options.get("produce", {})
if not produce_conf:
logger.debug("No 'produce' config found, skip v5→v6 migration.")
return None
# 检查是否已经是新格式(有 selected_solution_id 字段)
if "selected_solution_id" in produce_conf:
logger.debug("Produce config already in v6 format, skip migration.")
return None
msg = ""
try:
# 创建默认培育方案
solution, solution_id = _create_default_solution(produce_conf)
# 保存方案到文件
_save_solution_to_file(solution)
# 更新配置为新格式
new_produce_conf = {
"enabled": produce_conf.get("enabled", False),
"selected_solution_id": solution_id,
"produce_count": produce_conf.get("produce_count", 1)
}
options["produce"] = new_produce_conf
user_config["options"] = options
msg = f"已将培育配置迁移到新的方案系统。默认方案已创建并保存为 '{solution['name']}'"
logger.info("Successfully migrated produce config to v6 format with solution ID: %s", solution_id)
except Exception as e:
logger.error("Failed to migrate produce config: %s", e)
msg = f"培育配置迁移失败:{e}"
return msg or None

View File

@ -0,0 +1,255 @@
import os
import json
import uuid
import re
import logging
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
from .const import ProduceAction, RecommendCardDetectionMode
logger = logging.getLogger(__name__)
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class ProduceData(ConfigBaseModel):
mode: Literal['regular', 'pro', 'master'] = 'regular'
"""
培育模式
进行一次 REGULAR 培育需要 ~30min进行一次 PRO 培育需要 ~1h具体视设备性能而定
"""
idol: str | None = None
"""
要培育偶像的 IdolCardSkin.id
"""
memory_set: int | None = None
"""要使用的回忆编成编号,从 1 开始。"""
support_card_set: int | None = None
"""要使用的支援卡编成编号,从 1 开始。"""
auto_set_memory: bool = False
"""是否自动编成回忆。此选项优先级高于回忆编成编号。"""
auto_set_support_card: bool = False
"""是否自动编成支援卡。此选项优先级高于支援卡编成编号。"""
use_pt_boost: bool = False
"""是否使用支援强化 Pt 提升。"""
use_note_boost: bool = False
"""是否使用笔记数提升。"""
follow_producer: bool = False
"""是否关注租借了支援卡的制作人。"""
self_study_lesson: Literal['dance', 'visual', 'vocal'] = 'dance'
"""自习课类型。"""
prefer_lesson_ap: bool = False
"""
优先 SP 课程
启用后若出现 SP 课程则会优先执行 SP 课程而不是推荐课程
若出现多个 SP 课程随机选择一个
"""
actions_order: list[ProduceAction] = [
ProduceAction.RECOMMENDED,
ProduceAction.VISUAL,
ProduceAction.VOCAL,
ProduceAction.DANCE,
ProduceAction.ALLOWANCE,
ProduceAction.OUTING,
ProduceAction.STUDY,
ProduceAction.CONSULT,
ProduceAction.REST,
]
"""
行动优先级
每一周的行动将会按这里设置的优先级执行
"""
recommend_card_detection_mode: RecommendCardDetectionMode = RecommendCardDetectionMode.NORMAL
"""
推荐卡检测模式
严格模式下识别速度会降低但识别准确率会提高
"""
use_ap_drink: bool = False
"""
AP 不足时自动使用 AP 饮料
"""
skip_commu: bool = True
"""检测并跳过交流"""
class ProduceSolution(ConfigBaseModel):
"""培育方案"""
type: Literal['produce_solution'] = 'produce_solution'
"""方案类型标识"""
id: str
"""方案唯一标识符"""
name: str
"""方案名称"""
description: str | None = None
"""方案描述"""
data: ProduceData
"""培育数据"""
class ProduceSolutionManager:
"""培育方案管理器"""
SOLUTIONS_DIR = "conf/produce"
def __init__(self):
"""初始化管理器,确保目录存在"""
os.makedirs(self.SOLUTIONS_DIR, exist_ok=True)
def _sanitize_filename(self, name: str) -> str:
"""
清理文件名中的非法字符
:param name: 原始名称
:return: 清理后的文件名
"""
# 替换 \/:*?"<>| 为下划线
return re.sub(r'[\\/:*?"<>|]', '_', name)
def _get_file_path(self, name: str) -> str:
"""
根据方案名称获取文件路径
:param name: 方案名称
:return: 文件路径
"""
safe_name = self._sanitize_filename(name)
return os.path.join(self.SOLUTIONS_DIR, f"{safe_name}.json")
def _find_file_path_by_id(self, id: str) -> str | None:
"""
根据方案ID查找文件路径
:param id: 方案ID
:return: 文件路径如果未找到则返回 None
"""
if not os.path.exists(self.SOLUTIONS_DIR):
return None
for filename in os.listdir(self.SOLUTIONS_DIR):
if filename.endswith('.json'):
try:
file_path = os.path.join(self.SOLUTIONS_DIR, filename)
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('id') == id:
return file_path
except Exception:
continue
return None
def new(self, name: str) -> ProduceSolution:
"""
创建新的培育方案
:param name: 方案名称
:return: 新创建的方案
"""
solution = ProduceSolution(
id=uuid.uuid4().hex,
name=name,
data=ProduceData()
)
return solution
def list(self) -> list[ProduceSolution]:
"""
列出所有培育方案
:return: 方案列表
"""
solutions = []
if not os.path.exists(self.SOLUTIONS_DIR):
return solutions
for filename in os.listdir(self.SOLUTIONS_DIR):
if filename.endswith('.json'):
try:
file_path = os.path.join(self.SOLUTIONS_DIR, filename)
with open(file_path, 'r', encoding='utf-8') as f:
solution = ProduceSolution.model_validate_json(f.read())
solutions.append(solution)
logger.info(f"Loaded produce solution from {file_path}")
except Exception:
logger.warning(f"Failed to load produce solution from {file_path}")
continue
return solutions
def delete(self, id: str) -> None:
"""
删除指定ID的培育方案
:param id: 方案ID
"""
file_path = self._find_file_path_by_id(id)
if file_path:
os.remove(file_path)
def save(self, id: str, solution: ProduceSolution) -> None:
"""
保存培育方案
:param id: 方案ID
:param solution: 方案对象
"""
# 确保ID一致
solution.id = id
# 先删除具有相同ID的旧文件如果存在避免名称变更时产生重复文件
old_file_path = self._find_file_path_by_id(id)
if old_file_path:
os.remove(old_file_path)
# 保存新文件
file_path = self._get_file_path(solution.name)
with open(file_path, 'w', encoding='utf-8') as f:
# 使用 model_dump 并指定 mode='json' 来正确序列化枚举
data = solution.model_dump(mode='json')
json.dump(data, f, ensure_ascii=False, indent=4)
def read(self, id: str) -> ProduceSolution:
"""
读取指定ID的培育方案
:param id: 方案ID
:return: 方案对象
:raises FileNotFoundError: 当方案不存在时
"""
file_path = self._find_file_path_by_id(id)
if not file_path:
raise FileNotFoundError(f"Solution with id '{id}' not found")
try:
with open(file_path, 'r', encoding='utf-8') as f:
return ProduceSolution.model_validate_json(f.read())
except Exception as e:
raise FileNotFoundError(f"Failed to read solution with id '{id}': {e}")
def duplicate(self, id: str) -> ProduceSolution:
"""
复制指定ID的培育方案
:param id: 要复制的方案ID
:return: 新的方案对象具有新的ID和名称
:raises FileNotFoundError: 当原方案不存在时
"""
original = self.read(id)
# 生成新的ID和名称
new_id = uuid.uuid4().hex
new_name = f"{original.name} - 副本"
# 创建新的方案对象
new_solution = ProduceSolution(
type=original.type,
id=new_id,
name=new_name,
description=original.description,
data=original.data.model_copy() # 深拷贝数据
)
return new_solution

View File

@ -0,0 +1,236 @@
from typing import TypeVar, Literal, Sequence
from pydantic import BaseModel, ConfigDict
from kotonebot import config
from kotonebot.kaa.config.produce import ProduceSolution, ProduceSolutionManager
from .const import (
ConfigEnum,
Priority,
APShopItems,
DailyMoneyShopItems,
)
T = TypeVar('T')
class ConfigBaseModel(BaseModel):
model_config = ConfigDict(use_attribute_docstrings=True)
class PurchaseConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用商店购买"""
money_enabled: bool = False
"""是否启用金币购买"""
money_items: list[DailyMoneyShopItems] = []
"""金币商店要购买的物品"""
money_refresh: bool = True
"""
是否使用每日一次免费刷新金币商店
"""
ap_enabled: bool = False
"""是否启用AP购买"""
ap_items: Sequence[Literal[0, 1, 2, 3]] = []
"""AP商店要购买的物品"""
class ActivityFundsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取活动费"""
class PresentsConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用收取礼物"""
class AssignmentConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用工作"""
mini_live_reassign_enabled: bool = False
"""是否启用重新分配 MiniLive"""
mini_live_duration: Literal[4, 6, 12] = 12
"""MiniLive 工作时长"""
online_live_reassign_enabled: bool = False
"""是否启用重新分配 OnlineLive"""
online_live_duration: Literal[4, 6, 12] = 12
"""OnlineLive 工作时长"""
class ContestConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用竞赛"""
select_which_contestant: Literal[1, 2, 3] = 1
"""选择第几个挑战者"""
class ProduceConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用培育"""
selected_solution_id: str | None = None
"""选中的培育方案ID"""
produce_count: int = 1
"""培育的次数。"""
class MissionRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取任务奖励"""
class ClubRewardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用领取社团奖励"""
selected_note: DailyMoneyShopItems = DailyMoneyShopItems.AnomalyNoteVisual
"""想在社团奖励中获取到的笔记"""
class UpgradeSupportCardConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用支援卡升级"""
class CapsuleToysConfig(ConfigBaseModel):
enabled: bool = False
"""是否启用扭蛋机"""
friend_capsule_toys_count: int = 0
"""好友扭蛋机次数"""
sense_capsule_toys_count: int = 0
"""感性扭蛋机次数"""
logic_capsule_toys_count: int = 0
"""理性扭蛋机次数"""
anomaly_capsule_toys_count: int = 0
"""非凡扭蛋机次数"""
class TraceConfig(ConfigBaseModel):
recommend_card_detection: bool = False
"""跟踪推荐卡检测"""
class StartGameConfig(ConfigBaseModel):
enabled: bool = True
"""是否启用自动启动游戏。默认为True"""
start_through_kuyo: bool = False
"""是否通过Kuyo来启动游戏"""
game_package_name: str = 'com.bandainamcoent.idolmaster_gakuen'
"""游戏包名"""
kuyo_package_name: str = 'org.kuyo.game'
"""Kuyo包名"""
disable_gakumas_localify: bool = False
"""
自动检测并禁用 Gakumas Localify 汉化插件
目前仅对 DMM 版有效
"""
dmm_game_path: str | None = None
"""
DMM 版游戏路径若不填写会自动检测
`F:\\Games\\gakumas\\gakumas.exe`
"""
class EndGameConfig(ConfigBaseModel):
exit_kaa: bool = False
"""退出 kaa"""
kill_game: bool = False
"""关闭游戏"""
kill_dmm: bool = False
"""关闭 DMMGamePlayer"""
kill_emulator: bool = False
"""关闭模拟器"""
shutdown: bool = False
"""关闭系统"""
hibernate: bool = False
"""休眠系统"""
restore_gakumas_localify: bool = False
"""
恢复 Gakumas Localify 汉化插件状态至启动前通常与
`disable_gakumas_localify` 配对使用
目前仅对 DMM 版有效
"""
class MiscConfig(ConfigBaseModel):
check_update: Literal['never', 'startup'] = 'startup'
"""
检查更新时机
* never: 从不检查更新
* startup: 启动时检查更新
"""
auto_install_update: bool = True
"""
是否自动安装更新
若启用则每次自动检查更新时若有新版本会自动安装否则只是会提示
"""
expose_to_lan: bool = False
"""
是否允许局域网访问 Web 界面
启用后局域网内的其他设备可以通过本机 IP 地址访问 Web 界面
"""
class BaseConfig(ConfigBaseModel):
purchase: PurchaseConfig = PurchaseConfig()
"""商店购买配置"""
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
"""活动费配置"""
presents: PresentsConfig = PresentsConfig()
"""收取礼物配置"""
assignment: AssignmentConfig = AssignmentConfig()
"""工作配置"""
contest: ContestConfig = ContestConfig()
"""竞赛配置"""
produce: ProduceConfig = ProduceConfig()
"""培育配置"""
mission_reward: MissionRewardConfig = MissionRewardConfig()
"""领取任务奖励配置"""
club_reward: ClubRewardConfig = ClubRewardConfig()
"""领取社团奖励配置"""
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
"""支援卡升级配置"""
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
"""扭蛋机配置"""
trace: TraceConfig = TraceConfig()
"""跟踪配置"""
start_game: StartGameConfig = StartGameConfig()
"""启动游戏配置"""
end_game: EndGameConfig = EndGameConfig()
"""关闭游戏配置"""
misc: MiscConfig = MiscConfig()
"""杂项配置"""
def conf() -> BaseConfig:
"""获取当前配置数据"""
c = config.to(BaseConfig).current
return c.options
def produce_solution() -> ProduceSolution:
"""获取当前培育方案"""
id = conf().produce.selected_solution_id
if id is None:
raise ValueError("No produce solution selected")
# TODO: 这里需要缓存,不能每次都从磁盘读取
return ProduceSolutionManager().read(id)

View File

@ -0,0 +1,63 @@
import os
import json
import logging
import shutil
from typing import Any
logger = logging.getLogger(__name__)
def upgrade_config() -> str | None:
"""检查并升级 `config.json` 到最新版本。
若配置已是最新版本则返回 ``None``否则返回合并后的迁移提示信息
"""
# 避免循环依赖,这里再进行本地导入
from .migrations import MIGRATION_REGISTRY, LATEST_VERSION # pylint: disable=import-outside-toplevel
logger.setLevel(logging.DEBUG)
print('1212121212')
config_path = "config.json"
if not os.path.exists(config_path):
logger.debug("config.json not found. Skip upgrade.")
return None
# 读取配置
with open(config_path, "r", encoding="utf-8") as f:
root: dict[str, Any] = json.load(f)
version: int = root.get("version", 1)
if version >= LATEST_VERSION:
logger.info("Config already at latest version (v%s).", version)
return None
logger.info("Start upgrading config: current v%s → target v%s", version, LATEST_VERSION)
messages: list[str] = []
# 循环依次升级
while version < LATEST_VERSION:
migrator = MIGRATION_REGISTRY.get(version)
if migrator is None:
logger.warning("No migrator registered for version v%s. Abort upgrade.", version)
break
# 备份文件
backup_path = f"config.v{version}.json"
shutil.copy(config_path, backup_path)
logger.info("Backup saved: %s", backup_path)
# 对每个 user_config 应用迁移
for user_cfg in root.get("user_configs", []):
msg = migrator(user_cfg)
if msg:
messages.append(f"v{version} → v{version+1}:\n{msg}")
# 更新版本号并写回
version += 1
root["version"] = version
with open(config_path, "w", encoding="utf-8") as f:
json.dump(root, f, ensure_ascii=False, indent=4)
logger.info("Config upgrade finished. Now at v%s", version)
return "\n---\n".join(messages) if messages else None

View File

@ -32,10 +32,13 @@ YELLOW_TARGET = (39, 81, 97)
YELLOW_LOW = (30, 70, 90)
YELLOW_HIGH = (45, 90, 100)
ORANGE_RANGE = ((14, 178, 229), (16, 229, 255))
DEFAULT_COLORS = [
(web2cv(PINK_LOW), web2cv(PINK_HIGH)),
(web2cv(YELLOW_LOW), web2cv(YELLOW_HIGH)),
(web2cv(BLUE_LOW), web2cv(BLUE_HIGH)),
ORANGE_RANGE
]
# 参考图片:

View File

@ -6,7 +6,7 @@ from cv2.typing import MatLike
from kotonebot.primitives import Rect
from kotonebot import ocr, device, image, action
from kotonebot.backend.core import HintBox
from kotonebot.kaa.common import ProduceAction
from kotonebot.kaa.config import ProduceAction
from kotonebot.kaa.tasks import R
logger = logging.getLogger(__name__)

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ from kotonebot import KotoneBot
from ..util.paths import get_ahk_path
from ..kaa_context import _set_instance
from .dmm_host import DmmHost, DmmInstance
from ..common import BaseConfig, upgrade_config
from ..config import BaseConfig, upgrade_config
from kotonebot.config.base_config import UserConfig
from kotonebot.client.host import (
Mumu12Host, LeidianHost, Mumu12Instance,
@ -30,32 +30,32 @@ from kotonebot.client.host.protocol import (
)
# 初始化日志
log_formatter = logging.Formatter('[%(asctime)s][%(levelname)s][%(name)s] %(message)s')
console_handler = logging.StreamHandler()
console_handler.setFormatter(log_formatter)
console_handler.setLevel(logging.CRITICAL)
format = '[%(asctime)s][%(levelname)s][%(name)s:%(lineno)d] %(message)s'
log_formatter = logging.Formatter(format)
logging.basicConfig(level=logging.INFO, format=format)
log_stream = io.StringIO()
stream_handler = logging.StreamHandler(log_stream)
stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] [%(filename)s:%(lineno)d] - %(message)s'))
memo_handler = logging.StreamHandler(log_stream)
memo_handler.setFormatter(log_formatter)
memo_handler.setLevel(logging.DEBUG)
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(console_handler)
root_logger.addHandler(memo_handler)
logging.getLogger("kotonebot").setLevel(logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
# 升级配置
upgrade_msg = upgrade_config()
class Kaa(KotoneBot):
"""
琴音小助手 kaa 主类由其他 GUI/TUI 调用
"""
def __init__(self, config_path: str):
# 升级配置
upgrade_msg = upgrade_config()
super().__init__(module='kotonebot.kaa.tasks', config_path=config_path, config_type=BaseConfig)
self.upgrade_msg = upgrade_msg
self.version = importlib.metadata.version('ksaa')
@ -70,7 +70,12 @@ class Kaa(KotoneBot):
root_logger.addHandler(file_handler)
def set_log_level(self, level: int):
console_handler.setLevel(level)
handlers = logging.getLogger().handlers
if len(handlers) == 0:
print('Warning: No default handler found.')
else:
# 第一个 handler 是默认的 StreamHandler
handlers[0].setLevel(level)
def dump_error_report(
self,
@ -128,7 +133,8 @@ class Kaa(KotoneBot):
config_path=self.config_path,
config_type=self.config_type,
target_device=d,
target_screenshot_interval=target_screenshot_interval
target_screenshot_interval=target_screenshot_interval,
force=True # 强制重新初始化,用于配置热重载
)
@override

View File

@ -3,7 +3,7 @@ import logging
from kotonebot.backend.loop import Loop
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, color

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..actions.scenes import at_home, goto_home
from kotonebot import device, image, task, color, rect_expand, sleep

View File

@ -4,7 +4,7 @@ from typing import Literal
from datetime import timedelta
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, action, ocr, contains, cropped, rect_expand, color, sleep, regex

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui.scrollable import Scrollable
from ..actions.scenes import at_home, goto_home
from kotonebot.backend.image import TemplateMatchResult

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import toolbar_menu
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, sleep, ocr

View File

@ -3,7 +3,7 @@ import logging
from gettext import gettext as _
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import WhiteFilter
from ..actions.scenes import at_home, goto_home
from ..actions.loading import wait_loading_end

View File

@ -4,7 +4,7 @@ import logging
from kotonebot.kaa.tasks import R
from kotonebot.primitives import Rect
from kotonebot.kaa.common import conf, Priority
from kotonebot.kaa.config import conf, Priority
from ..actions.loading import wait_loading_end
from ..actions.scenes import at_home, goto_home
from kotonebot import device, image, color, task, action, rect_expand, sleep

View File

@ -4,7 +4,7 @@ from typing import Optional
from kotonebot.backend.loop import Loop
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf, DailyMoneyShopItems
from kotonebot.kaa.config import conf, DailyMoneyShopItems
from kotonebot.primitives.geometry import Point
from kotonebot.util import Countdown, cropped
from kotonebot import task, device, image, action, sleep

View File

@ -2,7 +2,7 @@
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui.scrollable import Scrollable
from ..actions.scenes import at_home, goto_home
from kotonebot import task, device, image, sleep

View File

@ -7,7 +7,7 @@ import threading
from kotonebot.ui import user
from ..kaa_context import instance
from kotonebot.kaa.common import Priority, conf
from kotonebot.kaa.config import Priority, conf
from kotonebot import task, action, config, device
logger = logging.getLogger(__name__)

View File

@ -7,7 +7,7 @@ import numpy as np
from cv2.typing import MatLike
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import dialog
from kotonebot.kaa.util.trace import trace
from kotonebot.primitives import RectTuple, Rect

View File

@ -9,11 +9,12 @@ from kotonebot import (
sleep,
Interval,
)
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.primitives import Rect
from kotonebot.kaa.tasks import R
from .p_drink import acquire_p_drink
from kotonebot.util import measure_time
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.tasks.actions.loading import loading
from kotonebot.kaa.game_ui import CommuEventButtonUI, dialog, badge
from kotonebot.kaa.tasks.actions.commu import handle_unread_commu
@ -188,7 +189,7 @@ def fast_acquisitions() -> AcquisitionType | None:
# 跳过未读交流
logger.debug("Check skip commu...")
if conf().produce.skip_commu and handle_unread_commu(img):
if produce_solution().data.skip_commu and handle_unread_commu(img):
return "SkipCommu"
device.click(10, 10)

View File

@ -2,6 +2,7 @@ import logging
from typing_extensions import assert_never
from typing import Literal
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.kaa.game_ui.schedule import Schedule
from kotonebot.kaa.tasks import R
from ..actions import loading
@ -12,7 +13,8 @@ from ..actions.commu import handle_unread_commu
from kotonebot.errors import UnrecoverableError
from kotonebot.util import Countdown, Interval, cropped
from kotonebot.backend.dispatch import DispatcherContext
from kotonebot.kaa.common import ProduceAction, RecommendCardDetectionMode, conf
from kotonebot.kaa.config import ProduceAction, RecommendCardDetectionMode
from kotonebot.kaa.config import conf
from ..produce.common import until_acquisition_clear, commu_event, fast_acquisitions
from kotonebot import ocr, device, contains, image, regex, action, sleep, wait
from ..produce.non_lesson_actions import (
@ -192,7 +194,7 @@ def practice():
def threshold_predicate(card_count: int, result: CardDetectResult):
border_scores = (result.left_score, result.right_score, result.top_score, result.bottom_score)
is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
is_strict_mode = produce_solution().data.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
if is_strict_mode:
return (
result.score >= 0.05
@ -224,7 +226,7 @@ def exam(type: Literal['mid', 'final']):
logger.info("Exam started")
def threshold_predicate(card_count: int, result: CardDetectResult):
is_strict_mode = conf().produce.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
is_strict_mode = produce_solution().data.recommend_card_detection_mode == RecommendCardDetectionMode.STRICT
total = lambda t: result.score >= t
def borders(t):
# 卡片数量小于三时无遮挡,以及最后一张卡片也总是无遮挡
@ -422,7 +424,7 @@ def produce_end():
# [screenshots/produce_end/end_follow.png]
elif image.find(R.InPurodyuusu.ButtonCancel):
logger.info("Follow producer dialog found. Click to close.")
if conf().produce.follow_producer:
if produce_solution().data.follow_producer:
logger.info("Follow producer")
device.click(image.expect_wait(R.InPurodyuusu.ButtonFollowNoIcon))
else:
@ -506,12 +508,12 @@ def week_normal(week_first: bool = False):
action: ProduceAction | None = None
# SP 课程
if (
conf().produce.prefer_lesson_ap
produce_solution().data.prefer_lesson_ap
and handle_sp_lesson()
):
action = ProduceAction.DANCE
else:
actions = conf().produce.actions_order
actions = produce_solution().data.actions_order
for action in actions:
logger.debug("Checking action: %s", action)
if action := handle_action(action):
@ -539,7 +541,7 @@ def week_normal(week_first: bool = False):
def week_final_lesson():
until_action_scene()
action: ProduceAction | None = None
actions = conf().produce.actions_order
actions = produce_solution().data.actions_order
for action in actions:
logger.debug("Checking action: %s", action)
if action := handle_action(action, True):

View File

@ -5,10 +5,11 @@
"""
from logging import getLogger
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.kaa.game_ui import dialog
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from ..produce.common import fast_acquisitions
from kotonebot.kaa.game_ui.commu_event_buttons import CommuEventButtonUI
from kotonebot.util import Countdown, Interval
@ -66,7 +67,7 @@ def enter_study():
R.InPurodyuusu.TextSelfStudyVocal
]):
logger.info("授業 type: Self study.")
target = conf().produce.self_study_lesson
target = produce_solution().data.self_study_lesson
if target == 'dance':
logger.debug("Clicking on lesson dance.")
device.double_click(image.expect(R.InPurodyuusu.TextSelfStudyDance))

View File

@ -1,11 +1,11 @@
import logging
from itertools import cycle
from typing import Optional, Literal
from typing_extensions import assert_never
from kotonebot.kaa.config.schema import produce_solution
from kotonebot.ui import user
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import conf
from kotonebot.kaa.config import conf
from kotonebot.kaa.game_ui import dialog
from ..actions.scenes import at_home, goto_home
from kotonebot.backend.loop import Loop, StatedLoop
@ -150,7 +150,7 @@ def resume_produce():
max_retries = 5
current_week = None
while retry_count < max_retries:
week_text = ocr.ocr(R.Produce.BoxResumeDialogWeeks).squash().regex(r'\d+/\d+')
week_text = ocr.ocr(R.Produce.BoxResumeDialogWeeks, lang='en').squash().regex(r'\d+/\d+')
if week_text:
weeks = week_text[0].split('/')
logger.info(f'Current week: {weeks[0]}/{weeks[1]}')
@ -191,7 +191,7 @@ def do_produce(
前置条件可导航至首页的任意页面\n
结束状态游戏首页\n
:param memory_set_index: 回忆编成编号
:param idol_skin_id: 要培育的偶像如果为 None则使用配置文件中的偶像
:param mode: 培育模式
@ -242,7 +242,7 @@ def do_produce(
result = False
break
if not result:
if conf().produce.use_ap_drink:
if produce_solution().data.use_ap_drink:
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_1.png]
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_2.png]
# [kotonebot-resource\sprites\jp\produce\screenshot_no_enough_ap_3.png]
@ -351,11 +351,11 @@ def do_produce(
# 4. 选择道具 [screenshots/produce/screenshot_produce_start_4_end.png]
# TODO: 如果道具不足,这里加入推送提醒
if conf().produce.use_note_boost:
if produce_solution().data.use_note_boost:
if image.find(R.Produce.CheckboxIconNoteBoost):
device.click()
sleep(0.1)
if conf().produce.use_pt_boost:
if produce_solution().data.use_pt_boost:
if image.find(R.Produce.CheckboxIconSupportPtBoost):
device.click()
sleep(0.1)
@ -389,28 +389,33 @@ def produce():
return
import time
count = conf().produce.produce_count
idols = conf().produce.idols
memory_sets = conf().produce.memory_sets
mode = conf().produce.mode
idol = produce_solution().data.idol
memory_set = produce_solution().data.memory_set
support_card_set = produce_solution().data.support_card_set
mode = produce_solution().data.mode
# 数据验证
if count < 0:
user.warning('配置有误', '培育次数不能小于 0。将跳过本次培育。')
return
if idol is None:
user.warning('配置有误', '未设置要培育的偶像。将跳过本次培育。')
return
idol_iterator = cycle(idols)
memory_set_iterator = cycle(memory_sets)
for i in range(count):
start_time = time.time()
idol = next(idol_iterator)
if conf().produce.auto_set_memory:
memory_set = None
if produce_solution().data.auto_set_memory:
memory_set_to_use = None
else:
memory_set = next(memory_set_iterator, None)
memory_set_to_use = memory_set
if produce_solution().data.auto_set_support_card:
support_card_set_to_use = None
else:
support_card_set_to_use = support_card_set
logger.info(
f'Produce start with: '
f'idol: {idol}, mode: {mode}, memory_set: #{memory_set}'
f'idol: {idol}, mode: {mode}, memory_set: #{memory_set_to_use}, support_card_set: #{support_card_set_to_use}'
)
if not do_produce(idol, mode, memory_set):
if not do_produce(idol, mode, memory_set_to_use):
user.info('AP 不足', f'由于 AP 不足,跳过了 {count - i} 次培育。')
logger.info('%d produce(s) skipped because of insufficient AP.', count - i)
break
@ -427,11 +432,11 @@ if __name__ == '__main__':
from kotonebot.kaa.main import Kaa
conf().produce.enabled = True
conf().produce.mode = 'pro'
conf().produce.produce_count = 1
# conf().produce.idols = ['i_card-skin-hski-3-002']
conf().produce.memory_sets = [1]
conf().produce.auto_set_memory = False
produce_solution().data.mode = 'pro'
# produce_solution().data.idol = 'i_card-skin-hski-3-002'
produce_solution().data.memory_set = 1
produce_solution().data.auto_set_memory = False
# do_produce(PIdol.月村手毬_初声, 'pro', 5)
produce()
# a()

View File

@ -5,7 +5,7 @@ import ctypes
import logging
from kotonebot.kaa.tasks import R
from kotonebot.kaa.common import Priority, conf
from kotonebot.kaa.config import Priority, conf
from .actions.loading import loading
from kotonebot.util import Countdown, Interval
from .actions.scenes import at_home, goto_home

View File

@ -0,0 +1,569 @@
import os
import json
import tempfile
import shutil
import uuid
from unittest import TestCase
from kotonebot.kaa.config.produce import (
ProduceData,
ProduceSolution,
ProduceSolutionManager
)
from kotonebot.kaa.config.const import ProduceAction, RecommendCardDetectionMode
class TestProduceData(TestCase):
def test_produce_data_field_validation(self):
"""测试字段验证"""
# 测试有效的 mode 值
for mode in ['regular', 'pro', 'master']:
data = ProduceData(mode=mode) # type: ignore[arg-type]
self.assertEqual(data.mode, mode)
# 测试有效的 self_study_lesson 值
for lesson in ['dance', 'visual', 'vocal']:
data = ProduceData(self_study_lesson=lesson) # type: ignore[arg-type]
self.assertEqual(data.self_study_lesson, lesson)
def test_produce_data_serialization(self):
"""测试序列化和反序列化"""
# 创建测试数据
data = ProduceData(
mode='pro',
idol='test_idol_123',
memory_set=2,
support_card_set=3,
auto_set_memory=True,
auto_set_support_card=True,
use_pt_boost=True,
use_note_boost=True,
follow_producer=True,
self_study_lesson='vocal',
prefer_lesson_ap=True,
actions_order=[ProduceAction.DANCE, ProduceAction.VOCAL],
recommend_card_detection_mode=RecommendCardDetectionMode.STRICT,
use_ap_drink=True,
skip_commu=False
)
# 序列化
json_data = data.model_dump(mode='json')
# 反序列化
restored_data = ProduceData.model_validate(json_data)
# 验证数据一致性
self.assertEqual(restored_data.mode, 'pro')
self.assertEqual(restored_data.idol, 'test_idol_123')
self.assertEqual(restored_data.memory_set, 2)
self.assertEqual(restored_data.support_card_set, 3)
self.assertTrue(restored_data.auto_set_memory)
self.assertTrue(restored_data.auto_set_support_card)
self.assertTrue(restored_data.use_pt_boost)
self.assertTrue(restored_data.use_note_boost)
self.assertTrue(restored_data.follow_producer)
self.assertEqual(restored_data.self_study_lesson, 'vocal')
self.assertTrue(restored_data.prefer_lesson_ap)
self.assertEqual(restored_data.actions_order, [ProduceAction.DANCE, ProduceAction.VOCAL])
self.assertEqual(restored_data.recommend_card_detection_mode, RecommendCardDetectionMode.STRICT)
self.assertTrue(restored_data.use_ap_drink)
self.assertFalse(restored_data.skip_commu)
class TestProduceSolution(TestCase):
"""测试 ProduceSolution 类"""
def test_produce_solution_creation(self):
"""测试创建培育方案"""
data = ProduceData(mode='pro', idol='test_idol')
solution = ProduceSolution(
id='test_id_123',
name='测试方案',
description='这是一个测试方案',
data=data
)
self.assertEqual(solution.type, 'produce_solution')
self.assertEqual(solution.id, 'test_id_123')
self.assertEqual(solution.name, '测试方案')
self.assertEqual(solution.description, '这是一个测试方案')
self.assertEqual(solution.data.mode, 'pro')
self.assertEqual(solution.data.idol, 'test_idol')
def test_produce_solution_validation(self):
"""测试字段验证"""
data = ProduceData()
# 测试必需字段
solution = ProduceSolution(
id='test_id',
name='测试方案',
data=data
)
self.assertEqual(solution.id, 'test_id')
self.assertEqual(solution.name, '测试方案')
self.assertIsNone(solution.description)
def test_produce_solution_serialization(self):
"""测试序列化和反序列化"""
data = ProduceData(mode='master', idol='test_idol_456')
solution = ProduceSolution(
id='test_id_456',
name='高级测试方案',
description='这是一个高级测试方案',
data=data
)
# 序列化
json_data = solution.model_dump(mode='json')
# 反序列化
restored_solution = ProduceSolution.model_validate(json_data)
# 验证数据一致性
self.assertEqual(restored_solution.type, 'produce_solution')
self.assertEqual(restored_solution.id, 'test_id_456')
self.assertEqual(restored_solution.name, '高级测试方案')
self.assertEqual(restored_solution.description, '这是一个高级测试方案')
self.assertEqual(restored_solution.data.mode, 'master')
self.assertEqual(restored_solution.data.idol, 'test_idol_456')
class TestProduceSolutionManager(TestCase):
"""测试 ProduceSolutionManager 类"""
def setUp(self):
"""设置测试环境"""
# 创建临时目录
self.temp_dir = tempfile.mkdtemp()
self.original_solutions_dir = ProduceSolutionManager.SOLUTIONS_DIR
ProduceSolutionManager.SOLUTIONS_DIR = os.path.join(self.temp_dir, "test_solutions") # type: ignore[assignment]
self.manager = ProduceSolutionManager()
def tearDown(self):
"""清理测试环境"""
# 恢复原始目录设置
ProduceSolutionManager.SOLUTIONS_DIR = self.original_solutions_dir # type: ignore[assignment]
# 删除临时目录
shutil.rmtree(self.temp_dir)
def test_manager_init(self):
"""测试管理器初始化和目录创建"""
# 验证目录已创建
self.assertTrue(os.path.exists(self.manager.SOLUTIONS_DIR))
self.assertTrue(os.path.isdir(self.manager.SOLUTIONS_DIR))
def test_sanitize_filename(self):
"""测试文件名清理功能"""
test_cases = [
('正常文件名', '正常文件名'),
('包含\\斜杠/的:文件*名?', '包含_斜杠_的_文件_名_'),
('包含"引号"和<尖括号>', '包含_引号_和_尖括号_'),
('包含|管道符', '包含_管道符'),
('', ''),
]
for input_name, expected_output in test_cases:
with self.subTest(input_name=input_name):
result = self.manager._sanitize_filename(input_name)
self.assertEqual(result, expected_output)
def test_get_file_path(self):
"""测试根据名称获取文件路径"""
name = '测试方案'
expected_path = os.path.join(self.manager.SOLUTIONS_DIR, '测试方案.json')
result = self.manager._get_file_path(name)
self.assertEqual(result, expected_path)
# 测试特殊字符处理
name_with_special = '测试/方案:名称'
expected_path_special = os.path.join(self.manager.SOLUTIONS_DIR, '测试_方案_名称.json')
result_special = self.manager._get_file_path(name_with_special)
self.assertEqual(result_special, expected_path_special)
def test_find_file_path_by_id(self):
"""测试根据ID查找文件路径"""
# 创建测试文件
test_id = 'test_id_123'
solution = ProduceSolution(
id=test_id,
name='测试方案',
data=ProduceData()
)
# 保存文件
file_path = self.manager._get_file_path(solution.name)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
# 测试查找
found_path = self.manager._find_file_path_by_id(test_id)
self.assertEqual(found_path, file_path)
# 测试查找不存在的ID
not_found_path = self.manager._find_file_path_by_id('nonexistent_id')
self.assertIsNone(not_found_path)
def test_new_solution(self):
"""测试创建新方案"""
name = '新测试方案'
solution = self.manager.new(name)
self.assertEqual(solution.name, name)
self.assertEqual(solution.type, 'produce_solution')
self.assertIsNotNone(solution.id)
self.assertIsNone(solution.description)
self.assertIsInstance(solution.data, ProduceData)
# 验证ID是有效的UUID
try:
uuid.UUID(solution.id)
except ValueError:
self.fail("Generated ID is not a valid UUID")
def test_list_solutions_empty(self):
"""测试空目录时列出方案"""
solutions = self.manager.list()
self.assertEqual(solutions, [])
def test_list_solutions_with_files(self):
"""测试有文件时列出方案"""
# 创建测试方案
solution1 = ProduceSolution(
id='id1',
name='方案1',
data=ProduceData(mode='regular')
)
solution2 = ProduceSolution(
id='id2',
name='方案2',
data=ProduceData(mode='pro')
)
# 保存文件
for solution in [solution1, solution2]:
file_path = self.manager._get_file_path(solution.name)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
# 列出方案
solutions = self.manager.list()
self.assertEqual(len(solutions), 2)
# 验证方案内容(顺序可能不同)
solution_ids = {s.id for s in solutions}
self.assertEqual(solution_ids, {'id1', 'id2'})
def test_list_solutions_with_invalid_files(self):
"""测试包含无效文件时列出方案"""
# 创建有效方案文件
valid_solution = ProduceSolution(
id='valid_id',
name='有效方案',
data=ProduceData()
)
valid_file_path = self.manager._get_file_path(valid_solution.name)
with open(valid_file_path, 'w', encoding='utf-8') as f:
json.dump(valid_solution.model_dump(mode='json'), f, ensure_ascii=False, indent=4)
# 创建无效JSON文件
invalid_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '无效文件.json')
with open(invalid_file_path, 'w', encoding='utf-8') as f:
f.write('invalid json content')
# 创建非JSON文件
non_json_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '非JSON文件.txt')
with open(non_json_file_path, 'w', encoding='utf-8') as f:
f.write('not a json file')
# 列出方案,应该只返回有效的方案
solutions = self.manager.list()
self.assertEqual(len(solutions), 1)
self.assertEqual(solutions[0].id, 'valid_id')
def test_save_solution(self):
"""测试保存方案"""
solution = ProduceSolution(
id='save_test_id',
name='保存测试方案',
description='测试保存功能',
data=ProduceData(mode='master', idol='test_idol')
)
# 保存方案
self.manager.save(solution.id, solution)
# 验证文件已创建
expected_file_path = self.manager._get_file_path(solution.name)
self.assertTrue(os.path.exists(expected_file_path))
# 验证文件内容
with open(expected_file_path, 'r', encoding='utf-8') as f:
saved_data = json.load(f)
self.assertEqual(saved_data['id'], 'save_test_id')
self.assertEqual(saved_data['name'], '保存测试方案')
self.assertEqual(saved_data['description'], '测试保存功能')
self.assertEqual(saved_data['data']['mode'], 'master')
self.assertEqual(saved_data['data']['idol'], 'test_idol')
def test_save_solution_with_name_change(self):
"""测试保存时名称变更的处理"""
solution_id = 'name_change_test_id'
# 创建初始方案
original_solution = ProduceSolution(
id=solution_id,
name='原始名称',
data=ProduceData()
)
self.manager.save(solution_id, original_solution)
original_file_path = self.manager._get_file_path('原始名称')
self.assertTrue(os.path.exists(original_file_path))
# 修改名称并保存
updated_solution = ProduceSolution(
id=solution_id,
name='新名称',
data=ProduceData()
)
self.manager.save(solution_id, updated_solution)
# 验证旧文件已删除,新文件已创建
self.assertFalse(os.path.exists(original_file_path))
new_file_path = self.manager._get_file_path('新名称')
self.assertTrue(os.path.exists(new_file_path))
def test_read_solution(self):
"""测试读取方案"""
# 创建并保存方案
solution = ProduceSolution(
id='read_test_id',
name='读取测试方案',
description='测试读取功能',
data=ProduceData(mode='pro', memory_set=5)
)
self.manager.save(solution.id, solution)
# 读取方案
read_solution = self.manager.read(solution.id)
# 验证读取的数据
self.assertEqual(read_solution.id, 'read_test_id')
self.assertEqual(read_solution.name, '读取测试方案')
self.assertEqual(read_solution.description, '测试读取功能')
self.assertEqual(read_solution.data.mode, 'pro')
self.assertEqual(read_solution.data.memory_set, 5)
def test_read_nonexistent_solution(self):
"""测试读取不存在的方案"""
with self.assertRaises(FileNotFoundError) as context:
self.manager.read('nonexistent_id')
self.assertIn("Solution with id 'nonexistent_id' not found", str(context.exception))
def test_delete_solution(self):
"""测试删除方案"""
# 创建并保存方案
solution = ProduceSolution(
id='delete_test_id',
name='删除测试方案',
data=ProduceData()
)
self.manager.save(solution.id, solution)
# 验证文件存在
file_path = self.manager._get_file_path(solution.name)
self.assertTrue(os.path.exists(file_path))
# 删除方案
self.manager.delete(solution.id)
# 验证文件已删除
self.assertFalse(os.path.exists(file_path))
def test_delete_nonexistent_solution(self):
"""测试删除不存在的方案"""
# 删除不存在的方案不应该抛出异常
try:
self.manager.delete('nonexistent_id')
except Exception as e:
self.fail(f"Deleting nonexistent solution should not raise exception: {e}")
def test_duplicate_solution(self):
"""测试复制方案"""
# 创建原始方案
original_solution = ProduceSolution(
id='original_id',
name='原始方案',
description='原始描述',
data=ProduceData(mode='master', idol='test_idol', memory_set=3)
)
self.manager.save(original_solution.id, original_solution)
# 复制方案
duplicated_solution = self.manager.duplicate(original_solution.id)
# 验证复制的方案
self.assertNotEqual(duplicated_solution.id, original_solution.id)
self.assertEqual(duplicated_solution.name, '原始方案 - 副本')
self.assertEqual(duplicated_solution.description, '原始描述')
self.assertEqual(duplicated_solution.type, 'produce_solution')
# 验证数据深拷贝
self.assertEqual(duplicated_solution.data.mode, 'master')
self.assertEqual(duplicated_solution.data.idol, 'test_idol')
self.assertEqual(duplicated_solution.data.memory_set, 3)
# 验证是深拷贝而不是浅拷贝
self.assertIsNot(duplicated_solution.data, original_solution.data)
# 验证新ID是有效的UUID
try:
uuid.UUID(duplicated_solution.id)
except ValueError:
self.fail("Duplicated solution ID is not a valid UUID")
def test_duplicate_nonexistent_solution(self):
"""测试复制不存在的方案"""
with self.assertRaises(FileNotFoundError):
self.manager.duplicate('nonexistent_id')
def test_corrupted_json_handling(self):
"""测试处理损坏的JSON文件"""
# 创建损坏的JSON文件
corrupted_file_path = os.path.join(self.manager.SOLUTIONS_DIR, '损坏文件.json')
with open(corrupted_file_path, 'w', encoding='utf-8') as f:
f.write('{"id": "corrupted_id", "name": "corrupted", invalid json}')
# list() 方法应该跳过损坏的文件
solutions = self.manager.list()
self.assertEqual(len(solutions), 0)
# _find_file_path_by_id 应该跳过损坏的文件
found_path = self.manager._find_file_path_by_id('corrupted_id')
self.assertIsNone(found_path)
def test_special_characters_in_names(self):
"""测试名称中的特殊字符处理"""
special_names = [
'包含/斜杠的名称',
'包含:冒号的名称',
'包含*星号的名称',
'包含?问号的名称',
'包含"引号的名称',
'包含<尖括号>的名称',
'包含|管道符的名称',
]
for name in special_names:
with self.subTest(name=name):
# 创建方案
solution = ProduceSolution(
id=f'special_id_{hash(name)}',
name=name,
data=ProduceData()
)
# 保存方案
self.manager.save(solution.id, solution)
# 验证能够读取
read_solution = self.manager.read(solution.id)
self.assertEqual(read_solution.name, name)
# 验证能够列出
solutions = self.manager.list()
names = [s.name for s in solutions]
self.assertIn(name, names)
def test_full_workflow(self):
"""测试完整的工作流程(创建→保存→读取→修改→删除)"""
# 1. 创建新方案
solution = self.manager.new('工作流程测试')
original_id = solution.id
# 2. 修改方案数据
solution.description = '完整工作流程测试'
solution.data.mode = 'pro'
solution.data.idol = 'workflow_test_idol'
# 3. 保存方案
self.manager.save(solution.id, solution)
# 4. 读取方案
read_solution = self.manager.read(solution.id)
self.assertEqual(read_solution.id, original_id)
self.assertEqual(read_solution.name, '工作流程测试')
self.assertEqual(read_solution.description, '完整工作流程测试')
self.assertEqual(read_solution.data.mode, 'pro')
self.assertEqual(read_solution.data.idol, 'workflow_test_idol')
# 5. 修改方案名称
read_solution.name = '修改后的名称'
self.manager.save(read_solution.id, read_solution)
# 6. 验证修改
modified_solution = self.manager.read(read_solution.id)
self.assertEqual(modified_solution.name, '修改后的名称')
# 7. 复制方案
duplicated = self.manager.duplicate(modified_solution.id)
self.assertNotEqual(duplicated.id, modified_solution.id)
self.assertEqual(duplicated.name, '修改后的名称 - 副本')
# 8. 列出所有方案
all_solutions = self.manager.list()
self.assertEqual(len(all_solutions), 1) # 只有原始方案,复制的方案还没保存
# 9. 保存复制的方案
self.manager.save(duplicated.id, duplicated)
all_solutions = self.manager.list()
self.assertEqual(len(all_solutions), 2)
# 10. 删除原始方案
self.manager.delete(modified_solution.id)
remaining_solutions = self.manager.list()
self.assertEqual(len(remaining_solutions), 1)
self.assertEqual(remaining_solutions[0].id, duplicated.id)
# 11. 删除复制的方案
self.manager.delete(duplicated.id)
final_solutions = self.manager.list()
self.assertEqual(len(final_solutions), 0)
def test_concurrent_operations(self):
"""测试并发操作的安全性"""
# 这个测试主要验证基本的文件操作不会相互干扰
solutions = []
# 创建多个方案
for i in range(5):
solution = self.manager.new(f'并发测试方案{i}')
solution.data.mode = 'pro' if i % 2 == 0 else 'regular'
solutions.append(solution)
# 同时保存所有方案
for solution in solutions:
self.manager.save(solution.id, solution)
# 验证所有方案都已保存
saved_solutions = self.manager.list()
self.assertEqual(len(saved_solutions), 5)
# 验证每个方案的数据完整性
for original in solutions:
read_solution = self.manager.read(original.id)
self.assertEqual(read_solution.name, original.name)
self.assertEqual(read_solution.data.mode, original.data.mode)
# 同时删除所有方案
for solution in solutions:
self.manager.delete(solution.id)
# 验证所有方案都已删除
remaining_solutions = self.manager.list()
self.assertEqual(len(remaining_solutions), 0)

View File

View File

@ -0,0 +1,200 @@
"""测试 v5 到 v6 的配置迁移脚本"""
import unittest
import tempfile
import os
import json
import shutil
from typing import Any
from kotonebot.kaa.config.migrations._v5_to_v6 import migrate
class TestMigrationV5ToV6(unittest.TestCase):
"""测试 v5 到 v6 的配置迁移"""
def setUp(self):
"""设置测试环境"""
# 创建临时目录
self.temp_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.temp_dir)
def tearDown(self):
"""清理测试环境"""
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir)
def test_migrate_empty_config(self):
"""测试空配置的迁移"""
user_config = {}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_no_options(self):
"""测试没有 options 的配置"""
user_config = {"backend": {"type": "mumu12"}}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_no_produce_config(self):
"""测试没有 produce 配置的情况"""
user_config = {"options": {"purchase": {"enabled": False}}}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_already_v6_format(self):
"""测试已经是 v6 格式的配置"""
user_config = {
"options": {
"produce": {
"enabled": True,
"selected_solution_id": "test-id",
"produce_count": 1
}
}
}
result = migrate(user_config)
self.assertIsNone(result)
def test_migrate_v5_to_v6_basic(self):
"""测试基本的 v5 到 v6 迁移"""
# 创建 v5 格式的配置
old_produce_config = {
"enabled": True,
"mode": "pro",
"produce_count": 3,
"idols": ["i_card-skin-fktn-3-000"],
"memory_sets": [1],
"support_card_sets": [2],
"auto_set_memory": False,
"auto_set_support_card": True,
"use_pt_boost": True,
"use_note_boost": False,
"follow_producer": True,
"self_study_lesson": "vocal",
"prefer_lesson_ap": True,
"actions_order": ["recommended", "visual", "vocal"],
"recommend_card_detection_mode": "strict",
"use_ap_drink": True,
"skip_commu": False
}
user_config = {"options": {"produce": old_produce_config}}
# 执行迁移
result = migrate(user_config)
# 验证结果
self.assertIsNotNone(result)
assert result is not None # make pylance happy
self.assertIn("已将培育配置迁移到新的方案系统", result)
# 验证新配置格式
new_produce_config = user_config["options"]["produce"]
self.assertEqual(new_produce_config["enabled"], True)
self.assertEqual(new_produce_config["produce_count"], 3)
self.assertIsNotNone(new_produce_config["selected_solution_id"])
# 验证方案文件是否创建
solutions_dir = "conf/produce"
self.assertTrue(os.path.exists(solutions_dir))
# 查找创建的方案文件
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
self.assertEqual(len(solution_files), 1)
# 验证方案文件内容
solution_file = os.path.join(solutions_dir, solution_files[0])
with open(solution_file, 'r', encoding='utf-8') as f:
solution_data = json.load(f)
self.assertEqual(solution_data["type"], "produce_solution")
self.assertEqual(solution_data["name"], "默认方案")
self.assertEqual(solution_data["description"], "从旧配置迁移的默认培育方案")
# 验证培育数据
produce_data = solution_data["data"]
self.assertEqual(produce_data["mode"], "pro")
self.assertEqual(produce_data["idol"], "i_card-skin-fktn-3-000")
self.assertEqual(produce_data["memory_set"], 1)
self.assertEqual(produce_data["support_card_set"], 2)
self.assertEqual(produce_data["auto_set_memory"], False)
self.assertEqual(produce_data["auto_set_support_card"], True)
self.assertEqual(produce_data["use_pt_boost"], True)
self.assertEqual(produce_data["use_note_boost"], False)
self.assertEqual(produce_data["follow_producer"], True)
self.assertEqual(produce_data["self_study_lesson"], "vocal")
self.assertEqual(produce_data["prefer_lesson_ap"], True)
self.assertEqual(produce_data["actions_order"], ["recommended", "visual", "vocal"])
self.assertEqual(produce_data["recommend_card_detection_mode"], "strict")
self.assertEqual(produce_data["use_ap_drink"], True)
self.assertEqual(produce_data["skip_commu"], False)
def test_migrate_v5_to_v6_with_defaults(self):
"""测试使用默认值的 v5 到 v6 迁移"""
# 创建最小的 v5 格式配置
old_produce_config = {"enabled": False}
user_config = {"options": {"produce": old_produce_config}}
# 执行迁移
result = migrate(user_config)
# 验证结果
self.assertIsNotNone(result)
# 验证新配置格式
new_produce_config = user_config["options"]["produce"]
self.assertEqual(new_produce_config["enabled"], False)
self.assertEqual(new_produce_config["produce_count"], 1)
self.assertIsNotNone(new_produce_config["selected_solution_id"])
# 验证方案文件内容使用了默认值
solutions_dir = "conf/produce"
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
solution_file = os.path.join(solutions_dir, solution_files[0])
with open(solution_file, 'r', encoding='utf-8') as f:
solution_data = json.load(f)
produce_data = solution_data["data"]
self.assertEqual(produce_data["mode"], "regular")
self.assertIsNone(produce_data["idol"])
self.assertIsNone(produce_data["memory_set"])
self.assertIsNone(produce_data["support_card_set"])
self.assertEqual(produce_data["auto_set_memory"], False)
self.assertEqual(produce_data["auto_set_support_card"], False)
self.assertEqual(produce_data["self_study_lesson"], "dance")
self.assertEqual(produce_data["skip_commu"], True)
def test_migrate_v5_to_v6_multiple_idols_memory_support(self):
"""测试多个偶像、回忆、支援卡的迁移(只取第一个)"""
old_produce_config = {
"enabled": True,
"idols": ["idol1", "idol2", "idol3"],
"memory_sets": [1, 2, 3],
"support_card_sets": [4, 5, 6]
}
user_config = {"options": {"produce": old_produce_config}}
# 执行迁移
result = migrate(user_config)
# 验证结果
self.assertIsNotNone(result)
# 验证方案文件内容只使用了第一个值
solutions_dir = "conf/produce"
solution_files = [f for f in os.listdir(solutions_dir) if f.endswith('.json')]
solution_file = os.path.join(solutions_dir, solution_files[0])
with open(solution_file, 'r', encoding='utf-8') as f:
solution_data = json.load(f)
produce_data = solution_data["data"]
self.assertEqual(produce_data["idol"], "idol1")
self.assertEqual(produce_data["memory_set"], 1)
self.assertEqual(produce_data["support_card_set"], 4)
if __name__ == '__main__':
unittest.main()