src/webui/services/characters.py
Rama main · réplica de lectura del repositorio original.
"""角色管理服务:角色列表 / 规则属性辅助 / 角色CRUD / 建卡。"""
from __future__ import annotations
from src.engine.modules import game_config
import logging
import re
import time
from collections.abc import Awaitable, Callable
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
from src.engine.character_utils import (
build_starter_items,
calc_hp_from_rule,
initial_special_stat_value,
make_default_character,
normalize_character_sheet,
set_hp,
)
from src.compat.characters import MAX_SKILL_EFFECT_CHARS
from src.content.worlds import localize_lorebook_entries
from src.engine.language import localized_text
from src.engine.health import record_health_event
from src.engine.dice_rng import parse_dice_formula
from src.engine.economy import (
MAX_ECONOMY_AMOUNT,
complete_effect_group,
queue_proposal,
queue_purchase_offer,
resolve_proposal,
cancel_proposals_for_player,
)
from src.engine.memory_outbox import (
complete_memory_delivery,
complete_memory_reversal,
pending_memory_deliveries,
pending_memory_reversals,
queue_memory_delivery,
)
from src.engine.game_instance import GameInstance
from src.engine import module_hooks
from src.engine.modules import progression_state, room_access, ruleset_runtime, skill_growth
from src.engine.player_control import claim_seat, is_human_controlled
from src.content_modules.projection import ContentProjectionService, entries_for_world
from src.commands.economy_effects import pending_decision_notice
from src.commands.state_items import grant_classified_item
from src.rulesets.contracts import GameDetailProjectionRuntime, PlayerJoinRuntime
from src.webui.character_contracts import MAX_BIO_CHARS
from src.webui.services._common import professional_character_validation_error
from src.webui.character_sheet_authority import (
EDITABLE_SHEET_FIELDS,
adopt_requires_gm_failure,
delete_requires_gm_failure,
field_requires_gm_failure,
player_field_violations,
)
if TYPE_CHECKING:
from src.rulesets.registry import RulesetRuntimeRegistry
logger = logging.getLogger("trpg")
def _validate_damage_dice(equipment: Any) -> str | None:
"""Validate an optional damage formula without changing rule mechanics.
``damage`` remains the fixed fallback. ``damage_dice`` uses the shared
generic parser (NdM with an optional +/-K modifier); the field is merely
preserved on the generic character sheet and is consumed only by rules
that already declare dice damage.
"""
if not isinstance(equipment, list):
return None
for item in equipment:
if not isinstance(item, dict):
continue
raw_formula = item.get("damage_dice")
if raw_formula is None or (isinstance(raw_formula, str) and not raw_formula.strip()):
continue
if not isinstance(raw_formula, str):
return f"装备 {item.get('name', '?')} 的伤害骰公式无效: 必须是文本"
try:
parse_dice_formula(raw_formula)
except ValueError as exc:
return f"装备 {item.get('name', '?')} 的伤害骰公式无效: {exc}"
return None
_TRAILING_SENTENCE_PUNCTUATION = "。..!!??;;,,、"
def _settlement_reason_text(reason: str) -> str:
"""Strip trailing sentence punctuation so the composed card never shows
a doubled full stop(提案 reason 由模型书写,常自带句号)。"""
return str(reason or "").rstrip().rstrip(_TRAILING_SENTENCE_PUNCTUATION).rstrip()
def _record_economy_outcome_in_round(
instance: Any,
outcome: dict[str, Any],
) -> None:
"""Attach the authoritative decision to its originating public round."""
status = str(outcome.get("status") or "")
effects_status = str(outcome.get("effects_status") or "none")
amount = int(outcome.get("amount", 0) or 0)
reason = _settlement_reason_text(str(outcome.get("reason") or "经济提案"))
if status == "committed" and effects_status == "pending":
message = localized_text(instance.language, {
"en": f"Settlement confirmed ({amount}): {reason}. Linked results are waiting for the remaining decisions.",
"zh-CN": f"结算已确认({amount}):{reason}。关联结果仍在等待其余决定。",
"ja": f"決済確認済み({amount}):{reason}。関連結果は残りの判断を待っています。",
"de": f"Abrechnung bestätigt ({amount}): {reason}. Verknüpfte Ergebnisse warten noch auf die restlichen Entscheidungen.",
})
elif status == "committed":
message = localized_text(instance.language, {
"en": f"Settlement confirmed ({amount}): {reason}. Dependent results are now effective.",
"zh-CN": f"结算已确认({amount}):{reason}。关联结果现已生效。",
"ja": f"決済確認済み({amount}):{reason}。関連結果が発効しました。",
"de": f"Abrechnung bestätigt ({amount}): {reason}. Abhängige Ergebnisse sind jetzt wirksam.",
})
else:
message = localized_text(instance.language, {
"en": f"Settlement {status} ({amount}): {reason}. No payment or dependent result occurred.",
"zh-CN": f"结算未成立({amount}):{reason}。没有付款,关联结果也未生效。",
"ja": f"決済不成立({amount}):{reason}。支払いも関連結果も発生していません。",
"de": f"Abrechnung {status} ({amount}): {reason}. Es erfolgte weder eine Zahlung noch ein abhängiges Ergebnis.",
})
round_number = int(outcome.get("round", 0) or 0)
# Purchases are always a party-visible settlement event. Keep the
# explicit private path for other GM payments, but also promote purchases
# created before the visibility default was corrected. Solo tables have
# no information asymmetry: the only player is the GM, so settlement
# messages belong on the shared timeline.
is_purchase = str(outcome.get("kind") or "") == "purchase"
if (
str(outcome.get("visibility") or "private") != "party"
and not is_purchase
and not bool(game_config.solo_mode(instance))
):
recipients = {
str(outcome.get("payer_uid") or ""),
str(outcome.get("recipient_uid") or ""),
}
for uid in recipients:
if uid in instance.players:
instance.append_private_message(uid, {
"round": round_number,
"text": message,
"kind": "economy_resolution",
})
return
entry = next(
(
item for item in reversed(instance.log)
if int(item.get("round", -1) or -1) == round_number
),
None,
)
if entry is None:
return
resolutions = entry.setdefault("economy_resolutions", [])
outcome_id = str(outcome.get("id") or "")
if not any(str(item.get("id") or "") == outcome_id for item in resolutions):
resolutions.append(dict(outcome))
changes = entry.setdefault("state_changes", [])
if message not in changes:
changes.append(message)
def _remove_stale_pending_notice(instance: Any) -> None:
"""Drop the round's "settlement pending" notice once nothing is pending.
The notice is only accurate while the response's proposals and effect
groups are unresolved; once everything is settled, declined, or
discarded, it must not keep telling the table that dependent results are
still pending (real incident: a sale settled but the round kept showing
the pending banner as its only status line).
"""
from src.engine.economy import has_pending_economy_decision
if has_pending_economy_decision(instance):
return
notice = pending_decision_notice(str(getattr(instance, "language", "") or ""))
for entry in instance.log:
changes = entry.get("state_changes")
if isinstance(changes, list) and notice in changes:
changes.remove(notice)
@dataclass(frozen=True)
class CharacterGameDependencies:
get_instance: Callable[[tuple[str, ...]], GameInstance | None]
parse_game_key: Callable[[str], tuple[str, ...]]
save_instance: Callable[[GameInstance], Awaitable[None]]
@dataclass(frozen=True)
class CharacterRuleDependencies:
rules_dir: Path | None
load_rule_by_id: Callable[[str, str], Any | None]
load_rule_for_game: Callable[[Any], Any | None]
ruleset_registry: "RulesetRuntimeRegistry"
@dataclass(frozen=True)
class CharacterAssetDependencies:
lorebook: Any | None
load_world_template: Callable[[str, str], dict[str, Any] | None]
avatar_file: Callable[[str], Path | None]
generated_image_file: Callable[[str], Path | None]
content_projection: ContentProjectionService | None = None
# GAL P8:立绘资产边界校验(与 avatar_file 同语义)。缺省 None 时立绘
# 引用一律拒绝(fail closed),不猜文件是否存在。
standing_portrait_file: Callable[[str], Path | None] | None = None
@dataclass(frozen=True)
class CharacterDependencies:
games: CharacterGameDependencies
rules: CharacterRuleDependencies
assets: CharacterAssetDependencies
save_character_card: Callable[[dict[str, Any]], dict[str, Any]]
apply_economy_effects: Callable[[Any, dict[str, Any]], Awaitable[None]] | None = None
schedule_economy_scene_image: Callable[[Any, dict[str, Any]], Any] | None = None
apply_economy_memory: Callable[[str, str, dict[str, Any], int], Awaitable[None]] | None = None
reverse_economy_memory: Callable[[str, str], Awaitable[bool]] | None = None
# GAL P8:个人音色存在性检查(speech 的 profile store)。投影用它把已删除
# 的音色绑定省略掉(失效引用保持未配置);None 时投影原样带出,
# 合成端仍会 fail closed 明确报错。
voice_profile_exists: Callable[[str], bool] | None = None
async def create_payment_proposal(
dependencies: CharacterDependencies,
game_key: str,
*,
payer_uid: str,
amount: int,
reason: str = "",
recipient_uid: str = "",
items: list[str] | None = None,
) -> dict[str, Any]:
"""Create a GM-authored payment proposal without changing balances."""
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if inst is None:
return {"ok": False, "error": "游戏不存在"}
payer_uid = str(payer_uid or "").strip()
recipient_uid = str(recipient_uid or payer_uid).strip()
try:
amount = int(amount)
except (TypeError, ValueError):
return {"ok": False, "error": "金额必须是整数"}
if not 0 < amount <= MAX_ECONOMY_AMOUNT:
return {"ok": False, "error": f"金额必须在 1 到 {MAX_ECONOMY_AMOUNT} 之间"}
if payer_uid not in inst.players:
return {"ok": False, "error": "付款角色不存在"}
if recipient_uid not in inst.players:
return {"ok": False, "error": "接收角色不存在"}
normalized_items = [
str(item).strip()[:120]
for item in list(items or [])[:8]
if str(item).strip()
]
reason = str(reason or "").strip()[:240]
if not reason:
reason = f"购买 {'、'.join(normalized_items)}" if normalized_items else "GM 发起的支付"
async with inst.authoritative_write() as write_entered, inst._lock:
if not write_entered:
return {
"ok": False,
"code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请稍后再发起支付",
}
current = dependencies.games.get_instance(inst.game_key)
if current is None or current is not inst:
return {
"ok": False,
"code": "STALE_RUN",
"error": "游戏已重开或重置,请刷新后再发起支付",
}
if normalized_items:
try:
proposal = queue_purchase_offer(
inst,
payer_uid=payer_uid,
amount=amount,
items=normalized_items,
reason=reason,
source="gm_manual",
source_ref=f"gm_manual:{inst.run_id}:{uuid4().hex}",
)
except ValueError as exc:
return {"ok": False, "code": "ORDER_INVALID", "error": str(exc)}
else:
proposal = queue_proposal(
inst,
kind="payment",
payer_uid=payer_uid,
recipient_uid=recipient_uid,
amount=amount,
rewards=[],
reason=reason,
source="gm_manual",
source_ref=f"gm_manual:{inst.run_id}:{uuid4().hex}",
approval_policy="payer",
visibility="party",
)
await dependencies.games.save_instance(inst)
return {"ok": True, "proposal": proposal}
_ATTR_NAME_EN = {
"str": "STR",
"con": "CON",
"dex": "DEX",
"int": "INT",
"edu": "EDU",
"app": "APP",
"pow": "POW",
"siz": "SIZ",
"wis": "WIS",
"cha": "CHA",
}
_ATTR_NAME_ZH = {
"str": "力量",
"con": "体质",
"dex": "敏捷",
"int": "智力",
"edu": "教育",
"app": "外貌",
"pow": "意志",
"siz": "体型",
"wis": "感知",
"cha": "魅力",
}
def _creation_bounds_enabled(rule) -> bool:
policy = rule.template.get("character_creation") if rule else None
return isinstance(policy, dict) and policy.get("clamp_to_rule_bounds") is True
def creation_bounds_violation(rule: Any, character_sheet: dict[str, Any]) -> dict[str, Any] | None:
"""规则声明了服务端建卡边界时,返回拒绝响应;通过则返回 None。
属性 clamp 只是第一道:技能点预算(含 CoC 的 above_base 公式)、技能条数、
单技能上限、装备品质都必须在这里按同一套规则再校验一次,否则绕过前端直接调
API 就能把超预算角色带进局内。只对显式声明 ``character_creation.clamp_to_rule_bounds``
的规则生效,避免把 CoC 的限制推广到 D&D / Freeform。
中性/旧式部分角色卡(属性缺失或只有个别属性)不在此校验:``validate_character``
把缺失属性当 0,会把中性冒险入场、旧卡迁移这类合法形态误判为非法——它们继续
走既有的 clamp/normalize 兜底(与修复前行为一致)。只要角色卡带齐了规则声明的
全部属性,就视为正式建卡结果,执行全量校验。
"""
if rule is None:
return None
# 只有「带齐规则声明的全部属性」的卡才视为正式建卡结果;属性缺失/为空/部分
# (中性冒险入场、旧卡迁移)一律走既有 clamp/normalize 兜底,不在此校验。
attributes = character_sheet.get("attributes")
declared_keys = [str(spec.get("key") or "") for spec in rule.attributes if spec.get("key")]
if not isinstance(attributes, dict) or any(key not in attributes for key in declared_keys):
return None
violations = rule.validate_character(character_sheet)
if not violations:
return None
return {
"ok": False,
"error_code": "CHARACTER_SHEET_INVALID",
"error": "角色卡不符合当前规则:" + ";".join(str(item) for item in violations[:3]),
}
def _clamp_attributes(rule, attributes: Any) -> dict:
"""入场时把属性压回规则声明的上下限:客户端不能自报超出范围的属性。"""
result = dict(attributes) if isinstance(attributes, dict) else {}
for spec in rule.attributes:
key = str(spec.get("key") or "")
if not key or key not in result:
continue
try:
value = int(result[key])
except (TypeError, ValueError):
value = int(spec.get("min", 0) or 0)
low, high = spec.get("min"), spec.get("max")
if isinstance(low, int):
value = max(low, value)
if isinstance(high, int):
value = min(high, value)
result[key] = value
return result
def _normalize_skills(skills: list, rule=None, *, attributes: Any = None, clamp: bool = False) -> list[dict]:
"""规范化技能列表:字符串转为含数值的对象格式,并保留可选 effect 说明。
「超出基础值才计点」的规则(CoC)缺省数值取该角色的技能基础值(含闪避、
母语等由属性派生的基础值);其它规则保持旧的缺省 20。``clamp`` 为真时
单项技能不超过规则的上限。
"""
base_values: dict[str, int] = rule.skill_base_values if rule else {}
above_base = bool(rule and rule.skill_point_spend_mode == "above_base")
cap = int(rule.max_skill_value or 0) if (rule and clamp) else 0
def default_value(name: str) -> int:
return rule.skill_base_value_for(name, attributes) if above_base else base_values.get(name, 20)
def bounded(value: Any) -> Any:
if not cap:
return value
try:
return max(0, min(cap, int(value)))
except (TypeError, ValueError):
return 0
result: list[dict] = []
for s in skills:
if isinstance(s, str):
result.append({"name": s, "value": bounded(default_value(s))})
elif isinstance(s, dict):
name = s.get("name", "")
row: dict = {
"name": name,
"value": bounded(s.get("value", default_value(name))),
}
effect = str(s.get("effect") or "").strip()
if effect:
row["effect"] = effect[:MAX_SKILL_EFFECT_CHARS]
result.append(row)
return result
def _format_rule_attr(attr: dict) -> dict:
key = attr["key"]
name = attr.get("name") or _ATTR_NAME_ZH.get(key, key)
name_en = attr.get("name_en") or _ATTR_NAME_EN.get(key, key.upper())
return {
"key": key,
"name": name,
"name_en": name_en,
"display_name": attr.get("display_name") or (f"{name} ({name_en})" if name_en else name),
"min": attr.get("min", 3),
"max": attr.get("max", 18),
}
def _fallback_rule_attrs() -> list[dict[str, Any]]:
return [
_format_rule_attr({"key": key, "name": name, "min": 3, "max": 18})
for key, name in [
("str", "力量"), ("dex", "敏捷"), ("con", "体质"),
("int", "智力"), ("wis", "感知"), ("cha", "魅力"),
]
]
def _fallback_rule_meta() -> dict[str, Any]:
return {
"dice_system": "d20",
"rule_id": "",
"mechanics": "freeform_d20_core",
"hp_formula": "",
"auto_hp": False,
"attr_hint": "",
"skill_mode": "narrative",
"skill_hint": "",
"max_skills": 3,
"skill_point_total": 0,
"max_skill_value": 0,
"skill_point_spend_mode": "total_value",
"skill_pools": {},
"skill_base_values": {},
"currency": "金币",
"conflict_model": {"type": "hp_based"},
"currency_system": {"base_unit": "unit", "units": [{"id": "unit", "name": "金币", "rate": 1}]},
"resource_schema": [{"key": "hp", "label": "生命", "min": 0}],
"identity_schema": [
{"key": "origin", "label": "种族", "type": "text", "legacy_field": "race"},
{"key": "archetype", "label": "职业", "type": "text", "legacy_field": "class"},
{"key": "background", "label": "背景", "type": "text", "legacy_field": "background"},
],
"progression_schema": {"type": "xp_level"},
"ui_schema": {
"primary_resources": ["hp"],
"secondary_resources": [],
"identity_labels": {"origin": "种族", "archetype": "职业", "background": "背景"},
"show_level": True,
"show_xp": True,
"currency_label": "金币",
"equipment_label": "装备",
},
}
def _character_schema_for_rule(rule) -> dict[str, Any]:
"""Build the one character-creation contract used with and without a game."""
if not rule:
return {
"rule_attrs": _fallback_rule_attrs(),
"rule_attrs_total": 60,
"rule_classes": ["战士", "法师", "游侠", "盗贼", "牧师", "冒险者"],
"rule_special_stats": [],
"rule_meta": _fallback_rule_meta(),
"skill_pool": [],
}
meta = {
"dice_system": rule.dice_system,
"rule_id": rule.rule_id,
"rule_name": rule.rule_name,
"rule_version": str(rule.template.get("rule_version") or ""),
"mechanics": rule.mechanics,
"hp_formula": rule.hp_formula,
"auto_hp": rule.mechanics == "coc7e_core",
"attr_hint": rule.attr_hint,
"skill_mode": rule.skill_mode,
"skill_hint": rule.skill_hint,
"max_skills": rule.max_skills,
"skill_point_total": rule.skill_point_total,
"skill_point_formula": rule.skill_point_formula,
"skill_base_lookup": rule.skill_base_lookup(),
"custom_skill_base": 5,
"max_skill_value": rule.max_skill_value,
"skill_point_spend_mode": rule.skill_point_spend_mode,
"skill_pools": rule.skill_pools,
"skill_base_values": rule.skill_base_values,
"currency": rule.currency,
"conflict_model": rule.conflict_model,
"currency_system": rule.currency_system,
"resource_schema": rule.resource_schema,
"identity_schema": rule.identity_schema,
"progression_schema": rule.progression_schema,
"ui_schema": rule.ui_schema,
}
explicit_skill_pool = rule.template.get("skill_pool")
legacy_skills = rule.template.get("skills")
if isinstance(explicit_skill_pool, list):
skill_pool = list(explicit_skill_pool)
elif isinstance(legacy_skills, list):
skill_pool = list(legacy_skills)
else:
skill_pool = []
if not skill_pool:
# Standalone creation has no class selection context yet. Offer the
# union of class pools so the same rule schema remains useful before a
# game (and before the user has settled on an archetype).
for class_pool in rule.skill_pools.values():
for skill in class_pool:
if skill not in skill_pool:
skill_pool.append(skill)
return {
"rule_attrs": [_format_rule_attr(attr) for attr in rule.attributes],
"rule_attrs_total": rule.attribute_points,
"rule_classes": rule.get_class_names(),
"rule_special_stats": rule.special_stats,
"rule_meta": meta,
"skill_pool": skill_pool,
}
def character_schema(
dependencies: CharacterDependencies,
rule_id: str,
language: str = "",
) -> dict[str, Any]:
"""Return rule-driven creation fields without requiring a GameInstance."""
rule = dependencies.rules.load_rule_by_id(rule_id, language)
if not rule:
return {"ok": False, "error": f"规则不存在: {rule_id}"}
return {
"ok": True,
**_character_schema_for_rule(rule),
"ruleset_runtime": dependencies.rules.ruleset_registry.describe(
rule.template,
).to_dict(),
}
def format_attribute_map(attributes: dict, rule_attrs: list[dict]) -> str:
"""按规则属性顺序格式化属性,中文界面同时显示中文名与英文 key。"""
attr_by_key = {a["key"]: a for a in rule_attrs}
keys = [a["key"] for a in rule_attrs]
keys.extend(k for k in attributes if k not in attr_by_key)
parts = []
for key in keys:
if key not in attributes:
continue
attr = _format_rule_attr(attr_by_key.get(key) or {"key": key})
parts.append(f"{attr['display_name']}:{attributes[key]}")
return " ".join(parts)
def list_characters(
dependencies: CharacterDependencies,
game_key: str,
*,
viewer_is_gm: bool,
) -> dict[str, Any]:
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"players": [], "npcs": [], "rule_attrs": []}
players = [
# ``has_acted``: after its first action a seat's card / deletion is GM-only.
{"user_id": uid, **p, "has_acted": module_hooks.seat_has_acted(inst, uid)}
for uid, p in inst.players.items()
]
rule_attrs = _get_rule_attrs_for_game(dependencies, inst)
rule = dependencies.rules.load_rule_for_game(inst)
for player in players:
cs = player.get("character_sheet", {})
normalize_character_sheet(cs, rule)
cs["attributes_display"] = format_attribute_map(cs.get("attributes", {}), rule_attrs)
npcs_by_name: dict[str, dict[str, Any]] = {}
if viewer_is_gm:
for nid, npc in inst.npcs.items():
name = npc.get("character_name") or npc.get("name") or nid
row = {"npc_id": nid, **npc, "name": name}
_project_npc_bindings(row, dependencies)
npcs_by_name[name] = row
if dependencies.assets.lorebook and inst.world_id:
if dependencies.assets.content_projection is not None:
entries = dependencies.assets.content_projection.for_game(
inst, viewer_kind="gm", entry_type="npc",
)
else:
entries = entries_for_world(dependencies.assets.lorebook, inst.world_id, "npc")
world_data = dependencies.assets.load_world_template(
inst.world_id,
str(getattr(inst, "language", "") or ""),
)
lore_status = localized_text(
getattr(inst, "language", ""),
{"en": "Lorebook", "zh-CN": "世界书", "ja": "ワールドブック", "de": "Lorebook"},
)
for entry in localize_lorebook_entries(entries, world_data):
name = entry.get("name", "")
if not name or name in npcs_by_name:
continue
npcs_by_name[name] = {
"npc_id": entry.get("id", name),
"name": name,
"character_name": name,
"tier": entry.get("tier", ""),
"status": lore_status,
"relation": entry.get("relation", ""),
"content": entry.get("content", ""),
"portrait": entry.get("portrait"),
}
npcs = list(npcs_by_name.values())
rule_attrs_total = _get_rule_attrs_total(dependencies, inst)
result: dict[str, Any] = {
"players": players,
"npcs": npcs,
"rule_attrs": rule_attrs,
"rule_attrs_total": rule_attrs_total,
"rule_classes": _get_rule_classes_for_game(dependencies, inst),
"rule_special_stats": _get_rule_special_stats(dependencies, inst),
"rule_meta": _get_rule_meta_for_game(dependencies, inst),
}
if rule is not None:
runtime_metadata = dependencies.rules.ruleset_registry.describe(
rule.template,
).to_dict()
result["ruleset_runtime"] = runtime_metadata
runtime = dependencies.rules.ruleset_registry.resolve(rule.template)
if isinstance(runtime, GameDetailProjectionRuntime):
result.update(runtime.game_detail_projection(inst))
return result
def _get_rule_classes_for_game(
dependencies: CharacterDependencies,
inst: Any,
) -> list[str]:
try:
rule = dependencies.rules.load_rule_for_game(inst)
if rule:
return _character_schema_for_rule(rule)["rule_classes"]
except Exception:
logger.exception("读取规则职业失败: world_id=%s", inst.world_id)
return ["战士", "法师", "游侠", "盗贼", "牧师", "冒险者"]
def _get_rule_attrs_for_game(
dependencies: CharacterDependencies,
inst: Any,
) -> list[dict]:
try:
rule = dependencies.rules.load_rule_for_game(inst)
if rule:
return _character_schema_for_rule(rule)["rule_attrs"]
except Exception:
logger.exception("读取规则属性失败: world_id=%s", inst.world_id)
return _fallback_rule_attrs()
def _get_rule_attrs_total(dependencies: CharacterDependencies, inst: Any) -> int:
try:
rule = dependencies.rules.load_rule_for_game(inst)
if rule:
return _character_schema_for_rule(rule)["rule_attrs_total"]
except Exception:
logger.exception("读取规则属性点失败: world_id=%s", inst.world_id)
return 60
def _get_rule_special_stats(
dependencies: CharacterDependencies,
inst: Any,
) -> list[dict]:
try:
rule = dependencies.rules.load_rule_for_game(inst)
if rule:
return _character_schema_for_rule(rule)["rule_special_stats"]
except Exception:
logger.exception("读取特殊属性失败: world_id=%s", inst.world_id)
return []
def _get_rule_meta_for_game(
dependencies: CharacterDependencies,
inst: Any,
) -> dict[str, Any]:
try:
rule = dependencies.rules.load_rule_for_game(inst)
if rule:
return _character_schema_for_rule(rule)["rule_meta"]
except Exception:
logger.exception("读取规则建卡提示失败: world_id=%s", inst.world_id)
return _fallback_rule_meta()
def get_character(
dependencies: CharacterDependencies,
game_key: str,
user_id: str,
) -> dict[str, Any] | None:
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst or user_id not in inst.players:
return None
player = inst.get_player(user_id) or {}
character_sheet = player.get("character_sheet", {})
normalize_character_sheet(
character_sheet, dependencies.rules.load_rule_for_game(inst),
)
inst.set_character_sheet(user_id, character_sheet)
return {"user_id": user_id, **(inst.get_player(user_id) or {})}
async def update_character(
dependencies: CharacterDependencies,
game_key: str,
user_id: str,
updates: dict,
*,
gm_authority: bool = False,
) -> dict[str, Any]:
"""Patch a classic character sheet.
``gm_authority`` is the caller's right to change mechanics (the table GM
or the owner acting as itself). Without it only profile fields and a
level-up point allocation are accepted (see ``character_sheet_authority``).
"""
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "角色不存在"}
async with inst.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
if dependencies.games.get_instance(inst.game_key) is not inst:
return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
return await _update_character_authority(
dependencies, inst, user_id, updates, gm_authority=gm_authority,
)
async def adopt_library_card(
dependencies: CharacterDependencies,
game_key: str,
user_id: str,
card: dict[str, Any],
*,
gm_authority: bool,
) -> dict[str, Any]:
"""Replace a classic character from a server-owned library card.
The card comes from the library, not from the request, so it is applied
with full sheet authority. Who may adopt is still the caller's
``gm_authority``: once the seat has acted (``seat_activity``) a
player-side caller may not swap its sheet (a free refill / re-roll); only
the GM may. Before that -- a fresh seat, or one just claimed from the GM
or the AI -- the player may still switch to a card. The check runs inside
the authoritative write so it cannot race the seat's first action.
A rules-aware game answers ``RULESET_CHARACTER_OPERATION_REQUIRED`` and
the caller uses the ruleset adoption instead.
"""
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "角色不存在"}
instance: GameInstance = inst
async with instance.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
player_side_after_acting = (
not gm_authority
and user_id in instance.players
and not _is_rules_aware_game(dependencies, instance)
and module_hooks.seat_has_acted(instance, user_id)
)
if dependencies.games.get_instance(instance.game_key) is not instance:
return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
if player_side_after_acting:
return adopt_requires_gm_failure()
card = deepcopy(card)
for skill in card.get('skills') or []:
if isinstance(skill, dict):
skill.pop('id', None)
return await _update_character_authority(
dependencies, instance, user_id, card, gm_authority=True,
)
def _is_rules_aware_game(dependencies: CharacterDependencies, inst: GameInstance) -> bool:
rule = dependencies.rules.load_rule_for_game(inst)
if rule is None:
return False
runtime = dependencies.rules.ruleset_registry.resolve(rule.template)
return getattr(runtime.capabilities, "character_lifecycle", "legacy") == "rules_aware"
def _special_stat_fields(dependencies: CharacterDependencies, inst: GameInstance) -> set[str]:
"""Rule-defined special stats (sanity, luck...) and their maxima."""
rule = dependencies.rules.load_rule_for_game(inst)
fields: set[str] = set()
for stat in getattr(rule, "special_stats", None) or []:
key = str(stat.get("key") or "") if isinstance(stat, dict) else ""
if key:
fields.update({key, f"max_{key}"})
return fields
async def _update_character_authority(
dependencies: CharacterDependencies,
instance: GameInstance,
user_id: str,
updates: dict,
*,
gm_authority: bool = False,
) -> dict[str, Any]:
inst = instance
if not inst or user_id not in inst.players:
return {"ok": False, "error": "角色不存在"}
if _is_rules_aware_game(dependencies, inst):
return {
"ok": False,
"error_code": "RULESET_CHARACTER_OPERATION_REQUIRED",
"error": "专业规则角色不能使用旧版通用编辑接口",
}
# Only player-editable sheet fields; anything else (card metadata, plugin
# provenance, server-owned state) is dropped instead of mass-assigned.
updates = {
key: value for key, value in dict(updates).items()
if key in EDITABLE_SHEET_FIELDS or key in _special_stat_fields(dependencies, inst)
}
if not gm_authority:
denied = player_field_violations(
inst.get_character_sheet(user_id), updates,
rule_attrs=_get_rule_attrs_for_game(dependencies, inst),
)
if denied:
return field_requires_gm_failure(denied)
if "skills" in updates:
if not isinstance(updates["skills"], list):
return {"ok": False, "error": "Skills must be a list"}
old_skills = inst.get_character_sheet(user_id).get("skills", [])
accepted = set()
for row in updates["skills"]:
if not isinstance(row, dict) or not row.get("id"):
continue
sid = row['id']
matches = [s for s in old_skills if isinstance(s, dict) and s.get('id') == sid]
if (not isinstance(sid, str) or len(matches) != 1 or sid in accepted
or matches[0].get('name') != row.get('name')):
return {"ok": False, "error": "Skill identity changed or is ambiguous; reload the character and retry"}
accepted.add(sid)
pending = {mark['skill_id'] for mark in skill_growth.view(inst)['pending'] if mark['actor_uid'] == user_id}
if not pending.issubset(accepted):
return {"ok": False, "error": "A marked skill cannot be removed or renamed before settlement"}
character_name = str(updates.pop("character_name", "")).strip()
if character_name:
inst.set_player_name(user_id, character_name)
cs = inst.get_character_sheet(user_id)
if "background" in updates and len(str(updates.get("background", ""))) > MAX_BIO_CHARS:
return {"ok": False, "error": f"角色背景过长(上限 {MAX_BIO_CHARS} 字)"}
rule = dependencies.rules.load_rule_for_game(inst)
damage_dice_error = _validate_damage_dice(updates.get("equipment"))
if damage_dice_error:
return {"ok": False, "error": damage_dice_error}
if "portrait" in updates:
try:
portrait = _validated_portrait(
dependencies.assets, updates.pop("portrait"),
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if portrait is None:
cs.pop("portrait", None)
else:
cs["portrait"] = portrait
# GAL P8:立绘 / 表情差分 / 音色绑定(全部可选字段,旧存档照常)。
standing_portrait_present = "standing_portrait" in updates
standing_expressions_present = "standing_portrait_expressions" in updates
if standing_portrait_present or standing_expressions_present:
try:
# A missing PATCH field means "leave it alone". Validate every
# supplied field before mutating either stored value so a bad
# partial update cannot produce a half-applied standing portrait.
standing = (
_validated_standing_portrait(
dependencies.assets, updates["standing_portrait"],
)
if standing_portrait_present else None
)
standing_expressions = (
_validated_standing_portrait_expressions(
dependencies.assets, updates["standing_portrait_expressions"],
)
if standing_expressions_present else None
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if standing_portrait_present:
updates.pop("standing_portrait")
if standing is None:
cs.pop("standing_portrait", None)
else:
cs["standing_portrait"] = standing
if standing_expressions_present:
updates.pop("standing_portrait_expressions")
if standing_expressions is None:
cs.pop("standing_portrait_expressions", None)
else:
cs["standing_portrait_expressions"] = standing_expressions
if "voice_profile" in updates:
try:
voice = _validated_voice_profile(updates.pop("voice_profile"))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if voice is None:
cs.pop("voice_profile", None)
else:
cs["voice_profile"] = voice
explicit_hp_update = (
"hp" in updates
or "max_hp" in updates
or (
isinstance(updates.get("resources"), dict)
and isinstance(updates.get("resources", {}).get("hp"), dict)
)
)
# 消耗属性点
new_attrs = updates.get("attributes")
old_attrs = cs.get("attributes", {})
pool = cs.get("level_up_points", 0)
if isinstance(new_attrs, dict) and pool > 0 and isinstance(old_attrs, dict):
used = sum(max(0, int(new_attrs.get(k, 0)) - int(old_attrs.get(k, 0)))
for k in set(old_attrs) | set(new_attrs))
updates["level_up_points"] = max(0, pool - used)
cs.update(updates)
# 确保 attr_points_max 不低于当前属性总和,避免升级后编辑界面卡上线
try:
cur_attrs = cs.get("attributes", {})
if isinstance(cur_attrs, dict) and cur_attrs:
total = sum(int(v) for v in cur_attrs.values())
stored = cs.get("attr_points_max", 0)
if total > stored:
cs["attr_points_max"] = total
logger.info("attr_points_max 修正: %d -> %d (uid=%s)", stored, total, user_id)
except (TypeError, ValueError):
logger.warning("attr_points_max 自动修正失败: uid=%s", user_id, exc_info=True)
# 规范化技能格式
if "skills" in updates:
cs["skills"] = _normalize_skills(updates.get("skills", []), rule)
for source, row in zip((s for s in updates['skills'] if isinstance(s, (str, dict))), cs['skills']):
if isinstance(source, dict) and isinstance(source.get('id'), str) and source['id'] in accepted:
original = next(s for s in old_skills if isinstance(s, dict) and s.get('id') == source['id'])
row['id'] = original['id']
if 'improvable' in original:
row['improvable'] = original['improvable']
# 属性变化后可按规则补算 HP;若用户明确手填 HP,则尊重手填值。
new_attrs = updates.get("attributes")
if isinstance(new_attrs, dict) and not explicit_hp_update:
def formula_hp(attributes: dict) -> int:
return int(
rule.calculate_hp(attributes, cs.get("class", ""))
if rule
else calc_hp_from_rule(
attributes,
rules_dir=dependencies.rules.rules_dir,
language=getattr(inst, "language", ""),
)
)
try:
current_hp = int(cs.get("hp", 0) or 0)
if gm_authority:
# GM editor: rebuild max HP from the rule formula and keep the
# HP ratio. A downed character (HP <= 0) stays down.
lv_bonus = max(0, (cs.get("level", 1) - 1) * 5)
new_max = formula_hp(new_attrs) + lv_bonus
ratio = current_hp / max(1, int(cs.get("max_hp", 1) or 1))
new_current = max(1, round(new_max * ratio)) if current_hp > 0 else 0
else:
# Player level-up spend: apply only the formula delta the
# attribute change causes, so a GM-adjusted max HP survives
# and nothing is healed by rounding. A downed character
# (HP <= 0) stays down.
old_formula = (
formula_hp(old_attrs) if isinstance(old_attrs, dict) and old_attrs else None
)
delta = formula_hp(new_attrs) - old_formula if old_formula is not None else 0
new_max = max(0, int(cs.get("max_hp", 0) or 0) + delta)
new_current = max(1, current_hp + delta) if current_hp > 0 else 0
set_hp(cs, new_current, new_max)
logger.info("HP 重算: con=%s max_hp=%d hp=%d",
new_attrs.get("con", "?"), new_max, cs.get("hp", 0))
except Exception as exc:
logger.warning("属性变化后 HP 重算失败: %s", exc)
normalize_character_sheet(cs, rule)
inst.set_character_sheet(user_id, cs)
await dependencies.games.save_instance(inst)
try:
dependencies.save_character_card({
"character_name": inst.players[user_id].get("character_name", ""),
"character_sheet": cs,
})
except Exception:
logger.warning("角色卡同步入库失败: uid=%s", user_id, exc_info=True)
return {"ok": True}
def _validated_portrait(
dependencies: CharacterAssetDependencies,
portrait: Any,
) -> dict[str, str] | None:
return validate_character_portrait(dependencies, portrait)
def validate_character_portrait(
dependencies: CharacterAssetDependencies,
portrait: Any,
) -> dict[str, str] | None:
"""Validate and normalize a character portrait at the asset boundary."""
# Existing saves use an empty object as "no explicit portrait". Treat it
# exactly like null so editing unrelated profile fields remains possible.
if portrait is None or portrait == {}:
return None
if not isinstance(portrait, dict):
raise ValueError("头像数据无效")
kind = str(portrait.get("kind") or "")
if kind == "builtin":
portrait_id = str(portrait.get("id") or "")
if not portrait_id or len(portrait_id) > 100:
raise ValueError("内置头像编号无效")
return {"kind": "builtin", "id": portrait_id}
if kind == "upload":
asset_id = str(portrait.get("asset_id") or "")
if not asset_id or dependencies.avatar_file(asset_id) is None:
raise ValueError("上传头像不存在")
return {"kind": "upload", "asset_id": asset_id}
if kind == "generated":
asset_id = str(portrait.get("asset_id") or "")
if not asset_id or dependencies.generated_image_file(asset_id) is None:
raise ValueError("生成头像不存在")
return {"kind": "generated", "asset_id": asset_id}
raise ValueError("头像类型无效")
# ---- GAL P8:立绘 / 表情差分 / 音色绑定 --------------------------------------
_EXPRESSION_LABEL_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
MAX_STANDING_PORTRAIT_EXPRESSIONS = 16
_VOICE_PROFILE_ID_RE = re.compile(r"^personal:[a-f0-9]{32}$")
_PLUGIN_VOICE_PREFIX = "plugin:"
MAX_VOICE_PROFILE_ID_CHARS = 200
def _validated_standing_portrait(
dependencies: CharacterAssetDependencies, value: Any,
) -> dict[str, str] | None:
from src.webui.services.standing_portraits import validate_standing_portrait_ref
if dependencies.standing_portrait_file is None:
if value is None or value == {}:
return None
raise ValueError("立绘资产服务未配置")
return validate_standing_portrait_ref(value, dependencies.standing_portrait_file)
def _validated_standing_portrait_expressions(
dependencies: CharacterAssetDependencies, value: Any,
) -> dict[str, dict[str, str]] | None:
if value is None or value == {}:
return None
if not isinstance(value, dict):
raise ValueError("表情差分必须是对象")
if len(value) > MAX_STANDING_PORTRAIT_EXPRESSIONS:
raise ValueError(f"表情差分不能超过 {MAX_STANDING_PORTRAIT_EXPRESSIONS} 条")
normalized: dict[str, dict[str, str]] = {}
for label, reference in value.items():
if not _EXPRESSION_LABEL_RE.fullmatch(str(label or "")):
raise ValueError(f"表情标签无效:{label!r}(小写字母、数字、- 或 _,最长 32)")
item = _validated_standing_portrait(dependencies, reference)
if item is None:
raise ValueError(f"表情差分 {label!r} 缺少立绘引用")
normalized[str(label)] = item
return normalized
def _validated_voice_profile(value: Any) -> str | None:
"""Validate a voice id shape (speech vocabulary); existence is checked at use."""
if value is None or value == "":
return None
if not isinstance(value, str):
raise ValueError("音色绑定必须是字符串")
text = value.strip()
if not text or len(text) > MAX_VOICE_PROFILE_ID_CHARS:
raise ValueError("音色绑定无效")
if text.startswith("personal:") and not _VOICE_PROFILE_ID_RE.fullmatch(text):
raise ValueError("个人音色 ID 无效")
if text.startswith(_PLUGIN_VOICE_PREFIX) and len(text) <= len(_PLUGIN_VOICE_PREFIX):
raise ValueError("插件音色 ID 无效")
return text
def _project_npc_bindings(row: dict[str, Any], dependencies: CharacterDependencies) -> None:
"""Drop voice bindings whose profile no longer exists (fail closed).
失效引用保持未配置:投影省略后前端自然回退默认音色;未注入检查器时
原样带出,合成端仍会对已删除音色明确报错。
"""
exists = dependencies.voice_profile_exists
voice = str(row.get("voice_profile") or "")
if voice and voice.startswith("personal:") and exists is not None and not exists(voice):
row.pop("voice_profile", None)
async def update_npc_portrait(
dependencies: CharacterDependencies,
game_key: str,
npc_id: str,
portrait: Any,
) -> dict[str, Any]:
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "游戏不存在"}
async with inst.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
return await _update_npc_portrait_authority(
dependencies, inst, npc_id, portrait,
)
def _resolve_npc_for_update(
dependencies: CharacterDependencies,
inst: GameInstance,
npc_id: str,
) -> tuple[str, dict[str, Any]] | None:
"""Locate an NPC (by key or id), materializing a lorebook entry if needed."""
npc_key = str(npc_id or "").strip()
if not npc_key:
return None
npc = inst.npcs.get(npc_key)
if npc is None:
for key, candidate in inst.npcs.items():
if str(candidate.get("id") or candidate.get("npc_id") or "") == npc_key:
npc_key, npc = key, candidate
break
if npc is None and dependencies.assets.lorebook and inst.world_id:
if dependencies.assets.content_projection is not None:
entries = dependencies.assets.content_projection.for_game(
inst, viewer_kind="gm", entry_type="npc",
)
entry = next(
(candidate for candidate in entries if str(candidate.get("id") or "") == npc_key),
None,
)
else:
entry = dependencies.assets.lorebook.get_entry(npc_key)
if entry and ("world_id" not in entry or entry.get("world_id") == inst.world_id) and entry.get("type") == "npc":
world_data = dependencies.assets.load_world_template(
inst.world_id,
str(getattr(inst, "language", "") or ""),
)
entry = localize_lorebook_entries([entry], world_data)[0]
name = str(entry.get("name") or npc_key)
npc = {
"name": name,
"character_name": name,
"tier": entry.get("tier", ""),
}
inst.npcs[npc_key] = npc
if npc is None:
return None
return npc_key, npc
async def _update_npc_portrait_authority(
dependencies: CharacterDependencies,
inst: GameInstance,
npc_id: str,
portrait: Any,
) -> dict[str, Any]:
resolved = _resolve_npc_for_update(dependencies, inst, npc_id)
if resolved is None:
return {"ok": False, "error": "NPC 不存在"}
_npc_key, npc = resolved
try:
normalized = _validated_portrait(dependencies.assets, portrait)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if normalized is None:
npc.pop("portrait", None)
else:
npc["portrait"] = normalized
await dependencies.games.save_instance(inst)
return {"ok": True, "portrait": npc.get("portrait")}
# Marks an NPC standing-portrait field the request did not carry.
UNCHANGED: Any = object()
async def update_npc_standing_portrait(
dependencies: CharacterDependencies,
game_key: str,
npc_id: str,
standing_portrait: Any = UNCHANGED,
expressions: Any = UNCHANGED,
) -> dict[str, Any]:
"""GAL P8:设置 NPC 的立绘主图与表情差分集(一次事务,全有或全无)。
与玩家角色卡同一语义:未提供的字段保持不变,显式 ``None`` 清除该字段。
"""
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "游戏不存在"}
async with inst.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
resolved = _resolve_npc_for_update(dependencies, inst, npc_id)
if resolved is None:
return {"ok": False, "error": "NPC 不存在"}
_npc_key, npc = resolved
portrait_present = standing_portrait is not UNCHANGED
expressions_present = expressions is not UNCHANGED
if not portrait_present and not expressions_present:
return {"ok": False, "error": "未提供立绘字段"}
try:
# Validate every supplied field before mutating either one.
normalized = (
_validated_standing_portrait(dependencies.assets, standing_portrait)
if portrait_present else None
)
normalized_expressions = (
_validated_standing_portrait_expressions(dependencies.assets, expressions)
if expressions_present else None
)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if portrait_present:
if normalized is None:
npc.pop("standing_portrait", None)
else:
npc["standing_portrait"] = normalized
if expressions_present:
if normalized_expressions is None:
npc.pop("standing_portrait_expressions", None)
else:
npc["standing_portrait_expressions"] = normalized_expressions
await dependencies.games.save_instance(inst)
return {
"ok": True,
"standing_portrait": npc.get("standing_portrait"),
"standing_portrait_expressions": npc.get("standing_portrait_expressions"),
}
async def update_npc_voice(
dependencies: CharacterDependencies,
game_key: str,
npc_id: str,
voice_profile: Any,
) -> dict[str, Any]:
"""GAL P8:绑定 NPC 的固定音色(voice id,speech 同词汇)。"""
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "游戏不存在"}
async with inst.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
resolved = _resolve_npc_for_update(dependencies, inst, npc_id)
if resolved is None:
return {"ok": False, "error": "NPC 不存在"}
_npc_key, npc = resolved
try:
normalized = _validated_voice_profile(voice_profile)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
if normalized is None:
npc.pop("voice_profile", None)
else:
npc["voice_profile"] = normalized
await dependencies.games.save_instance(inst)
return {"ok": True, "voice_profile": npc.get("voice_profile")}
def grant_reward_to_sheet(sheet: dict[str, Any], reward: dict[str, Any]) -> None:
"""Write one settled reward onto a character sheet.
``item_ref`` 是**不透明身份 metadata**:本层不解释 D&D 规则,只保证它
随奖励一起落到背包行,让规则集侧能按身份取件(缺失时行为与旧版一致)。
"""
item_name = str(reward.get("name") or "").strip()
if not item_name:
return
grant_classified_item(
sheet,
item_name,
str(reward.get("category") or ""),
item_ref=str(reward.get("item_ref") or ""),
)
async def resolve_payment(
dependencies: CharacterDependencies,
game_key: str,
payment_id: str,
accepted: bool,
session_uid: str = "",
) -> dict[str, Any]:
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if inst is None:
return {"ok": False, "error": "游戏不存在"}
actor_uid = str(session_uid or "")
def grant_reward(sheet: dict[str, Any], reward: dict[str, Any]) -> None:
grant_reward_to_sheet(sheet, reward)
scene_image_payload: dict[str, Any] | None = None
async with inst.authoritative_write() as write_entered, inst._lock:
if not write_entered:
return {
"ok": False,
"code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后再处理支付",
}
current = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if current is None or id(current) != id(inst):
return {
"ok": False,
"code": "STALE_RUN",
"error": "对局已重开,请刷新后处理新的请求",
}
staged = type(inst).from_dict(deepcopy(inst.to_dict()))
# The persistence projection intentionally keeps only the recent log
# window. A transaction stage must retain the live aggregate's full
# in-memory history when it is committed back.
staged.log = deepcopy(inst.log)
result = resolve_proposal(
staged,
payment_id,
actor_uid=actor_uid,
accepted=bool(accepted),
grant_reward=grant_reward,
)
effect_group = result.get("effect_group")
if (
result.get("ok")
and isinstance(effect_group, dict)
and isinstance(effect_group.get("effects"), dict)
and dependencies.apply_economy_effects is not None
):
effect_payload = dict(effect_group.get("effects") or {})
memory_delta = dict(effect_payload.pop("memory_delta", {}) or {})
scene_image_prompt = str(
effect_payload.pop("scene_image_prompt", "") or ""
).strip()
if scene_image_prompt:
scene_image_payload = {
"scene_image_prompt": scene_image_prompt,
"state_update": deepcopy(effect_payload.get("state_update") or {}),
}
try:
# Deferred narrative effects are allowed to apply item
# candidates only under the transaction receipt produced by
# resolve_proposal. The callback shape remains compatible
# with legacy two-argument test/application adapters; the
# receipt travels on the staged aggregate and is never
# persisted.
staged._economy_authorized_receipt_id = str(
(result.get("transaction") or {}).get("id") or ""
)
await dependencies.apply_economy_effects(
staged, effect_payload,
)
except Exception:
logger.exception(
"经济提案关联效果提交失败,已回滚: game=%s proposal=%s",
game_key,
payment_id,
)
return {
"ok": False,
"code": "EFFECT_COMMIT_FAILED",
"error": "关联结果应用失败,本次结算未生效,请重试",
}
finally:
staged.__dict__.pop("_economy_authorized_receipt_id", None)
if memory_delta and dependencies.apply_economy_memory is not None:
queue_memory_delivery(
staged,
effect_group_id=str(effect_group.get("id") or ""),
memory_delta=memory_delta,
round_number=int(effect_group.get("round", progression_state.round_value(staged)) or 0),
)
result["effects_committed"] = complete_effect_group(
staged, str(effect_group.get("id") or ""),
)
if result["effects_committed"] and isinstance(result.get("outcome"), dict):
result["outcome"]["effects_status"] = "committed"
outcome = result.get("outcome")
if isinstance(outcome, dict):
_record_economy_outcome_in_round(staged, outcome)
proposal = result.get("proposal") or {}
if result.get("ok"):
uid = str(
proposal.get("payer_uid")
or proposal.get("recipient_uid")
or ""
)
amount = int(proposal.get("amount", 0) or 0)
name = staged.players.get(uid, {}).get("character_name", uid)
awaiting_party = bool(accepted and result.get("committed") is False)
record_health_event(
staged,
component="economy",
code=(
"economy_approved"
if awaiting_party
else "economy_committed"
if accepted
else "economy_declined"
),
severity="info",
title="经济提案已处理",
message=(
f"{name} 已确认,等待其他队员:{proposal.get('reason') or amount}"
if awaiting_party
else f"{name} {'确认' if accepted else '拒绝'}:{proposal.get('reason') or amount}"
),
)
if result.get("ok") or result.get("code") == "INSUFFICIENT_FUNDS":
_remove_stale_pending_notice(staged)
before_commit = type(inst).from_dict(deepcopy(inst.to_dict()))
before_commit.log = deepcopy(inst.log)
inst.replace_persisted_state_from(staged)
# Persist while still holding the aggregate lock. This closes the
# gap where restart could replace the run between mutation and save.
try:
await dependencies.games.save_instance(inst)
except Exception:
inst.replace_persisted_state_from(before_commit)
raise
if (
scene_image_payload is not None
and dependencies.schedule_economy_scene_image is not None
):
try:
result["scene_image_scheduled"] = bool(
dependencies.schedule_economy_scene_image(
inst, scene_image_payload,
)
)
except Exception:
logger.exception(
"经济结算后的场景图任务创建失败,权威结算已保留: game=%s",
game_key,
)
result["scene_image_scheduled"] = False
result["external_effects_committed"] = await _deliver_memory_outbox_locked(
dependencies, inst,
)
if not result.get("ok"):
return result
return {**result, "payment": proposal}
async def _deliver_memory_outbox_locked(
dependencies: CharacterDependencies,
instance: GameInstance,
) -> bool:
"""Deliver persisted memory effects; caller must hold the aggregate lock."""
reversals = pending_memory_reversals(instance)
pending = pending_memory_deliveries(instance)
if not reversals and not pending:
return True
reverse_memory = dependencies.reverse_economy_memory
changed = False
if reversals:
if reverse_memory is None:
return False
for delivery in reversals:
try:
reversed_ok = await reverse_memory(
instance.memory_namespace,
str(delivery.get("id") or ""),
)
except Exception:
logger.exception(
"经济结算记忆撤销失败,已保留待重试记录: game=%s delivery=%s",
instance.game_key,
delivery.get("id"),
)
return False
if not reversed_ok:
logger.error(
"经济结算记忆缺少可验证的撤销记录: game=%s delivery=%s",
instance.game_key,
delivery.get("id"),
)
return False
complete_memory_reversal(instance, str(delivery.get("id") or ""))
changed = True
apply_memory = dependencies.apply_economy_memory
if pending:
if apply_memory is None:
return False
for delivery in pending:
try:
await apply_memory(
instance.memory_namespace,
str(delivery.get("id") or ""),
dict(delivery.get("payload") or {}),
int(delivery.get("round", progression_state.round_value(instance)) or 0),
)
except Exception:
logger.exception(
"经济结算记忆 outbox 投递失败,已保留待重试记录: game=%s delivery=%s",
instance.game_key,
delivery.get("id"),
)
return False
complete_memory_delivery(instance, str(delivery.get("id") or ""))
changed = True
if changed:
# Memory delivery was the last outstanding piece of the settlement;
# the round's stale "settlement pending" notice can finally go.
_remove_stale_pending_notice(instance)
await dependencies.games.save_instance(instance)
return not (
pending_memory_deliveries(instance)
or pending_memory_reversals(instance)
)
async def drain_economy_outbox(
dependencies: CharacterDependencies,
instance: GameInstance,
) -> bool:
"""Retry durable external effects before allowing narrative progression."""
async with instance._lock:
current = dependencies.games.get_instance(instance.game_key)
if current is not instance:
return False
return await _deliver_memory_outbox_locked(dependencies, instance)
async def delete_character(
dependencies: CharacterDependencies,
game_key: str,
user_id: str,
*,
gm_authority: bool,
) -> dict[str, Any]:
"""Remove a seat. Once it has acted only the GM may (no delete-and-rejoin
reset); checked inside the authoritative write."""
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "角色不存在"}
async with inst.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
player_side_after_acting = (
not gm_authority
and user_id in inst.players
and module_hooks.seat_has_acted(inst, user_id)
)
current = dependencies.games.get_instance(inst.game_key)
if current is not inst:
return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
if player_side_after_acting:
return delete_requires_gm_failure()
return await _delete_character_authority(dependencies, inst, user_id)
async def _delete_character_authority(
dependencies: CharacterDependencies,
instance: GameInstance,
user_id: str,
) -> dict[str, Any]:
inst = instance
if not inst or user_id not in inst.players:
return {"ok": False, "error": "角色不存在"}
if len(inst.players) <= 1:
return {"ok": False, "error": "至少保留一个角色,无法删除最后一个"}
name = inst.players[user_id].get("character_name", user_id)
removed = await inst.remove_player(user_id)
if not removed:
return {"ok": False, "error": "角色不存在"}
cancel_proposals_for_player(inst, user_id)
inst.clear_private_messages(user_id)
await dependencies.games.save_instance(inst)
logger.info("角色已删除: %s (%s)", name, inst.game_key)
return {"ok": True}
async def create_player(
dependencies: CharacterDependencies, game_key: str, character: dict,
force_uid: str = "", assign_new_id: bool = False,
*, seat_token_uid: str = "", require_seat_proof: bool = False,
) -> dict[str, Any]:
inst = dependencies.games.get_instance(
dependencies.games.parse_game_key(game_key),
)
if not inst:
return {"ok": False, "error": "游戏不存在"}
inst = cast(GameInstance, inst)
async with inst.authoritative_write() as write_entered:
if not write_entered:
return {
"ok": False, "error_code": "REWRITE_IN_PROGRESS",
"error": "GM 正在重写历史回合,请等待完成后重试",
}
if dependencies.games.get_instance(inst.game_key) is not inst:
return {"ok": False, "code": "STALE_RUN", "error": "对局已重开,请刷新后重试"}
# Serialize membership changes with authoritative combat actions. A
# player joining during an active encounter must be enrolled in the
# ruleset's live turn state before the new seat is persisted.
state_lock = getattr(inst, "_lock")
async with state_lock:
return await _create_player_authority(
dependencies, inst, character, force_uid, assign_new_id,
seat_token_uid=seat_token_uid, require_seat_proof=require_seat_proof,
)
def _record_seat_claim(inst: GameInstance, uid: str) -> bool:
"""把"重新加入一个已有席位"记录为一次控制权认领。
新席位由 ``put_player`` 直接生成为 ``human``;只有当前是 ``ai`` /
``unclaimed`` 的已有席位才存在需要记录的转换,因此普通真人重连不会触发
``CONTROL_NOT_CLAIMABLE``。转换走 :func:`claim_seat`,与其它控制权变更是
同一个写入口。
返回是否真的发生了转换:认领是持久化状态,必须立刻落盘,否则真人认领过的
席位会在重启后变回 AI;而普通重连什么都没改,不能因此在热路径上多写一次盘。
"""
if is_human_controlled(inst, uid):
return False
claim_seat(inst, uid)
return True
def _seat_proof_missing(inst: GameInstance, uid: str, seat_token_uid: str, *, named: bool) -> bool:
"""Re-joining an existing seat needs that seat's token.
Naming another seat's uid is never proof. A session already bound to a
seat may rejoin it only while the seat has no credential yet (the
pre-token migration); once a credential exists only the token speaks.
"""
if seat_token_uid == uid:
return False
return named or room_access.has_seat_token(inst, uid)
_SEAT_PROOF_REQUIRED = {
"ok": False,
"error_code": "SEAT_TOKEN_REQUIRED",
"error": "加入已有席位需要该席位的链接",
}
async def _create_player_authority(dependencies: CharacterDependencies, inst: GameInstance, character: dict,
force_uid: str = "", assign_new_id: bool = False,
*, seat_token_uid: str = "", require_seat_proof: bool = False) -> dict[str, Any]:
requested_uid = str(character.get("user_id") or "").strip()
if (
require_seat_proof
and requested_uid in inst.players
and _seat_proof_missing(inst, requested_uid, seat_token_uid, named=True)
):
return dict(_SEAT_PROOF_REQUIRED)
if requested_uid and requested_uid in inst.players:
# 已有席位被重新加入:按控制权权威记录这次认领(已经是真人则是普通重连)。
if _record_seat_claim(inst, requested_uid):
await dependencies.games.save_instance(inst)
return {
"ok": True,
"user_id": requested_uid,
"character_name": inst.players[requested_uid].get("character_name", requested_uid),
"reused": True,
}
# uid 决策:GM 代建(assign_new_id)生成独立 uid;否则优先 force_uid(session 身份)或显式 requested_uid
if assign_new_id:
uid = "player_" + str(time.time_ns())[-12:]
elif force_uid:
if (
require_seat_proof
and force_uid in inst.players
and _seat_proof_missing(inst, force_uid, seat_token_uid, named=False)
):
return dict(_SEAT_PROOF_REQUIRED)
if force_uid in inst.players:
# 同上:session 身份命中的是已有席位,而不是新建席位。
if _record_seat_claim(inst, force_uid):
await dependencies.games.save_instance(inst)
return {"ok": True, "user_id": force_uid,
"character_name": inst.players[force_uid].get("character_name", force_uid),
"reused": True}
uid = force_uid
elif requested_uid:
uid = requested_uid
else:
uid = "player_" + str(time.time_ns())[-12:]
# A new seat is issued its token in the same write, so the slot must be
# writable before anything changes.
room_access.require_writable(inst)
max_players = max(1, int(room_access.max_players(inst) or 6))
if uid not in inst.players and len(inst.players) >= max_players:
return {
"ok": False,
"error": f"房间已满(最多 {max_players} 人)",
"error_code": "game_room_full",
}
rule = dependencies.rules.load_rule_for_game(inst)
rule_id = rule.rule_id if rule else "freeform_fantasy"
professional_character = False
runtime: Any | None = None
if rule is not None:
runtime = dependencies.rules.ruleset_registry.resolve(rule.template)
professional_character = runtime.capabilities.character_builder == "professional"
if professional_character:
try:
character = runtime.normalize_character_submission(
rule, character, getattr(inst, "language", ""),
)
except ValueError as exc:
error_code, error = professional_character_validation_error(exc)
return {
"ok": False,
"error_code": error_code,
"error": error,
}
canonical = character.get("ruleset_character")
binding = canonical.get("rule_binding") if isinstance(canonical, dict) else None
# 绑定由游戏在创建时决定;加入的角色只能匹配,不能反向建立绑定。
if not ruleset_runtime.binding(inst):
return {
"ok": False,
"error_code": "RULESET_BINDING_MISSING",
"error": "这个存档没有绑定专业规则版本(旧版存档),无法加入专业角色,请新建一局游戏。",
}
if not isinstance(binding, dict) or not inst.ruleset_binding_matches(binding):
return {
"ok": False,
"error_code": "INCOMPATIBLE_RULESET_CHARACTER",
"error": "角色与当前游戏的专业规则版本不兼容",
}
# 仅传了名字的轻量加入会自动生成默认角色卡。
has_full_sheet = bool(character.get("attributes") or character.get("equipment") or character.get("skills"))
if not has_full_sheet:
name = character.get("name") or character.get("character_name") or "冒险者"
try:
templates_base = (
dependencies.rules.rules_dir.parent
if dependencies.rules.rules_dir
else None
)
character = make_default_character(name, rule_id or "freeform_fantasy", templates_base, language=getattr(inst, "language", ""))
character["character_name"] = name
except Exception:
logger.exception("生成默认角色卡失败: %s", name)
attrs = character.get("attributes", {})
clamp_to_rule = _creation_bounds_enabled(rule)
if clamp_to_rule:
attrs = _clamp_attributes(rule, attrs)
rule_attrs = _get_rule_attrs_for_game(dependencies, inst)
total_points = sum(int(attrs.get(r["key"], 10)) for r in rule_attrs) if rule_attrs else 60
default_weapons = [{"name": "徒手", "type": "weapon", "damage": 2, "slot": "main_hand", "quality": "common"}]
hp = character.get("hp")
max_hp = character.get("max_hp")
# 声明了入场边界的规则(CoC 的 HP 由体质与体型派生)不接受客户端自报 HP。
if hp is None or max_hp is None or clamp_to_rule:
hp = (
rule.calculate_hp(attrs, character.get("class", ""))
if rule
else calc_hp_from_rule(
attrs,
rule_id,
dependencies.rules.rules_dir,
character.get("class", ""),
language=getattr(inst, "language", ""),
)
)
max_hp = hp
default_class = rule.classes[0]["name"] if (rule and rule.classes) else "冒险者"
if len(str(character.get("background", ""))) > MAX_BIO_CHARS:
return {"ok": False, "error": f"角色背景过长(上限 {MAX_BIO_CHARS} 字)"}
damage_dice_error = _validate_damage_dice(character.get("equipment"))
if damage_dice_error:
return {"ok": False, "error": damage_dice_error}
starter_equip, starter_inv = build_starter_items(rule, character.get("class") or default_class)
cs = {
"race": character.get("race", "人类"),
"class": character.get("class") or default_class,
"identity": character.get("identity", {}),
"level": 1, "xp": 0,
"attributes": attrs,
"hp": hp, "max_hp": max_hp,
"equipment": character.get("equipment") or starter_equip or default_weapons,
"inventory": character.get("inventory") or starter_inv,
"key_items": character.get("key_items", []),
"skills": _normalize_skills(character.get("skills", []), rule, attributes=attrs, clamp=clamp_to_rule),
"background": character.get("background", ""),
"deceased": False,
"gold": character.get("gold", 30),
"currency": character.get("currency", {}),
"portrait": character.get("portrait", {}),
"attr_points_max": total_points,
}
if professional_character:
cs["rule_binding"] = deepcopy(character["rule_binding"])
cs["ruleset_character"] = deepcopy(character["ruleset_character"])
cs["armor_class"] = int(character.get("armor_class", 10) or 10)
# 初始化 special_stats(理智值/幸运值/内力等)
try:
if rule:
for ss in rule.special_stats:
max_val = ss.get("max", 99)
init_val = initial_special_stat_value(ss, attrs)
cs[ss["key"]] = init_val
cs[f"max_{ss['key']}"] = max_val
except Exception as exc:
logger.exception("初始化特殊属性失败: %s", exc)
normalize_character_sheet(cs, rule)
# 规则显式声明了服务端建卡边界(CoC 7e 的 character_creation.clamp_to_rule_bounds)时,
# 属性 clamp 只是第一道;技能点预算、技能条数、单技能上限也必须在这里按同一套规则
# 校验,否则绕过前端直接调 API 就能把超预算角色带进局内。
if clamp_to_rule:
rejection = creation_bounds_violation(rule, cs)
if rejection is not None:
return rejection
player = {
"character_name": character.get("character_name") or character.get("name") or "冒险者",
"character_sheet": cs,
}
inst.put_player(uid, player)
# 新建席位由 put_player/ensure_control 直接生成为 human 控制,无需认领转换。
if isinstance(runtime, PlayerJoinRuntime):
try:
runtime.on_player_join(inst, uid)
except Exception:
# Do not leave a seat visible without a corresponding ruleset
# combat actor if the optional live-state hook rejects the join.
inst.players.pop(uid, None)
raise
# The new seat's share credential; the plaintext leaves only in this
# response. A GM-created seat's token lets the GM hand out its link.
# The GM seat itself never holds one (e.g. the GM recreating its character).
seat_token = "" if uid == str(inst.gm_uid or "") else room_access.issue_seat_token(inst, uid)
dependencies.save_character_card({
**player,
"rule_id": rule_id,
"rule_name": rule.rule_name if rule else rule_id,
"rule_version": str(rule.template.get("rule_version") or "") if rule else "",
"mechanics": rule.mechanics if rule else "",
"language": getattr(inst, "language", ""),
})
await dependencies.games.save_instance(inst)
created: dict[str, Any] = {"ok": True, "user_id": uid, "character_name": player["character_name"]}
if seat_token:
created["seat_token"] = seat_token
return created